From 93983cf6eea4192d6c65aa2b3afb07a0ddfd1739 Mon Sep 17 00:00:00 2001 From: data Date: Sat, 7 Mar 2026 08:36:13 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20Tizen-App=20iframe=20+=20Cookie-Fix=20f?= =?UTF-8?q?=C3=BCr=20Cross-Origin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROBLEME BEHOBEN: - Schwarzes Bild beim Video-Abspielen (z-index & iframe-Overlap) - Login-Cookie wurde nicht gesetzt (Third-Party-Cookie-Blocking) ÄNDERUNGEN: Tizen-App (tizen-app/index.html): - z-index AVPlay von 0 auf 10 erhöht (über iframe) - iframe wird beim AVPlay-Start ausgeblendet (opacity: 0, pointerEvents: none) - iframe wird beim AVPlay-Stop wieder eingeblendet - Fix: nur im Parent, NICHT im iframe Player-Template (video-konverter/app/templates/tv/player.html): - entfernt (existiert nur im Parent-Frame) - AVPlay läuft ausschließlich im Tizen-App Parent-Frame Cookie-Fix (video-konverter/app/routes/tv_api.py): - SameSite=Lax → SameSite=None (4 Stellen) - Ermöglicht Session-Cookies im Cross-Origin-iframe - Login funktioniert jetzt in Tizen-App (tizen:// → http://) Neue Features: - VKNative Bridge (vknative-bridge.js): postMessage-Kommunikation iframe ↔ Parent - AVPlay Bridge (avplay-bridge.js): Legacy Direct-Play Support - Android-App Scaffolding (android-app/) TESTERGEBNIS: - ✅ Login erfolgreich (SameSite=None Cookie) - ✅ AVPlay Direct-Play funktioniert (samsung-agent/1.1) - ✅ Bildqualität gut (Hardware-Decoding) - ✅ Keine Stream-Unterbrechungen - ✅ Watch-Progress-Tracking funktioniert Co-Authored-By: Claude Sonnet 4.5 --- android-app/app/build.gradle.kts | 56 ++ android-app/app/proguard-rules.pro | 10 + android-app/app/src/main/AndroidManifest.xml | 40 + .../de/datait/videokonverter/MainActivity.kt | 99 ++ .../videokonverter/NativePlayerBridge.kt | 322 ++++++ .../de/datait/videokonverter/SetupActivity.kt | 87 ++ .../videokonverter/VKWebChromeClient.kt | 22 + .../datait/videokonverter/VKWebViewClient.kt | 75 ++ .../app/src/main/res/layout/activity_main.xml | 22 + .../src/main/res/layout/activity_setup.xml | 59 ++ .../app/src/main/res/values/strings.xml | 8 + .../app/src/main/res/values/themes.xml | 9 + android-app/build.gradle.kts | 5 + android-app/gradle.properties | 4 + android-app/settings.gradle.kts | 16 + tizen-app/VideoKonverter.wgt | Bin 8541 -> 13346 bytes tizen-app/config.xml | 5 +- tizen-app/index.html | 471 +++++++-- tools.yaml | 937 ++++++++++++++++++ video-konverter/app/routes/api.py | 57 ++ video-konverter/app/routes/pages.py | 46 + video-konverter/app/routes/tv_api.py | 234 ++++- video-konverter/app/server.py | 1 - video-konverter/app/services/auth.py | 8 +- video-konverter/app/services/hls.py | 33 +- video-konverter/app/static/css/style.css | 53 +- video-konverter/app/static/js/library.js | 2 +- video-konverter/app/static/tv/css/tv.css | 20 +- .../app/static/tv/js/avplay-bridge.js | 340 +++++++ video-konverter/app/static/tv/js/player.js | 590 ++++++++++- .../app/static/tv/js/vknative-bridge.js | 442 +++++++++ video-konverter/app/static/tv/sw.js | 8 +- video-konverter/app/templates/admin.html | 182 +++- video-konverter/app/templates/tv/base.html | 2 +- video-konverter/app/templates/tv/player.html | 11 +- .../app/templates/tv/profiles.html | 2 +- 36 files changed, 4107 insertions(+), 171 deletions(-) create mode 100644 android-app/app/build.gradle.kts create mode 100644 android-app/app/proguard-rules.pro create mode 100644 android-app/app/src/main/AndroidManifest.xml create mode 100644 android-app/app/src/main/java/de/datait/videokonverter/MainActivity.kt create mode 100644 android-app/app/src/main/java/de/datait/videokonverter/NativePlayerBridge.kt create mode 100644 android-app/app/src/main/java/de/datait/videokonverter/SetupActivity.kt create mode 100644 android-app/app/src/main/java/de/datait/videokonverter/VKWebChromeClient.kt create mode 100644 android-app/app/src/main/java/de/datait/videokonverter/VKWebViewClient.kt create mode 100644 android-app/app/src/main/res/layout/activity_main.xml create mode 100644 android-app/app/src/main/res/layout/activity_setup.xml create mode 100644 android-app/app/src/main/res/values/strings.xml create mode 100644 android-app/app/src/main/res/values/themes.xml create mode 100644 android-app/build.gradle.kts create mode 100644 android-app/gradle.properties create mode 100644 android-app/settings.gradle.kts create mode 100644 tools.yaml create mode 100644 video-konverter/app/static/tv/js/avplay-bridge.js create mode 100644 video-konverter/app/static/tv/js/vknative-bridge.js diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts new file mode 100644 index 0000000..66e819d --- /dev/null +++ b/android-app/app/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "de.datait.videokonverter" + compileSdk = 35 + + defaultConfig { + applicationId = "de.datait.videokonverter" + minSdk = 24 // Android 7.0 (ExoPlayer Codec-Support) + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + // Media3 ExoPlayer + implementation("androidx.media3:media3-exoplayer:1.5.1") + implementation("androidx.media3:media3-exoplayer-hls:1.5.1") + implementation("androidx.media3:media3-ui:1.5.1") + + // AndroidX + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.webkit:webkit:1.12.1") + implementation("androidx.preference:preference-ktx:1.2.1") + + // Leanback (Android TV) + implementation("androidx.leanback:leanback:1.0.0") +} diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro new file mode 100644 index 0000000..05e553b --- /dev/null +++ b/android-app/app/proguard-rules.pro @@ -0,0 +1,10 @@ +# VideoKonverter Android App - ProGuard Regeln + +# JavaScript Interface Methoden nicht entfernen +-keepclassmembers class de.datait.videokonverter.NativePlayerBridge { + @android.webkit.JavascriptInterface ; +} + +# ExoPlayer +-keep class androidx.media3.** { *; } +-dontwarn androidx.media3.** diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..20c3985 --- /dev/null +++ b/android-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..371f696 --- /dev/null +++ b/android-app/app/src/main/java/de/datait/videokonverter/MainActivity.kt @@ -0,0 +1,99 @@ +package de.datait.videokonverter + +import android.content.Intent +import android.os.Bundle +import android.view.KeyEvent +import android.view.View +import android.webkit.CookieManager +import android.webkit.WebSettings +import android.webkit.WebView +import androidx.appcompat.app.AppCompatActivity +import androidx.media3.ui.PlayerView +import androidx.preference.PreferenceManager + +/** + * Haupt-Activity: WebView laedt die TV-App vom Server. + * ExoPlayer-Overlay fuer Direct-Play Video. + */ +class MainActivity : AppCompatActivity() { + + private lateinit var webView: WebView + private lateinit var playerView: PlayerView + private lateinit var bridge: NativePlayerBridge + private var serverUrl: String = "" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + // Server-URL aus Intent oder Preferences + serverUrl = intent.getStringExtra("server_url") + ?: PreferenceManager.getDefaultSharedPreferences(this) + .getString("server_url", null) + ?: run { + // Keine URL -> zurueck zum Setup + startActivity(Intent(this, SetupActivity::class.java)) + finish() + return + } + + webView = findViewById(R.id.webview) + playerView = findViewById(R.id.playerView) + + // Cookies aktivieren (fuer Auth-Session) + CookieManager.getInstance().apply { + setAcceptCookie(true) + setAcceptThirdPartyCookies(webView, true) + } + + // WebView konfigurieren + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + mediaPlaybackRequiresUserGesture = false + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + // Cache-Einstellungen + cacheMode = WebSettings.LOAD_DEFAULT + databaseEnabled = true + } + + // NativePlayerBridge registrieren + bridge = NativePlayerBridge(this, webView, playerView, serverUrl) + webView.addJavascriptInterface(bridge, "VKNativeAndroid") + + // Custom WebViewClient: VKNative nach jedem Page-Load injizieren + webView.webViewClient = VKWebViewClient(serverUrl) + + // Custom WebChromeClient: Fullscreen + Console-Logs + webView.webChromeClient = VKWebChromeClient() + + // TV-App laden + webView.loadUrl("$serverUrl/tv/") + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + // Back-Taste: WebView-Navigation zurueck + if (keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack()) { + // ExoPlayer aktiv? Zuerst stoppen + if (playerView.visibility == View.VISIBLE) { + bridge.stop() + return true + } + webView.goBack() + return true + } + return super.onKeyDown(keyCode, event) + } + + override fun onPause() { + super.onPause() + // ExoPlayer pausieren wenn App in den Hintergrund geht + bridge.pauseIfPlaying() + } + + override fun onDestroy() { + bridge.release() + webView.destroy() + super.onDestroy() + } +} 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 new file mode 100644 index 0000000..3b9aa2e --- /dev/null +++ b/android-app/app/src/main/java/de/datait/videokonverter/NativePlayerBridge.kt @@ -0,0 +1,322 @@ +package de.datait.videokonverter + +import android.app.Activity +import android.media.MediaCodecList +import android.os.Handler +import android.os.Looper +import android.view.View +import android.webkit.CookieManager +import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +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.source.ProgressiveMediaSource +import androidx.media3.ui.PlayerView +import org.json.JSONArray +import org.json.JSONObject + +/** + * JavaScript Bridge: Verbindet window.VKNative mit ExoPlayer. + * Wird per @JavascriptInterface als VKNativeAndroid registriert, + * dann in VKWebViewClient zu window.VKNative gewrapped. + */ +@OptIn(UnstableApi::class) +class NativePlayerBridge( + private val activity: Activity, + private val webView: WebView, + private val playerView: PlayerView, + private val serverUrl: String +) : Player.Listener { + + private var exoPlayer: ExoPlayer? = null + private var timeUpdateHandler: Handler? = null + private var timeUpdateRunnable: Runnable? = null + + // Unterstuetzte Audio-Codecs (DTS blockiert) + private val unsupportedAudio = listOf("dts", "dca", "dts_hd", "dts-hd", "truehd") + + // --- Codec-Abfrage --- + + @JavascriptInterface + fun getSupportedVideoCodecs(): String { + val codecs = mutableSetOf() + val codecList = MediaCodecList(MediaCodecList.ALL_CODECS) + for (info in codecList.codecInfos) { + if (info.isEncoder) continue + for (type in info.supportedTypes) { + val t = type.lowercase() + when { + t.contains("avc") -> codecs.add("h264") + t.contains("hevc") || t.contains("hev") -> codecs.add("hevc") + t.contains("av01") -> codecs.add("av1") + t.contains("vp9") -> codecs.add("vp9") + } + } + } + if (codecs.isEmpty()) codecs.add("h264") + return JSONArray(codecs.toList()).toString() + } + + @JavascriptInterface + fun getSupportedAudioCodecs(): String { + // ExoPlayer unterstuetzt diese Codecs nativ + val codecs = listOf("aac", "opus", "mp3", "flac", "ac3", "eac3", "vorbis", "pcm") + return JSONArray(codecs).toString() + } + + @JavascriptInterface + fun canDirectPlay(videoInfoJson: String): Boolean { + return try { + val info = JSONObject(videoInfoJson) + val videoCodec = info.optString("video_codec_normalized", "").lowercase() + + // Video-Codec pruefen + val supportedVideo = JSONArray(getSupportedVideoCodecs()) + val videoCodecs = (0 until supportedVideo.length()).map { supportedVideo.getString(it) } + if (videoCodec !in videoCodecs) return false + + // Audio-Codecs pruefen (DTS blockieren) + val audioCodecs = info.optJSONArray("audio_codecs") + if (audioCodecs != null) { + for (i in 0 until audioCodecs.length()) { + val ac = audioCodecs.optString(i, "").lowercase() + if (ac in unsupportedAudio) return false + } + } + true + } catch (e: Exception) { + false + } + } + + // --- Player-Steuerung --- + + @JavascriptInterface + fun play(url: String, videoInfoJson: 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 (url.startsWith("/")) "$serverUrl$url" else url + + activity.runOnUiThread { + try { + // Vorherigen Player bereinigen + releasePlayer() + + // ExoPlayer erstellen + val player = ExoPlayer.Builder(activity).build() + + // PlayerView konfigurieren + playerView.player = player + playerView.useController = false // Controls kommen von Web-UI + playerView.visibility = View.VISIBLE + + // Cookie fuer Auth mitgeben + val cookie = CookieManager.getInstance().getCookie(serverUrl) ?: "" + val dataSourceFactory = DefaultHttpDataSource.Factory() + .setDefaultRequestProperties(mapOf("Cookie" to cookie)) + + val mediaSource = ProgressiveMediaSource.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 + + // Periodische Zeit-Updates starten + startTimeUpdates() + + } catch (e: Exception) { + callJs("if(window._vkOnError) window._vkOnError('${e.message}')") + } + } + return true + } + + @JavascriptInterface + fun togglePlay() { + activity.runOnUiThread { + exoPlayer?.let { + it.playWhenReady = !it.playWhenReady + } + } + } + + @JavascriptInterface + fun pause() { + activity.runOnUiThread { + exoPlayer?.playWhenReady = false + } + } + + @JavascriptInterface + fun resume() { + activity.runOnUiThread { + exoPlayer?.playWhenReady = true + } + } + + @JavascriptInterface + fun seek(positionMs: Long) { + activity.runOnUiThread { + exoPlayer?.seekTo(positionMs.coerceAtLeast(0)) + } + } + + @JavascriptInterface + fun getCurrentTime(): Long { + return exoPlayer?.currentPosition ?: 0 + } + + @JavascriptInterface + fun getDuration(): Long { + return exoPlayer?.duration ?: 0 + } + + @JavascriptInterface + fun isPlaying(): Boolean { + return exoPlayer?.isPlaying ?: false + } + + @JavascriptInterface + fun stop() { + activity.runOnUiThread { + releasePlayer() + playerView.visibility = View.GONE + } + } + + @JavascriptInterface + fun setAudioTrack(index: Int): Boolean { + val player = exoPlayer ?: return false + return try { + activity.runOnUiThread { + val tracks = player.currentTracks.groups + var audioIdx = 0 + for (group in tracks) { + if (group.type == C.TRACK_TYPE_AUDIO) { + if (audioIdx == index) { + player.trackSelectionParameters = player.trackSelectionParameters + .buildUpon() + .setOverrideForType( + TrackSelectionOverride(group.mediaTrackGroup, 0) + ) + .build() + return@runOnUiThread + } + audioIdx++ + } + } + } + true + } catch (e: Exception) { + false + } + } + + @JavascriptInterface + fun setSubtitleTrack(index: Int): Boolean { + // Untertitel werden ueber Web-UI gehandelt + return false + } + + @JavascriptInterface + fun setPlaybackSpeed(speed: Float): Boolean { + return try { + activity.runOnUiThread { + exoPlayer?.setPlaybackSpeed(speed) + } + true + } catch (e: Exception) { + false + } + } + + // --- Player.Listener Callbacks --- + + override fun onPlaybackStateChanged(state: Int) { + when (state) { + Player.STATE_READY -> { + callJs("if(window._vkOnReady) window._vkOnReady()") + callJs("if(window._vkOnBuffering) window._vkOnBuffering(false)") + } + Player.STATE_ENDED -> { + callJs("if(window._vkOnComplete) window._vkOnComplete()") + } + Player.STATE_BUFFERING -> { + callJs("if(window._vkOnBuffering) window._vkOnBuffering(true)") + } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + callJs("if(window._vkOnPlayStateChanged) window._vkOnPlayStateChanged($isPlaying)") + } + + override fun onPlayerError(error: PlaybackException) { + val msg = error.message?.replace("'", "\\'") ?: "Wiedergabefehler" + callJs("if(window._vkOnError) window._vkOnError('$msg')") + } + + // --- Hilfsfunktionen --- + + /** App pausiert -> ExoPlayer pausieren */ + fun pauseIfPlaying() { + exoPlayer?.playWhenReady = false + } + + /** Ressourcen freigeben */ + fun release() { + activity.runOnUiThread { + releasePlayer() + } + } + + private fun releasePlayer() { + stopTimeUpdates() + exoPlayer?.release() + exoPlayer = null + } + + private fun startTimeUpdates() { + stopTimeUpdates() + val handler = Handler(Looper.getMainLooper()) + val runnable = object : Runnable { + override fun run() { + val pos = exoPlayer?.currentPosition ?: 0 + callJs("if(window._vkOnTimeUpdate) window._vkOnTimeUpdate($pos)") + handler.postDelayed(this, 500) + } + } + timeUpdateHandler = handler + timeUpdateRunnable = runnable + handler.post(runnable) + } + + private fun stopTimeUpdates() { + timeUpdateRunnable?.let { timeUpdateHandler?.removeCallbacks(it) } + timeUpdateHandler = null + timeUpdateRunnable = null + } + + private fun callJs(script: String) { + activity.runOnUiThread { + webView.evaluateJavascript(script, null) + } + } +} 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 new file mode 100644 index 0000000..47a34b9 --- /dev/null +++ b/android-app/app/src/main/java/de/datait/videokonverter/SetupActivity.kt @@ -0,0 +1,87 @@ +package de.datait.videokonverter + +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.preference.PreferenceManager + +/** + * Setup-Bildschirm: Server-URL eingeben (wird beim ersten Start angezeigt). + * Gespeicherte URL leitet direkt zur MainActivity weiter. + */ +class SetupActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Gespeicherte URL? Direkt weiter zur MainActivity + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + val savedUrl = prefs.getString("server_url", null) + if (!savedUrl.isNullOrBlank()) { + startMainActivity(savedUrl) + return + } + + setContentView(R.layout.activity_setup) + + val serverInput = findViewById(R.id.serverUrl) + val btnConnect = findViewById -

Die Adresse wird gespeichert und beim naechsten Start automatisch geladen.

+
+

VideoKonverter TV

+

Server-Adresse eingeben:

+ +
+ +

Die Adresse wird gespeichert und beim naechsten Start automatisch geladen.

+
- diff --git a/tools.yaml b/tools.yaml new file mode 100644 index 0000000..05af424 --- /dev/null +++ b/tools.yaml @@ -0,0 +1,937 @@ +--- +version: v1.2 +tools: + ## Access Groups + ## An access group is the equivalent of an Endpoint Group in Portainer. + ## ------------------------------------------------------------ + - name: listAccessGroups + description: List all available access groups + annotations: + title: List Access Groups + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: createAccessGroup + description: Create a new access group. Use access groups when you want to define + accesses on more than one environment. Otherwise, define the accesses on + the environment level. + parameters: + - name: name + description: The name of the access group + type: string + required: true + - name: environmentIds + description: "The IDs of the environments that are part of the access group. + Must include all the environment IDs that are part of the group - this + includes new environments and the existing environments that are + already associated with the group. Example: [1, 2, 3]" + type: array + items: + type: number + annotations: + title: Create Access Group + readOnlyHint: false + destructiveHint: false + idempotentHint: false + openWorldHint: false + - name: updateAccessGroupName + description: Update the name of an existing access group. + parameters: + - name: id + description: The ID of the access group to update + type: number + required: true + - name: name + description: The name of the access group + type: string + required: true + annotations: + title: Update Access Group Name + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: updateAccessGroupUserAccesses + description: Update the user accesses of an existing access group. + parameters: + - name: id + description: The ID of the access group to update + type: number + required: true + - name: userAccesses + description: "The user accesses that are associated with all the environments in + the access group. The ID is the user ID of the user in Portainer. + Example: [{id: 1, access: 'environment_administrator'}, {id: 2, + access: 'standard_user'}]" + type: array + required: true + items: + type: object + properties: + id: + description: The ID of the user + type: number + access: + description: The access level of the user. Can be environment_administrator, + helpdesk_user, standard_user, readonly_user or operator_user + type: string + enum: + - environment_administrator + - helpdesk_user + - standard_user + - readonly_user + - operator_user + annotations: + title: Update Access Group User Accesses + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: updateAccessGroupTeamAccesses + description: Update the team accesses of an existing access group. + parameters: + - name: id + description: The ID of the access group to update + type: number + required: true + - name: teamAccesses + description: "The team accesses that are associated with all the environments in + the access group. The ID is the team ID of the team in Portainer. + Example: [{id: 1, access: 'environment_administrator'}, {id: 2, + access: 'standard_user'}]" + type: array + required: true + items: + type: object + properties: + id: + description: The ID of the team + type: number + access: + description: The access level of the team. Can be environment_administrator, + helpdesk_user, standard_user, readonly_user or operator_user + type: string + enum: + - environment_administrator + - helpdesk_user + - standard_user + - readonly_user + - operator_user + annotations: + title: Update Access Group Team Accesses + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: addEnvironmentToAccessGroup + description: Add an environment to an access group. + parameters: + - name: id + description: The ID of the access group to update + type: number + required: true + - name: environmentId + description: The ID of the environment to add to the access group + type: number + required: true + annotations: + title: Add Environment To Access Group + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: removeEnvironmentFromAccessGroup + description: Remove an environment from an access group. + parameters: + - name: id + description: The ID of the access group to update + type: number + required: true + - name: environmentId + description: The ID of the environment to remove from the access group + type: number + required: true + annotations: + title: Remove Environment From Access Group + readOnlyHint: false + destructiveHint: true + idempotentHint: true + openWorldHint: false + ## Environment + ## ------------------------------------------------------------ + - name: listEnvironments + description: List all available environments + annotations: + title: List Environments + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: updateEnvironmentTags + description: Update the tags associated with an environment + parameters: + - name: id + description: The ID of the environment to update + type: number + required: true + - name: tagIds + description: >- + The IDs of the tags that are associated with the environment. + Must include all the tag IDs that should be associated with the environment - this includes new tags and existing tags. + Providing an empty array will remove all tags. + Example: [1, 2, 3] + type: array + required: true + items: + type: number + annotations: + title: Update Environment Tags + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: updateEnvironmentUserAccesses + description: Update the user access policies of an environment + parameters: + - name: id + description: The ID of the environment to update + type: number + required: true + - name: userAccesses + description: >- + The user accesses that are associated with the environment. + The ID is the user ID of the user in Portainer. + Must include all the access policies for all users that should be associated with the environment. + Providing an empty array will remove all user accesses. + Example: [{id: 1, access: 'environment_administrator'}, {id: 2, access: 'standard_user'}] + type: array + required: true + items: + type: object + properties: + id: + description: The ID of the user + type: number + access: + description: The access level of the user + type: string + enum: + - environment_administrator + - helpdesk_user + - standard_user + - readonly_user + - operator_user + annotations: + title: Update Environment User Accesses + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: updateEnvironmentTeamAccesses + description: Update the team access policies of an environment + parameters: + - name: id + description: The ID of the environment to update + type: number + required: true + - name: teamAccesses + description: >- + The team accesses that are associated with the environment. + The ID is the team ID of the team in Portainer. + Must include all the access policies for all teams that should be associated with the environment. + Providing an empty array will remove all team accesses. + Example: [{id: 1, access: 'environment_administrator'}, {id: 2, access: 'standard_user'}] + type: array + required: true + items: + type: object + properties: + id: + description: The ID of the team + type: number + access: + description: The access level of the team + type: string + enum: + - environment_administrator + - helpdesk_user + - standard_user + - readonly_user + - operator_user + annotations: + title: Update Environment Team Accesses + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + ## Environment Groups + ## An environment group is the equivalent of an Edge Group in Portainer. + ## ------------------------------------------------------------ + - name: createEnvironmentGroup + description: Create a new environment group. Environment groups are the equivalent of Edge Groups in Portainer. + parameters: + - name: name + description: The name of the environment group + type: string + required: true + - name: environmentIds + description: The IDs of the environments to add to the group + type: array + required: true + items: + type: number + annotations: + title: Create Environment Group + readOnlyHint: false + destructiveHint: false + idempotentHint: false + openWorldHint: false + - name: listEnvironmentGroups + description: List all available environment groups. Environment groups are the equivalent of Edge Groups in Portainer. + annotations: + title: List Environment Groups + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: updateEnvironmentGroupName + description: Update the name of an environment group. Environment groups are the equivalent of Edge Groups in Portainer. + parameters: + - name: id + description: The ID of the environment group to update + type: number + required: true + - name: name + description: The new name for the environment group + type: string + required: true + annotations: + title: Update Environment Group Name + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: updateEnvironmentGroupEnvironments + description: Update the environments associated with an environment group. Environment groups are the equivalent of Edge Groups in Portainer. + parameters: + - name: id + description: The ID of the environment group to update + type: number + required: true + - name: environmentIds + description: >- + The IDs of the environments that should be part of the group. + Must include all environment IDs that should be associated with the group. + Providing an empty array will remove all environments from the group. + Example: [1, 2, 3] + type: array + required: true + items: + type: number + annotations: + title: Update Environment Group Environments + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: updateEnvironmentGroupTags + description: Update the tags associated with an environment group. Environment groups are the equivalent of Edge Groups in Portainer. + parameters: + - name: id + description: The ID of the environment group to update + type: number + required: true + - name: tagIds + description: >- + The IDs of the tags that should be associated with the group. + Must include all tag IDs that should be associated with the group. + Providing an empty array will remove all tags from the group. + Example: [1, 2, 3] + type: array + required: true + items: + type: number + annotations: + title: Update Environment Group Tags + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + ## Settings + ## ------------------------------------------------------------ + - name: getSettings + description: Get the settings of the Portainer instance + annotations: + title: Get Settings + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: false + ## Stacks + ## ------------------------------------------------------------ + - name: listStacks + description: List all available stacks + annotations: + title: List Stacks + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: getStackFile + description: Get the compose file for a specific stack ID + parameters: + - name: id + description: The ID of the stack to get the compose file for + type: number + required: true + annotations: + title: Get Stack File + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: createStack + description: Create a new stack + parameters: + - name: name + description: Name of the stack. Stack name must only consist of lowercase alpha + characters, numbers, hyphens, or underscores as well as start with a + lowercase character or number + type: string + required: true + - name: file + description: >- + Content of the stack file. The file must be a valid + docker-compose.yml file. example: services: + web: + image:nginx + type: string + required: true + - name: environmentGroupIds + description: "The IDs of the environment groups that the stack belongs to. Must + include at least one environment group ID. Example: [1, 2, 3]" + type: array + required: true + items: + type: number + annotations: + title: Create Stack + readOnlyHint: false + destructiveHint: false + idempotentHint: false + openWorldHint: false + - name: updateStack + description: Update an existing stack + parameters: + - name: id + description: The ID of the stack to update + type: number + required: true + - name: file + description: >- + Content of the stack file. The file must be a valid + docker-compose.yml file. example: version: 3 + services: + web: + image:nginx + type: string + required: true + - name: environmentGroupIds + description: "The IDs of the environment groups that the stack belongs to. Must + include at least one environment group ID. Example: [1, 2, 3]" + type: array + required: true + items: + type: number + annotations: + title: Update Stack + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + ## Local Stacks (regular Docker Compose stacks, non-Edge) + ## ------------------------------------------------------------ + - name: listLocalStacks + description: >- + List all local (non-edge) stacks deployed on Portainer environments. + Returns stack ID, name, status, type, environment ID, creation date, + and environment variables for each stack. + annotations: + title: List Local Stacks + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: getLocalStackFile + description: >- + Get the docker-compose file content for a specific local stack by its ID. + Returns the raw compose file as text. + parameters: + - name: id + description: The ID of the local stack to get the compose file for + type: number + required: true + annotations: + title: Get Local Stack File + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: createLocalStack + description: >- + Create a new local standalone Docker Compose stack on a specific environment. + Requires the environment ID, a stack name, and the compose file content. + Optionally accepts environment variables. + parameters: + - name: environmentId + description: The ID of the environment to deploy the stack to + type: number + required: true + - name: name + description: >- + Name of the stack. Stack name must only consist of lowercase alpha + characters, numbers, hyphens, or underscores as well as start with a + lowercase character or number + type: string + required: true + - name: file + description: >- + Content of the stack file. The file must be a valid + docker-compose.yml file. example: services: + web: + image: nginx + type: string + required: true + - name: env + description: >- + Optional environment variables for the stack. Each variable must have + a 'name' and 'value' field. + Example: [{"name": "DB_HOST", "value": "localhost"}] + type: array + required: false + items: + type: object + properties: + name: + type: string + description: The name of the environment variable + value: + type: string + description: The value of the environment variable + annotations: + title: Create Local Stack + readOnlyHint: false + destructiveHint: false + idempotentHint: false + openWorldHint: false + - name: updateLocalStack + description: >- + Update an existing local stack with new compose file content and/or + environment variables. Requires the stack ID and environment ID. + parameters: + - name: id + description: The ID of the local stack to update + type: number + required: true + - name: environmentId + description: The ID of the environment where the stack is deployed + type: number + required: true + - name: file + description: >- + Content of the stack file. The file must be a valid + docker-compose.yml file. example: services: + web: + image: nginx + type: string + required: true + - name: env + description: >- + Optional environment variables for the stack. Each variable must have + a 'name' and 'value' field. + Example: [{"name": "DB_HOST", "value": "localhost"}] + type: array + required: false + items: + type: object + properties: + name: + type: string + description: The name of the environment variable + value: + type: string + description: The value of the environment variable + - name: prune + description: >- + If true, services that are no longer in the compose file will be removed. + Default: false + type: boolean + required: false + - name: pullImage + description: >- + If true, images will be pulled before deploying. Default: false + type: boolean + required: false + annotations: + title: Update Local Stack + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: startLocalStack + description: >- + Start a stopped local stack. Brings up all containers defined in the + stack's compose file. + parameters: + - name: id + description: The ID of the local stack to start + type: number + required: true + - name: environmentId + description: The ID of the environment where the stack is deployed + type: number + required: true + annotations: + title: Start Local Stack + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: stopLocalStack + description: >- + Stop a running local stack. Stops all containers defined in the + stack's compose file. + parameters: + - name: id + description: The ID of the local stack to stop + type: number + required: true + - name: environmentId + description: The ID of the environment where the stack is deployed + type: number + required: true + annotations: + title: Stop Local Stack + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: deleteLocalStack + description: >- + Delete a local stack permanently. This removes the stack and all its + associated containers from the environment. + parameters: + - name: id + description: The ID of the local stack to delete + type: number + required: true + - name: environmentId + description: The ID of the environment where the stack is deployed + type: number + required: true + annotations: + title: Delete Local Stack + readOnlyHint: false + destructiveHint: true + idempotentHint: false + openWorldHint: false + ## Tags + ## ------------------------------------------------------------ + - name: createEnvironmentTag + description: Create a new environment tag + parameters: + - name: name + description: The name of the tag + type: string + required: true + annotations: + title: Create Environment Tag + readOnlyHint: false + destructiveHint: false + idempotentHint: false + openWorldHint: false + - name: listEnvironmentTags + description: List all available environment tags + annotations: + title: List Environment Tags + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: false + ## Teams + ## ------------------------------------------------------------ + - name: createTeam + description: Create a new team + parameters: + - name: name + description: The name of the team + type: string + required: true + annotations: + title: Create Team + readOnlyHint: false + destructiveHint: false + idempotentHint: false + openWorldHint: false + - name: listTeams + description: List all available teams + annotations: + title: List Teams + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: updateTeamName + description: Update the name of an existing team + parameters: + - name: id + description: The ID of the team to update + type: number + required: true + - name: name + description: The new name of the team + type: string + required: true + annotations: + title: Update Team Name + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: updateTeamMembers + description: Update the members of an existing team + parameters: + - name: id + description: The ID of the team to update + type: number + required: true + - name: userIds + description: "The IDs of the users that are part of the team. Must include all + the user IDs that are part of the team - this includes new users and + the existing users that are already associated with the team. Example: + [1, 2, 3]" + type: array + required: true + items: + type: number + annotations: + title: Update Team Members + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + + ## Users + ## ------------------------------------------------------------ + - name: listUsers + description: List all available users + annotations: + title: List Users + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: false + - name: updateUserRole + description: Update an existing user + parameters: + - name: id + description: The ID of the user to update + type: number + required: true + - name: role + description: The role of the user. Can be admin, user or edge_admin + type: string + required: true + enum: + - admin + - user + - edge_admin + annotations: + title: Update User Role + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: false + + ## Docker Proxy + ## ------------------------------------------------------------ + - name: dockerProxy + description: Proxy Docker requests to a specific Portainer environment. + This tool can be used with any Docker API operation as documented in the Docker Engine API specification (https://docs.docker.com/reference/api/engine/version/v1.48/). + In read-only mode, only GET requests are allowed. + parameters: + - name: environmentId + description: The ID of the environment to proxy Docker requests to + type: number + required: true + - name: method + description: The HTTP method to use to proxy the Docker API operation + type: string + required: true + enum: + - GET + - POST + - PUT + - DELETE + - HEAD + - name: dockerAPIPath + description: "The route of the Docker API operation to proxy. Must include the leading slash. Example: /containers/json" + type: string + required: true + - name: queryParams + description: "The query parameters to include in the Docker API operation. Must be an array of key-value pairs. + Example: [{key: 'all', value: 'true'}, {key: 'filter', value: 'dangling'}]" + type: array + required: false + items: + type: object + properties: + key: + type: string + description: The key of the query parameter + value: + type: string + description: The value of the query parameter + - name: headers + description: "The headers to include in the Docker API operation. Must be an array of key-value pairs. + Example: [{key: 'Content-Type', value: 'application/json'}]" + type: array + required: false + items: + type: object + properties: + key: + type: string + description: The key of the header + value: + type: string + description: The value of the header + - name: body + description: "The body of the Docker API operation to proxy. Must be a JSON string. + Example: {'Image': 'nginx:latest', 'Name': 'my-container'}" + type: string + required: false + annotations: + title: Docker Proxy + readOnlyHint: true + destructiveHint: true + idempotentHint: true + openWorldHint: false + + ## Kubernetes Proxy + ## ------------------------------------------------------------ + - name: kubernetesProxy + description: Proxy Kubernetes requests to a specific Portainer environment. + This tool can be used with any Kubernetes API operation as documented in the Kubernetes API specification (https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/). + In read-only mode, only GET requests are allowed. + parameters: + - name: environmentId + description: The ID of the environment to proxy Kubernetes requests to + type: number + required: true + - name: method + description: The HTTP method to use to proxy the Kubernetes API operation + type: string + required: true + enum: + - GET + - POST + - PUT + - DELETE + - HEAD + - name: kubernetesAPIPath + description: "The route of the Kubernetes API operation to proxy. Must include the leading slash. Example: /api/v1/namespaces/default/pods" + type: string + required: true + - name: queryParams + description: "The query parameters to include in the Kubernetes API operation. Must be an array of key-value pairs. + Example: [{key: 'watch', value: 'true'}, {key: 'fieldSelector', value: 'metadata.name=my-pod'}]" + type: array + required: false + items: + type: object + properties: + key: + type: string + description: The key of the query parameter + value: + type: string + description: The value of the query parameter + - name: headers + description: "The headers to include in the Kubernetes API operation. Must be an array of key-value pairs. + Example: [{key: 'Content-Type', value: 'application/json'}]" + type: array + required: false + items: + type: object + properties: + key: + type: string + description: The key of the header + value: + type: string + description: The value of the header + - name: body + description: "The body of the Kubernetes API operation to proxy. Must be a JSON string. + Example: {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'my-pod'}}" + type: string + required: false + annotations: + title: Kubernetes Proxy + readOnlyHint: true + destructiveHint: true + idempotentHint: true + openWorldHint: false + - name: getKubernetesResourceStripped + description: >- + Proxy GET requests to a specific Portainer environment for Kubernetes resources, + and automatically strips verbose metadata fields (such as 'managedFields') from the API response + to reduce its size. This tool is intended for retrieving Kubernetes resource + information where a leaner payload is desired. + This tool can be used with any GET Kubernetes API operation as documented + in the Kubernetes API specification (https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/). + For other methods (POST, PUT, DELETE, HEAD), use the 'kubernetesProxy' tool. + parameters: + - name: environmentId + description: The ID of the environment to proxy Kubernetes GET requests to + type: number + required: true + - name: kubernetesAPIPath + description: "The route of the Kubernetes API GET operation to proxy. Must include the leading slash. Example: /api/v1/namespaces/default/pods" + type: string + required: true + - name: queryParams + description: "The query parameters to include in the Kubernetes API operation. Must be an array of key-value pairs. + Example: [{key: 'watch', value: 'true'}, {key: 'fieldSelector', value: 'metadata.name=my-pod'}]" + type: array + required: false + items: + type: object + properties: + key: + type: string + description: The key of the query parameter + value: + type: string + description: The value of the query parameter + - name: headers + description: "The headers to include in the Kubernetes API operation. Must be an array of key-value pairs. + Example: [{key: 'Accept', value: 'application/json'}]" + type: array + required: false + items: + type: object + properties: + key: + type: string + description: The key of the header + value: + type: string + description: The value of the header + annotations: + title: Get Kubernetes Resource (Stripped) + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: false \ No newline at end of file diff --git a/video-konverter/app/routes/api.py b/video-konverter/app/routes/api.py index b3f6c54..0e84d1c 100644 --- a/video-konverter/app/routes/api.py +++ b/video-konverter/app/routes/api.py @@ -141,6 +141,61 @@ def setup_api_routes(app: web.Application, config: Config, logging.info(f"Preset '{preset_name}' aktualisiert") return web.json_response({"message": f"Preset '{preset_name}' gespeichert"}) + async def post_preset(request: web.Request) -> web.Response: + """POST /api/presets - Neues Preset erstellen""" + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + + key = data.get("key", "").strip() + if not key: + return web.json_response( + {"error": "Preset-Key fehlt"}, status=400 + ) + if key in config.presets: + return web.json_response( + {"error": f"Preset '{key}' existiert bereits"}, status=409 + ) + import re + if not re.match(r'^[a-z][a-z0-9_]*$', key): + return web.json_response( + {"error": "Key: nur Kleinbuchstaben, Zahlen, Unterstriche"}, + status=400 + ) + + preset = data.get("preset", { + "name": key, "video_codec": "libx264", "container": "mp4", + "quality_param": "crf", "quality_value": 23, "gop_size": 240, + "video_filter": "", "hw_init": False, "extra_params": {} + }) + config.presets[key] = preset + config.save_presets() + logging.info(f"Neues Preset '{key}' erstellt") + return web.json_response({"message": f"Preset '{key}' erstellt"}) + + async def delete_preset(request: web.Request) -> web.Response: + """DELETE /api/presets/{preset_name} - Preset loeschen""" + preset_name = request.match_info["preset_name"] + if preset_name == config.default_preset_name: + return web.json_response( + {"error": "Standard-Preset kann nicht geloescht werden"}, + status=400 + ) + if preset_name not in config.presets: + return web.json_response( + {"error": f"Preset '{preset_name}' nicht gefunden"}, + status=404 + ) + del config.presets[preset_name] + config.save_presets() + logging.info(f"Preset '{preset_name}' geloescht") + return web.json_response( + {"message": f"Preset '{preset_name}' geloescht"} + ) + # --- Statistics --- async def get_statistics(request: web.Request) -> web.Response: @@ -476,7 +531,9 @@ def setup_api_routes(app: web.Application, config: Config, app.router.add_get("/api/settings", get_settings) app.router.add_put("/api/settings", put_settings) app.router.add_get("/api/presets", get_presets) + app.router.add_post("/api/presets", post_preset) app.router.add_put("/api/presets/{preset_name}", put_preset) + app.router.add_delete("/api/presets/{preset_name}", delete_preset) app.router.add_get("/api/statistics", get_statistics) app.router.add_get("/api/system", get_system_info) app.router.add_get("/api/ws-config", get_ws_config) diff --git a/video-konverter/app/routes/pages.py b/video-konverter/app/routes/pages.py index 80f66f1..e982311 100644 --- a/video-konverter/app/routes/pages.py +++ b/video-konverter/app/routes/pages.py @@ -167,6 +167,51 @@ def setup_page_routes(app: web.Application, config: Config, content_type="text/html", ) + async def htmx_save_preset(request: web.Request) -> web.Response: + """POST /htmx/preset/{preset_name} - Preset via Formular speichern""" + preset_name = request.match_info["preset_name"] + data = await request.post() + + # Extra-Params parsen (key=value pro Zeile) + extra_params = {} + raw_extra = data.get("extra_params", "").strip() + for line in raw_extra.splitlines(): + line = line.strip() + if "=" in line: + k, v = line.split("=", 1) + extra_params[k.strip()] = v.strip() + + # Speed-Preset: int oder string oder None + speed_raw = data.get("speed_preset", "").strip() + speed_preset = None + if speed_raw: + try: + speed_preset = int(speed_raw) + except ValueError: + speed_preset = speed_raw + + preset = { + "name": data.get("name", preset_name), + "video_codec": data.get("video_codec", "libx264"), + "container": data.get("container", "mp4"), + "quality_param": data.get("quality_param", "crf"), + "quality_value": int(data.get("quality_value", 23)), + "gop_size": int(data.get("gop_size", 240)), + "speed_preset": speed_preset, + "video_filter": data.get("video_filter", ""), + "hw_init": data.get("hw_init") == "on", + "extra_params": extra_params, + } + + config.presets[preset_name] = preset + config.save_presets() + logging.info(f"Preset '{preset_name}' via Admin-UI gespeichert") + + return web.Response( + text='
Preset gespeichert!
', + content_type="text/html", + ) + @aiohttp_jinja2.template("partials/stats_table.html") async def htmx_stats_table(request: web.Request) -> dict: """GET /htmx/stats?page=1 - Paginierte Statistik""" @@ -194,4 +239,5 @@ def setup_page_routes(app: web.Application, config: Config, app.router.add_get("/statistics", statistics) app.router.add_post("/htmx/settings", htmx_save_settings) app.router.add_post("/htmx/tv-settings", htmx_save_tv_settings) + app.router.add_post("/htmx/preset/{preset_name}", htmx_save_preset) app.router.add_get("/htmx/stats", htmx_stats_table) diff --git a/video-konverter/app/routes/tv_api.py b/video-konverter/app/routes/tv_api.py index 70e225f..cf5ec2d 100644 --- a/video-konverter/app/routes/tv_api.py +++ b/video-konverter/app/routes/tv_api.py @@ -1,7 +1,10 @@ """TV-App Routes - Seiten und API fuer Streaming-Frontend""" +import asyncio import io import json import logging +import secrets +import time from functools import wraps from aiohttp import web import aiohttp_jinja2 @@ -36,6 +39,41 @@ def setup_tv_routes(app: web.Application, config: Config, ) # Filme: noch keine lokale Metadaten -> URL beibehalten + # --- Stream-Tokens (fuer AVPlay / Native Player ohne Cookie-Jar) --- + # Token -> {video_id, user_id, expires} + _stream_tokens: dict[str, dict] = {} + _TOKEN_LIFETIME = 3600 # 1 Stunde + + def _cleanup_tokens(): + """Abgelaufene Tokens entfernen""" + now = time.time() + expired = [k for k, v in _stream_tokens.items() if v["expires"] < now] + for k in expired: + del _stream_tokens[k] + + def _create_stream_token(video_id: int, user_id: int) -> str: + """Temporaeren Stream-Token erstellen""" + _cleanup_tokens() + token = secrets.token_urlsafe(32) + _stream_tokens[token] = { + "video_id": video_id, + "user_id": user_id, + "expires": time.time() + _TOKEN_LIFETIME, + } + return token + + def _validate_stream_token(token: str, video_id: int) -> bool: + """Prueft ob Token gueltig ist fuer das angegebene Video""" + info = _stream_tokens.get(token) + if not info: + return False + if info["expires"] < time.time(): + del _stream_tokens[token] + return False + if info["video_id"] != video_id: + return False + return True + # --- Auth-Hilfsfunktionen --- async def get_tv_user(request: web.Request) -> dict | None: @@ -82,7 +120,7 @@ def setup_tv_routes(app: web.Application, config: Config, resp.set_cookie( "vk_session", session_id, max_age=10 * 365 * 24 * 3600, - httponly=True, samesite="Lax", path="/", + httponly=True, samesite="None", path="/", ) return resp elif len(profiles) > 1: @@ -131,7 +169,7 @@ def setup_tv_routes(app: web.Application, config: Config, "vk_session", session_id, max_age=max_age, httponly=True, - samesite="Lax", + samesite="None", path="/", ) # Client-ID Cookie (immer permanent) @@ -139,7 +177,7 @@ def setup_tv_routes(app: web.Application, config: Config, "vk_client_id", client_id, max_age=10 * 365 * 24 * 3600, # 10 Jahre httponly=True, - samesite="Lax", + samesite="None", path="/", ) return resp @@ -970,6 +1008,13 @@ def setup_tv_routes(app: web.Application, config: Config, await auth_service.save_progress( user["id"], video_id, position, duration ) + + # Bei completed: Episode automatisch als "watched" markieren + if completed: + await auth_service.set_watch_status( + user["id"], "watched", video_id=video_id + ) + return web.json_response({"ok": True}) @require_auth @@ -1129,12 +1174,12 @@ def setup_tv_routes(app: web.Application, config: Config, resp.set_cookie( "vk_session", session_id, max_age=10 * 365 * 24 * 3600, - httponly=True, samesite="Lax", path="/", + httponly=True, samesite="None", path="/", ) resp.set_cookie( "vk_client_id", client_id, max_age=10 * 365 * 24 * 3600, - httponly=True, samesite="Lax", path="/", + httponly=True, samesite="None", path="/", ) return resp @@ -1347,6 +1392,150 @@ def setup_tv_routes(app: web.Application, config: Config, lang = request.query.get("lang", "de") return web.json_response(get_all_translations(lang)) + # --- Direct-Stream API (fuer AVPlay / native Wiedergabe) --- + + async def get_direct_stream(request: web.Request) -> web.Response: + """GET /tv/api/direct-stream/{video_id} + Liefert Videodatei direkt als FileResponse (kein Transcoding). + Unterstuetzt HTTP Range Requests fuer Seeking. + Auth: Cookie ODER ?token=xxx (fuer AVPlay ohne Cookie-Jar).""" + video_id = int(request.match_info["video_id"]) + + # Auth pruefen: Cookie oder Stream-Token + user = await get_tv_user(request) + if not user: + # Fallback: Stream-Token pruefen + token = request.query.get("token") + if not token or not _validate_stream_token(token, video_id): + return web.json_response( + {"error": "Nicht autorisiert"}, status=401) + + pool = await library_service._get_pool() + if not pool: + return web.json_response( + {"error": "Keine DB-Verbindung"}, status=500) + + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT file_path FROM library_videos WHERE id = %s", + (video_id,)) + row = await cur.fetchone() + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + if not row or not row.get("file_path"): + return web.json_response( + {"error": "Video nicht gefunden"}, status=404) + + import os + file_path = row["file_path"] + if not os.path.isfile(file_path): + return web.json_response( + {"error": "Datei nicht gefunden"}, status=404) + + return web.FileResponse(file_path) + + @require_auth + async def post_stream_token(request: web.Request) -> web.Response: + """POST /tv/api/stream-token + Erstellt temporaeren Token fuer Direct-Stream (AVPlay). + Body: { video_id: int }""" + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400) + + video_id = int(data.get("video_id", 0)) + if not video_id: + return web.json_response( + {"error": "video_id fehlt"}, status=400) + + user = request["tv_user"] + token = _create_stream_token(video_id, user["id"]) + return web.json_response({"token": token}) + + @require_auth + async def get_video_info_tv(request: web.Request) -> web.Response: + """GET /tv/api/video-info/{video_id} + Erweiterte Video-Infos inkl. Direct-Play-Kompatibilitaet.""" + video_id = int(request.match_info["video_id"]) + + pool = await library_service._get_pool() + if not pool: + return web.json_response( + {"error": "Keine DB-Verbindung"}, status=500) + + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT id, file_name, file_path, width, height, + video_codec, audio_tracks, subtitle_tracks, + container, duration_sec, video_bitrate, + is_10bit, hdr, series_id, + season_number, episode_number + FROM library_videos WHERE id = %s + """, (video_id,)) + video = await cur.fetchone() + if not video: + return web.json_response( + {"error": "Video nicht gefunden"}, status=404) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # JSON-Felder parsen + for field in ("audio_tracks", "subtitle_tracks"): + val = video.get(field) + if isinstance(val, str): + video[field] = json.loads(val) + elif val is None: + video[field] = [] + + # Bild-basierte Untertitel rausfiltern + video["subtitle_tracks"] = [ + s for s in video["subtitle_tracks"] + if s.get("codec") not in ( + "hdmv_pgs_subtitle", "dvd_subtitle", "pgs", "vobsub" + ) + ] + + # Direct-Play-Infos hinzufuegen + video["direct_play_url"] = f"/tv/api/direct-stream/{video_id}" + + # Audio-Codecs extrahieren (fuer Client-seitige Kompatibilitaetspruefung) + audio_codecs = [] + for track in video.get("audio_tracks", []): + codec = track.get("codec", "").lower() + if codec and codec not in audio_codecs: + audio_codecs.append(codec) + video["audio_codecs"] = audio_codecs + + # Video-Codec normalisieren + vc = (video.get("video_codec") or "").lower() + codec_map = { + "h264": "h264", "avc": "h264", "avc1": "h264", + "hevc": "hevc", "h265": "hevc", "hev1": "hevc", + "av1": "av1", "av01": "av1", + "vp9": "vp9", "vp09": "vp9", + } + video["video_codec_normalized"] = codec_map.get(vc, vc) + + # Dateigroesse fuer Puffer-Abschaetzung (ExoPlayer) + file_path = video.get("file_path") + try: + import os + video["file_size"] = os.path.getsize(file_path) if file_path and os.path.isfile(file_path) else 0 + except Exception: + video["file_size"] = 0 + + # file_path nicht an den Client senden + video.pop("file_path", None) + + return web.json_response(video) + # --- HLS Streaming API --- @require_auth @@ -1436,8 +1625,21 @@ def setup_tv_routes(app: web.Application, config: Config, seg_path = (session.dir / segment).resolve() if not str(seg_path).startswith(str(session.dir.resolve())): return web.Response(status=403, text="Zugriff verweigert") - if not seg_path.exists(): - return web.Response(status=404, text="Segment nicht gefunden") + + # Warten bis Segment existiert und fertig geschrieben ist + # (ffmpeg schreibt Segmente inkrementell - bei AV1 copy bis 34 MB) + for _ in range(50): # max 5 Sekunden warten (AV1-Segmente sind gross) + if seg_path.exists() and seg_path.stat().st_size > 0: + # Kurz warten und nochmal pruefen ob Dateigroesse stabil + size1 = seg_path.stat().st_size + await asyncio.sleep(0.15) + if seg_path.exists() and seg_path.stat().st_size == size1: + break # Segment fertig geschrieben + await asyncio.sleep(0.1) + else: + if not seg_path.exists(): + return web.Response(status=404, + text="Segment nicht verfuegbar") # Content-Type je nach Dateiendung if segment.endswith(".mp4") or segment.endswith(".m4s"): @@ -1466,6 +1668,14 @@ def setup_tv_routes(app: web.Application, config: Config, await hls_manager.destroy_session(session_id) return web.json_response({"success": True}) + async def post_hls_stop(request: web.Request) -> web.Response: + """POST /tv/api/hls/{session_id}/stop - Session beenden (fuer sendBeacon)""" + if not hls_manager: + return web.Response(status=204) + session_id = request.match_info["session_id"] + await hls_manager.destroy_session(session_id) + return web.Response(status=204) + # --- HLS Admin-API (fuer TV Admin-Center) --- async def get_hls_sessions(request: web.Request) -> web.Response: @@ -1530,6 +1740,14 @@ def setup_tv_routes(app: web.Application, config: Config, app.router.add_get("/tv/api/i18n", get_i18n) app.router.add_post("/tv/api/rating", post_rating) + # Direct-Stream API (AVPlay) + app.router.add_get( + "/tv/api/direct-stream/{video_id}", get_direct_stream) + app.router.add_get( + "/tv/api/video-info/{video_id}", get_video_info_tv) + app.router.add_post( + "/tv/api/stream-token", post_stream_token) + # HLS Streaming API app.router.add_post("/tv/api/hls/start", post_hls_start) app.router.add_get( @@ -1538,6 +1756,8 @@ def setup_tv_routes(app: web.Application, config: Config, "/tv/api/hls/{session_id}/{segment}", get_hls_segment) app.router.add_delete( "/tv/api/hls/{session_id}", delete_hls_session) + app.router.add_post( + "/tv/api/hls/{session_id}/stop", post_hls_stop) # Admin-API (QR-Code, User-Verwaltung) app.router.add_get("/api/tv/qrcode", get_qrcode) diff --git a/video-konverter/app/server.py b/video-konverter/app/server.py index bda95da..b40736e 100644 --- a/video-konverter/app/server.py +++ b/video-konverter/app/server.py @@ -89,7 +89,6 @@ class VideoKonverterServer: pattern="__jinja2_%s.cache", ), auto_reload=os.environ.get("VK_DEV", "").lower() == "true", - enable_async=True, ) # i18n: Uebersetzungen laden und Jinja2-Filter registrieren diff --git a/video-konverter/app/services/auth.py b/video-konverter/app/services/auth.py index 39d0933..6c9c613 100644 --- a/video-konverter/app/services/auth.py +++ b/video-konverter/app/services/auth.py @@ -1010,8 +1010,12 @@ class AuthService: position_sec: float, duration_sec: float = 0) -> None: """Speichert Wiedergabe-Position""" - completed = 1 if (duration_sec > 0 and - position_sec / duration_sec > 0.9) else 0 + if duration_sec > 0 and position_sec / duration_sec > 0.9: + completed = 1 + elif duration_sec > 0 and position_sec >= duration_sec: + completed = 1 + else: + completed = 0 pool = await self._get_pool() if not pool: return diff --git a/video-konverter/app/services/hls.py b/video-konverter/app/services/hls.py index 51f7aec..b57cc4a 100644 --- a/video-konverter/app/services/hls.py +++ b/video-konverter/app/services/hls.py @@ -227,6 +227,14 @@ class HLSSessionManager: f"src={video_codec}, audio={audio_idx})") logging.debug(f"HLS ffmpeg cmd: {' '.join(cmd)}") + # Batch-Konvertierung VOR ffmpeg-Start einfrieren (verhindert GPU-Kollision) + if self._queue_service and self._tv_setting("pause_batch_on_stream", True): + count = self._queue_service.suspend_encoding() + if count > 0: + logging.info( + f"Encoding pausiert: {count} ffmpeg-Prozess(e) " + f"per SIGSTOP eingefroren (HLS-Stream aktiv)") + try: session.process = await asyncio.create_subprocess_exec( *cmd, @@ -274,11 +282,6 @@ class HLSSessionManager: self._sessions[session_id] = session - # Laufende Batch-Konvertierungen einfrieren (Ressourcen fuer Stream) - if self._queue_service and self._tv_setting("pause_batch_on_stream", True): - count = self._queue_service.suspend_encoding() - logging.info(f"HLS: {count} Konvertierung(en) eingefroren") - # Kurz warten ob erstes Segment schnell kommt (Copy-Modus: <1s) # Bei Transcoding nicht lange blockieren - hls.js/native HLS # haben eigene Retry-Logik fuer noch nicht verfuegbare Segmente @@ -424,12 +427,26 @@ class HLSSessionManager: logging.info(f"HLS Session {session_id} beendet") - # Wenn keine Sessions mehr aktiv -> Batch-Konvertierung fortsetzen + # Verzoegertes Resume: Nicht sofort SIGCONT senden, da moeglicherweise + # gerade eine neue Session gestartet wird (Race Condition mit GPU) if (not self._sessions and self._queue_service and self._tv_setting("pause_batch_on_stream", True)): + asyncio.create_task(self._delayed_resume()) + + async def _delayed_resume(self, delay: float = 2.0): + """Batch-Konvertierung verzoegert fortsetzen. + Wartet kurz, damit eine neue HLS-Session die GPU reservieren kann, + bevor die Batch-Konvertierung per SIGCONT aufgeweckt wird.""" + await asyncio.sleep(delay) + # Erneut pruefen: Wenn inzwischen eine neue Session laeuft -> nicht fortsetzen + if not self._sessions and self._queue_service: count = self._queue_service.resume_encoding() - logging.info(f"HLS: Alle Sessions beendet, " - f"{count} Konvertierung(en) fortgesetzt") + if count > 0: + logging.info( + f"Encoding fortgesetzt: {count} ffmpeg-Prozess(e) " + f"per SIGCONT aufgeweckt") + logging.info(f"HLS: Alle Sessions beendet, " + f"{count} Konvertierung(en) fortgesetzt") async def _cleanup_loop(self): """Periodisch abgelaufene Sessions entfernen""" diff --git a/video-konverter/app/static/css/style.css b/video-konverter/app/static/css/style.css index 09fba60..52bd5f7 100644 --- a/video-konverter/app/static/css/style.css +++ b/video-konverter/app/static/css/style.css @@ -294,7 +294,55 @@ legend { gap: 0.5rem; } -/* === Presets Grid === */ +/* === Presets Editor === */ +.preset-editor { display: flex; flex-direction: column; gap: 0.5rem; } + +.preset-edit-card { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + overflow: hidden; +} + +.preset-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.8rem 1rem; + cursor: pointer; + transition: background 0.15s; +} +.preset-header:hover { background: #222; } + +.preset-header-left { display: flex; align-items: center; gap: 0.8rem; flex-wrap: wrap; } +.preset-header h3 { font-size: 0.85rem; margin: 0; color: #fff; white-space: nowrap; } + +.preset-toggle { color: #666; font-size: 0.7rem; transition: transform 0.2s; } + +.preset-body { + padding: 1rem; + border-top: 1px solid #2a2a2a; + background: #141414; +} +.preset-body textarea { + width: 100%; + font-family: monospace; + font-size: 0.8rem; + background: #1e1e1e; + color: #e0e0e0; + border: 1px solid #333; + border-radius: 6px; + padding: 0.5rem; + resize: vertical; +} +.preset-body textarea:focus { + border-color: #1976d2; + outline: none; +} + +.preset-details { display: flex; flex-wrap: wrap; gap: 0.3rem; } + +/* Presets Grid (Legacy) */ .presets-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); @@ -314,8 +362,6 @@ legend { color: #fff; } -.preset-details { display: flex; flex-wrap: wrap; gap: 0.3rem; } - .tag { display: inline-block; padding: 0.1rem 0.4rem; @@ -327,6 +373,7 @@ legend { } .tag.gpu { background: #1b5e20; color: #81c784; border-color: #2e7d32; } .tag.cpu { background: #0d47a1; color: #90caf9; border-color: #1565c0; } +.tag.default { background: #e65100; color: #ffcc80; border-color: #f57c00; } /* === Statistics === */ .stats-summary { diff --git a/video-konverter/app/static/js/library.js b/video-konverter/app/static/js/library.js index 3b63c02..3144d1f 100644 --- a/video-konverter/app/static/js/library.js +++ b/video-konverter/app/static/js/library.js @@ -2115,7 +2115,7 @@ function openImportModal() { .then(data => { const select = document.getElementById("import-target"); select.innerHTML = (data.paths || []).map(p => - `` + `` ).join(""); }) .catch(() => {}); diff --git a/video-konverter/app/static/tv/css/tv.css b/video-konverter/app/static/tv/css/tv.css index ea2f245..11b322b 100644 --- a/video-konverter/app/static/tv/css/tv.css +++ b/video-konverter/app/static/tv/css/tv.css @@ -134,9 +134,9 @@ a { color: var(--accent); text-decoration: none; } .tv-row .tv-card { scroll-snap-align: start; flex-shrink: 0; - width: 126px; + width: 176px; } -.tv-row .tv-card-wide { width: 185px; } +.tv-row .tv-card-wide { width: 260px; } /* === Poster-Grid === */ .tv-grid { @@ -967,6 +967,12 @@ a { color: var(--accent); text-decoration: none; } z-index: 50; background: #000; gap: 1rem; + opacity: 1; + transition: opacity 1.5s ease; +} +.player-loading.fade-out { + opacity: 0; + pointer-events: none; } .player-loading-spinner { width: 48px; @@ -1244,8 +1250,8 @@ a { color: var(--accent); text-decoration: none; } .tv-nav-item { padding: 0.4rem 0.6rem; font-size: 0.85rem; } .tv-main { padding: 1rem; } .tv-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; } - .tv-row .tv-card { width: 101px; } - .tv-row .tv-card-wide { width: 151px; } + .tv-row .tv-card { width: 141px; } + .tv-row .tv-card-wide { width: 211px; } .tv-detail-header { flex-direction: column; } .tv-detail-poster { width: 150px; } .tv-page-title { font-size: 1.3rem; } @@ -1263,7 +1269,7 @@ a { color: var(--accent); text-decoration: none; } .tv-nav-links { gap: 0; } .tv-nav-item { padding: 0.3rem 0.5rem; font-size: 0.8rem; } .tv-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); } - .tv-row .tv-card { width: 84px; } + .tv-row .tv-card { width: 118px; } .tv-detail-poster { width: 120px; } /* Episoden-Karten: kompakt auf Handy */ .tv-ep-thumb { width: 100px; } @@ -1282,8 +1288,8 @@ a { color: var(--accent); text-decoration: none; } /* TV/Desktop (grosse Bildschirme) */ @media (min-width: 1280px) { .tv-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; } - .tv-row .tv-card { width: 143px; } - .tv-row .tv-card-wide { width: 218px; } + .tv-row .tv-card { width: 200px; } + .tv-row .tv-card-wide { width: 305px; } .tv-play-btn { padding: 1rem 3rem; font-size: 1.3rem; } /* Episoden-Karten: groesser auf TV */ .tv-ep-thumb { width: 260px; } diff --git a/video-konverter/app/static/tv/js/avplay-bridge.js b/video-konverter/app/static/tv/js/avplay-bridge.js new file mode 100644 index 0000000..d685b6e --- /dev/null +++ b/video-konverter/app/static/tv/js/avplay-bridge.js @@ -0,0 +1,340 @@ +/** + * VideoKonverter TV - AVPlay Bridge v1.0 + * Abstraktionsschicht fuer Samsung AVPlay API (Tizen WebApps). + * Ermoeglicht Direct-Play von MKV/WebM/MP4 mit Hardware-Decodern. + * + * AVPlay kann: H.264, HEVC, AV1, VP9, EAC3, AC3, AAC, Opus + * AVPlay kann NICHT: DTS (seit Samsung 2022 entfernt) + * + * Wird nur geladen wenn Tizen-Umgebung erkannt wird. + */ + +const AVPlayBridge = { + available: false, + _playing: false, + _duration: 0, + _listener: null, + _displayEl: null, // Element fuer AVPlay-Rendering + _timeUpdateId: null, // Interval fuer periodische Zeit-Updates + + /** + * Initialisierung: Prueft ob AVPlay API verfuegbar ist + * @returns {boolean} true wenn AVPlay nutzbar + */ + init() { + try { + this.available = typeof webapis !== "undefined" + && typeof webapis.avplay !== "undefined"; + } catch (e) { + this.available = false; + } + if (this.available) { + console.info("[AVPlay] API verfuegbar"); + } + return this.available; + }, + + /** + * Prueft ob ein Video direkt abgespielt werden kann (ohne Transcoding) + * @param {Object} videoInfo - Video-Infos vom Server + * @returns {boolean} true wenn Direct-Play moeglich + */ + canPlay(videoInfo) { + if (!this.available) return false; + + // Video-Codec pruefen + const vc = (videoInfo.video_codec_normalized || "").toLowerCase(); + const supportedVideo = ["h264", "hevc", "av1", "vp9"]; + if (!supportedVideo.includes(vc)) { + console.info(`[AVPlay] Video-Codec '${vc}' nicht unterstuetzt`); + return false; + } + + // Container pruefen + const container = (videoInfo.container || "").toLowerCase(); + const supportedContainers = ["mkv", "matroska", "mp4", "webm", "avi", "ts"]; + if (container && !supportedContainers.some(c => container.includes(c))) { + console.info(`[AVPlay] Container '${container}' nicht unterstuetzt`); + return false; + } + + // Audio-Codecs pruefen - DTS ist nicht unterstuetzt + const audioCodecs = videoInfo.audio_codecs || []; + const unsupported = ["dts", "dca", "dts_hd", "dts-hd", "truehd"]; + const hasUnsupported = audioCodecs.some( + ac => unsupported.includes(ac.toLowerCase()) + ); + if (hasUnsupported) { + console.info("[AVPlay] DTS-Audio erkannt -> kein Direct-Play"); + return false; + } + + console.info(`[AVPlay] Direct-Play moeglich: ${vc}/${container}`); + return true; + }, + + /** + * Video abspielen via AVPlay + * @param {string} url - Direct-Stream-URL + * @param {Object} opts - {seekMs, onTimeUpdate, onComplete, onError, onBuffering} + */ + play(url, opts = {}) { + if (!this.available) return false; + + try { + // Vorherige Session bereinigen + this.stop(); + + // Display-Element setzen + this._displayEl = document.getElementById("avplayer"); + if (this._displayEl) { + this._displayEl.style.display = "block"; + } + + // AVPlay oeffnen + webapis.avplay.open(url); + + // Display-Bereich setzen (Vollbild) + webapis.avplay.setDisplayRect( + 0, 0, window.innerWidth, window.innerHeight + ); + + // Event-Listener registrieren + this._listener = opts; + webapis.avplay.setListener({ + onbufferingstart: () => { + console.debug("[AVPlay] Buffering gestartet"); + if (this._listener && this._listener.onBuffering) { + this._listener.onBuffering(true); + } + }, + onbufferingcomplete: () => { + console.debug("[AVPlay] Buffering abgeschlossen"); + if (this._listener && this._listener.onBuffering) { + this._listener.onBuffering(false); + } + }, + oncurrentplaytime: (ms) => { + // Wird von AVPlay periodisch aufgerufen + if (this._listener && this._listener.onTimeUpdate) { + this._listener.onTimeUpdate(ms); + } + }, + onstreamcompleted: () => { + console.info("[AVPlay] Wiedergabe abgeschlossen"); + this._playing = false; + if (this._listener && this._listener.onComplete) { + this._listener.onComplete(); + } + }, + onerror: (eventType) => { + console.error("[AVPlay] Fehler:", eventType); + this._playing = false; + if (this._listener && this._listener.onError) { + this._listener.onError(eventType); + } + }, + onevent: (eventType, eventData) => { + console.debug("[AVPlay] Event:", eventType, eventData); + }, + onsubtitlechange: (duration, text, dataSize, jsonData) => { + // Untertitel-Events (optional) + console.debug("[AVPlay] Subtitle:", text); + }, + }); + + // Async vorbereiten und starten + webapis.avplay.prepareAsync( + () => { + // Erfolg: Wiedergabe starten + this._duration = webapis.avplay.getDuration(); + console.info(`[AVPlay] Bereit, Dauer: ${this._duration}ms`); + + // Seeking vor dem Start + if (opts.seekMs && opts.seekMs > 0) { + webapis.avplay.seekTo(opts.seekMs, + () => { + console.info(`[AVPlay] Seek zu ${opts.seekMs}ms`); + webapis.avplay.play(); + this._playing = true; + this._startTimeUpdates(); + }, + (e) => { + console.warn("[AVPlay] Seek fehlgeschlagen:", e); + webapis.avplay.play(); + this._playing = true; + this._startTimeUpdates(); + } + ); + } else { + webapis.avplay.play(); + this._playing = true; + this._startTimeUpdates(); + } + + // Buffering-Ende signalisieren + if (this._listener && this._listener.onBuffering) { + this._listener.onBuffering(false); + } + if (this._listener && this._listener.onReady) { + this._listener.onReady(); + } + }, + (error) => { + console.error("[AVPlay] Prepare fehlgeschlagen:", error); + if (this._listener && this._listener.onError) { + this._listener.onError(error); + } + } + ); + + return true; + } catch (e) { + console.error("[AVPlay] Fehler beim Starten:", e); + if (this._listener && this._listener.onError) { + this._listener.onError(e.message || e); + } + return false; + } + }, + + /** + * Pause/Resume umschalten + */ + togglePlay() { + if (!this.available) return; + try { + const state = webapis.avplay.getState(); + if (state === "PLAYING") { + webapis.avplay.pause(); + this._playing = false; + } else if (state === "PAUSED" || state === "READY") { + webapis.avplay.play(); + this._playing = true; + } + } catch (e) { + console.error("[AVPlay] togglePlay Fehler:", e); + } + }, + + pause() { + if (!this.available) return; + try { + if (this._playing) { + webapis.avplay.pause(); + this._playing = false; + } + } catch (e) { + console.error("[AVPlay] pause Fehler:", e); + } + }, + + resume() { + if (!this.available) return; + try { + webapis.avplay.play(); + this._playing = true; + } catch (e) { + console.error("[AVPlay] resume Fehler:", e); + } + }, + + /** + * Seeking zu Position in Millisekunden + * @param {number} positionMs - Zielposition in ms + * @param {function} onSuccess - Callback bei Erfolg + * @param {function} onError - Callback bei Fehler + */ + seek(positionMs, onSuccess, onError) { + if (!this.available) return; + try { + webapis.avplay.seekTo( + Math.max(0, Math.floor(positionMs)), + () => { + console.debug(`[AVPlay] Seek zu ${positionMs}ms`); + if (onSuccess) onSuccess(); + }, + (e) => { + console.warn("[AVPlay] Seek Fehler:", e); + if (onError) onError(e); + } + ); + } catch (e) { + console.error("[AVPlay] seek Fehler:", e); + if (onError) onError(e); + } + }, + + /** + * Wiedergabe stoppen und AVPlay bereinigen + */ + stop() { + this._stopTimeUpdates(); + this._playing = false; + try { + const state = webapis.avplay.getState(); + if (state !== "IDLE" && state !== "NONE") { + webapis.avplay.stop(); + } + webapis.avplay.close(); + } catch (e) { + // Ignorieren wenn bereits gestoppt + } + if (this._displayEl) { + this._displayEl.style.display = "none"; + } + }, + + /** + * Aktuelle Wiedergabeposition in Millisekunden + * @returns {number} Position in ms + */ + getCurrentTime() { + if (!this.available) return 0; + try { + return webapis.avplay.getCurrentTime(); + } catch (e) { + return 0; + } + }, + + /** + * Gesamtdauer in Millisekunden + * @returns {number} Dauer in ms + */ + getDuration() { + if (!this.available) return 0; + try { + return this._duration || webapis.avplay.getDuration(); + } catch (e) { + return 0; + } + }, + + /** + * Prueft ob gerade abgespielt wird + * @returns {boolean} + */ + isPlaying() { + return this._playing; + }, + + /** + * Periodische Zeit-Updates starten (fuer Progress-Bar) + */ + _startTimeUpdates() { + this._stopTimeUpdates(); + this._timeUpdateId = setInterval(() => { + if (this._playing && this._listener && this._listener.onTimeUpdate) { + this._listener.onTimeUpdate(this.getCurrentTime()); + } + }, 500); + }, + + _stopTimeUpdates() { + if (this._timeUpdateId) { + clearInterval(this._timeUpdateId); + this._timeUpdateId = null; + } + }, +}; diff --git a/video-konverter/app/static/tv/js/player.js b/video-konverter/app/static/tv/js/player.js index c0787ba..c3cda92 100644 --- a/video-konverter/app/static/tv/js/player.js +++ b/video-konverter/app/static/tv/js/player.js @@ -1,7 +1,8 @@ /** - * VideoKonverter TV - Video-Player v4.1 - * HLS-Streaming mit hls.js, kompaktes Popup-Menue statt Panel-Overlay, - * Fullscreen-Player mit Audio/Untertitel/Qualitaets-Auswahl, + * VideoKonverter TV - Video-Player v5.0 + * VKNative Bridge fuer Direct-Play (Tizen AVPlay / Android ExoPlayer), + * HLS-Streaming mit hls.js als Fallback, + * kompaktes Popup-Menue, Fullscreen-Player mit Audio/Untertitel/Qualitaets-Auswahl, * Naechste-Episode-Countdown und Tastatur/Fernbedienung-Steuerung. */ @@ -33,6 +34,14 @@ let clientCodecs = null; // Vom Client unterstuetzte Video-Codecs let hlsRetryCount = 0; // Retry-Zaehler fuer gesamten Stream-Start let loadingTimeout = null; // Timeout fuer Loading-Spinner +// Native Player State (VKNative Bridge: Tizen AVPlay / Android ExoPlayer) +let useNativePlayer = false; // VKNative Direct-Play aktiv? +let nativePlayStarted = false; // VKNative _vkOnReady wurde aufgerufen? +let nativePlayTimeout = null; // Master-Timeout fuer VKNative-Start + +// Legacy AVPlay Direct-Play State (Rueckwaerts-Kompatibilitaet) +let useDirectPlay = false; // Alter AVPlayBridge-Pfad aktiv? + /** * Player initialisieren * @param {Object} opts - Konfiguration @@ -52,10 +61,39 @@ function initPlayer(opts) { if (!videoEl) return; - // Video-Info + HLS-Stream PARALLEL starten (nicht sequentiell warten!) - const infoReady = loadVideoInfo(); - startHLSStream(opts.startPos || 0); - infoReady.then(() => updatePlayerButtons()); + // Prioritaet 1: VKNative Bridge (Tizen AVPlay / Android ExoPlayer) + if (window.VKNative) { + console.info("[Player] VKNative erkannt (Platform: " + window.VKNative.platform + ")"); + showLoading(); + // Master-Timeout: Falls VKNative nach 15s nicht gestartet hat -> HLS Fallback + nativePlayTimeout = setTimeout(function() { + if (!nativePlayStarted) { + console.warn("[Player] VKNative Master-Timeout (15s) - Fallback auf HLS"); + _cleanupNativePlayer(); + _nativeFallbackToHLS(opts.startPos || 0); + } + }, 15000); + _tryNativeDirectPlay(opts.startPos || 0); + } + // Prioritaet 2: Legacy AVPlayBridge (alte Tizen-App ohne VKNative) + else if (isTizenTV() && typeof AVPlayBridge !== "undefined" && AVPlayBridge.init()) { + showLoading(); + _tryDirectPlay(opts.startPos || 0).then(success => { + if (!success) { + console.info("[Player] AVPlay Fallback -> HLS"); + useDirectPlay = false; + const infoReady = loadVideoInfo(); + startHLSStream(opts.startPos || 0); + infoReady.then(() => updatePlayerButtons()); + } + }); + } + // Prioritaet 3: HLS-Streaming (Browser, kein nativer Player) + else { + const infoReady = loadVideoInfo(); + startHLSStream(opts.startPos || 0); + infoReady.then(() => updatePlayerButtons()); + } // Events videoEl.addEventListener("timeupdate", onTimeUpdate); @@ -65,7 +103,8 @@ function initPlayer(opts) { videoEl.addEventListener("click", togglePlay); // Loading ausblenden sobald Video laeuft (mehrere Events als Sicherheit) videoEl.addEventListener("playing", onPlaying); - videoEl.addEventListener("canplay", hideLoading, {once: true}); + videoEl.addEventListener("canplay", hideLoading); + videoEl.addEventListener("loadeddata", hideLoading); // Video-Error: Automatisch Retry mit Fallback videoEl.addEventListener("error", onVideoError); @@ -183,20 +222,29 @@ async function loadVideoInfo() { * -> Alle unterstuetzten Codecs melden */ function detectSupportedCodecs() { + // VKNative Bridge: Codec-Liste vom nativen Player abfragen + if (window.VKNative && window.VKNative.getSupportedVideoCodecs) { + var nativeCodecs = window.VKNative.getSupportedVideoCodecs(); + if (nativeCodecs && nativeCodecs.length) { + console.info("[Player] VKNative Video-Codecs:", nativeCodecs.join(", ")); + return nativeCodecs; + } + } + const codecs = []; const el = document.createElement("video"); const hasNativeHLS = !!el.canPlayType("application/vnd.apple.mpegurl"); const hasMSE = typeof MediaSource !== "undefined" && MediaSource.isTypeSupported; - if (!hasNativeHLS && hasMSE) { - // MSE-basiert (hls.js auf Chrome/Firefox/Edge): zuverlaessige Erkennung + if (hasMSE) { + // MSE-basiert: zuverlaessige Codec-Erkennung + // (auch auf Tizen, da wir hls.js dem nativen Player vorziehen) if (MediaSource.isTypeSupported('video/mp4; codecs="avc1.640028"')) codecs.push("h264"); if (MediaSource.isTypeSupported('video/mp4; codecs="hev1.1.6.L93.B0"')) codecs.push("hevc"); if (MediaSource.isTypeSupported('video/mp4; codecs="av01.0.05M.08"')) codecs.push("av1"); if (MediaSource.isTypeSupported('video/mp4; codecs="vp09.00.10.08"')) codecs.push("vp9"); } else { - // Natives HLS (Samsung Tizen, Safari, iOS): - // Konservativ - nur H.264 melden, da AV1/VP9 in HLS-fMP4 nicht zuverlaessig + // Kein MSE: konservativ nur H.264 + HEVC melden codecs.push("h264"); if (el.canPlayType('video/mp4; codecs="hev1.1.6.L93.B0"') || el.canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"')) { @@ -214,18 +262,34 @@ let loadingTimer = null; function showLoading() { var el = document.getElementById("player-loading"); - if (el) { el.classList.remove("hidden"); el.style.display = ""; } - // Fallback: Loading nach 8 Sekunden ausblenden (falls Events nicht feuern) + if (el) { + el.classList.remove("hidden"); + el.classList.remove("fade-out"); + el.style.display = ""; + } + // Fallback: Loading nach 8 Sekunden ausblenden und Controls zeigen clearTimeout(loadingTimer); - loadingTimer = setTimeout(hideLoading, 8000); + loadingTimer = setTimeout(function() { + hideLoading(); + // Falls Video noch nicht spielt: Controls anzeigen damit User manuell starten kann + if (videoEl && videoEl.paused) { + showControls(); + if (playBtn) playBtn.innerHTML = "▶"; + } + }, 8000); } function hideLoading() { clearTimeout(loadingTimer); clearTimeout(loadingTimeout); loadingTimeout = null; var el = document.getElementById("player-loading"); - if (!el) return; - el.style.display = "none"; + if (!el || el.classList.contains("fade-out")) return; + // Sanfter Uebergang: Loading-Overlay blendet ueber 1.5s aus + // Puffert gleichzeitig das Video waehrend der Ueberblendung + el.classList.add("fade-out"); + setTimeout(function() { + el.style.display = "none"; + }, 1600); } function onPlaying() { @@ -252,6 +316,304 @@ function onVideoError() { } } +// === AVPlay Direct-Play === + +/** + * Versucht Direct-Play via AVPlay (nur auf Tizen). + * Laedt erweiterte Video-Info vom Server und prueft Kompatibilitaet. + * @returns {boolean} true wenn Direct-Play gestartet wurde + */ +async function _tryDirectPlay(startPosSec) { + try { + // Erweiterte Video-Info laden (inkl. audio_codecs, video_codec_normalized) + const resp = await fetch(`/tv/api/video-info/${cfg.videoId}`); + if (!resp.ok) return false; + + const info = await resp.json(); + videoInfo = info; // Video-Info global setzen + + // Bevorzugte Audio-/Untertitel-Spur finden + if (info.audio_tracks) { + const prefIdx = info.audio_tracks.findIndex( + a => a.lang === cfg.preferredAudio); + if (prefIdx >= 0) currentAudio = prefIdx; + } + if (cfg.subtitlesEnabled && cfg.preferredSub && info.subtitle_tracks) { + const subIdx = info.subtitle_tracks.findIndex( + s => s.lang === cfg.preferredSub); + if (subIdx >= 0) currentSub = subIdx; + } + + // AVPlay Kompatibilitaet pruefen + if (!AVPlayBridge.canPlay(info)) { + console.info("[Player] Direct-Play nicht kompatibel"); + return false; + } + + // Direct-Play starten + useDirectPlay = true; + const directUrl = info.direct_play_url; + if (!directUrl) return false; + + // Video-Element verstecken, AVPlay-Object anzeigen + if (videoEl) videoEl.style.display = "none"; + + const ok = AVPlayBridge.play(directUrl, { + seekMs: Math.floor(startPosSec * 1000), + onReady: () => { + hideLoading(); + showControls(); + scheduleHideControls(); + }, + onTimeUpdate: (ms) => { + // Progress-Bar und Zeit-Anzeige aktualisieren + const current = ms / 1000; + const dur = getDuration(); + if (progressBar && dur > 0) { + progressBar.style.width = ((current / dur) * 100) + "%"; + } + if (timeDisplay) { + timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur); + } + }, + onComplete: () => { + onEnded(); + }, + onError: (err) => { + console.error("[Player] AVPlay Fehler:", err); + if (typeof showToast === "function") + showToast("Direct-Play Fehler, wechsle zu HLS...", "error"); + // Fallback auf HLS + _cleanupDirectPlay(); + useDirectPlay = false; + if (videoEl) videoEl.style.display = ""; + startHLSStream(startPosSec); + }, + onBuffering: (buffering) => { + if (buffering) showLoading(); + else hideLoading(); + }, + }); + + if (!ok) { + _cleanupDirectPlay(); + return false; + } + + updatePlayerButtons(); + return true; + } catch (e) { + console.error("[Player] Direct-Play Init fehlgeschlagen:", e); + _cleanupDirectPlay(); + return false; + } +} + +/** AVPlay Direct-Play bereinigen */ +function _cleanupDirectPlay() { + if (typeof AVPlayBridge !== "undefined") { + AVPlayBridge.stop(); + } + useDirectPlay = false; + if (videoEl) videoEl.style.display = ""; +} + +// === VKNative Direct-Play === + +/** + * Versucht Direct-Play ueber VKNative Bridge (Tizen AVPlay / Android ExoPlayer). + * Laedt Video-Info und prueft Kompatibilitaet. Faellt bei Fehler auf HLS zurueck. + */ +async function _tryNativeDirectPlay(startPosSec) { + console.info("[Player] _tryNativeDirectPlay start (videoId=" + cfg.videoId + ", startPos=" + startPosSec + ")"); + try { + // Erweiterte Video-Info laden + console.info("[Player] Lade Video-Info..."); + var resp = await fetch("/tv/api/video-info/" + cfg.videoId); + console.info("[Player] Video-Info Response: HTTP " + resp.status); + if (!resp.ok) { + console.warn("[Player] Video-Info nicht ladbar (HTTP " + resp.status + ")"); + _nativeFallbackToHLS(startPosSec); + return; + } + + var info = await resp.json(); + videoInfo = info; + console.info("[Player] Video-Info geladen: " + (info.video_codec_normalized || "?") + + "/" + (info.container || "?") + ", Audio: " + (info.audio_codecs || []).join(",")); + + // Bevorzugte Audio-/Untertitel-Spur finden + if (info.audio_tracks) { + var prefIdx = info.audio_tracks.findIndex( + function(a) { return a.lang === cfg.preferredAudio; }); + if (prefIdx >= 0) currentAudio = prefIdx; + } + if (cfg.subtitlesEnabled && cfg.preferredSub && info.subtitle_tracks) { + var subIdx = info.subtitle_tracks.findIndex( + function(s) { return s.lang === cfg.preferredSub; }); + if (subIdx >= 0) currentSub = subIdx; + } + + // Untertitel als hinzufuegen + if (info.subtitle_tracks) { + info.subtitle_tracks.forEach(function(sub, i) { + var track = document.createElement("track"); + track.kind = "subtitles"; + track.src = "/api/library/videos/" + cfg.videoId + "/subtitles/" + i; + track.srclang = sub.lang || "und"; + track.label = langName(sub.lang) || "Spur " + (i + 1); + if (i === currentSub) track.default = true; + videoEl.appendChild(track); + }); + updateSubtitleTrack(); + } + + // VKNative Kompatibilitaets-Pruefung + var canPlay = window.VKNative.canDirectPlay(info); + console.info("[Player] VKNative canDirectPlay: " + canPlay); + if (!canPlay) { + console.info("[Player] VKNative: Codec nicht kompatibel -> HLS Fallback"); + _nativeFallbackToHLS(startPosSec); + return; + } + + // VKNative Callbacks registrieren + window._vkOnReady = function() { + nativePlayStarted = true; + clearTimeout(nativePlayTimeout); + nativePlayTimeout = null; + hideLoading(); + showControls(); + scheduleHideControls(); + console.info("[Player] VKNative Direct-Play gestartet"); + }; + window._vkOnTimeUpdate = function(ms) { + var current = 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 Fehler:", msg); + clearTimeout(nativePlayTimeout); + nativePlayTimeout = null; + if (typeof showToast === "function") + showToast("Direct-Play Fehler, wechsle zu HLS...", "error"); + _cleanupNativePlayer(); + startHLSStream(startPosSec); + }; + window._vkOnBuffering = function(buffering) { + // Nur Loading-Text aendern, nicht den ganzen Spinner neu starten + // (wuerde den Master-Timeout zuruecksetzen) + var el = document.getElementById("player-loading"); + if (el) { + var textEl = el.querySelector(".player-loading-text"); + if (textEl) textEl.textContent = buffering ? "Puffert..." : "Stream wird geladen..."; + } + }; + window._vkOnPlayStateChanged = function(playing) { + if (playing) { + nativePlayStarted = true; + clearTimeout(nativePlayTimeout); + nativePlayTimeout = null; + onPlay(); + } else { + onPause(); + } + }; + + // Direct-Play starten + var directUrl = info.direct_play_url; + console.info("[Player] Direct-Play URL: " + directUrl); + if (!directUrl) { + console.warn("[Player] Keine Direct-Play-URL vorhanden"); + _nativeFallbackToHLS(startPosSec); + return; + } + + // Stream-Token holen (fuer AVPlay, das keinen Cookie-Jar hat) + try { + var tokenResp = await fetch("/tv/api/stream-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ video_id: cfg.videoId }), + }); + if (tokenResp.ok) { + var tokenData = await tokenResp.json(); + if (tokenData.token) { + directUrl += (directUrl.indexOf("?") >= 0 ? "&" : "?") + + "token=" + encodeURIComponent(tokenData.token); + console.info("[Player] Stream-Token angehaengt"); + } + } + } catch (tokenErr) { + console.warn("[Player] Stream-Token Fehler (ignoriert):", tokenErr); + } + + useNativePlayer = true; + console.info("[Player] VKNative.play() aufrufen..."); + var ok = window.VKNative.play(directUrl, info, { + seekMs: Math.floor(startPosSec * 1000) + }); + console.info("[Player] VKNative.play() Ergebnis: " + ok); + + if (!ok) { + console.warn("[Player] VKNative.play() fehlgeschlagen"); + _cleanupNativePlayer(); + _nativeFallbackToHLS(startPosSec); + return; + } + + updatePlayerButtons(); + + } catch (e) { + console.error("[Player] VKNative Init fehlgeschlagen:", e); + clearTimeout(nativePlayTimeout); + nativePlayTimeout = null; + _cleanupNativePlayer(); + _nativeFallbackToHLS(startPosSec); + } +} + +/** VKNative bereinigen */ +function _cleanupNativePlayer() { + console.info("[Player] _cleanupNativePlayer()"); + clearTimeout(nativePlayTimeout); + nativePlayTimeout = null; + if (window.VKNative) { + try { window.VKNative.stop(); } catch (e) { console.warn("[Player] VKNative.stop() Fehler:", e); } + } + useNativePlayer = false; + nativePlayStarted = false; + // Callbacks entfernen + window._vkOnReady = null; + window._vkOnTimeUpdate = null; + window._vkOnComplete = null; + window._vkOnError = null; + window._vkOnBuffering = null; + window._vkOnPlayStateChanged = null; +} + +/** Fallback von VKNative auf HLS */ +function _nativeFallbackToHLS(startPosSec) { + console.info("[Player] Fallback auf HLS (startPos=" + startPosSec + ")"); + clearTimeout(nativePlayTimeout); + nativePlayTimeout = null; + useNativePlayer = false; + nativePlayStarted = false; + if (videoEl) videoEl.style.display = ""; + var infoReady = loadVideoInfo(); + startHLSStream(startPosSec); + infoReady.then(function() { updatePlayerButtons(); }); +} + // === HLS Streaming === async function startHLSStream(seekSec) { @@ -295,19 +657,24 @@ async function startHLSStream(seekSec) { let networkRetries = 0; const MAX_RETRIES = 3; - // HLS abspielen - if (videoEl.canPlayType("application/vnd.apple.mpegurl")) { - // Native HLS (Safari, Tizen) - videoEl.src = playlistUrl; - hlsReady = true; - videoEl.addEventListener("playing", hideLoading, {once: true}); - videoEl.play().catch(() => {}); - } else if (typeof Hls !== "undefined" && Hls.isSupported()) { - // hls.js Polyfill (Chrome, Firefox, Edge) + // HLS abspielen - hls.js bevorzugen (bessere A/V-Sync-Korrektur + // als native HLS-Player, besonders bei fMP4-Remux von WebM-Quellen) + if (typeof Hls !== "undefined" && Hls.isSupported()) { + // hls.js (bevorzugt - funktioniert zuverlaessig auf allen Plattformen) hlsInstance = new Hls({ - maxBufferLength: 30, - maxMaxBufferLength: 60, + maxBufferLength: 60, // 60s Vorpuffer (mehr Reserve) + maxMaxBufferLength: 120, // Max 120s vorpuffern + maxBufferSize: 200 * 1024 * 1024, // 200 MB RAM-Limit + backBufferLength: 30, // Bereits gesehene 30s behalten startLevel: -1, + fragLoadPolicy: { + default: { + maxTimeToFirstByteMs: 10000, + maxLoadTimeMs: 30000, // 30s fuer grosse Segmente (AV1 copy: 20-34 MB) + timeoutRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 }, + errorRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 }, + }, + }, }); hlsInstance.loadSource(playlistUrl); hlsInstance.attachMedia(videoEl); @@ -315,7 +682,12 @@ async function startHLSStream(seekSec) { hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { hlsReady = true; videoEl.addEventListener("playing", hideLoading, {once: true}); - videoEl.play().catch(() => {}); + videoEl.play().catch(e => { + console.warn("Autoplay blockiert:", e); + hideLoading(); + showControls(); + if (playBtn) playBtn.innerHTML = "▶"; + }); }); hlsInstance.on(Hls.Events.ERROR, (event, data) => { @@ -339,9 +711,20 @@ async function startHLSStream(seekSec) { } } }); + } else if (videoEl.canPlayType("application/vnd.apple.mpegurl")) { + // Nativer HLS-Player (Fallback wenn hls.js nicht verfuegbar) + videoEl.src = playlistUrl; + hlsReady = true; + videoEl.addEventListener("playing", hideLoading, {once: true}); + videoEl.play().catch(e => { + console.warn("Autoplay blockiert (nativ):", e); + hideLoading(); + showControls(); + if (playBtn) playBtn.innerHTML = "▶"; + }); } else { // Kein HLS moeglich -> Fallback - console.warn("Weder natives HLS noch hls.js verfuegbar"); + console.warn("Weder hls.js noch natives HLS verfuegbar"); setStreamUrlLegacy(seekSec); } } catch (e) { @@ -363,7 +746,12 @@ function setStreamUrlLegacy(seekSec) { if (seekSec > 0) params.set("t", Math.floor(seekSec)); videoEl.src = `/api/library/videos/${cfg.videoId}/stream?${params}`; videoEl.addEventListener("playing", hideLoading, {once: true}); - videoEl.play().catch(() => {}); + videoEl.play().catch(e => { + console.warn("Autoplay blockiert (Legacy):", e); + hideLoading(); + showControls(); + if (playBtn) playBtn.innerHTML = "▶"; + }); } /** HLS aufraumen: hls.js + Server-Session beenden */ @@ -384,6 +772,17 @@ async function cleanupHLS() { // === Playback-Controls === function togglePlay() { + if (useNativePlayer && window.VKNative) { + window.VKNative.togglePlay(); + // PlayStateChanged-Callback aktualisiert Icon automatisch + return; + } + if (useDirectPlay && typeof AVPlayBridge !== "undefined") { + AVPlayBridge.togglePlay(); + if (AVPlayBridge.isPlaying()) onPlay(); + else onPause(); + return; + } if (!videoEl) return; if (videoEl.paused) videoEl.play(); else videoEl.pause(); @@ -400,8 +799,9 @@ function onPause() { saveProgress(); } -function onEnded() { - saveProgress(true); +async function onEnded() { + // Fortschritt + Watch-Status speichern (wartet auf API-Antwort) + await saveProgress(true); episodesWatched++; // Schaust du noch? (wenn Max-Episoden erreicht) @@ -421,6 +821,22 @@ function onEnded() { // === Seeking === function seekRelative(seconds) { + if (useNativePlayer && window.VKNative) { + let cur = getCurrentTime(); + let dur = getDuration(); + let newMs = Math.max(0, Math.min((cur + seconds) * 1000, dur * 1000)); + window.VKNative.seek(newMs); + showControls(); + return; + } + if (useDirectPlay && typeof AVPlayBridge !== "undefined") { + const cur = getCurrentTime(); + const dur = getDuration(); + const newMs = Math.max(0, Math.min((cur + seconds) * 1000, dur * 1000)); + AVPlayBridge.seek(newMs); + showControls(); + return; + } if (!videoEl) return; const dur = getDuration(); const cur = getCurrentTime(); @@ -439,15 +855,26 @@ function seekRelative(seconds) { } function onProgressClick(e) { - if (!videoEl) return; const rect = e.currentTarget.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const dur = getDuration(); if (!dur) return; - // Absolute Seek-Position im Video const seekTo = pct * dur; - // Immer neuen HLS-Stream starten (server-seitiger Seek) + + if (useNativePlayer && window.VKNative) { + window.VKNative.seek(seekTo * 1000); + showControls(); + return; + } + if (useDirectPlay && typeof AVPlayBridge !== "undefined") { + AVPlayBridge.seek(seekTo * 1000); + showControls(); + return; + } + + if (!videoEl) return; + // HLS: Immer neuen Stream starten (server-seitiger Seek) startHLSStream(seekTo); showControls(); } @@ -455,6 +882,12 @@ function onProgressClick(e) { // === Zeit-Funktionen === function getCurrentTime() { + if (useNativePlayer && window.VKNative) { + return window.VKNative.getCurrentTime() / 1000; // ms -> sec + } + if (useDirectPlay && typeof AVPlayBridge !== "undefined") { + return AVPlayBridge.getCurrentTime() / 1000; // ms -> sec + } if (!videoEl) return 0; // Bei HLS mit Server-Seek: videoEl.currentTime + Offset = echte Position return hlsSeekOffset + (videoEl.currentTime || 0); @@ -497,7 +930,10 @@ function showControls() { } function hideControls() { - if (!videoEl || videoEl.paused || popupOpen) return; + // Bei VKNative: Controls ausblenden wenn abgespielt wird + if (useNativePlayer && window.VKNative) { + if (!window.VKNative.isPlaying() || popupOpen) return; + } else if (!videoEl || videoEl.paused || popupOpen) return; const wrapper = document.getElementById("player-wrapper"); if (wrapper) wrapper.classList.add("player-hide-controls"); controlsVisible = false; @@ -691,8 +1127,29 @@ function _renderSpeedOptions() { function switchAudio(idx) { if (idx === currentAudio) return; currentAudio = idx; - // Neuen HLS-Stream mit anderer Audio-Spur starten const currentTime = getCurrentTime(); + + if (useNativePlayer && window.VKNative) { + // VKNative: Audio-Track wechseln versuchen + var ok = window.VKNative.setAudioTrack(idx); + if (ok) { + // Erfolg (z.B. Android ExoPlayer kann Track direkt wechseln) + renderPopup(popupSection); + updatePlayerButtons(); + return; + } + // Nicht moeglich (z.B. Tizen AVPlay) -> HLS Fallback + console.info("[Player] VKNative Audio-Wechsel nicht moeglich -> HLS Fallback"); + _cleanupNativePlayer(); + if (videoEl) videoEl.style.display = ""; + } + if (useDirectPlay) { + console.info("[Player] Audio-Wechsel -> HLS Fallback"); + _cleanupDirectPlay(); + useDirectPlay = false; + if (videoEl) videoEl.style.display = ""; + } + // Neuen HLS-Stream mit anderer Audio-Spur starten startHLSStream(currentTime); renderPopup(popupSection); updatePlayerButtons(); @@ -723,6 +1180,9 @@ function switchQuality(q) { function switchSpeed(s) { currentSpeed = s; + if (useNativePlayer && window.VKNative) { + window.VKNative.setPlaybackSpeed(s); + } if (videoEl) videoEl.playbackRate = s; renderPopup(popupSection); } @@ -747,9 +1207,11 @@ function showNextEpisodeOverlay() { if (countdownEl) countdownEl.textContent = remaining + "s"; } -function playNextEpisode() { +async function playNextEpisode() { if (nextCountdown) clearInterval(nextCountdown); - cleanupHLS(); + if (useNativePlayer) _cleanupNativePlayer(); + if (useDirectPlay) _cleanupDirectPlay(); + await cleanupHLS(); if (cfg.nextUrl) window.location.href = cfg.nextUrl; } @@ -798,7 +1260,7 @@ function _focusNext(direction) { // === Tastatur-Steuerung === function onKeyDown(e) { - // Samsung Tizen Remote Keys + // Samsung Tizen Remote Keys + Android TV Media Keys const keyMap = { 10009: "Escape", 10182: "Escape", 415: "Play", 19: "Pause", 413: "Stop", @@ -806,6 +1268,15 @@ function onKeyDown(e) { // Samsung Farbtasten 403: "ColorRed", 404: "ColorGreen", 405: "ColorYellow", 406: "ColorBlue", + // Android TV Media Keys + 85: "Play", // KEYCODE_MEDIA_PLAY_PAUSE + 126: "Play", // KEYCODE_MEDIA_PLAY + 127: "Pause", // KEYCODE_MEDIA_PAUSE + 86: "Stop", // KEYCODE_MEDIA_STOP + 87: "FastForward", // KEYCODE_MEDIA_NEXT + 88: "Rewind", // KEYCODE_MEDIA_PREVIOUS + 90: "FastForward", // KEYCODE_MEDIA_FAST_FORWARD + 89: "Rewind", // KEYCODE_MEDIA_REWIND }; const key = keyMap[e.keyCode] || e.key; @@ -912,6 +1383,8 @@ function onKeyDown(e) { e.preventDefault(); break; case "Escape": case "Backspace": case "Stop": saveProgress(); + if (useNativePlayer) _cleanupNativePlayer(); + if (useDirectPlay) _cleanupDirectPlay(); cleanupHLS(); setTimeout(() => window.history.back(), 100); e.preventDefault(); break; @@ -937,7 +1410,7 @@ function onKeyDown(e) { // === Watch-Progress speichern === function saveProgress(completed) { - if (!cfg.videoId || !videoEl) return; + if (!cfg.videoId || (!videoEl && !useDirectPlay && !useNativePlayer)) return; const dur = getDuration(); // Bei completed: Position = Duration (garantiert ueber Schwelle) const pos = completed ? dur : getCurrentTime(); @@ -950,7 +1423,7 @@ function saveProgress(completed) { }; if (completed) payload.completed = true; - fetch("/tv/api/watch-progress", { + return fetch("/tv/api/watch-progress", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(payload), @@ -958,8 +1431,35 @@ function saveProgress(completed) { } window.addEventListener("beforeunload", () => { - saveProgress(); - cleanupHLS(); + // Fortschritt per sendBeacon speichern (zuverlaessig beim Seitenabbau) + if (cfg.videoId && (videoEl || useDirectPlay || useNativePlayer)) { + const dur = getDuration(); + const pos = getCurrentTime(); + if (pos >= 5) { + navigator.sendBeacon("/tv/api/watch-progress", + new Blob([JSON.stringify({ + video_id: cfg.videoId, + position_sec: pos, + duration_sec: dur, + })], {type: "application/json"})); + } + } + // VKNative bereinigen + if (useNativePlayer && window.VKNative) { + try { window.VKNative.stop(); } catch (e) { /* ignorieren */ } + } + // Legacy AVPlay bereinigen + if (useDirectPlay && typeof AVPlayBridge !== "undefined") { + AVPlayBridge.stop(); + } + // HLS-Session per sendBeacon beenden (fetch wird beim Seitenabbau abgebrochen) + if (hlsSessionId) { + navigator.sendBeacon(`/tv/api/hls/${hlsSessionId}/stop`, ""); + } + if (hlsInstance) { + hlsInstance.destroy(); + hlsInstance = null; + } }); // === Button-Status aktualisieren === diff --git a/video-konverter/app/static/tv/js/vknative-bridge.js b/video-konverter/app/static/tv/js/vknative-bridge.js new file mode 100644 index 0000000..dc61a8a --- /dev/null +++ b/video-konverter/app/static/tv/js/vknative-bridge.js @@ -0,0 +1,442 @@ +/** + * VideoKonverter TV - VKNative Bridge v2.0 + * Einheitliches Interface fuer native Video-Player auf allen Plattformen. + * + * Modus 1 - Tizen iframe (postMessage): + * Laeuft im iframe auf Tizen-TV. Kommuniziert per postMessage mit dem + * Parent-Frame (tizen-app/index.html), der AVPlay steuert. + * webapis.avplay ist im iframe NICHT verfuegbar. + * + * Modus 2 - Tizen direkt (legacy, falls webapis doch verfuegbar): + * Direkter Zugriff auf webapis.avplay (Fallback). + * + * Android: window.VKNative wird von der Kotlin-App per @JavascriptInterface injiziert. + * Diese Bridge erkennt das und ueberspringt sich selbst. + * + * Interface: window.VKNative + * Callbacks: window._vkOnReady, _vkOnTimeUpdate, _vkOnComplete, + * _vkOnError, _vkOnBuffering, _vkOnPlayStateChanged + */ + +(function() { + "use strict"; + + // Bereits von Android injiziert? Dann nichts tun + if (window.VKNative) { + console.info("[VKNative] Bridge bereits vorhanden (platform: " + window.VKNative.platform + ")"); + return; + } + + // Tizen-Erkennung (User-Agent, auch im iframe gueltig) + var isTizen = /Tizen/i.test(navigator.userAgent); + if (!isTizen) { + // Kein Tizen -> Bridge nicht noetig (Desktop/Handy nutzt HLS) + return; + } + + // Im iframe? (Parent-Frame hat AVPlay) + var inIframe = (window.parent !== window); + + // AVPlay direkt verfuegbar? (nur im Parent-Frame oder bei altem Redirect-Ansatz) + var avplayDirect = false; + try { + avplayDirect = typeof webapis !== "undefined" && typeof webapis.avplay !== "undefined"; + } catch (e) { + avplayDirect = false; + } + + // === MODUS 1: iframe + postMessage === + if (inIframe && !avplayDirect) { + console.info("[VKNative] Tizen iframe-Modus: postMessage Bridge wird initialisiert"); + + var _parentReady = false; + var _videoCodecs = ["h264", "hevc", "av1", "vp9"]; + var _audioCodecs = ["aac", "opus", "ac3", "eac3", "flac", "mp3", "vorbis", "pcm"]; + var _playing = false; + var _currentTimeMs = 0; + var _durationMs = 0; + + // Unterstuetzte/Nicht-unterstuetzte Codecs (fuer canDirectPlay) + var UNSUPPORTED_AUDIO = ["dts", "dca", "dts_hd", "dts-hd", "truehd"]; + var SUPPORTED_CONTAINERS = ["mkv", "matroska", "mp4", "webm", "avi", "ts"]; + + // Events vom Parent empfangen + window.addEventListener("message", function(event) { + // Nur Nachrichten vom Parent akzeptieren + if (event.source !== window.parent) return; + + var data = event.data; + if (!data || !data.type) return; + + switch (data.type) { + case "vknative_ready": + // Parent bestaetigt: AVPlay ist verfuegbar + _parentReady = true; + if (data.videoCodecs) _videoCodecs = data.videoCodecs; + if (data.audioCodecs) _audioCodecs = data.audioCodecs; + console.info("[VKNative] Parent bereit, Codecs: " + + _videoCodecs.join(",") + " / " + _audioCodecs.join(",")); + break; + + case "vknative_event": + _handleParentEvent(data.event, data.detail || {}); + break; + } + }); + + // Events vom Parent an die Callbacks weiterleiten + function _handleParentEvent(event, detail) { + switch (event) { + case "ready": + _playing = true; + if (window._vkOnReady) window._vkOnReady(); + break; + + case "timeupdate": + _currentTimeMs = detail.ms || 0; + if (window._vkOnTimeUpdate) window._vkOnTimeUpdate(_currentTimeMs); + break; + + case "complete": + _playing = false; + if (window._vkOnComplete) window._vkOnComplete(); + break; + + case "error": + _playing = false; + if (window._vkOnError) window._vkOnError(detail.msg || "Unbekannter Fehler"); + break; + + case "buffering": + if (window._vkOnBuffering) window._vkOnBuffering(detail.buffering); + break; + + case "playstatechanged": + _playing = !!detail.playing; + if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(_playing); + break; + + case "stopped": + _playing = false; + _currentTimeMs = 0; + if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(false); + break; + + case "duration": + _durationMs = detail.ms || 0; + break; + } + } + + // Nachricht an Parent senden + function _callParent(method, args) { + window.parent.postMessage({ + type: "vknative_call", + method: method, + args: args || [], + }, "*"); + } + + // Probe senden: "Bist du bereit?" + function _probeParent() { + window.parent.postMessage({ type: "vknative_probe" }, "*"); + } + + // === VKNative Interface (postMessage-Modus) === + window.VKNative = { + platform: "tizen", + version: "2.0.0", + + getSupportedVideoCodecs: function() { + return _videoCodecs.slice(); + }, + + getSupportedAudioCodecs: function() { + return _audioCodecs.slice(); + }, + + canDirectPlay: function(videoInfo) { + // Video-Codec pruefen + var vc = (videoInfo.video_codec_normalized || "").toLowerCase(); + if (_videoCodecs.indexOf(vc) === -1) { + console.info("[VKNative] Video-Codec '" + vc + "' nicht unterstuetzt"); + return false; + } + + // Container pruefen + var container = (videoInfo.container || "").toLowerCase(); + if (container) { + var containerOk = false; + for (var i = 0; i < SUPPORTED_CONTAINERS.length; i++) { + if (container.indexOf(SUPPORTED_CONTAINERS[i]) !== -1) { + containerOk = true; + break; + } + } + if (!containerOk) { + console.info("[VKNative] Container '" + container + "' nicht unterstuetzt"); + return false; + } + } + + // Audio-Codecs pruefen - DTS/TrueHD blockieren + var audioCodecs = videoInfo.audio_codecs || []; + for (var j = 0; j < audioCodecs.length; j++) { + var ac = audioCodecs[j].toLowerCase(); + if (UNSUPPORTED_AUDIO.indexOf(ac) !== -1) { + console.info("[VKNative] Audio-Codec '" + ac + "' nicht unterstuetzt -> kein Direct-Play"); + return false; + } + } + + console.info("[VKNative] Direct-Play moeglich: " + vc + "/" + container); + return true; + }, + + play: function(url, videoInfo, opts) { + console.info("[VKNative] play() per postMessage: " + url); + _callParent("play", [url, videoInfo, opts]); + return true; + }, + + togglePlay: function() { + _callParent("togglePlay"); + }, + + pause: function() { + _callParent("pause"); + }, + + resume: function() { + _callParent("resume"); + }, + + seek: function(positionMs) { + _callParent("seek", [positionMs]); + }, + + getCurrentTime: function() { + // Letzte bekannte Position (wird per timeupdate-Event aktualisiert) + return _currentTimeMs; + }, + + getDuration: function() { + return _durationMs; + }, + + isPlaying: function() { + return _playing; + }, + + stop: function() { + _playing = false; + _currentTimeMs = 0; + _callParent("stop"); + }, + + setAudioTrack: function(index) { + console.info("[VKNative] Audio-Track-Wechsel auf Tizen nicht moeglich"); + return false; + }, + + setSubtitleTrack: function(index) { + return false; + }, + + setPlaybackSpeed: function(speed) { + _callParent("setPlaybackSpeed", [speed]); + return true; + }, + }; + + // Parent proben (wiederholt, falls Parent noch nicht bereit) + _probeParent(); + var _probeRetries = 0; + var _probeInterval = setInterval(function() { + if (_parentReady || _probeRetries > 20) { + clearInterval(_probeInterval); + if (!_parentReady) { + console.warn("[VKNative] Parent hat nach 10s nicht geantwortet"); + } + return; + } + _probeRetries++; + _probeParent(); + }, 500); + + console.info("[VKNative] Tizen postMessage Bridge bereit"); + return; + } + + // === MODUS 2: Direkter AVPlay-Zugriff (Legacy-Fallback) === + if (avplayDirect) { + console.info("[VKNative] Tizen Direct-AVPlay Bridge wird initialisiert"); + + var _playing2 = false; + var _duration2 = 0; + var _displayEl2 = null; + var _timeUpdateId2 = null; + + var SUPPORTED_VIDEO = ["h264", "hevc", "av1", "vp9"]; + var SUPPORTED_AUDIO = ["aac", "opus", "ac3", "eac3", "flac", "mp3", "vorbis", "pcm"]; + var UNSUPPORTED_AUDIO2 = ["dts", "dca", "dts_hd", "dts-hd", "truehd"]; + var SUPPORTED_CONTAINERS2 = ["mkv", "matroska", "mp4", "webm", "avi", "ts"]; + + function _startTimeUpdates2() { + _stopTimeUpdates2(); + _timeUpdateId2 = setInterval(function() { + if (_playing2 && window._vkOnTimeUpdate) { + try { + window._vkOnTimeUpdate(webapis.avplay.getCurrentTime()); + } catch (e) {} + } + }, 500); + } + + function _stopTimeUpdates2() { + if (_timeUpdateId2) { + clearInterval(_timeUpdateId2); + _timeUpdateId2 = null; + } + } + + function _resolveUrl2(url) { + if (url.indexOf("://") !== -1) return url; + return window.location.origin + url; + } + + window.VKNative = { + platform: "tizen", + version: "2.0.0", + + getSupportedVideoCodecs: function() { return SUPPORTED_VIDEO.slice(); }, + getSupportedAudioCodecs: function() { return SUPPORTED_AUDIO.slice(); }, + + canDirectPlay: function(videoInfo) { + var vc = (videoInfo.video_codec_normalized || "").toLowerCase(); + if (SUPPORTED_VIDEO.indexOf(vc) === -1) return false; + var container = (videoInfo.container || "").toLowerCase(); + if (container) { + var ok = false; + for (var i = 0; i < SUPPORTED_CONTAINERS2.length; i++) { + if (container.indexOf(SUPPORTED_CONTAINERS2[i]) !== -1) { ok = true; break; } + } + if (!ok) return false; + } + var ac2 = videoInfo.audio_codecs || []; + for (var j = 0; j < ac2.length; j++) { + if (UNSUPPORTED_AUDIO2.indexOf(ac2[j].toLowerCase()) !== -1) return false; + } + return true; + }, + + play: function(url, videoInfo, opts) { + opts = opts || {}; + var seekMs = opts.seekMs || 0; + var fullUrl = _resolveUrl2(url); + + 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() {}, + }); + + function _start() { + 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)); + } + } + + webapis.avplay.prepareAsync( + function() { + try { _duration2 = webapis.avplay.getDuration(); } catch (e) { _duration2 = 0; } + if (seekMs > 0) { + try { + webapis.avplay.seekTo(seekMs, function() { _start(); }, function() { _start(); }); + } catch (e) { _start(); } + } else { + _start(); + } + }, + function(err) { if (window._vkOnError) window._vkOnError(String(err)); } + ); + return true; + } catch (e) { + if (window._vkOnError) window._vkOnError(e.message || String(e)); + return false; + } + }, + + togglePlay: function() { + try { + var state = webapis.avplay.getState(); + if (state === "PLAYING") { + webapis.avplay.pause(); _playing2 = false; + if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(false); + } else if (state === "PAUSED" || state === "READY") { + webapis.avplay.play(); _playing2 = true; + if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(true); + } + } catch (e) {} + }, + + pause: function() { + try { if (_playing2) { webapis.avplay.pause(); _playing2 = false; if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(false); } } catch (e) {} + }, + resume: function() { + try { webapis.avplay.play(); _playing2 = true; if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(true); } catch (e) {} + }, + seek: function(ms) { + try { webapis.avplay.seekTo(Math.max(0, Math.floor(ms)), function(){}, function(){}); } catch (e) {} + }, + getCurrentTime: function() { try { return webapis.avplay.getCurrentTime(); } catch (e) { return 0; } }, + getDuration: function() { try { return _duration2 || webapis.avplay.getDuration(); } catch (e) { return 0; } }, + isPlaying: function() { return _playing2; }, + stop: function() { + _stopTimeUpdates2(); _playing2 = false; + try { var s = webapis.avplay.getState(); if (s !== "IDLE" && s !== "NONE") webapis.avplay.stop(); webapis.avplay.close(); } catch (e) {} + if (_displayEl2) { _displayEl2.style.display = "none"; _displayEl2 = null; } + var v = document.getElementById("player-video"); if (v) v.style.display = ""; + }, + setAudioTrack: function() { return false; }, + setSubtitleTrack: function() { return false; }, + setPlaybackSpeed: function(speed) { + try { webapis.avplay.setSpeed(speed); return true; } catch (e) { return false; } + }, + }; + + console.info("[VKNative] Tizen Direct-AVPlay Bridge bereit"); + return; + } + + // Weder iframe noch AVPlay verfuegbar -> kein VKNative + console.warn("[VKNative] Tizen erkannt, aber weder iframe-Parent noch webapis.avplay verfuegbar"); +})(); diff --git a/video-konverter/app/static/tv/sw.js b/video-konverter/app/static/tv/sw.js index c5c47e0..7ae6899 100644 --- a/video-konverter/app/static/tv/sw.js +++ b/video-konverter/app/static/tv/sw.js @@ -4,11 +4,12 @@ * Kein Offline-Caching noetig (Streaming braucht Netzwerk) */ -const CACHE_NAME = "vk-tv-v1"; +const CACHE_NAME = "vk-tv-v11"; const STATIC_ASSETS = [ "/static/tv/css/tv.css", "/static/tv/js/tv.js", "/static/tv/js/player.js", + "/static/tv/js/vknative-bridge.js", "/static/tv/icons/icon-192.png", ]; @@ -38,9 +39,10 @@ self.addEventListener("fetch", (event) => { // Nur GET-Requests cachen if (event.request.method !== "GET") return; - // Streaming/API nie cachen + // API-Requests und Streaming nie cachen const url = new URL(event.request.url); - if (url.pathname.startsWith("/api/") || url.pathname.includes("/stream")) { + if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/tv/api/") + || url.pathname.includes("/stream") || url.pathname.includes("/direct-stream")) { return; } diff --git a/video-konverter/app/templates/admin.html b/video-konverter/app/templates/admin.html index 9a3fcd4..ad27bd2 100644 --- a/video-konverter/app/templates/admin.html +++ b/video-konverter/app/templates/admin.html @@ -247,19 +247,113 @@

Encoding-Presets

-
+
{% for key, preset in presets.items() %} -
-

{{ preset.name }}

-
- {{ preset.video_codec }} - {{ preset.container }} - {{ preset.quality_param }}={{ preset.quality_value }} - {% if preset.hw_init %}GPU{% else %}CPU{% endif %} +
+
+
+

{{ preset.name }}

+
+ {{ preset.video_codec }} + {{ preset.container }} + {{ preset.quality_param }}={{ preset.quality_value }} + {% if preset.hw_init %}GPU{% else %}CPU{% endif %} + {% if key == settings.encoding.default_preset %}Standard{% endif %} +
+
+ +
+ +
{% endfor %}
+
+ +
{% endblock %} @@ -340,6 +434,78 @@ function scanPath(pathId) { } +// === Preset-Editor === + +function togglePresetEdit(key) { + const body = document.getElementById("preset-body-" + key); + const toggle = document.getElementById("toggle-" + key); + const open = body.style.display === "none"; + body.style.display = open ? "" : "none"; + toggle.innerHTML = open ? "▲" : "▼"; +} + +async function setDefaultPreset(key) { + const resp = await fetch("/api/settings", { + method: "PUT", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({encoding: {default_preset: key}}) + }); + if (resp.ok) { + showToast("Standard-Preset geaendert", "success"); + location.reload(); + } else { + showToast("Fehler beim Aendern", "error"); + } +} + +async function deletePreset(key) { + if (!await showConfirm('Preset "' + key + '" wirklich loeschen?', + {title: "Preset loeschen", okText: "Loeschen", icon: "danger", danger: true})) return; + + const resp = await fetch("/api/presets/" + key, {method: "DELETE"}); + const data = await resp.json(); + if (data.error) { + showToast("Fehler: " + data.error, "error"); + } else { + showToast("Preset geloescht", "success"); + location.reload(); + } +} + +async function showNewPresetForm() { + const key = prompt("Preset-Schluessel (z.B. gpu_vp9):"); + if (!key) return; + if (!/^[a-z][a-z0-9_]*$/.test(key.trim())) { + showToast("Nur Kleinbuchstaben, Zahlen und Unterstriche erlaubt", "error"); + return; + } + const resp = await fetch("/api/presets", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + key: key.trim(), + preset: { + name: key.trim(), + video_codec: "libx264", + container: "mp4", + quality_param: "crf", + quality_value: 23, + gop_size: 240, + video_filter: "", + hw_init: false, + extra_params: {} + } + }) + }); + const data = await resp.json(); + if (data.error) { + showToast("Fehler: " + data.error, "error"); + } else { + showToast("Preset erstellt", "success"); + location.reload(); + } +} + document.addEventListener("DOMContentLoaded", () => { loadLibraryPaths(); }); diff --git a/video-konverter/app/templates/tv/base.html b/video-konverter/app/templates/tv/base.html index 17a2e53..12d0882 100644 --- a/video-konverter/app/templates/tv/base.html +++ b/video-konverter/app/templates/tv/base.html @@ -44,7 +44,7 @@ {% block content %}{% endblock %} - + {% block scripts %}{% endblock %} - + + + + + + +