feat: VideoKonverter v5.3 - Android APK Fix, Tizen HLS-Surround, Native Player Verbesserungen

Android-App v1.2.0:
- Fix: 404-Fehler durch doppelten /tv/tv/ Pfad (URL-Bereinigung in SetupActivity)
- Fix: Kein Ton - AudioAttributes (AUDIO_CONTENT_TYPE_MOVIE + handleAudioFocus)
- Neu: ExoPlayer HLS-Support (playHLS) fuer DTS/TrueHD-Audio Fallback
- Neu: Back-Taste auf Root-Seite -> zurueck zum Setup (Server aendern)
- VKWebViewClient: playHLS in JS-Bridge exponiert

Tizen-App:
- Fix: Tonausfaelle bei Opus 6ch (Akte X) - canDirectPlay blockt Opus >2ch
- Neu: AVPlay HLS-Fallback (playHLS) mit AAC 5.1 Surround-Erhalt
- Neu: Buffer-Konfiguration (setBufferingParam) fuer stabilere Wiedergabe
- VKNative-Bridge v2.0: playHLS in beiden Modi (postMessage + Direct AVPlay)

Player:
- Native-HLS Default Sound auf "surround" (AVPlay/ExoPlayer koennen 5.1)
- PWA Direct-Play, Template-Fixes, UX-Verbesserungen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-09 21:18:07 +01:00
parent 78368db582
commit 8302ff953a
17 changed files with 845 additions and 62 deletions

Binary file not shown.

View file

@ -11,13 +11,23 @@ android {
applicationId = "de.datait.videokonverter" applicationId = "de.datait.videokonverter"
minSdk = 24 // Android 7.0 (ExoPlayer Codec-Support) minSdk = 24 // Android 7.0 (ExoPlayer Codec-Support)
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 3
versionName = "1.0.0" versionName = "1.2.0"
}
signingConfigs {
create("release") {
storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
signingConfig = signingConfigs.getByName("release")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"

View file

@ -72,16 +72,24 @@ class MainActivity : AppCompatActivity() {
} }
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
// Back-Taste: WebView-Navigation zurueck if (keyCode == KeyEvent.KEYCODE_BACK) {
if (keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack()) {
// ExoPlayer aktiv? Zuerst stoppen // ExoPlayer aktiv? Zuerst stoppen
if (playerView.visibility == View.VISIBLE) { if (playerView.visibility == View.VISIBLE) {
bridge.stop() bridge.stop()
return true return true
} }
// WebView zurueck-navigieren
if (webView.canGoBack()) {
webView.goBack() webView.goBack()
return true return true
} }
// Kein Zurueck moeglich -> zurueck zum Setup (Server aendern)
val intent = Intent(this, SetupActivity::class.java)
intent.putExtra("force_setup", true)
startActivity(intent)
finish()
return true
}
return super.onKeyDown(keyCode, event) return super.onKeyDown(keyCode, event)
} }

View file

@ -9,6 +9,7 @@ import android.webkit.CookieManager
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.WebView import android.webkit.WebView
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
@ -17,6 +18,7 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import org.json.JSONArray import org.json.JSONArray
@ -111,8 +113,14 @@ class NativePlayerBridge(
// Vorherigen Player bereinigen // Vorherigen Player bereinigen
releasePlayer() releasePlayer()
// ExoPlayer erstellen // ExoPlayer erstellen (mit Audio-Attributen fuer korrekten Ton)
val player = ExoPlayer.Builder(activity).build() val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
val player = ExoPlayer.Builder(activity)
.setAudioAttributes(audioAttributes, true)
.build()
// PlayerView konfigurieren // PlayerView konfigurieren
playerView.player = player playerView.player = player
@ -148,6 +156,62 @@ class NativePlayerBridge(
return true return true
} }
/**
* HLS-Stream ueber ExoPlayer abspielen (Fallback fuer DTS/TrueHD-Audio).
* Wird von player.js aufgerufen wenn canDirectPlay() false ist.
*/
@JavascriptInterface
fun playHLS(playlistUrl: String, optsJson: String): Boolean {
val opts = try { JSONObject(optsJson) } catch (e: Exception) { JSONObject() }
val seekMs = opts.optLong("seekMs", 0)
// Relative URL -> Absolute URL
val fullUrl = if (playlistUrl.startsWith("/")) "$serverUrl$playlistUrl" else playlistUrl
activity.runOnUiThread {
try {
releasePlayer()
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
val player = ExoPlayer.Builder(activity)
.setAudioAttributes(audioAttributes, true)
.build()
playerView.player = player
playerView.useController = false
playerView.visibility = View.VISIBLE
// Cookie fuer Auth
val cookie = CookieManager.getInstance().getCookie(serverUrl) ?: ""
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(mapOf("Cookie" to cookie))
// HLS-MediaSource (nicht Progressive!)
val mediaSource = HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(fullUrl))
player.setMediaSource(mediaSource)
player.addListener(this)
player.prepare()
if (seekMs > 0) {
player.seekTo(seekMs)
}
player.playWhenReady = true
exoPlayer = player
startTimeUpdates()
} catch (e: Exception) {
callJs("if(window._vkOnError) window._vkOnError('${e.message}')")
}
}
return true
}
@JavascriptInterface @JavascriptInterface
fun togglePlay() { fun togglePlay() {
activity.runOnUiThread { activity.runOnUiThread {

View file

@ -19,13 +19,24 @@ class SetupActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Gespeicherte URL? Direkt weiter zur MainActivity // "reset" Extra: Setup erzwingen (von MainActivity gesendet)
val forceSetup = intent.getBooleanExtra("force_setup", false)
val prefs = PreferenceManager.getDefaultSharedPreferences(this) val prefs = PreferenceManager.getDefaultSharedPreferences(this)
if (!forceSetup) {
// Gespeicherte URL? Bereinigen + weiter zur MainActivity
val savedUrl = prefs.getString("server_url", null) val savedUrl = prefs.getString("server_url", null)
if (!savedUrl.isNullOrBlank()) { if (!savedUrl.isNullOrBlank()) {
startMainActivity(savedUrl) val cleanUrl = cleanServerUrl(savedUrl)
if (cleanUrl != savedUrl) {
// Kaputte URL korrigieren (z.B. mit /tv/ Pfad)
prefs.edit().putString("server_url", cleanUrl).apply()
}
startMainActivity(cleanUrl)
return return
} }
}
setContentView(R.layout.activity_setup) setContentView(R.layout.activity_setup)
@ -69,8 +80,8 @@ class SetupActivity : AppCompatActivity() {
url = "$url:8080" url = "$url:8080"
} }
// Trailing Slash entfernen // Nur Schema + Host + Port behalten (Pfad entfernen)
url = url.trimEnd('/') url = cleanServerUrl(url)
// URL speichern // URL speichern
prefs.edit().putString("server_url", url).apply() prefs.edit().putString("server_url", url).apply()
@ -78,6 +89,16 @@ class SetupActivity : AppCompatActivity() {
startMainActivity(url) startMainActivity(url)
} }
/** Nur Schema + Host + Port aus einer URL extrahieren */
private fun cleanServerUrl(raw: String): String {
return try {
val uri = android.net.Uri.parse(raw)
"${uri.scheme}://${uri.host}${if (uri.port > 0) ":${uri.port}" else ""}"
} catch (e: Exception) {
raw.trimEnd('/')
}
}
private fun startMainActivity(serverUrl: String) { private fun startMainActivity(serverUrl: String) {
val intent = Intent(this, MainActivity::class.java) val intent = Intent(this, MainActivity::class.java)
intent.putExtra("server_url", serverUrl) intent.putExtra("server_url", serverUrl)

View file

@ -52,6 +52,10 @@ class VKWebViewClient(private val serverUrl: String) : WebViewClient() {
getDuration: function() { return VKNativeAndroid.getDuration(); }, getDuration: function() { return VKNativeAndroid.getDuration(); },
isPlaying: function() { return VKNativeAndroid.isPlaying(); }, isPlaying: function() { return VKNativeAndroid.isPlaying(); },
stop: function() { VKNativeAndroid.stop(); }, stop: function() { VKNativeAndroid.stop(); },
playHLS: function(url, opts) {
try { return VKNativeAndroid.playHLS(url, JSON.stringify(opts || {})); }
catch(e) { return false; }
},
setAudioTrack: function(i) { return VKNativeAndroid.setAudioTrack(i); }, setAudioTrack: function(i) { return VKNativeAndroid.setAudioTrack(i); },
setSubtitleTrack: function(i) { return VKNativeAndroid.setSubtitleTrack(i); }, setSubtitleTrack: function(i) { return VKNativeAndroid.setSubtitleTrack(i); },
setPlaybackSpeed: function(s) { return VKNativeAndroid.setPlaybackSpeed(s); }, setPlaybackSpeed: function(s) { return VKNativeAndroid.setPlaybackSpeed(s); },

Binary file not shown.

View file

@ -200,6 +200,9 @@
case "play": case "play":
_avplay_play(args[0], args[1], args[2]); _avplay_play(args[0], args[1], args[2]);
break; break;
case "playHLS":
_avplay_playHLS(args[0], args[1]);
break;
case "stop": case "stop":
_avplay_stop(); _avplay_stop();
break; break;
@ -252,6 +255,17 @@
webapis.avplay.open(fullUrl); webapis.avplay.open(fullUrl);
webapis.avplay.setDisplayRect(0, 0, window.innerWidth, window.innerHeight); webapis.avplay.setDisplayRect(0, 0, window.innerWidth, window.innerHeight);
// Buffer-Konfiguration fuer stabilere Wiedergabe
try {
// Initial-Buffer: 8 Sekunden vorpuffern bevor Wiedergabe startet
webapis.avplay.setBufferingParam("PLAYER_BUFFER_FOR_PLAY", "PLAYER_BUFFER_SIZE_IN_SECOND", 8);
// Resume-Buffer: 5 Sekunden nach Unterbrechung puffern
webapis.avplay.setBufferingParam("PLAYER_BUFFER_FOR_RESUME", "PLAYER_BUFFER_SIZE_IN_SECOND", 5);
console.info("[TizenApp] Buffer-Params gesetzt: Play=8s, Resume=5s");
} catch (e) {
console.debug("[TizenApp] setBufferingParam nicht moeglich:", e.message || e);
}
// Event-Listener // Event-Listener
webapis.avplay.setListener({ webapis.avplay.setListener({
onbufferingstart: function() { onbufferingstart: function() {
@ -340,6 +354,112 @@
} }
} }
/**
* HLS-Stream ueber AVPlay abspielen (Fallback fuer Opus-Surround).
* Server transkodiert Audio zu AAC 5.1, AVPlay spielt HLS nativ.
*/
function _avplay_playHLS(playlistUrl, opts) {
opts = opts || {};
// Relative URL -> Absolute URL
var fullUrl = playlistUrl;
if (playlistUrl.indexOf("://") === -1) {
fullUrl = _serverUrl + playlistUrl;
}
try {
// Vorherige Session bereinigen
_avplay_stop();
// AVPlay-Display einblenden
var avEl = document.getElementById("avplayer");
if (avEl) avEl.style.display = "block";
// iframe deaktivieren
if (_iframe) {
_iframe.style.pointerEvents = "none";
_iframe.style.opacity = "0";
}
// AVPlay mit HLS-URL oeffnen
console.info("[TizenApp] AVPlay HLS oeffne: " + fullUrl);
webapis.avplay.open(fullUrl);
webapis.avplay.setDisplayRect(0, 0, window.innerWidth, window.innerHeight);
// HLS-Streaming: Groesserer Buffer fuer stabilen Surround-Sound
try {
webapis.avplay.setBufferingParam("PLAYER_BUFFER_FOR_PLAY", "PLAYER_BUFFER_SIZE_IN_SECOND", 10);
webapis.avplay.setBufferingParam("PLAYER_BUFFER_FOR_RESUME", "PLAYER_BUFFER_SIZE_IN_SECOND", 6);
console.info("[TizenApp] HLS Buffer-Params gesetzt: Play=10s, Resume=6s");
} catch (e) {
console.debug("[TizenApp] HLS setBufferingParam:", e.message || e);
}
// Event-Listener
webapis.avplay.setListener({
onbufferingstart: function() {
_sendEvent("buffering", { buffering: true });
},
onbufferingcomplete: function() {
_sendEvent("buffering", { buffering: false });
},
oncurrentplaytime: function(ms) {
_sendEvent("timeupdate", { ms: ms });
},
onstreamcompleted: function() {
_playing = false;
_sendEvent("playstatechanged", { playing: false });
_sendEvent("complete");
},
onerror: function(eventType) {
console.error("[TizenApp] AVPlay HLS Fehler:", eventType);
_playing = false;
_sendEvent("error", { msg: String(eventType) });
},
onevent: function(eventType, eventData) {
console.debug("[TizenApp] AVPlay HLS Event:", eventType, eventData);
},
onsubtitlechange: function() {},
});
// Async vorbereiten und abspielen
console.info("[TizenApp] AVPlay HLS prepareAsync...");
webapis.avplay.prepareAsync(
function() {
try {
_duration = webapis.avplay.getDuration();
} catch (e) {
_duration = 0;
}
console.info("[TizenApp] AVPlay HLS bereit, Dauer: " + _duration + "ms");
_sendEvent("duration", { ms: _duration });
try {
webapis.avplay.play();
_playing = true;
_avplayActive = true;
_startTimeUpdates();
_sendEvent("playstatechanged", { playing: true });
_sendEvent("ready");
console.info("[TizenApp] AVPlay HLS Wiedergabe gestartet (Surround)");
} catch (e) {
console.error("[TizenApp] AVPlay HLS play() Fehler:", e);
_playing = false;
_sendEvent("error", { msg: e.message || String(e) });
}
},
function(error) {
console.error("[TizenApp] AVPlay HLS prepareAsync fehlgeschlagen:", error);
_sendEvent("error", { msg: String(error) });
}
);
} catch (e) {
console.error("[TizenApp] AVPlay HLS Start-Fehler:", e);
_sendEvent("error", { msg: e.message || String(e) });
}
}
function _avplay_stop() { function _avplay_stop() {
_stopTimeUpdates(); _stopTimeUpdates();
_playing = false; _playing = false;
@ -443,6 +563,18 @@
403: "colorred", 404: "colorgreen", 405: "coloryellow", 406: "colorblue" 403: "colorred", 404: "colorgreen", 405: "coloryellow", 406: "colorblue"
}; };
// Tasten die bei aktivem AVPlay an den iframe weitergeleitet werden
// (fuer Player-Controls: Seek, Menue, Debug-Info etc.)
var FORWARD_KEYCODES = {
13: true, // Enter
37: true, // ArrowLeft
38: true, // ArrowUp
39: true, // ArrowRight
40: true, // ArrowDown
27: true, // Escape
8: true, // Backspace
};
document.addEventListener("keydown", function(e) { document.addEventListener("keydown", function(e) {
// Samsung Remote: Return/Back = 10009 // Samsung Remote: Return/Back = 10009
if (e.keyCode === 10009) { if (e.keyCode === 10009) {
@ -460,8 +592,15 @@
return; return;
} }
// iframe sichtbar -> History-Back im iframe // iframe sichtbar -> Key an iframe weiterleiten
// (wird vom iframe selbst gehandelt via keydown event) // (FocusManager behandelt 10009 als Escape -> history.back())
if (_iframe && _iframe.contentWindow) {
_sendToIframe({
type: "vknative_keyevent",
keyCode: e.keyCode
});
}
e.preventDefault();
return; return;
} }
@ -502,6 +641,17 @@
}); });
e.preventDefault(); e.preventDefault();
} }
return;
}
// Pfeiltasten, Enter, Escape immer an iframe weiterleiten
// (iframe bekommt auf Tizen keinen eigenen Keyboard-Focus)
if ((e.keyCode in FORWARD_KEYCODES) && _iframe && _iframe.contentWindow) {
_sendToIframe({
type: "vknative_keyevent",
keyCode: e.keyCode
});
e.preventDefault();
} }
}); });

View file

@ -1754,6 +1754,7 @@ def setup_tv_routes(app: web.Application, config: Config,
app.router.add_get("/tv/logout", get_logout) app.router.add_get("/tv/logout", get_logout)
app.router.add_get("/tv/profiles", get_profiles) app.router.add_get("/tv/profiles", get_profiles)
app.router.add_post("/tv/switch-profile", post_switch_profile) app.router.add_post("/tv/switch-profile", post_switch_profile)
app.router.add_get("/tv", lambda r: web.HTTPFound("/tv/"))
app.router.add_get("/tv/", get_home) app.router.add_get("/tv/", get_home)
app.router.add_get("/tv/series", get_series_list) app.router.add_get("/tv/series", get_series_list)
app.router.add_get("/tv/series/{id}", get_series_detail) app.router.add_get("/tv/series/{id}", get_series_detail)

View file

@ -316,6 +316,17 @@ class QueueService:
await self.ws_manager.broadcast_queue_update() await self.ws_manager.broadcast_queue_update()
# Berechtigungspruefung BEVOR ffmpeg gestartet wird
target_dir = os.path.dirname(job.target_path)
if not self._check_write_permission(target_dir, job):
job.status = JobStatus.FAILED
job.finished_at = time.time()
self._active_count = max(0, self._active_count - 1)
self._save_queue()
await self._save_stats(job)
await self.ws_manager.broadcast_queue_update()
return
command = self.encoder.build_command(job) command = self.encoder.build_command(job)
logging.info( logging.info(
f"Starte Konvertierung: {job.media.source_filename}\n" f"Starte Konvertierung: {job.media.source_filename}\n"
@ -346,10 +357,18 @@ class QueueService:
else: else:
job.status = JobStatus.FAILED job.status = JobStatus.FAILED
error_output = progress.get_error_output() error_output = progress.get_error_output()
# Bei Permission-Fehlern zusaetzliche Diagnose
extra = ""
if "Permission denied" in error_output:
uid, gid = os.getuid(), os.getgid()
extra = (
f"\n -> Berechtigungsfehler! Container UID:GID = "
f"{uid}:{gid}, Ziel: {job.target_path}"
)
logging.error( logging.error(
f"Konvertierung fehlgeschlagen (Code {job.process.returncode}): " f"Konvertierung fehlgeschlagen (Code {job.process.returncode}): "
f"{job.media.source_filename}\n" f"{job.media.source_filename}\n"
f" ffmpeg stderr:\n{error_output}" f" ffmpeg stderr:\n{error_output}{extra}"
) )
except asyncio.CancelledError: except asyncio.CancelledError:
@ -424,6 +443,59 @@ class QueueService:
f"in {source_dir}" f"in {source_dir}"
) )
def _check_write_permission(self, target_dir: str,
job: ConversionJob) -> bool:
"""Prueft Schreibzugriff auf das Zielverzeichnis.
Gibt True zurueck wenn OK, False bei Fehler (Job wird FAILED)."""
uid = os.getuid()
gid = os.getgid()
if not os.path.isdir(target_dir):
logging.error(
f"Zielverzeichnis existiert nicht: {target_dir}\n"
f" Datei: {job.media.source_filename}\n"
f" Container UID:GID = {uid}:{gid}"
)
return False
# Echten Schreibtest machen (os.access ist bei CIFS/NFS unzuverlaessig)
test_file = os.path.join(target_dir, f".vk_write_test_{uid}")
try:
with open(test_file, "w") as f:
f.write("test")
os.remove(test_file)
return True
except PermissionError:
# Verzeichnis-Info sammeln fuer hilfreiche Fehlermeldung
try:
stat = os.stat(target_dir)
dir_uid = stat.st_uid
dir_gid = stat.st_gid
dir_mode = oct(stat.st_mode)[-3:]
except OSError:
dir_uid = dir_gid = "?"
dir_mode = "???"
logging.error(
f"Kein Schreibzugriff auf Zielverzeichnis!\n"
f" Datei: {job.media.source_filename}\n"
f" Ziel: {job.target_path}\n"
f" Verzeichnis: {target_dir}\n"
f" Container laeuft als UID:GID = {uid}:{gid}\n"
f" Verzeichnis gehoert UID:GID = {dir_uid}:{dir_gid} "
f"(Modus: {dir_mode})\n"
f" Loesung: PUID/PGID im Container auf {dir_uid}:{dir_gid} "
f"setzen oder Verzeichnis-Berechtigungen anpassen"
)
return False
except OSError as e:
logging.error(
f"Schreibtest fehlgeschlagen: {e}\n"
f" Verzeichnis: {target_dir}\n"
f" Container UID:GID = {uid}:{gid}"
)
return False
def _get_next_queued(self) -> Optional[ConversionJob]: def _get_next_queued(self) -> Optional[ConversionJob]:
"""Naechster Job mit Status QUEUED (FIFO)""" """Naechster Job mit Status QUEUED (FIFO)"""
for job in self.jobs.values(): for job in self.jobs.values():

View file

@ -67,8 +67,10 @@ body {
line-height: 1.5; line-height: 1.5;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
scrollbar-width: none;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
body::-webkit-scrollbar { display: none; }
a { color: var(--accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
@ -123,20 +125,24 @@ a { color: var(--accent); text-decoration: none; }
display: flex; display: flex;
gap: 12px; gap: 12px;
overflow-x: auto; overflow-x: auto;
overflow-y: clip;
scroll-behavior: smooth; scroll-behavior: smooth;
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
padding: 4px 4px; padding: 10px 8px;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: none;
} }
.tv-row::-webkit-scrollbar { height: 4px; } /* Scrollbar komplett verstecken (Navigation per D-Pad/Touch) */
.tv-row::-webkit-scrollbar-track { background: transparent; } .tv-row::-webkit-scrollbar { display: none; }
.tv-row::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.tv-row .tv-card { .tv-row .tv-card {
scroll-snap-align: start; scroll-snap-align: start;
flex-shrink: 0; flex-shrink: 0;
width: 176px; width: 176px;
} }
.tv-row .tv-card-wide { width: 260px; } /* Weiterspielen-Cards: gleiche Breite wie Standard, horizontales Scrollen */
.tv-row .tv-card-wide { width: 176px; }
.tv-card-wide .tv-card-img { aspect-ratio: 2/3; }
.tv-card-wide .tv-card-placeholder { aspect-ratio: 2/3; }
/* === Poster-Grid === */ /* === Poster-Grid === */
.tv-grid { .tv-grid {
@ -1086,6 +1092,36 @@ a { color: var(--accent); text-decoration: none; }
pointer-events: none; pointer-events: none;
} }
/* === Player Debug-Info-Overlay === */
.player-debug {
position: absolute;
top: 4rem;
left: 1rem;
z-index: 20;
background: rgba(0, 0, 0, 0.82);
color: #ccc;
font-family: "SF Mono", "Fira Code", "Consolas", monospace;
font-size: 0.72rem;
line-height: 1.45;
padding: 0.7rem 1rem;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.12);
max-width: 420px;
pointer-events: none;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.player-debug b { color: #fff; font-weight: 600; }
.player-debug .dbg-label { color: #888; display: inline-block; min-width: 90px; }
.player-debug .dbg-val { color: #64b5f6; }
.player-debug .dbg-ok { color: #81c784; }
.player-debug .dbg-warn { color: #ffb74d; }
.player-debug .dbg-sep {
border: none;
border-top: 1px solid rgba(255,255,255,0.08);
margin: 0.3rem 0;
}
/* === Player-Popup-Menue (kompakt, ersetzt das grosse Overlay-Panel) === */ /* === Player-Popup-Menue (kompakt, ersetzt das grosse Overlay-Panel) === */
.player-popup { .player-popup {
position: absolute; position: absolute;
@ -1251,7 +1287,7 @@ a { color: var(--accent); text-decoration: none; }
.tv-main { padding: 1rem; } .tv-main { padding: 1rem; }
.tv-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; } .tv-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
.tv-row .tv-card { width: 141px; } .tv-row .tv-card { width: 141px; }
.tv-row .tv-card-wide { width: 211px; } .tv-row .tv-card-wide { width: 141px; }
.tv-detail-header { flex-direction: column; } .tv-detail-header { flex-direction: column; }
.tv-detail-poster { width: 150px; } .tv-detail-poster { width: 150px; }
.tv-page-title { font-size: 1.3rem; } .tv-page-title { font-size: 1.3rem; }
@ -1289,7 +1325,7 @@ a { color: var(--accent); text-decoration: none; }
@media (min-width: 1280px) { @media (min-width: 1280px) {
.tv-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; } .tv-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.tv-row .tv-card { width: 200px; } .tv-row .tv-card { width: 200px; }
.tv-row .tv-card-wide { width: 305px; } .tv-row .tv-card-wide { width: 200px; }
.tv-play-btn { padding: 1rem 3rem; font-size: 1.3rem; } .tv-play-btn { padding: 1rem 3rem; font-size: 1.3rem; }
/* Episoden-Karten: groesser auf TV */ /* Episoden-Karten: groesser auf TV */
.tv-ep-thumb { width: 260px; } .tv-ep-thumb { width: 260px; }
@ -1697,7 +1733,7 @@ textarea.input-editing {
/* === Alphabet-Seitenleiste === */ /* === Alphabet-Seitenleiste === */
.tv-alpha-sidebar { .tv-alpha-sidebar {
position: fixed; position: fixed;
right: 6px; right: 12px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
display: flex; display: flex;
@ -1706,35 +1742,35 @@ textarea.input-editing {
z-index: 50; z-index: 50;
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 14px;
padding: 4px 2px; padding: 6px 4px;
} }
.tv-alpha-letter { .tv-alpha-letter {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 22px; width: 44px;
height: 19px; height: 36px;
font-size: 0.65rem; font-size: 1.1rem;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 6px;
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
font-weight: 600; font-weight: 700;
user-select: none; user-select: none;
} }
.tv-alpha-letter:hover { color: var(--text); background: var(--bg-hover); } .tv-alpha-letter:hover { color: var(--text); background: var(--bg-hover); }
.tv-alpha-letter:focus { outline: var(--focus-ring); outline-offset: -1px; } .tv-alpha-letter:focus { outline: var(--focus-ring); outline-offset: 2px; }
.tv-alpha-letter.active { color: #000; background: var(--accent); } .tv-alpha-letter.active { color: #000; background: var(--accent); }
.tv-alpha-letter.dimmed { color: var(--border); pointer-events: none; } .tv-alpha-letter.dimmed { color: var(--border); opacity: 0.4; }
@media (max-width: 768px) { @media (max-width: 768px) {
.tv-alpha-sidebar { right: 2px; padding: 3px 1px; } .tv-alpha-sidebar { right: 4px; padding: 4px 2px; }
.tv-alpha-letter { width: 20px; height: 17px; font-size: 0.58rem; } .tv-alpha-letter { width: 32px; height: 26px; font-size: 0.85rem; }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.tv-alpha-sidebar { right: 1px; padding: 2px 1px; } .tv-alpha-sidebar { right: 2px; padding: 3px 1px; }
.tv-alpha-letter { width: 16px; height: 14px; font-size: 0.5rem; } .tv-alpha-letter { width: 24px; height: 20px; font-size: 0.7rem; }
} }
/* ============================================================ /* ============================================================

View file

@ -618,6 +618,15 @@ function _nativeFallbackToHLS(startPosSec) {
console.info("[Player] Fallback auf HLS (startPos=" + startPosSec + ")"); console.info("[Player] Fallback auf HLS (startPos=" + startPosSec + ")");
clearTimeout(nativePlayTimeout); clearTimeout(nativePlayTimeout);
nativePlayTimeout = null; nativePlayTimeout = null;
// Android: ExoPlayer fuer HLS nutzen (WebView-Audio hat Probleme)
if (window.VKNative && window.VKNative.playHLS) {
console.info("[Player] VKNative.playHLS() verfuegbar - nutze ExoPlayer fuer HLS");
_startNativeHLS(startPosSec);
return;
}
// Kein VKNative HLS (Tizen etc.) -> WebView-HLS
useNativePlayer = false; useNativePlayer = false;
nativePlayStarted = false; nativePlayStarted = false;
if (videoEl) videoEl.style.display = ""; if (videoEl) videoEl.style.display = "";
@ -626,6 +635,104 @@ function _nativeFallbackToHLS(startPosSec) {
infoReady.then(function() { updatePlayerButtons(); }); infoReady.then(function() { updatePlayerButtons(); });
} }
/** HLS ueber VKNative (ExoPlayer) abspielen - Audio laeuft zuverlaessig */
async function _startNativeHLS(startPosSec) {
try {
// HLS-Session vom Server anfordern
var hlsSeek = startPosSec > 0 ? Math.floor(startPosSec) : 0;
var resp = await fetch("/tv/api/hls/start", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
video_id: cfg.videoId,
quality: currentQuality,
audio: currentAudio,
// Native Player (AVPlay/ExoPlayer) koennen Surround -> Default "surround"
sound: cfg.soundMode || "surround",
t: hlsSeek,
codecs: clientCodecs || ["h264"],
}),
});
if (!resp.ok) {
console.warn("[Player] HLS-Session fehlgeschlagen (HTTP " + resp.status + ")");
useNativePlayer = false;
nativePlayStarted = false;
if (videoEl) videoEl.style.display = "";
startHLSStream(startPosSec);
return;
}
var data = await resp.json();
hlsSessionId = data.session_id;
var playlistUrl = data.playlist_url;
hlsSeekOffset = hlsSeek;
// VKNative Callbacks
window._vkOnReady = function() {
nativePlayStarted = true;
clearTimeout(nativePlayTimeout);
nativePlayTimeout = null;
hideLoading();
showControls();
scheduleHideControls();
console.info("[Player] VKNative HLS gestartet");
};
window._vkOnTimeUpdate = function(ms) {
// ExoPlayer meldet HLS-Stream-Position + Server-Seek-Offset
var current = hlsSeekOffset + (ms / 1000);
var dur = getDuration();
if (progressBar && dur > 0) {
progressBar.style.width = ((current / dur) * 100) + "%";
}
if (timeDisplay) {
timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur);
}
};
window._vkOnComplete = function() { onEnded(); };
window._vkOnError = function(msg) {
console.error("[Player] VKNative HLS Fehler:", msg);
_cleanupNativePlayer();
if (videoEl) videoEl.style.display = "";
startHLSStream(startPosSec);
};
window._vkOnBuffering = function(buffering) {
if (buffering) showLoading();
else hideLoading();
};
window._vkOnPlayStateChanged = function(playing) {
if (playing) {
nativePlayStarted = true;
clearTimeout(nativePlayTimeout);
nativePlayTimeout = null;
onPlay();
} else {
onPause();
}
};
// ExoPlayer HLS starten
useNativePlayer = true;
var ok = window.VKNative.playHLS(playlistUrl, {});
console.info("[Player] VKNative.playHLS() Ergebnis: " + ok);
if (!ok) {
_cleanupNativePlayer();
if (videoEl) videoEl.style.display = "";
startHLSStream(startPosSec);
return;
}
loadVideoInfo().then(function() { updatePlayerButtons(); });
} catch (e) {
console.error("[Player] _startNativeHLS Fehler:", e);
_cleanupNativePlayer();
if (videoEl) videoEl.style.display = "";
startHLSStream(startPosSec);
}
}
// === Browser Direct-Play (MP4 mit Range-Requests) === // === Browser Direct-Play (MP4 mit Range-Requests) ===
/** /**
@ -1450,9 +1557,11 @@ function onKeyDown(e) {
case "ArrowRight": case "ArrowRight":
_focusNext(1); showControls(); e.preventDefault(); return; _focusNext(1); showControls(); e.preventDefault(); return;
case "ArrowUp": case "ArrowUp":
active.blur(); showControls(); e.preventDefault(); return; // Focus auf Controls behalten (nicht blurren!)
showControls(); e.preventDefault(); return;
case "ArrowDown": case "ArrowDown":
active.blur(); showControls(); e.preventDefault(); return; // Focus auf Controls behalten (nicht blurren!)
showControls(); e.preventDefault(); return;
case "Enter": case "Enter":
active.click(); showControls(); e.preventDefault(); return; active.click(); showControls(); e.preventDefault(); return;
} }
@ -1464,8 +1573,13 @@ function onKeyDown(e) {
togglePlay(); e.preventDefault(); break; togglePlay(); e.preventDefault(); break;
case "Enter": case "Enter":
if (!controlsVisible) { if (!controlsVisible) {
// Controls einblenden und Play-Button fokussieren
showControls(); showControls();
if (playBtn) playBtn.focus(); if (playBtn) playBtn.focus();
} else if (!buttonFocused) {
// Controls sichtbar aber kein Button fokussiert -> Focus auf Play-Button
// (NICHT togglePlay - sonst kommt man nie ins Menue!)
if (playBtn) playBtn.focus();
} else { } else {
togglePlay(); togglePlay();
} }
@ -1478,20 +1592,19 @@ function onKeyDown(e) {
if (!controlsVisible) { if (!controlsVisible) {
showControls(); showControls();
if (playBtn) playBtn.focus(); if (playBtn) playBtn.focus();
} else { } else if (!buttonFocused) {
// Kein Button fokussiert -> Play-Button fokussieren
if (playBtn) playBtn.focus(); if (playBtn) playBtn.focus();
showControls();
} }
e.preventDefault(); break; showControls(); e.preventDefault(); break;
case "ArrowDown": case "ArrowDown":
if (!controlsVisible) { if (!controlsVisible) {
showControls(); showControls();
if (playBtn) playBtn.focus(); if (playBtn) playBtn.focus();
} else { } else if (!buttonFocused) {
if (playBtn) playBtn.focus(); if (playBtn) playBtn.focus();
showControls();
} }
e.preventDefault(); break; showControls(); e.preventDefault(); break;
case "Escape": case "Backspace": case "Stop": case "Escape": case "Backspace": case "Stop":
saveProgress(); saveProgress();
if (useNativePlayer) _cleanupNativePlayer(); if (useNativePlayer) _cleanupNativePlayer();
@ -1514,7 +1627,9 @@ function onKeyDown(e) {
case "ColorYellow": case "ColorYellow":
openPopupSection("quality"); e.preventDefault(); break; openPopupSection("quality"); e.preventDefault(); break;
case "ColorBlue": case "ColorBlue":
openPopupSection("speed"); e.preventDefault(); break; toggleDebugInfo(); e.preventDefault(); break;
case "i":
toggleDebugInfo(); e.preventDefault(); break;
} }
} }
@ -1606,3 +1721,182 @@ const LANG_NAMES = {
function langName(code) { function langName(code) {
return LANG_NAMES[code] || code || ""; return LANG_NAMES[code] || code || "";
} }
// === Debug-Info-Overlay ===
let debugVisible = false;
let debugUpdateId = null;
/** Debug-Info ein-/ausblenden (Toggle mit "i"-Taste oder Blau-Taste auf Fernbedienung) */
function toggleDebugInfo() {
debugVisible = !debugVisible;
var el = document.getElementById("player-debug");
if (!el) return;
if (debugVisible) {
el.style.display = "";
_updateDebugInfo();
// Alle 500ms aktualisieren
debugUpdateId = setInterval(_updateDebugInfo, 500);
} else {
el.style.display = "none";
if (debugUpdateId) { clearInterval(debugUpdateId); debugUpdateId = null; }
}
}
function _updateDebugInfo() {
var el = document.getElementById("player-debug");
if (!el || !debugVisible) return;
var lines = [];
// Decoder / Wiedergabe-Modus
var decoder = "Unbekannt";
var decoderClass = "dbg-val";
if (useNativePlayer && window.VKNative) {
decoder = "VKNative AVPlay (Direct-Play)";
decoderClass = "dbg-ok";
} else if (useDirectPlay && typeof AVPlayBridge !== "undefined") {
decoder = "AVPlay Legacy (Direct-Play)";
decoderClass = "dbg-ok";
} else if (hlsInstance) {
decoder = "HLS (hls.js)";
decoderClass = "dbg-val";
} else if (hlsSessionId) {
decoder = "HLS (nativ)";
decoderClass = "dbg-val";
} else if (videoEl && videoEl.src) {
if (videoEl.src.indexOf("/stream?") >= 0) {
decoder = "Legacy Pipe-Streaming";
decoderClass = "dbg-warn";
} else {
decoder = "Browser Direct-Play";
decoderClass = "dbg-ok";
}
}
lines.push(_dbgRow("Decoder", decoder, decoderClass));
// Plattform
var platform = "Browser";
if (window.VKNative) platform = "VKNative (" + window.VKNative.platform + " v" + (window.VKNative.version || "?") + ")";
else if (isTizenTV()) platform = "Tizen";
lines.push(_dbgRow("Plattform", platform));
lines.push('<hr class="dbg-sep">');
// Video-Codec + Aufloesung
if (videoInfo) {
var vc = videoInfo.video_codec_normalized || videoInfo.video_codec || "?";
var res = "";
if (videoInfo.width && videoInfo.height) res = videoInfo.width + "x" + videoInfo.height;
var container = videoInfo.container || "?";
lines.push(_dbgRow("Video", vc.toUpperCase() + (res ? " " + res : ""), "dbg-ok"));
lines.push(_dbgRow("Container", container));
// Audio-Tracks
if (videoInfo.audio_tracks && videoInfo.audio_tracks.length) {
var at = videoInfo.audio_tracks[currentAudio];
if (at) {
var aCodec = (at.codec || "?").toUpperCase();
var aCh = at.channels ? at.channels + "ch" : "";
var aLang = langName(at.lang);
lines.push(_dbgRow("Audio", aCodec + " " + aCh + " " + aLang));
}
lines.push(_dbgRow("Audio-Spuren", videoInfo.audio_tracks.length + ""));
}
// Audio-Codecs (Quell-Datei)
if (videoInfo.audio_codecs && videoInfo.audio_codecs.length) {
lines.push(_dbgRow("Quell-Audio", videoInfo.audio_codecs.join(", ")));
}
}
lines.push('<hr class="dbg-sep">');
// Position / Dauer
var curSec = getCurrentTime();
var durSec = getDuration();
lines.push(_dbgRow("Position", formatTime(curSec) + " / " + formatTime(durSec)));
// Qualitaet + Geschwindigkeit
var qualLabels = {uhd: "Ultra HD", hd: "HD", sd: "SD", low: "Niedrig"};
lines.push(_dbgRow("Qualitaet", qualLabels[currentQuality] || currentQuality));
if (currentSpeed !== 1.0) lines.push(_dbgRow("Speed", currentSpeed + "x"));
// HLS-spezifische Infos
if (hlsInstance) {
lines.push('<hr class="dbg-sep">');
lines.push(_dbgRow("HLS Session", hlsSessionId || "-"));
// Buffer-Level
try {
var buffered = videoEl.buffered;
if (buffered && buffered.length > 0) {
var bufEnd = buffered.end(buffered.length - 1);
var bufAhead = Math.max(0, bufEnd - (videoEl.currentTime || 0));
var bufClass = bufAhead > 10 ? "dbg-ok" : bufAhead > 3 ? "dbg-val" : "dbg-warn";
lines.push(_dbgRow("Puffer", bufAhead.toFixed(1) + "s voraus", bufClass));
}
} catch (e) {}
// hls.js interne Stats
if (hlsInstance.streamController) {
try {
var levels = hlsInstance.levels;
var curLevel = hlsInstance.currentLevel;
if (levels && levels[curLevel]) {
var lv = levels[curLevel];
if (lv.bitrate) lines.push(_dbgRow("Bitrate", _formatBitrate(lv.bitrate)));
if (lv.width && lv.height) lines.push(_dbgRow("HLS-Res.", lv.width + "x" + lv.height));
}
} catch (e) {}
}
if (hlsSeekOffset > 0) {
lines.push(_dbgRow("Seek-Offset", formatTime(hlsSeekOffset)));
}
}
// Browser Direct-Play: Buffer-Info
if (!hlsInstance && videoEl && videoEl.buffered && videoEl.buffered.length > 0) {
try {
var bufEnd2 = videoEl.buffered.end(videoEl.buffered.length - 1);
var bufAhead2 = Math.max(0, bufEnd2 - (videoEl.currentTime || 0));
if (bufAhead2 > 0) {
var bufClass2 = bufAhead2 > 10 ? "dbg-ok" : bufAhead2 > 3 ? "dbg-val" : "dbg-warn";
lines.push(_dbgRow("Puffer", bufAhead2.toFixed(1) + "s voraus", bufClass2));
}
} catch (e) {}
}
// Client-Codecs
if (clientCodecs && clientCodecs.length) {
lines.push('<hr class="dbg-sep">');
lines.push(_dbgRow("Client-Codecs", clientCodecs.join(", ")));
}
// Video-Element Stats (Dropped Frames etc.)
if (videoEl && videoEl.getVideoPlaybackQuality) {
try {
var q = videoEl.getVideoPlaybackQuality();
if (q.totalVideoFrames > 0) {
var dropped = q.droppedVideoFrames;
var total = q.totalVideoFrames;
var dropClass = dropped === 0 ? "dbg-ok" : dropped < 10 ? "dbg-val" : "dbg-warn";
lines.push(_dbgRow("Frames", total + " total, " + dropped + " dropped", dropClass));
}
} catch (e) {}
}
el.innerHTML = "<b>Debug-Info</b> <small>(i = schliessen)</small><hr class='dbg-sep'>" + lines.join("");
}
function _dbgRow(label, value, cls) {
cls = cls || "dbg-val";
return '<div><span class="dbg-label">' + label + '</span> <span class="' + cls + '">' + value + '</span></div>';
}
function _formatBitrate(bps) {
if (bps >= 1000000) return (bps / 1000000).toFixed(1) + " Mbit/s";
if (bps >= 1000) return (bps / 1000).toFixed(0) + " kbit/s";
return bps + " bit/s";
}

View file

@ -38,7 +38,7 @@ class FocusManager {
if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) { if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) {
if (!e.target.closest("#tv-nav")) { if (!e.target.closest("#tv-nav")) {
// Nur echte Content-Elemente merken (nicht Filter/Controls) // Nur echte Content-Elemente merken (nicht Filter/Controls)
if (e.target.closest(".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list")) { if (e.target.closest(".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list, .tv-episode-grid, .tv-tabs, .tv-detail-actions, .tv-alpha-sidebar, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid")) {
this._lastContentFocus = e.target; this._lastContentFocus = e.target;
} }
} }
@ -58,7 +58,7 @@ class FocusManager {
} }
// Erstes Element im sichtbaren Content-Bereich (Karten bevorzugen) // Erstes Element im sichtbaren Content-Bereich (Karten bevorzugen)
const contentAreas = document.querySelectorAll( const contentAreas = document.querySelectorAll(
".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list" ".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list, .tv-episode-grid, .tv-tabs, .tv-detail-actions, .tv-alpha-sidebar, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid"
); );
for (const area of contentAreas) { for (const area of contentAreas) {
if (!area.offsetHeight) continue; if (!area.offsetHeight) continue;
@ -122,13 +122,22 @@ class FocusManager {
// Nicht aktiv: alle Richtungen navigieren weiter // Nicht aktiv: alle Richtungen navigieren weiter
} }
// Select-Elemente: Nur wenn aktiviert (Enter gedrueckt) Hoch/Runter durchlassen // Select-Elemente: Nur wenn aktiviert (Enter gedrueckt) Hoch/Runter aendert Wert
if (active && active.tagName === "SELECT") { if (active && active.tagName === "SELECT") {
if (this._selectActive) { if (this._selectActive) {
// Editier-Modus: Hoch/Runter aendert den Wert // Editier-Modus: Wert manuell aendern (synthetische Events aendern SELECT nicht)
if (direction === "ArrowUp" || direction === "ArrowDown") return; if (direction === "ArrowUp" || direction === "ArrowDown") {
const idx = active.selectedIndex;
if (direction === "ArrowDown" && idx < active.options.length - 1) {
active.selectedIndex = idx + 1;
} else if (direction === "ArrowUp" && idx > 0) {
active.selectedIndex = idx - 1;
}
e.preventDefault();
return;
}
} else { } else {
// Nicht im Editier-Modus: native SELECT-Aenderung verhindern // Nicht im Editier-Modus: Navigation statt Wert-Aenderung
if (direction === "ArrowUp" || direction === "ArrowDown") { if (direction === "ArrowUp" || direction === "ArrowDown") {
e.preventDefault(); e.preventDefault();
} }
@ -175,7 +184,7 @@ class FocusManager {
} }
// Direkt zum sichtbaren Content-Bereich (Karten/Listen-Eintraege) // Direkt zum sichtbaren Content-Bereich (Karten/Listen-Eintraege)
const contentAreas = document.querySelectorAll( const contentAreas = document.querySelectorAll(
".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list" ".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list, .tv-episode-grid, .tv-tabs, .tv-detail-actions, .tv-alpha-sidebar, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid"
); );
for (const area of contentAreas) { for (const area of contentAreas) {
if (!area.offsetHeight) continue; if (!area.offsetHeight) continue;

View file

@ -83,11 +83,24 @@
break; break;
case "vknative_keyevent": case "vknative_keyevent":
// Media-Key vom Parent weitergeleitet -> als KeyboardEvent dispatchen // Key-Event vom Parent weitergeleitet -> als KeyboardEvent dispatchen
if (data.keyCode) { if (data.keyCode) {
// keyCode -> key-Name Mapping (KeyboardEvent setzt key nicht automatisch)
var keyNameMap = {
13: "Enter", 37: "ArrowLeft", 38: "ArrowUp",
39: "ArrowRight", 40: "ArrowDown", 27: "Escape",
8: "Backspace", 32: " ",
// Samsung-spezifische keyCodes (Media + Farbtasten)
10009: "Escape", 10182: "Escape",
415: "Play", 19: "Pause", 413: "Stop",
417: "FastForward", 412: "Rewind", 10252: "Play",
403: "ColorRed", 404: "ColorGreen",
405: "ColorYellow", 406: "ColorBlue",
};
var keyEvt = new KeyboardEvent("keydown", { var keyEvt = new KeyboardEvent("keydown", {
keyCode: data.keyCode, keyCode: data.keyCode,
which: data.keyCode, which: data.keyCode,
key: keyNameMap[data.keyCode] || "",
bubbles: true, bubbles: true,
}); });
document.dispatchEvent(keyEvt); document.dispatchEvent(keyEvt);
@ -203,6 +216,18 @@
} }
} }
// Tizen AVPlay: Opus mit >2 Kanaelen hat Tonausfaelle -> HLS Fallback
var audioTracks = videoInfo.audio_tracks || [];
for (var k = 0; k < audioTracks.length; k++) {
var track = audioTracks[k];
var trackCodec = (track.codec || "").toLowerCase();
var trackChannels = track.channels || 2;
if (trackCodec === "opus" && trackChannels > 2) {
console.info("[VKNative] Opus " + trackChannels + "ch auf Tizen -> HLS Fallback (Tonausfaelle bei AVPlay)");
return false;
}
}
console.info("[VKNative] Direct-Play moeglich: " + vc + "/" + container); console.info("[VKNative] Direct-Play moeglich: " + vc + "/" + container);
return true; return true;
}, },
@ -248,6 +273,16 @@
_callParent("stop"); _callParent("stop");
}, },
/**
* HLS-Stream ueber AVPlay abspielen (Fallback fuer Opus-Surround etc.)
* AVPlay spielt HLS nativ inkl. AAC 5.1 Surround.
*/
playHLS: function(playlistUrl, opts) {
console.info("[VKNative] playHLS() per postMessage: " + playlistUrl);
_callParent("playHLS", [playlistUrl, opts]);
return true;
},
setAudioTrack: function(index) { setAudioTrack: function(index) {
console.info("[VKNative] Audio-Track-Wechsel auf Tizen nicht moeglich"); console.info("[VKNative] Audio-Track-Wechsel auf Tizen nicht moeglich");
return false; return false;
@ -341,6 +376,17 @@
for (var j = 0; j < ac2.length; j++) { for (var j = 0; j < ac2.length; j++) {
if (UNSUPPORTED_AUDIO2.indexOf(ac2[j].toLowerCase()) !== -1) return false; if (UNSUPPORTED_AUDIO2.indexOf(ac2[j].toLowerCase()) !== -1) return false;
} }
// Tizen AVPlay: Opus mit >2 Kanaelen hat Tonausfaelle -> HLS Fallback
var audioTracks = videoInfo.audio_tracks || [];
for (var k = 0; k < audioTracks.length; k++) {
var trk = audioTracks[k];
var trkCodec = (trk.codec || "").toLowerCase();
var trkCh = trk.channels || 2;
if (trkCodec === "opus" && trkCh > 2) {
console.info("[VKNative] Opus " + trkCh + "ch auf Tizen -> HLS Fallback");
return false;
}
}
return true; return true;
}, },
@ -440,6 +486,60 @@
if (_displayEl2) { _displayEl2.style.display = "none"; _displayEl2 = null; } if (_displayEl2) { _displayEl2.style.display = "none"; _displayEl2 = null; }
var v = document.getElementById("player-video"); if (v) v.style.display = ""; var v = document.getElementById("player-video"); if (v) v.style.display = "";
}, },
/** HLS ueber AVPlay abspielen (Fallback fuer Opus-Surround) */
playHLS: function(playlistUrl, opts) {
opts = opts || {};
var seekMs = opts.seekMs || 0;
var fullUrl = _resolveUrl2(playlistUrl);
try {
this.stop();
_displayEl2 = document.getElementById("avplayer");
if (_displayEl2) _displayEl2.style.display = "block";
var videoEl = document.getElementById("player-video");
if (videoEl) videoEl.style.display = "none";
webapis.avplay.open(fullUrl);
webapis.avplay.setDisplayRect(0, 0, window.innerWidth, window.innerHeight);
webapis.avplay.setListener({
onbufferingstart: function() { if (window._vkOnBuffering) window._vkOnBuffering(true); },
onbufferingcomplete: function() { if (window._vkOnBuffering) window._vkOnBuffering(false); },
oncurrentplaytime: function(ms) { if (window._vkOnTimeUpdate) window._vkOnTimeUpdate(ms); },
onstreamcompleted: function() {
_playing2 = false;
if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(false);
if (window._vkOnComplete) window._vkOnComplete();
},
onerror: function(evt) {
_playing2 = false;
if (window._vkOnError) window._vkOnError(String(evt));
},
onevent: function() {},
onsubtitlechange: function() {},
});
webapis.avplay.prepareAsync(
function() {
try { _duration2 = webapis.avplay.getDuration(); } catch (e) { _duration2 = 0; }
try {
webapis.avplay.play();
_playing2 = true;
_startTimeUpdates2();
if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(true);
if (window._vkOnReady) window._vkOnReady();
} catch (e) {
_playing2 = false;
if (window._vkOnError) window._vkOnError(e.message || String(e));
}
},
function(err) { if (window._vkOnError) window._vkOnError(String(err)); }
);
return true;
} catch (e) {
if (window._vkOnError) window._vkOnError(e.message || String(e));
return false;
}
},
setAudioTrack: function() { return false; }, setAudioTrack: function() { return false; },
setSubtitleTrack: function() { return false; }, setSubtitleTrack: function() { return false; },
setPlaybackSpeed: function(speed) { setPlaybackSpeed: function(speed) {

View file

@ -4,7 +4,7 @@
* Kein Offline-Caching noetig (Streaming braucht Netzwerk) * Kein Offline-Caching noetig (Streaming braucht Netzwerk)
*/ */
const CACHE_NAME = "vk-tv-v11"; const CACHE_NAME = "vk-tv-v14";
const STATIC_ASSETS = [ const STATIC_ASSETS = [
"/static/tv/css/tv.css", "/static/tv/css/tv.css",
"/static/tv/js/tv.js", "/static/tv/js/tv.js",

View file

@ -53,6 +53,9 @@
<!-- Kompaktes Popup-Menue (ersetzt das grosse Overlay-Panel) --> <!-- Kompaktes Popup-Menue (ersetzt das grosse Overlay-Panel) -->
<div class="player-popup" id="player-popup" style="display:none"></div> <div class="player-popup" id="player-popup" style="display:none"></div>
<!-- Debug-Info-Overlay (Toggle mit "i"-Taste oder Blau-Taste) -->
<div class="player-debug" id="player-debug" style="display:none"></div>
<!-- Naechste Episode Overlay --> <!-- Naechste Episode Overlay -->
{% if next_video %} {% if next_video %}
<div class="player-next-overlay" id="next-overlay" style="display:none"> <div class="player-next-overlay" id="next-overlay" style="display:none">

View file

@ -127,7 +127,7 @@
</div> </div>
</a> </a>
<button class="tv-ep-tile-mark {% if ep.progress_pct >= watched_threshold_pct|default(90) %}active{% endif %}" <button class="tv-ep-tile-mark {% if ep.progress_pct >= watched_threshold_pct|default(90) %}active{% endif %}"
data-focusable tabindex="-1"
onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)"> onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)">
&#10003; &#10003;
</button> </button>
@ -349,17 +349,28 @@ document.addEventListener('focusin', function(e) {
} }
}, 1000); }, 1000);
// Abbrechen per Escape/Return // Enter = sofort abspielen, Escape/Return = abbrechen
function cancelAutoplay(e) { function handleAutoplayKey(e) {
if (e.keyCode === 10009 || e.keyCode === 27 || e.key === 'Escape') { var key = e.key || '';
var kc = e.keyCode || 0;
// Enter: Sofort naechste Episode starten
if (key === 'Enter' || kc === 13) {
clearInterval(timer);
document.removeEventListener('keydown', handleAutoplayKey);
e.preventDefault();
window.location.href = '/tv/player?v={{ next_video_id }}';
return;
}
// Escape/Return/Backspace: Countdown abbrechen, auf Seite bleiben
if (kc === 10009 || kc === 27 || key === 'Escape' || key === 'Backspace') {
clearInterval(timer); clearInterval(timer);
nextCard.classList.remove('tv-ep-next-loading'); nextCard.classList.remove('tv-ep-next-loading');
countdownEl.remove(); countdownEl.remove();
document.removeEventListener('keydown', cancelAutoplay); document.removeEventListener('keydown', handleAutoplayKey);
e.preventDefault(); e.preventDefault();
} }
} }
document.addEventListener('keydown', cancelAutoplay); document.addEventListener('keydown', handleAutoplayKey);
})(); })();
{% elif last_watched_id %} {% elif last_watched_id %}
// Zur letzten geschauten Episode scrollen // Zur letzten geschauten Episode scrollen