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('