fix: Tizen-App iframe + Cookie-Fix für Cross-Origin

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: <object id="avplayer"> nur im Parent, NICHT im iframe

Player-Template (video-konverter/app/templates/tv/player.html):
- <object id="avplayer"> 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 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-07 08:36:13 +01:00
parent e2bf70b280
commit 93983cf6ee
36 changed files with 4107 additions and 171 deletions

View file

@ -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")
}

10
android-app/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,10 @@
# VideoKonverter Android App - ProGuard Regeln
# JavaScript Interface Methoden nicht entfernen
-keepclassmembers class de.datait.videokonverter.NativePlayerBridge {
@android.webkit.JavascriptInterface <methods>;
}
# ExoPlayer
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Android TV (optional) -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.VideoKonverter"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<!-- Setup: Server-URL eingeben (erster Start) -->
<activity
android:name=".SetupActivity"
android:exported="true"
android:theme="@style/Theme.VideoKonverter">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- Haupt-Activity: WebView -->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="false" />
</application>
</manifest>

View file

@ -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()
}
}

View file

@ -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<String>()
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)
}
}
}

View file

@ -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<EditText>(R.id.serverUrl)
val btnConnect = findViewById<Button>(R.id.btnConnect)
val errorText = findViewById<TextView>(R.id.errorText)
// Enter-Taste -> Verbinden
serverInput.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
connectToServer(serverInput.text.toString(), errorText, prefs)
true
} else false
}
btnConnect.setOnClickListener {
connectToServer(serverInput.text.toString(), errorText, prefs)
}
}
private fun connectToServer(
input: String,
errorText: TextView,
prefs: android.content.SharedPreferences
) {
var url = input.trim()
if (url.isBlank()) {
errorText.text = "Bitte Server-Adresse eingeben"
errorText.visibility = View.VISIBLE
return
}
// Protokoll ergaenzen
if (!url.contains("://")) {
url = "http://$url"
}
// Port ergaenzen falls nicht vorhanden
val hasPort = url.substringAfter("://").contains(":")
if (!hasPort) {
url = "$url:8080"
}
// Trailing Slash entfernen
url = url.trimEnd('/')
// URL speichern
prefs.edit().putString("server_url", url).apply()
startMainActivity(url)
}
private fun startMainActivity(serverUrl: String) {
val intent = Intent(this, MainActivity::class.java)
intent.putExtra("server_url", serverUrl)
startActivity(intent)
finish()
}
}

View file

@ -0,0 +1,22 @@
package de.datait.videokonverter
import android.util.Log
import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
/**
* Custom WebChromeClient: Console-Logs weiterleiten.
*/
class VKWebChromeClient : WebChromeClient() {
override fun onConsoleMessage(msg: ConsoleMessage): Boolean {
val level = when (msg.messageLevel()) {
ConsoleMessage.MessageLevel.ERROR -> Log.ERROR
ConsoleMessage.MessageLevel.WARNING -> Log.WARN
ConsoleMessage.MessageLevel.DEBUG -> Log.DEBUG
else -> Log.INFO
}
Log.println(level, "VKWebView", "${msg.message()} [${msg.sourceId()}:${msg.lineNumber()}]")
return true
}
}

View file

@ -0,0 +1,75 @@
package de.datait.videokonverter
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
/**
* Custom WebViewClient: Injiziert VKNative Bridge nach jedem Page-Load.
* Haelt Navigation innerhalb der App (kein externer Browser).
*/
class VKWebViewClient(private val serverUrl: String) : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
// VKNative Bridge in JavaScript injizieren
// VKNativeAndroid ist per @JavascriptInterface registriert,
// muss aber zu window.VKNative gewrapped werden (korrekte API)
val js = """
(function() {
if (window.VKNative) return;
if (!window.VKNativeAndroid) return;
window.VKNative = {
platform: 'android',
version: '1.0.0',
getSupportedVideoCodecs: function() {
try { return JSON.parse(VKNativeAndroid.getSupportedVideoCodecs()); }
catch(e) { return ['h264']; }
},
getSupportedAudioCodecs: function() {
try { return JSON.parse(VKNativeAndroid.getSupportedAudioCodecs()); }
catch(e) { return ['aac']; }
},
canDirectPlay: function(videoInfo) {
try { return VKNativeAndroid.canDirectPlay(JSON.stringify(videoInfo)); }
catch(e) { return false; }
},
play: function(url, videoInfo, opts) {
try {
return VKNativeAndroid.play(url,
JSON.stringify(videoInfo || {}),
JSON.stringify(opts || {}));
} catch(e) { return false; }
},
togglePlay: function() { VKNativeAndroid.togglePlay(); },
pause: function() { VKNativeAndroid.pause(); },
resume: function() { VKNativeAndroid.resume(); },
seek: function(ms) { VKNativeAndroid.seek(ms); },
getCurrentTime: function() { return VKNativeAndroid.getCurrentTime(); },
getDuration: function() { return VKNativeAndroid.getDuration(); },
isPlaying: function() { return VKNativeAndroid.isPlaying(); },
stop: function() { VKNativeAndroid.stop(); },
setAudioTrack: function(i) { return VKNativeAndroid.setAudioTrack(i); },
setSubtitleTrack: function(i) { return VKNativeAndroid.setSubtitleTrack(i); },
setPlaybackSpeed: function(s) { return VKNativeAndroid.setPlaybackSpeed(s); },
};
console.info('[VKNative] Android Bridge initialisiert');
})();
""".trimIndent()
view.evaluateJavascript(js, null)
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url.toString()
// Nur Server-URLs in der WebView oeffnen
if (url.startsWith(serverUrl) || url.startsWith("http://") || url.startsWith("https://")) {
return false // WebView handelt die URL
}
return true // Andere URLs blockieren
}
}

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rootContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<!-- WebView: TV-App UI vom Server -->
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- ExoPlayer: Overlay fuer Direct-Play Video (initial versteckt) -->
<androidx.media3.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:background="@android:color/black" />
</FrameLayout>

View file

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:background="@android:color/black">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="VideoKonverter"
android:textColor="@android:color/white"
android:textSize="28sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/setup_title"
android:textColor="#AAAAAA"
android:textSize="16sp"
android:layout_marginBottom="32dp" />
<EditText
android:id="@+id/serverUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxWidth="400dp"
android:hint="@string/setup_hint"
android:inputType="textUri"
android:textColor="@android:color/white"
android:textColorHint="#888888"
android:backgroundTint="#4db8ff"
android:textSize="18sp"
android:padding="12dp"
android:imeOptions="actionDone" />
<Button
android:id="@+id/btnConnect"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/setup_connect"
android:layout_marginTop="16dp"
android:paddingHorizontal="32dp"
android:textSize="16sp" />
<TextView
android:id="@+id/errorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FF5555"
android:textSize="14sp"
android:layout_marginTop="12dp"
android:visibility="gone" />
</LinearLayout>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">VideoKonverter</string>
<string name="setup_title">Server verbinden</string>
<string name="setup_hint">Server-Adresse (z.B. 192.168.155.12:8080)</string>
<string name="setup_connect">Verbinden</string>
<string name="setup_error">Server nicht erreichbar</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.VideoKonverter" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowFullscreen">false</item>
</style>
</resources>

View file

@ -0,0 +1,5 @@
// Top-level build file
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}

View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View file

@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolution {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "VideoKonverter"
include(":app")

Binary file not shown.

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
id="http://data-it-solution.de/videokonverter" version="3.1.0" viewmodes="maximized">
id="http://data-it-solution.de/videokonverter" version="5.0.0" viewmodes="maximized">
<name>VideoKonverter</name>
<description>VideoKonverter TV-App - Serien und Filme streamen</description>
<description>VideoKonverter TV-App - Serien und Filme streamen mit AVPlay Direct-Play</description>
<author>data IT solution - Eduard Wisch</author>
@ -19,6 +19,7 @@
<tizen:privilege name="http://tizen.org/privilege/tv.inputdevice"/>
<tizen:privilege name="http://developer.samsung.com/privilege/network.public"/>
<tizen:privilege name="http://developer.samsung.com/privilege/productinfo"/>
<tizen:privilege name="http://developer.samsung.com/privilege/avplay"/>
<!-- Netzwerk-Zugriff erlauben (lokales Netz) -->
<access origin="*" subdomains="true"/>

View file

@ -10,81 +10,103 @@
background: #0f0f0f;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: hidden;
width: 100vw;
height: 100vh;
}
#app-iframe {
border: none;
width: 100%;
height: 100%;
position: absolute;
top: 0; left: 0;
z-index: 1;
}
/* AVPlay Overlay: ueber dem iframe wenn Direct-Play aktiv */
#avplayer {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 10;
display: none;
}
/* Setup-Bildschirm */
.setup {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
position: absolute;
top: 0; left: 0;
width: 100%;
z-index: 100;
}
.setup {
.setup-inner {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.setup h1 {
font-size: 2rem;
margin-bottom: 1rem;
color: #64b5f6;
}
.setup p {
font-size: 1.2rem;
color: #aaa;
margin-bottom: 2rem;
}
.setup h1 { font-size: 2rem; margin-bottom: 1rem; color: #64b5f6; }
.setup p { font-size: 1.2rem; color: #aaa; margin-bottom: 2rem; }
.setup input {
width: 100%;
padding: 1rem;
font-size: 1.5rem;
background: #1a1a1a;
border: 2px solid #333;
border-radius: 8px;
color: #fff;
text-align: center;
margin-bottom: 1rem;
}
.setup input:focus {
border-color: #64b5f6;
outline: none;
width: 100%; padding: 1rem; font-size: 1.5rem;
background: #1a1a1a; border: 2px solid #333; border-radius: 8px;
color: #fff; text-align: center; margin-bottom: 1rem;
}
.setup input:focus { border-color: #64b5f6; outline: none; }
.setup button {
padding: 1rem 3rem;
font-size: 1.3rem;
background: #1976d2;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
}
.setup button:focus {
outline: 3px solid #64b5f6;
outline-offset: 4px;
}
.hint {
margin-top: 1.5rem;
font-size: 0.9rem;
color: #666;
padding: 1rem 3rem; font-size: 1.3rem;
background: #1976d2; color: #fff; border: none; border-radius: 8px; cursor: pointer;
}
.setup button:focus { outline: 3px solid #64b5f6; outline-offset: 4px; }
.hint { margin-top: 1.5rem; font-size: 0.9rem; color: #666; }
</style>
</head>
<body>
<!-- Setup-Bildschirm (nur beim ersten Start sichtbar) -->
<div class="setup" id="setup">
<h1>VideoKonverter TV</h1>
<p>Server-Adresse eingeben:</p>
<input type="text" id="serverUrl" placeholder="z.B. 192.168.155.12:8080"
data-focusable autofocus>
<br>
<button id="connectBtn" onclick="connect()" data-focusable>Verbinden</button>
<p class="hint">Die Adresse wird gespeichert und beim naechsten Start automatisch geladen.</p>
<div class="setup-inner">
<h1>VideoKonverter TV</h1>
<p>Server-Adresse eingeben:</p>
<input type="text" id="serverUrl" placeholder="z.B. 192.168.155.12:8080"
data-focusable autofocus>
<br>
<button id="connectBtn" onclick="connect()" data-focusable>Verbinden</button>
<p class="hint">Die Adresse wird gespeichert und beim naechsten Start automatisch geladen.</p>
</div>
</div>
<script>
// Server-URL aus localStorage laden
var STORAGE_KEY = "vk_server_url";
var savedUrl = localStorage.getItem(STORAGE_KEY);
<!-- Full-Screen iframe fuer Server-Content -->
<iframe id="app-iframe" style="display:none" allow="autoplay; fullscreen"></iframe>
// Beim Start: Wenn URL gespeichert, direkt verbinden
<!-- AVPlay Container: rendert ueber den iframe wenn Direct-Play aktiv -->
<object id="avplayer" type="application/avplayer"
style="position:absolute;top:0;left:0;width:100%;height:100%;display:none;z-index:10">
</object>
<script>
/**
* VideoKonverter Tizen App v5.0
* Architektur: iframe (Server-UI) + AVPlay (Direct-Play im Parent-Frame)
* Kommunikation: postMessage zwischen iframe <-> Parent
*/
var STORAGE_KEY = "vk_server_url";
var _iframe = null;
var _serverUrl = "";
var _avplayActive = false;
var _playing = false;
var _duration = 0;
var _timeUpdateId = null;
// Unterstuetzte Codecs (Samsung Tizen 9.0+, AV1 HW-Decoder)
var SUPPORTED_VIDEO = ["h264", "hevc", "av1", "vp9"];
var SUPPORTED_AUDIO = ["aac", "opus", "ac3", "eac3", "flac", "mp3", "vorbis", "pcm"];
// === Setup ===
var savedUrl = localStorage.getItem(STORAGE_KEY);
if (savedUrl) {
connectTo(savedUrl);
startApp(savedUrl);
}
function connect() {
@ -92,45 +114,338 @@
var url = input.value.trim();
if (!url) return;
// Protokoll ergaenzen falls noetig
if (url.indexOf("://") === -1) {
url = "http://" + url;
}
// Slash am Ende sicherstellen
if (!url.endsWith("/")) url += "/";
// TV-Pfad anhaengen
if (url.indexOf("/tv") === -1) {
url += "tv/";
}
if (url.indexOf("://") === -1) url = "http://" + url;
url = url.replace(/\/+$/, ""); // Trailing slashes entfernen
localStorage.setItem(STORAGE_KEY, url);
connectTo(url);
startApp(url);
}
function connectTo(url) {
// Vollbild-Redirect zum VideoKonverter TV-App
window.location.href = url;
function startApp(serverUrl) {
_serverUrl = serverUrl;
// Setup ausblenden
document.getElementById("setup").style.display = "none";
// iframe erstellen und Server-URL laden
_iframe = document.getElementById("app-iframe");
_iframe.style.display = "block";
_iframe.src = serverUrl + "/tv/";
console.info("[TizenApp] iframe laedt: " + serverUrl + "/tv/");
}
// Enter-Taste zum Verbinden
document.getElementById("serverUrl").addEventListener("keydown", function(e) {
if (e.keyCode === 13) { // Enter
connect();
// === postMessage Handler ===
window.addEventListener("message", function(event) {
var data = event.data;
if (!data || !data.type) return;
// Nur Nachrichten vom iframe akzeptieren
if (event.source !== _iframe.contentWindow) return;
switch (data.type) {
case "vknative_probe":
// iframe fragt ob VKNative verfuegbar ist
_sendToIframe({
type: "vknative_ready",
platform: "tizen",
videoCodecs: SUPPORTED_VIDEO,
audioCodecs: SUPPORTED_AUDIO,
});
break;
case "vknative_call":
_handleCall(data);
break;
}
});
// Tizen: Zurueck-Taste abfangen (sonst schliesst die App sofort)
function _sendToIframe(msg) {
if (_iframe && _iframe.contentWindow) {
_iframe.contentWindow.postMessage(msg, "*");
}
}
function _sendEvent(event, detail) {
_sendToIframe({
type: "vknative_event",
event: event,
detail: detail || {},
});
}
// === AVPlay Controller ===
function _handleCall(data) {
var method = data.method;
var args = data.args || [];
switch (method) {
case "play":
_avplay_play(args[0], args[1], args[2]);
break;
case "stop":
_avplay_stop();
break;
case "togglePlay":
_avplay_togglePlay();
break;
case "pause":
_avplay_pause();
break;
case "resume":
_avplay_resume();
break;
case "seek":
_avplay_seek(args[0]);
break;
case "setPlaybackSpeed":
_avplay_setSpeed(args[0]);
break;
default:
console.warn("[TizenApp] Unbekannter VKNative-Aufruf:", method);
}
}
function _avplay_play(url, videoInfo, opts) {
opts = opts || {};
var seekMs = opts.seekMs || 0;
// Relative URL -> Absolute URL
var fullUrl = url;
if (url.indexOf("://") === -1) {
fullUrl = _serverUrl + url;
}
try {
// Vorherige Session bereinigen
_avplay_stop();
// AVPlay-Display einblenden (ueber iframe)
var avEl = document.getElementById("avplayer");
if (avEl) avEl.style.display = "block";
// iframe deaktivieren (keine Events abfangen)
if (_iframe) {
_iframe.style.pointerEvents = "none";
_iframe.style.opacity = "0";
}
// AVPlay oeffnen
console.info("[TizenApp] AVPlay oeffne: " + fullUrl);
webapis.avplay.open(fullUrl);
webapis.avplay.setDisplayRect(0, 0, window.innerWidth, window.innerHeight);
// Event-Listener
webapis.avplay.setListener({
onbufferingstart: function() {
_sendEvent("buffering", { buffering: true });
},
onbufferingcomplete: function() {
_sendEvent("buffering", { buffering: false });
},
oncurrentplaytime: function(ms) {
_sendEvent("timeupdate", { ms: ms });
},
onstreamcompleted: function() {
_playing = false;
_sendEvent("playstatechanged", { playing: false });
_sendEvent("complete");
},
onerror: function(eventType) {
console.error("[TizenApp] AVPlay Fehler:", eventType);
_playing = false;
_sendEvent("error", { msg: String(eventType) });
},
onevent: function(eventType, eventData) {
console.debug("[TizenApp] AVPlay Event:", eventType, eventData);
},
onsubtitlechange: function() {},
});
// Hilfsfunktion: Wiedergabe starten
function _startPlayback() {
try {
webapis.avplay.play();
_playing = true;
_avplayActive = true;
_startTimeUpdates();
_sendEvent("playstatechanged", { playing: true });
_sendEvent("ready");
console.info("[TizenApp] AVPlay Wiedergabe gestartet");
} catch (e) {
console.error("[TizenApp] play() Fehler:", e);
_playing = false;
_sendEvent("error", { msg: e.message || String(e) });
}
}
// Async vorbereiten
console.info("[TizenApp] AVPlay prepareAsync...");
webapis.avplay.prepareAsync(
function() {
try {
_duration = webapis.avplay.getDuration();
} catch (e) {
_duration = 0;
}
console.info("[TizenApp] AVPlay bereit, Dauer: " + _duration + "ms");
_sendEvent("duration", { ms: _duration });
if (seekMs > 0) {
try {
webapis.avplay.seekTo(seekMs,
function() {
console.info("[TizenApp] Seek zu " + seekMs + "ms");
_startPlayback();
},
function(e) {
console.warn("[TizenApp] Seek fehlgeschlagen:", e);
_startPlayback();
}
);
} catch (e) {
console.warn("[TizenApp] seekTo Exception:", e);
_startPlayback();
}
} else {
_startPlayback();
}
},
function(error) {
console.error("[TizenApp] prepareAsync fehlgeschlagen:", error);
_sendEvent("error", { msg: String(error) });
}
);
} catch (e) {
console.error("[TizenApp] AVPlay Start-Fehler:", e);
_sendEvent("error", { msg: e.message || String(e) });
}
}
function _avplay_stop() {
_stopTimeUpdates();
_playing = false;
_avplayActive = false;
try {
var state = webapis.avplay.getState();
if (state !== "IDLE" && state !== "NONE") {
webapis.avplay.stop();
}
webapis.avplay.close();
} catch (e) { /* ignorieren */ }
var avEl = document.getElementById("avplayer");
if (avEl) avEl.style.display = "none";
// iframe wieder aktivieren
if (_iframe) {
_iframe.style.pointerEvents = "auto";
_iframe.style.opacity = "1";
}
}
function _avplay_togglePlay() {
try {
var state = webapis.avplay.getState();
if (state === "PLAYING") {
webapis.avplay.pause();
_playing = false;
_sendEvent("playstatechanged", { playing: false });
} else if (state === "PAUSED" || state === "READY") {
webapis.avplay.play();
_playing = true;
_sendEvent("playstatechanged", { playing: true });
}
} catch (e) {
console.error("[TizenApp] togglePlay Fehler:", e);
}
}
function _avplay_pause() {
try {
if (_playing) {
webapis.avplay.pause();
_playing = false;
_sendEvent("playstatechanged", { playing: false });
}
} catch (e) {}
}
function _avplay_resume() {
try {
webapis.avplay.play();
_playing = true;
_sendEvent("playstatechanged", { playing: true });
} catch (e) {}
}
function _avplay_seek(positionMs) {
try {
webapis.avplay.seekTo(
Math.max(0, Math.floor(positionMs)),
function() { console.debug("[TizenApp] Seek OK: " + positionMs + "ms"); },
function(e) { console.warn("[TizenApp] Seek Fehler:", e); }
);
} catch (e) {}
}
function _avplay_setSpeed(speed) {
try {
webapis.avplay.setSpeed(speed);
} catch (e) {}
}
// Periodische Zeit-Updates (als Backup fuer oncurrentplaytime)
function _startTimeUpdates() {
_stopTimeUpdates();
_timeUpdateId = setInterval(function() {
if (_playing) {
try {
var ms = webapis.avplay.getCurrentTime();
_sendEvent("timeupdate", { ms: ms });
} catch (e) {}
}
}, 500);
}
function _stopTimeUpdates() {
if (_timeUpdateId) {
clearInterval(_timeUpdateId);
_timeUpdateId = null;
}
}
// === Tastatur-Handling ===
document.addEventListener("keydown", function(e) {
// Samsung Remote: Return/Back = 10009
if (e.keyCode === 10009) {
// Wenn auf Setup-Seite: App beenden
if (document.getElementById("setup").style.display !== "none") {
try { tizen.application.getCurrentApplication().exit(); } catch(ex) {}
if (_avplayActive) {
// AVPlay aktiv -> stoppen, zurueck zum iframe
_avplay_stop();
_sendEvent("stopped");
e.preventDefault();
return;
}
// Setup-Bildschirm sichtbar -> App beenden
if (document.getElementById("setup").style.display !== "none") {
try { tizen.application.getCurrentApplication().exit(); } catch (ex) {}
return;
}
// iframe sichtbar -> History-Back im iframe
// (wird vom iframe selbst gehandelt via keydown event)
}
});
// Enter-Taste zum Verbinden (Setup-Bildschirm)
document.getElementById("serverUrl").addEventListener("keydown", function(e) {
if (e.keyCode === 13) connect();
});
</script>
</body>
</html>

937
tools.yaml Normal file
View file

@ -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

View file

@ -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)

View file

@ -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='<div class="toast success">Preset gespeichert!</div>',
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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"""

View file

@ -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 {

View file

@ -2115,7 +2115,7 @@ function openImportModal() {
.then(data => {
const select = document.getElementById("import-target");
select.innerHTML = (data.paths || []).map(p =>
`<option value="${p.id}">${escapeHtml(p.name)} (${p.media_type === 'series' ? 'Serien' : 'Filme'})</option>`
`<option value="${p.id}" ${activePathId === p.id ? 'selected' : ''}>${escapeHtml(p.name)} (${p.media_type === 'series' ? 'Serien' : 'Filme'})</option>`
).join("");
})
.catch(() => {});

View file

@ -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; }

View file

@ -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, // <object> 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;
}
},
};

View file

@ -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 = "&#9654;";
}
}, 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 <track> 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 = "&#9654;";
});
});
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 = "&#9654;";
});
} 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 = "&#9654;";
});
}
/** 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 ===

View file

@ -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");
})();

View file

@ -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;
}

View file

@ -247,19 +247,113 @@
<!-- Presets -->
<section class="admin-section">
<h2>Encoding-Presets</h2>
<div class="presets-grid">
<div class="preset-editor" id="preset-editor">
{% for key, preset in presets.items() %}
<div class="preset-card">
<h3>{{ preset.name }}</h3>
<div class="preset-details">
<span class="tag">{{ preset.video_codec }}</span>
<span class="tag">{{ preset.container }}</span>
<span class="tag">{{ preset.quality_param }}={{ preset.quality_value }}</span>
{% if preset.hw_init %}<span class="tag gpu">GPU</span>{% else %}<span class="tag cpu">CPU</span>{% endif %}
<div class="preset-edit-card" id="preset-{{ key }}">
<div class="preset-header" onclick="togglePresetEdit('{{ key }}')">
<div class="preset-header-left">
<h3>{{ preset.name }}</h3>
<div class="preset-details">
<span class="tag">{{ preset.video_codec }}</span>
<span class="tag">{{ preset.container }}</span>
<span class="tag">{{ preset.quality_param }}={{ preset.quality_value }}</span>
{% if preset.hw_init %}<span class="tag gpu">GPU</span>{% else %}<span class="tag cpu">CPU</span>{% endif %}
{% if key == settings.encoding.default_preset %}<span class="tag default">Standard</span>{% endif %}
</div>
</div>
<span class="preset-toggle" id="toggle-{{ key }}">&#9660;</span>
</div>
<div class="preset-body" id="preset-body-{{ key }}" style="display:none">
<form hx-post="/htmx/preset/{{ key }}" hx-target="#preset-result-{{ key }}" hx-swap="innerHTML">
<div class="form-grid">
<div class="form-group">
<label>Anzeigename</label>
<input type="text" name="name" value="{{ preset.name }}">
</div>
<div class="form-group">
<label>Video-Codec</label>
<select name="video_codec">
{% for codec, label in [
('av1_vaapi', 'GPU AV1 (VAAPI)'),
('hevc_vaapi', 'GPU HEVC (VAAPI)'),
('h264_vaapi', 'GPU H.264 (VAAPI)'),
('libsvtav1', 'CPU SVT-AV1'),
('libx265', 'CPU x265'),
('libx264', 'CPU x264'),
('libvpx-vp9', 'CPU VP9')
] %}
<option value="{{ codec }}" {% if preset.video_codec == codec %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Container</label>
<select name="container">
<option value="webm" {% if preset.container == 'webm' %}selected{% endif %}>WebM</option>
<option value="mkv" {% if preset.container == 'mkv' %}selected{% endif %}>MKV</option>
<option value="mp4" {% if preset.container == 'mp4' %}selected{% endif %}>MP4</option>
</select>
</div>
<div class="form-group">
<label>Qualitaetsparameter</label>
<select name="quality_param">
<option value="crf" {% if preset.quality_param == 'crf' %}selected{% endif %}>CRF (Constant Rate Factor)</option>
<option value="qp" {% if preset.quality_param == 'qp' %}selected{% endif %}>QP (Quantization Parameter)</option>
</select>
</div>
<div class="form-group">
<label>Qualitaetswert <small>(niedrig = besser)</small></label>
<input type="number" name="quality_value" value="{{ preset.quality_value }}" min="0" max="63">
</div>
<div class="form-group">
<label>GOP-Groesse <small>(Keyframe-Intervall)</small></label>
<input type="number" name="gop_size" value="{{ preset.gop_size }}" min="1" max="1000">
</div>
<div class="form-group">
<label>Speed-Preset <small>(nur CPU)</small></label>
<input type="text" name="speed_preset"
value="{{ preset.speed_preset if preset.speed_preset is not none else '' }}"
placeholder="z.B. 5 (SVT-AV1) oder medium (x264/x265)">
</div>
<div class="form-group">
<label>Video-Filter</label>
<input type="text" name="video_filter" value="{{ preset.video_filter }}"
placeholder="z.B. format=nv12,hwupload">
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" name="hw_init" {% if preset.hw_init %}checked{% endif %}>
Hardware-Init (GPU VAAPI)
</label>
</div>
<div class="form-group" style="grid-column: 1 / -1">
<label>Extra-Parameter <small>(key=value, je Zeile)</small></label>
<textarea name="extra_params" rows="3"
placeholder="svtav1-params=tune=0:film-grain=8">{% for k, v in (preset.extra_params or {}).items() %}{{ k }}={{ v }}{% if not loop.last %}
{% endif %}{% endfor %}</textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Speichern</button>
{% if key != settings.encoding.default_preset %}
<button type="button" class="btn-secondary" onclick="setDefaultPreset('{{ key }}')">
Als Standard setzen
</button>
<button type="button" class="btn-danger" onclick="deletePreset('{{ key }}')">
Loeschen
</button>
{% endif %}
</div>
<div id="preset-result-{{ key }}"></div>
</form>
</div>
</div>
{% endfor %}
</div>
<div style="margin-top:1rem">
<button class="btn-secondary" onclick="showNewPresetForm()">+ Neues Preset</button>
</div>
</section>
{% 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 ? "&#9650;" : "&#9660;";
}
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();
});

View file

@ -44,7 +44,7 @@
{% block content %}{% endblock %}
</main>
<script src="/static/tv/js/tv.js"></script>
<script src="/static/tv/js/tv.js?v={{ v }}"></script>
{% block scripts %}{% endblock %}
<script>

View file

@ -21,7 +21,7 @@
<p class="player-loading-text">Stream wird geladen...</p>
</div>
<!-- Video -->
<!-- Video (HTML5 fuer HLS, versteckt bei AVPlay Direct-Play) -->
<video id="player-video" autoplay playsinline></video>
<!-- Controls (ausblendbar) -->
@ -80,9 +80,12 @@
</div>
</div>
<!-- hls.js fuer Browser ohne native HLS-Unterstuetzung -->
<script src="/static/tv/js/lib/hls.min.js"></script>
<script src="/static/tv/js/player.js"></script>
<!-- VKNative Bridge: Tizen AVPlay (auf Nicht-Tizen macht das Script nichts) -->
<!-- Android injiziert VKNative per @JavascriptInterface, Bridge erkennt das und ueberspringt -->
<script src="/static/tv/js/vknative-bridge.js?v={{ v }}"></script>
<!-- hls.js als Fallback fuer HLS-Streaming -->
<script src="/static/tv/js/lib/hls.min.js?v={{ v }}"></script>
<script src="/static/tv/js/player.js?v={{ v }}"></script>
<script>
initPlayer({
videoId: {{ video.id }},

View file

@ -32,6 +32,6 @@
</div>
</div>
<script src="/static/tv/js/tv.js"></script>
<script src="/static/tv/js/tv.js?v={{ v }}"></script>
</body>
</html>