diff --git a/android-app/VideoKonverter-v1.2.0.apk b/android-app/VideoKonverter-v1.2.0.apk new file mode 100644 index 0000000..9e757e5 Binary files /dev/null and b/android-app/VideoKonverter-v1.2.0.apk differ diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 66e819d..dd3df6f 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -11,13 +11,23 @@ android { applicationId = "de.datait.videokonverter" minSdk = 24 // Android 7.0 (ExoPlayer Codec-Support) targetSdk = 35 - versionCode = 1 - versionName = "1.0.0" + versionCode = 3 + versionName = "1.2.0" + } + + signingConfigs { + create("release") { + storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } } buildTypes { release { isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/android-app/app/src/main/java/de/datait/videokonverter/MainActivity.kt b/android-app/app/src/main/java/de/datait/videokonverter/MainActivity.kt index 371f696..c3e950e 100644 --- a/android-app/app/src/main/java/de/datait/videokonverter/MainActivity.kt +++ b/android-app/app/src/main/java/de/datait/videokonverter/MainActivity.kt @@ -72,14 +72,22 @@ class MainActivity : AppCompatActivity() { } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - // Back-Taste: WebView-Navigation zurueck - if (keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack()) { + if (keyCode == KeyEvent.KEYCODE_BACK) { // ExoPlayer aktiv? Zuerst stoppen if (playerView.visibility == View.VISIBLE) { bridge.stop() return true } - webView.goBack() + // WebView zurueck-navigieren + if (webView.canGoBack()) { + webView.goBack() + 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) diff --git a/android-app/app/src/main/java/de/datait/videokonverter/NativePlayerBridge.kt b/android-app/app/src/main/java/de/datait/videokonverter/NativePlayerBridge.kt index 3b9aa2e..1d4726d 100644 --- a/android-app/app/src/main/java/de/datait/videokonverter/NativePlayerBridge.kt +++ b/android-app/app/src/main/java/de/datait/videokonverter/NativePlayerBridge.kt @@ -9,6 +9,7 @@ import android.webkit.CookieManager import android.webkit.JavascriptInterface import android.webkit.WebView import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException @@ -17,6 +18,7 @@ import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.ui.PlayerView import org.json.JSONArray @@ -111,8 +113,14 @@ class NativePlayerBridge( // Vorherigen Player bereinigen releasePlayer() - // ExoPlayer erstellen - val player = ExoPlayer.Builder(activity).build() + // ExoPlayer erstellen (mit Audio-Attributen fuer korrekten Ton) + 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.player = player @@ -148,6 +156,62 @@ class NativePlayerBridge( 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 fun togglePlay() { activity.runOnUiThread { diff --git a/android-app/app/src/main/java/de/datait/videokonverter/SetupActivity.kt b/android-app/app/src/main/java/de/datait/videokonverter/SetupActivity.kt index 47a34b9..358ae13 100644 --- a/android-app/app/src/main/java/de/datait/videokonverter/SetupActivity.kt +++ b/android-app/app/src/main/java/de/datait/videokonverter/SetupActivity.kt @@ -19,12 +19,23 @@ class SetupActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { 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 savedUrl = prefs.getString("server_url", null) - if (!savedUrl.isNullOrBlank()) { - startMainActivity(savedUrl) - return + + if (!forceSetup) { + // Gespeicherte URL? Bereinigen + weiter zur MainActivity + val savedUrl = prefs.getString("server_url", null) + if (!savedUrl.isNullOrBlank()) { + 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 + } } setContentView(R.layout.activity_setup) @@ -69,8 +80,8 @@ class SetupActivity : AppCompatActivity() { url = "$url:8080" } - // Trailing Slash entfernen - url = url.trimEnd('/') + // Nur Schema + Host + Port behalten (Pfad entfernen) + url = cleanServerUrl(url) // URL speichern prefs.edit().putString("server_url", url).apply() @@ -78,6 +89,16 @@ class SetupActivity : AppCompatActivity() { 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) { val intent = Intent(this, MainActivity::class.java) intent.putExtra("server_url", serverUrl) diff --git a/android-app/app/src/main/java/de/datait/videokonverter/VKWebViewClient.kt b/android-app/app/src/main/java/de/datait/videokonverter/VKWebViewClient.kt index bc2b1ab..66313c0 100644 --- a/android-app/app/src/main/java/de/datait/videokonverter/VKWebViewClient.kt +++ b/android-app/app/src/main/java/de/datait/videokonverter/VKWebViewClient.kt @@ -52,6 +52,10 @@ class VKWebViewClient(private val serverUrl: String) : WebViewClient() { getDuration: function() { return VKNativeAndroid.getDuration(); }, isPlaying: function() { return VKNativeAndroid.isPlaying(); }, 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); }, setSubtitleTrack: function(i) { return VKNativeAndroid.setSubtitleTrack(i); }, setPlaybackSpeed: function(s) { return VKNativeAndroid.setPlaybackSpeed(s); }, diff --git a/tizen-app/VideoKonverter.wgt b/tizen-app/VideoKonverter.wgt index 332dd82..0bcd1c3 100644 Binary files a/tizen-app/VideoKonverter.wgt and b/tizen-app/VideoKonverter.wgt differ diff --git a/tizen-app/index.html b/tizen-app/index.html index ce7b468..ecbea1d 100644 --- a/tizen-app/index.html +++ b/tizen-app/index.html @@ -200,6 +200,9 @@ case "play": _avplay_play(args[0], args[1], args[2]); break; + case "playHLS": + _avplay_playHLS(args[0], args[1]); + break; case "stop": _avplay_stop(); break; @@ -252,6 +255,17 @@ webapis.avplay.open(fullUrl); 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 webapis.avplay.setListener({ 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() { _stopTimeUpdates(); _playing = false; @@ -443,6 +563,18 @@ 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) { // Samsung Remote: Return/Back = 10009 if (e.keyCode === 10009) { @@ -460,8 +592,15 @@ return; } - // iframe sichtbar -> History-Back im iframe - // (wird vom iframe selbst gehandelt via keydown event) + // iframe sichtbar -> Key an iframe weiterleiten + // (FocusManager behandelt 10009 als Escape -> history.back()) + if (_iframe && _iframe.contentWindow) { + _sendToIframe({ + type: "vknative_keyevent", + keyCode: e.keyCode + }); + } + e.preventDefault(); return; } @@ -502,6 +641,17 @@ }); 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(); } }); diff --git a/video-konverter/app/routes/tv_api.py b/video-konverter/app/routes/tv_api.py index 13fc701..81f2a1a 100644 --- a/video-konverter/app/routes/tv_api.py +++ b/video-konverter/app/routes/tv_api.py @@ -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/profiles", get_profiles) 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/series", get_series_list) app.router.add_get("/tv/series/{id}", get_series_detail) diff --git a/video-konverter/app/services/queue.py b/video-konverter/app/services/queue.py index 5eb44b6..9c16230 100644 --- a/video-konverter/app/services/queue.py +++ b/video-konverter/app/services/queue.py @@ -316,6 +316,17 @@ class QueueService: 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) logging.info( f"Starte Konvertierung: {job.media.source_filename}\n" @@ -346,10 +357,18 @@ class QueueService: else: job.status = JobStatus.FAILED 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( f"Konvertierung fehlgeschlagen (Code {job.process.returncode}): " f"{job.media.source_filename}\n" - f" ffmpeg stderr:\n{error_output}" + f" ffmpeg stderr:\n{error_output}{extra}" ) except asyncio.CancelledError: @@ -424,6 +443,59 @@ class QueueService: 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]: """Naechster Job mit Status QUEUED (FIFO)""" for job in self.jobs.values(): diff --git a/video-konverter/app/static/tv/css/tv.css b/video-konverter/app/static/tv/css/tv.css index 5c036f8..2036741 100644 --- a/video-konverter/app/static/tv/css/tv.css +++ b/video-konverter/app/static/tv/css/tv.css @@ -67,8 +67,10 @@ body { line-height: 1.5; min-height: 100vh; overflow-x: hidden; + scrollbar-width: none; -webkit-font-smoothing: antialiased; } +body::-webkit-scrollbar { display: none; } a { color: var(--accent); text-decoration: none; } @@ -123,20 +125,24 @@ a { color: var(--accent); text-decoration: none; } display: flex; gap: 12px; overflow-x: auto; + overflow-y: clip; scroll-behavior: smooth; scroll-snap-type: x mandatory; - padding: 4px 4px; + padding: 10px 8px; -webkit-overflow-scrolling: touch; + scrollbar-width: none; } -.tv-row::-webkit-scrollbar { height: 4px; } -.tv-row::-webkit-scrollbar-track { background: transparent; } -.tv-row::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } +/* Scrollbar komplett verstecken (Navigation per D-Pad/Touch) */ +.tv-row::-webkit-scrollbar { display: none; } .tv-row .tv-card { scroll-snap-align: start; flex-shrink: 0; 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 === */ .tv-grid { @@ -1086,6 +1092,36 @@ a { color: var(--accent); text-decoration: 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 { position: absolute; @@ -1251,7 +1287,7 @@ a { color: var(--accent); text-decoration: none; } .tv-main { padding: 1rem; } .tv-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; } .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-poster { width: 150px; } .tv-page-title { font-size: 1.3rem; } @@ -1289,7 +1325,7 @@ a { color: var(--accent); text-decoration: none; } @media (min-width: 1280px) { .tv-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; } .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; } /* Episoden-Karten: groesser auf TV */ .tv-ep-thumb { width: 260px; } @@ -1697,7 +1733,7 @@ textarea.input-editing { /* === Alphabet-Seitenleiste === */ .tv-alpha-sidebar { position: fixed; - right: 6px; + right: 12px; top: 50%; transform: translateY(-50%); display: flex; @@ -1706,35 +1742,35 @@ textarea.input-editing { z-index: 50; background: var(--bg-card); border: 1px solid var(--border); - border-radius: 12px; - padding: 4px 2px; + border-radius: 14px; + padding: 6px 4px; } .tv-alpha-letter { display: flex; align-items: center; justify-content: center; - width: 22px; - height: 19px; - font-size: 0.65rem; + width: 44px; + height: 36px; + font-size: 1.1rem; color: var(--text-muted); cursor: pointer; - border-radius: 4px; + border-radius: 6px; transition: color 0.15s, background 0.15s; - font-weight: 600; + font-weight: 700; user-select: none; } .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.dimmed { color: var(--border); pointer-events: none; } +.tv-alpha-letter.dimmed { color: var(--border); opacity: 0.4; } @media (max-width: 768px) { - .tv-alpha-sidebar { right: 2px; padding: 3px 1px; } - .tv-alpha-letter { width: 20px; height: 17px; font-size: 0.58rem; } + .tv-alpha-sidebar { right: 4px; padding: 4px 2px; } + .tv-alpha-letter { width: 32px; height: 26px; font-size: 0.85rem; } } @media (max-width: 480px) { - .tv-alpha-sidebar { right: 1px; padding: 2px 1px; } - .tv-alpha-letter { width: 16px; height: 14px; font-size: 0.5rem; } + .tv-alpha-sidebar { right: 2px; padding: 3px 1px; } + .tv-alpha-letter { width: 24px; height: 20px; font-size: 0.7rem; } } /* ============================================================ diff --git a/video-konverter/app/static/tv/js/player.js b/video-konverter/app/static/tv/js/player.js index b4a1d5f..52dbb2b 100644 --- a/video-konverter/app/static/tv/js/player.js +++ b/video-konverter/app/static/tv/js/player.js @@ -618,6 +618,15 @@ function _nativeFallbackToHLS(startPosSec) { console.info("[Player] Fallback auf HLS (startPos=" + startPosSec + ")"); clearTimeout(nativePlayTimeout); 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; nativePlayStarted = false; if (videoEl) videoEl.style.display = ""; @@ -626,6 +635,104 @@ function _nativeFallbackToHLS(startPosSec) { 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) === /** @@ -1450,9 +1557,11 @@ function onKeyDown(e) { case "ArrowRight": _focusNext(1); showControls(); e.preventDefault(); return; case "ArrowUp": - active.blur(); showControls(); e.preventDefault(); return; + // Focus auf Controls behalten (nicht blurren!) + showControls(); e.preventDefault(); return; case "ArrowDown": - active.blur(); showControls(); e.preventDefault(); return; + // Focus auf Controls behalten (nicht blurren!) + showControls(); e.preventDefault(); return; case "Enter": active.click(); showControls(); e.preventDefault(); return; } @@ -1464,8 +1573,13 @@ function onKeyDown(e) { togglePlay(); e.preventDefault(); break; case "Enter": if (!controlsVisible) { + // Controls einblenden und Play-Button fokussieren showControls(); 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 { togglePlay(); } @@ -1478,20 +1592,19 @@ function onKeyDown(e) { if (!controlsVisible) { showControls(); if (playBtn) playBtn.focus(); - } else { + } else if (!buttonFocused) { + // Kein Button fokussiert -> Play-Button fokussieren if (playBtn) playBtn.focus(); - showControls(); } - e.preventDefault(); break; + showControls(); e.preventDefault(); break; case "ArrowDown": if (!controlsVisible) { showControls(); if (playBtn) playBtn.focus(); - } else { + } else if (!buttonFocused) { if (playBtn) playBtn.focus(); - showControls(); } - e.preventDefault(); break; + showControls(); e.preventDefault(); break; case "Escape": case "Backspace": case "Stop": saveProgress(); if (useNativePlayer) _cleanupNativePlayer(); @@ -1514,7 +1627,9 @@ function onKeyDown(e) { case "ColorYellow": openPopupSection("quality"); e.preventDefault(); break; 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) { 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('
'); + + // 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('
'); + + // 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('
'); + 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('
'); + 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 = "Debug-Info (i = schliessen)
" + lines.join(""); +} + +function _dbgRow(label, value, cls) { + cls = cls || "dbg-val"; + return '
' + label + ' ' + value + '
'; +} + +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"; +} diff --git a/video-konverter/app/static/tv/js/tv.js b/video-konverter/app/static/tv/js/tv.js index ea475b0..f608c4b 100644 --- a/video-konverter/app/static/tv/js/tv.js +++ b/video-konverter/app/static/tv/js/tv.js @@ -38,7 +38,7 @@ class FocusManager { if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) { if (!e.target.closest("#tv-nav")) { // 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; } } @@ -58,7 +58,7 @@ class FocusManager { } // Erstes Element im sichtbaren Content-Bereich (Karten bevorzugen) 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) { if (!area.offsetHeight) continue; @@ -122,13 +122,22 @@ class FocusManager { // 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 (this._selectActive) { - // Editier-Modus: Hoch/Runter aendert den Wert - if (direction === "ArrowUp" || direction === "ArrowDown") return; + // Editier-Modus: Wert manuell aendern (synthetische Events aendern SELECT nicht) + 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 { - // Nicht im Editier-Modus: native SELECT-Aenderung verhindern + // Nicht im Editier-Modus: Navigation statt Wert-Aenderung if (direction === "ArrowUp" || direction === "ArrowDown") { e.preventDefault(); } @@ -175,7 +184,7 @@ class FocusManager { } // Direkt zum sichtbaren Content-Bereich (Karten/Listen-Eintraege) 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) { if (!area.offsetHeight) continue; diff --git a/video-konverter/app/static/tv/js/vknative-bridge.js b/video-konverter/app/static/tv/js/vknative-bridge.js index 54ea641..7d05dcc 100644 --- a/video-konverter/app/static/tv/js/vknative-bridge.js +++ b/video-konverter/app/static/tv/js/vknative-bridge.js @@ -83,11 +83,24 @@ break; case "vknative_keyevent": - // Media-Key vom Parent weitergeleitet -> als KeyboardEvent dispatchen + // Key-Event vom Parent weitergeleitet -> als KeyboardEvent dispatchen 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", { keyCode: data.keyCode, which: data.keyCode, + key: keyNameMap[data.keyCode] || "", bubbles: true, }); 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); return true; }, @@ -248,6 +273,16 @@ _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) { console.info("[VKNative] Audio-Track-Wechsel auf Tizen nicht moeglich"); return false; @@ -341,6 +376,17 @@ for (var j = 0; j < ac2.length; j++) { 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; }, @@ -440,6 +486,60 @@ if (_displayEl2) { _displayEl2.style.display = "none"; _displayEl2 = null; } 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; }, setSubtitleTrack: function() { return false; }, setPlaybackSpeed: function(speed) { diff --git a/video-konverter/app/static/tv/sw.js b/video-konverter/app/static/tv/sw.js index 7ae6899..0204d0b 100644 --- a/video-konverter/app/static/tv/sw.js +++ b/video-konverter/app/static/tv/sw.js @@ -4,7 +4,7 @@ * Kein Offline-Caching noetig (Streaming braucht Netzwerk) */ -const CACHE_NAME = "vk-tv-v11"; +const CACHE_NAME = "vk-tv-v14"; const STATIC_ASSETS = [ "/static/tv/css/tv.css", "/static/tv/js/tv.js", diff --git a/video-konverter/app/templates/tv/player.html b/video-konverter/app/templates/tv/player.html index 8f5b30b..0081489 100644 --- a/video-konverter/app/templates/tv/player.html +++ b/video-konverter/app/templates/tv/player.html @@ -53,6 +53,9 @@ + + + {% if next_video %} @@ -349,17 +349,28 @@ document.addEventListener('focusin', function(e) { } }, 1000); - // Abbrechen per Escape/Return - function cancelAutoplay(e) { - if (e.keyCode === 10009 || e.keyCode === 27 || e.key === 'Escape') { + // Enter = sofort abspielen, Escape/Return = abbrechen + function handleAutoplayKey(e) { + 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); nextCard.classList.remove('tv-ep-next-loading'); countdownEl.remove(); - document.removeEventListener('keydown', cancelAutoplay); + document.removeEventListener('keydown', handleAutoplayKey); e.preventDefault(); } } - document.addEventListener('keydown', cancelAutoplay); + document.addEventListener('keydown', handleAutoplayKey); })(); {% elif last_watched_id %} // Zur letzten geschauten Episode scrollen