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:
parent
e2bf70b280
commit
93983cf6ee
36 changed files with 4107 additions and 171 deletions
56
android-app/app/build.gradle.kts
Normal file
56
android-app/app/build.gradle.kts
Normal 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
10
android-app/app/proguard-rules.pro
vendored
Normal 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.**
|
||||
40
android-app/app/src/main/AndroidManifest.xml
Normal file
40
android-app/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
22
android-app/app/src/main/res/layout/activity_main.xml
Normal file
22
android-app/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
59
android-app/app/src/main/res/layout/activity_setup.xml
Normal file
59
android-app/app/src/main/res/layout/activity_setup.xml
Normal 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>
|
||||
8
android-app/app/src/main/res/values/strings.xml
Normal file
8
android-app/app/src/main/res/values/strings.xml
Normal 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>
|
||||
9
android-app/app/src/main/res/values/themes.xml
Normal file
9
android-app/app/src/main/res/values/themes.xml
Normal 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>
|
||||
5
android-app/build.gradle.kts
Normal file
5
android-app/build.gradle.kts
Normal 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
|
||||
}
|
||||
4
android-app/gradle.properties
Normal file
4
android-app/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
16
android-app/settings.gradle.kts
Normal file
16
android-app/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolution {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "VideoKonverter"
|
||||
include(":app")
|
||||
Binary file not shown.
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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
937
tools.yaml
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
340
video-konverter/app/static/tv/js/avplay-bridge.js
Normal file
340
video-konverter/app/static/tv/js/avplay-bridge.js
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
/**
|
||||
* VideoKonverter TV - Video-Player v4.1
|
||||
* HLS-Streaming mit hls.js, kompaktes Popup-Menue statt Panel-Overlay,
|
||||
* Fullscreen-Player mit Audio/Untertitel/Qualitaets-Auswahl,
|
||||
* VideoKonverter TV - Video-Player v5.0
|
||||
* VKNative Bridge fuer Direct-Play (Tizen AVPlay / Android ExoPlayer),
|
||||
* HLS-Streaming mit hls.js als Fallback,
|
||||
* kompaktes Popup-Menue, Fullscreen-Player mit Audio/Untertitel/Qualitaets-Auswahl,
|
||||
* Naechste-Episode-Countdown und Tastatur/Fernbedienung-Steuerung.
|
||||
*/
|
||||
|
||||
|
|
@ -33,6 +34,14 @@ let clientCodecs = null; // Vom Client unterstuetzte Video-Codecs
|
|||
let hlsRetryCount = 0; // Retry-Zaehler fuer gesamten Stream-Start
|
||||
let loadingTimeout = null; // Timeout fuer Loading-Spinner
|
||||
|
||||
// Native Player State (VKNative Bridge: Tizen AVPlay / Android ExoPlayer)
|
||||
let useNativePlayer = false; // VKNative Direct-Play aktiv?
|
||||
let nativePlayStarted = false; // VKNative _vkOnReady wurde aufgerufen?
|
||||
let nativePlayTimeout = null; // Master-Timeout fuer VKNative-Start
|
||||
|
||||
// Legacy AVPlay Direct-Play State (Rueckwaerts-Kompatibilitaet)
|
||||
let useDirectPlay = false; // Alter AVPlayBridge-Pfad aktiv?
|
||||
|
||||
/**
|
||||
* Player initialisieren
|
||||
* @param {Object} opts - Konfiguration
|
||||
|
|
@ -52,10 +61,39 @@ function initPlayer(opts) {
|
|||
|
||||
if (!videoEl) return;
|
||||
|
||||
// Video-Info + HLS-Stream PARALLEL starten (nicht sequentiell warten!)
|
||||
const infoReady = loadVideoInfo();
|
||||
startHLSStream(opts.startPos || 0);
|
||||
infoReady.then(() => updatePlayerButtons());
|
||||
// Prioritaet 1: VKNative Bridge (Tizen AVPlay / Android ExoPlayer)
|
||||
if (window.VKNative) {
|
||||
console.info("[Player] VKNative erkannt (Platform: " + window.VKNative.platform + ")");
|
||||
showLoading();
|
||||
// Master-Timeout: Falls VKNative nach 15s nicht gestartet hat -> HLS Fallback
|
||||
nativePlayTimeout = setTimeout(function() {
|
||||
if (!nativePlayStarted) {
|
||||
console.warn("[Player] VKNative Master-Timeout (15s) - Fallback auf HLS");
|
||||
_cleanupNativePlayer();
|
||||
_nativeFallbackToHLS(opts.startPos || 0);
|
||||
}
|
||||
}, 15000);
|
||||
_tryNativeDirectPlay(opts.startPos || 0);
|
||||
}
|
||||
// Prioritaet 2: Legacy AVPlayBridge (alte Tizen-App ohne VKNative)
|
||||
else if (isTizenTV() && typeof AVPlayBridge !== "undefined" && AVPlayBridge.init()) {
|
||||
showLoading();
|
||||
_tryDirectPlay(opts.startPos || 0).then(success => {
|
||||
if (!success) {
|
||||
console.info("[Player] AVPlay Fallback -> HLS");
|
||||
useDirectPlay = false;
|
||||
const infoReady = loadVideoInfo();
|
||||
startHLSStream(opts.startPos || 0);
|
||||
infoReady.then(() => updatePlayerButtons());
|
||||
}
|
||||
});
|
||||
}
|
||||
// Prioritaet 3: HLS-Streaming (Browser, kein nativer Player)
|
||||
else {
|
||||
const infoReady = loadVideoInfo();
|
||||
startHLSStream(opts.startPos || 0);
|
||||
infoReady.then(() => updatePlayerButtons());
|
||||
}
|
||||
|
||||
// Events
|
||||
videoEl.addEventListener("timeupdate", onTimeUpdate);
|
||||
|
|
@ -65,7 +103,8 @@ function initPlayer(opts) {
|
|||
videoEl.addEventListener("click", togglePlay);
|
||||
// Loading ausblenden sobald Video laeuft (mehrere Events als Sicherheit)
|
||||
videoEl.addEventListener("playing", onPlaying);
|
||||
videoEl.addEventListener("canplay", hideLoading, {once: true});
|
||||
videoEl.addEventListener("canplay", hideLoading);
|
||||
videoEl.addEventListener("loadeddata", hideLoading);
|
||||
// Video-Error: Automatisch Retry mit Fallback
|
||||
videoEl.addEventListener("error", onVideoError);
|
||||
|
||||
|
|
@ -183,20 +222,29 @@ async function loadVideoInfo() {
|
|||
* -> Alle unterstuetzten Codecs melden
|
||||
*/
|
||||
function detectSupportedCodecs() {
|
||||
// VKNative Bridge: Codec-Liste vom nativen Player abfragen
|
||||
if (window.VKNative && window.VKNative.getSupportedVideoCodecs) {
|
||||
var nativeCodecs = window.VKNative.getSupportedVideoCodecs();
|
||||
if (nativeCodecs && nativeCodecs.length) {
|
||||
console.info("[Player] VKNative Video-Codecs:", nativeCodecs.join(", "));
|
||||
return nativeCodecs;
|
||||
}
|
||||
}
|
||||
|
||||
const codecs = [];
|
||||
const el = document.createElement("video");
|
||||
const hasNativeHLS = !!el.canPlayType("application/vnd.apple.mpegurl");
|
||||
const hasMSE = typeof MediaSource !== "undefined" && MediaSource.isTypeSupported;
|
||||
|
||||
if (!hasNativeHLS && hasMSE) {
|
||||
// MSE-basiert (hls.js auf Chrome/Firefox/Edge): zuverlaessige Erkennung
|
||||
if (hasMSE) {
|
||||
// MSE-basiert: zuverlaessige Codec-Erkennung
|
||||
// (auch auf Tizen, da wir hls.js dem nativen Player vorziehen)
|
||||
if (MediaSource.isTypeSupported('video/mp4; codecs="avc1.640028"')) codecs.push("h264");
|
||||
if (MediaSource.isTypeSupported('video/mp4; codecs="hev1.1.6.L93.B0"')) codecs.push("hevc");
|
||||
if (MediaSource.isTypeSupported('video/mp4; codecs="av01.0.05M.08"')) codecs.push("av1");
|
||||
if (MediaSource.isTypeSupported('video/mp4; codecs="vp09.00.10.08"')) codecs.push("vp9");
|
||||
} else {
|
||||
// Natives HLS (Samsung Tizen, Safari, iOS):
|
||||
// Konservativ - nur H.264 melden, da AV1/VP9 in HLS-fMP4 nicht zuverlaessig
|
||||
// Kein MSE: konservativ nur H.264 + HEVC melden
|
||||
codecs.push("h264");
|
||||
if (el.canPlayType('video/mp4; codecs="hev1.1.6.L93.B0"')
|
||||
|| el.canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"')) {
|
||||
|
|
@ -214,18 +262,34 @@ let loadingTimer = null;
|
|||
|
||||
function showLoading() {
|
||||
var el = document.getElementById("player-loading");
|
||||
if (el) { el.classList.remove("hidden"); el.style.display = ""; }
|
||||
// Fallback: Loading nach 8 Sekunden ausblenden (falls Events nicht feuern)
|
||||
if (el) {
|
||||
el.classList.remove("hidden");
|
||||
el.classList.remove("fade-out");
|
||||
el.style.display = "";
|
||||
}
|
||||
// Fallback: Loading nach 8 Sekunden ausblenden und Controls zeigen
|
||||
clearTimeout(loadingTimer);
|
||||
loadingTimer = setTimeout(hideLoading, 8000);
|
||||
loadingTimer = setTimeout(function() {
|
||||
hideLoading();
|
||||
// Falls Video noch nicht spielt: Controls anzeigen damit User manuell starten kann
|
||||
if (videoEl && videoEl.paused) {
|
||||
showControls();
|
||||
if (playBtn) playBtn.innerHTML = "▶";
|
||||
}
|
||||
}, 8000);
|
||||
}
|
||||
function hideLoading() {
|
||||
clearTimeout(loadingTimer);
|
||||
clearTimeout(loadingTimeout);
|
||||
loadingTimeout = null;
|
||||
var el = document.getElementById("player-loading");
|
||||
if (!el) return;
|
||||
el.style.display = "none";
|
||||
if (!el || el.classList.contains("fade-out")) return;
|
||||
// Sanfter Uebergang: Loading-Overlay blendet ueber 1.5s aus
|
||||
// Puffert gleichzeitig das Video waehrend der Ueberblendung
|
||||
el.classList.add("fade-out");
|
||||
setTimeout(function() {
|
||||
el.style.display = "none";
|
||||
}, 1600);
|
||||
}
|
||||
|
||||
function onPlaying() {
|
||||
|
|
@ -252,6 +316,304 @@ function onVideoError() {
|
|||
}
|
||||
}
|
||||
|
||||
// === AVPlay Direct-Play ===
|
||||
|
||||
/**
|
||||
* Versucht Direct-Play via AVPlay (nur auf Tizen).
|
||||
* Laedt erweiterte Video-Info vom Server und prueft Kompatibilitaet.
|
||||
* @returns {boolean} true wenn Direct-Play gestartet wurde
|
||||
*/
|
||||
async function _tryDirectPlay(startPosSec) {
|
||||
try {
|
||||
// Erweiterte Video-Info laden (inkl. audio_codecs, video_codec_normalized)
|
||||
const resp = await fetch(`/tv/api/video-info/${cfg.videoId}`);
|
||||
if (!resp.ok) return false;
|
||||
|
||||
const info = await resp.json();
|
||||
videoInfo = info; // Video-Info global setzen
|
||||
|
||||
// Bevorzugte Audio-/Untertitel-Spur finden
|
||||
if (info.audio_tracks) {
|
||||
const prefIdx = info.audio_tracks.findIndex(
|
||||
a => a.lang === cfg.preferredAudio);
|
||||
if (prefIdx >= 0) currentAudio = prefIdx;
|
||||
}
|
||||
if (cfg.subtitlesEnabled && cfg.preferredSub && info.subtitle_tracks) {
|
||||
const subIdx = info.subtitle_tracks.findIndex(
|
||||
s => s.lang === cfg.preferredSub);
|
||||
if (subIdx >= 0) currentSub = subIdx;
|
||||
}
|
||||
|
||||
// AVPlay Kompatibilitaet pruefen
|
||||
if (!AVPlayBridge.canPlay(info)) {
|
||||
console.info("[Player] Direct-Play nicht kompatibel");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Direct-Play starten
|
||||
useDirectPlay = true;
|
||||
const directUrl = info.direct_play_url;
|
||||
if (!directUrl) return false;
|
||||
|
||||
// Video-Element verstecken, AVPlay-Object anzeigen
|
||||
if (videoEl) videoEl.style.display = "none";
|
||||
|
||||
const ok = AVPlayBridge.play(directUrl, {
|
||||
seekMs: Math.floor(startPosSec * 1000),
|
||||
onReady: () => {
|
||||
hideLoading();
|
||||
showControls();
|
||||
scheduleHideControls();
|
||||
},
|
||||
onTimeUpdate: (ms) => {
|
||||
// Progress-Bar und Zeit-Anzeige aktualisieren
|
||||
const current = ms / 1000;
|
||||
const dur = getDuration();
|
||||
if (progressBar && dur > 0) {
|
||||
progressBar.style.width = ((current / dur) * 100) + "%";
|
||||
}
|
||||
if (timeDisplay) {
|
||||
timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur);
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
onEnded();
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("[Player] AVPlay Fehler:", err);
|
||||
if (typeof showToast === "function")
|
||||
showToast("Direct-Play Fehler, wechsle zu HLS...", "error");
|
||||
// Fallback auf HLS
|
||||
_cleanupDirectPlay();
|
||||
useDirectPlay = false;
|
||||
if (videoEl) videoEl.style.display = "";
|
||||
startHLSStream(startPosSec);
|
||||
},
|
||||
onBuffering: (buffering) => {
|
||||
if (buffering) showLoading();
|
||||
else hideLoading();
|
||||
},
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
_cleanupDirectPlay();
|
||||
return false;
|
||||
}
|
||||
|
||||
updatePlayerButtons();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[Player] Direct-Play Init fehlgeschlagen:", e);
|
||||
_cleanupDirectPlay();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** AVPlay Direct-Play bereinigen */
|
||||
function _cleanupDirectPlay() {
|
||||
if (typeof AVPlayBridge !== "undefined") {
|
||||
AVPlayBridge.stop();
|
||||
}
|
||||
useDirectPlay = false;
|
||||
if (videoEl) videoEl.style.display = "";
|
||||
}
|
||||
|
||||
// === VKNative Direct-Play ===
|
||||
|
||||
/**
|
||||
* Versucht Direct-Play ueber VKNative Bridge (Tizen AVPlay / Android ExoPlayer).
|
||||
* Laedt Video-Info und prueft Kompatibilitaet. Faellt bei Fehler auf HLS zurueck.
|
||||
*/
|
||||
async function _tryNativeDirectPlay(startPosSec) {
|
||||
console.info("[Player] _tryNativeDirectPlay start (videoId=" + cfg.videoId + ", startPos=" + startPosSec + ")");
|
||||
try {
|
||||
// Erweiterte Video-Info laden
|
||||
console.info("[Player] Lade Video-Info...");
|
||||
var resp = await fetch("/tv/api/video-info/" + cfg.videoId);
|
||||
console.info("[Player] Video-Info Response: HTTP " + resp.status);
|
||||
if (!resp.ok) {
|
||||
console.warn("[Player] Video-Info nicht ladbar (HTTP " + resp.status + ")");
|
||||
_nativeFallbackToHLS(startPosSec);
|
||||
return;
|
||||
}
|
||||
|
||||
var info = await resp.json();
|
||||
videoInfo = info;
|
||||
console.info("[Player] Video-Info geladen: " + (info.video_codec_normalized || "?") +
|
||||
"/" + (info.container || "?") + ", Audio: " + (info.audio_codecs || []).join(","));
|
||||
|
||||
// Bevorzugte Audio-/Untertitel-Spur finden
|
||||
if (info.audio_tracks) {
|
||||
var prefIdx = info.audio_tracks.findIndex(
|
||||
function(a) { return a.lang === cfg.preferredAudio; });
|
||||
if (prefIdx >= 0) currentAudio = prefIdx;
|
||||
}
|
||||
if (cfg.subtitlesEnabled && cfg.preferredSub && info.subtitle_tracks) {
|
||||
var subIdx = info.subtitle_tracks.findIndex(
|
||||
function(s) { return s.lang === cfg.preferredSub; });
|
||||
if (subIdx >= 0) currentSub = subIdx;
|
||||
}
|
||||
|
||||
// Untertitel als <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 = "▶";
|
||||
});
|
||||
});
|
||||
|
||||
hlsInstance.on(Hls.Events.ERROR, (event, data) => {
|
||||
|
|
@ -339,9 +711,20 @@ async function startHLSStream(seekSec) {
|
|||
}
|
||||
}
|
||||
});
|
||||
} else if (videoEl.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
// Nativer HLS-Player (Fallback wenn hls.js nicht verfuegbar)
|
||||
videoEl.src = playlistUrl;
|
||||
hlsReady = true;
|
||||
videoEl.addEventListener("playing", hideLoading, {once: true});
|
||||
videoEl.play().catch(e => {
|
||||
console.warn("Autoplay blockiert (nativ):", e);
|
||||
hideLoading();
|
||||
showControls();
|
||||
if (playBtn) playBtn.innerHTML = "▶";
|
||||
});
|
||||
} else {
|
||||
// Kein HLS moeglich -> Fallback
|
||||
console.warn("Weder natives HLS noch hls.js verfuegbar");
|
||||
console.warn("Weder hls.js noch natives HLS verfuegbar");
|
||||
setStreamUrlLegacy(seekSec);
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -363,7 +746,12 @@ function setStreamUrlLegacy(seekSec) {
|
|||
if (seekSec > 0) params.set("t", Math.floor(seekSec));
|
||||
videoEl.src = `/api/library/videos/${cfg.videoId}/stream?${params}`;
|
||||
videoEl.addEventListener("playing", hideLoading, {once: true});
|
||||
videoEl.play().catch(() => {});
|
||||
videoEl.play().catch(e => {
|
||||
console.warn("Autoplay blockiert (Legacy):", e);
|
||||
hideLoading();
|
||||
showControls();
|
||||
if (playBtn) playBtn.innerHTML = "▶";
|
||||
});
|
||||
}
|
||||
|
||||
/** HLS aufraumen: hls.js + Server-Session beenden */
|
||||
|
|
@ -384,6 +772,17 @@ async function cleanupHLS() {
|
|||
// === Playback-Controls ===
|
||||
|
||||
function togglePlay() {
|
||||
if (useNativePlayer && window.VKNative) {
|
||||
window.VKNative.togglePlay();
|
||||
// PlayStateChanged-Callback aktualisiert Icon automatisch
|
||||
return;
|
||||
}
|
||||
if (useDirectPlay && typeof AVPlayBridge !== "undefined") {
|
||||
AVPlayBridge.togglePlay();
|
||||
if (AVPlayBridge.isPlaying()) onPlay();
|
||||
else onPause();
|
||||
return;
|
||||
}
|
||||
if (!videoEl) return;
|
||||
if (videoEl.paused) videoEl.play();
|
||||
else videoEl.pause();
|
||||
|
|
@ -400,8 +799,9 @@ function onPause() {
|
|||
saveProgress();
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
saveProgress(true);
|
||||
async function onEnded() {
|
||||
// Fortschritt + Watch-Status speichern (wartet auf API-Antwort)
|
||||
await saveProgress(true);
|
||||
episodesWatched++;
|
||||
|
||||
// Schaust du noch? (wenn Max-Episoden erreicht)
|
||||
|
|
@ -421,6 +821,22 @@ function onEnded() {
|
|||
// === Seeking ===
|
||||
|
||||
function seekRelative(seconds) {
|
||||
if (useNativePlayer && window.VKNative) {
|
||||
let cur = getCurrentTime();
|
||||
let dur = getDuration();
|
||||
let newMs = Math.max(0, Math.min((cur + seconds) * 1000, dur * 1000));
|
||||
window.VKNative.seek(newMs);
|
||||
showControls();
|
||||
return;
|
||||
}
|
||||
if (useDirectPlay && typeof AVPlayBridge !== "undefined") {
|
||||
const cur = getCurrentTime();
|
||||
const dur = getDuration();
|
||||
const newMs = Math.max(0, Math.min((cur + seconds) * 1000, dur * 1000));
|
||||
AVPlayBridge.seek(newMs);
|
||||
showControls();
|
||||
return;
|
||||
}
|
||||
if (!videoEl) return;
|
||||
const dur = getDuration();
|
||||
const cur = getCurrentTime();
|
||||
|
|
@ -439,15 +855,26 @@ function seekRelative(seconds) {
|
|||
}
|
||||
|
||||
function onProgressClick(e) {
|
||||
if (!videoEl) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const dur = getDuration();
|
||||
if (!dur) return;
|
||||
|
||||
// Absolute Seek-Position im Video
|
||||
const seekTo = pct * dur;
|
||||
// Immer neuen HLS-Stream starten (server-seitiger Seek)
|
||||
|
||||
if (useNativePlayer && window.VKNative) {
|
||||
window.VKNative.seek(seekTo * 1000);
|
||||
showControls();
|
||||
return;
|
||||
}
|
||||
if (useDirectPlay && typeof AVPlayBridge !== "undefined") {
|
||||
AVPlayBridge.seek(seekTo * 1000);
|
||||
showControls();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!videoEl) return;
|
||||
// HLS: Immer neuen Stream starten (server-seitiger Seek)
|
||||
startHLSStream(seekTo);
|
||||
showControls();
|
||||
}
|
||||
|
|
@ -455,6 +882,12 @@ function onProgressClick(e) {
|
|||
// === Zeit-Funktionen ===
|
||||
|
||||
function getCurrentTime() {
|
||||
if (useNativePlayer && window.VKNative) {
|
||||
return window.VKNative.getCurrentTime() / 1000; // ms -> sec
|
||||
}
|
||||
if (useDirectPlay && typeof AVPlayBridge !== "undefined") {
|
||||
return AVPlayBridge.getCurrentTime() / 1000; // ms -> sec
|
||||
}
|
||||
if (!videoEl) return 0;
|
||||
// Bei HLS mit Server-Seek: videoEl.currentTime + Offset = echte Position
|
||||
return hlsSeekOffset + (videoEl.currentTime || 0);
|
||||
|
|
@ -497,7 +930,10 @@ function showControls() {
|
|||
}
|
||||
|
||||
function hideControls() {
|
||||
if (!videoEl || videoEl.paused || popupOpen) return;
|
||||
// Bei VKNative: Controls ausblenden wenn abgespielt wird
|
||||
if (useNativePlayer && window.VKNative) {
|
||||
if (!window.VKNative.isPlaying() || popupOpen) return;
|
||||
} else if (!videoEl || videoEl.paused || popupOpen) return;
|
||||
const wrapper = document.getElementById("player-wrapper");
|
||||
if (wrapper) wrapper.classList.add("player-hide-controls");
|
||||
controlsVisible = false;
|
||||
|
|
@ -691,8 +1127,29 @@ function _renderSpeedOptions() {
|
|||
function switchAudio(idx) {
|
||||
if (idx === currentAudio) return;
|
||||
currentAudio = idx;
|
||||
// Neuen HLS-Stream mit anderer Audio-Spur starten
|
||||
const currentTime = getCurrentTime();
|
||||
|
||||
if (useNativePlayer && window.VKNative) {
|
||||
// VKNative: Audio-Track wechseln versuchen
|
||||
var ok = window.VKNative.setAudioTrack(idx);
|
||||
if (ok) {
|
||||
// Erfolg (z.B. Android ExoPlayer kann Track direkt wechseln)
|
||||
renderPopup(popupSection);
|
||||
updatePlayerButtons();
|
||||
return;
|
||||
}
|
||||
// Nicht moeglich (z.B. Tizen AVPlay) -> HLS Fallback
|
||||
console.info("[Player] VKNative Audio-Wechsel nicht moeglich -> HLS Fallback");
|
||||
_cleanupNativePlayer();
|
||||
if (videoEl) videoEl.style.display = "";
|
||||
}
|
||||
if (useDirectPlay) {
|
||||
console.info("[Player] Audio-Wechsel -> HLS Fallback");
|
||||
_cleanupDirectPlay();
|
||||
useDirectPlay = false;
|
||||
if (videoEl) videoEl.style.display = "";
|
||||
}
|
||||
// Neuen HLS-Stream mit anderer Audio-Spur starten
|
||||
startHLSStream(currentTime);
|
||||
renderPopup(popupSection);
|
||||
updatePlayerButtons();
|
||||
|
|
@ -723,6 +1180,9 @@ function switchQuality(q) {
|
|||
|
||||
function switchSpeed(s) {
|
||||
currentSpeed = s;
|
||||
if (useNativePlayer && window.VKNative) {
|
||||
window.VKNative.setPlaybackSpeed(s);
|
||||
}
|
||||
if (videoEl) videoEl.playbackRate = s;
|
||||
renderPopup(popupSection);
|
||||
}
|
||||
|
|
@ -747,9 +1207,11 @@ function showNextEpisodeOverlay() {
|
|||
if (countdownEl) countdownEl.textContent = remaining + "s";
|
||||
}
|
||||
|
||||
function playNextEpisode() {
|
||||
async function playNextEpisode() {
|
||||
if (nextCountdown) clearInterval(nextCountdown);
|
||||
cleanupHLS();
|
||||
if (useNativePlayer) _cleanupNativePlayer();
|
||||
if (useDirectPlay) _cleanupDirectPlay();
|
||||
await cleanupHLS();
|
||||
if (cfg.nextUrl) window.location.href = cfg.nextUrl;
|
||||
}
|
||||
|
||||
|
|
@ -798,7 +1260,7 @@ function _focusNext(direction) {
|
|||
// === Tastatur-Steuerung ===
|
||||
|
||||
function onKeyDown(e) {
|
||||
// Samsung Tizen Remote Keys
|
||||
// Samsung Tizen Remote Keys + Android TV Media Keys
|
||||
const keyMap = {
|
||||
10009: "Escape", 10182: "Escape",
|
||||
415: "Play", 19: "Pause", 413: "Stop",
|
||||
|
|
@ -806,6 +1268,15 @@ function onKeyDown(e) {
|
|||
// Samsung Farbtasten
|
||||
403: "ColorRed", 404: "ColorGreen",
|
||||
405: "ColorYellow", 406: "ColorBlue",
|
||||
// Android TV Media Keys
|
||||
85: "Play", // KEYCODE_MEDIA_PLAY_PAUSE
|
||||
126: "Play", // KEYCODE_MEDIA_PLAY
|
||||
127: "Pause", // KEYCODE_MEDIA_PAUSE
|
||||
86: "Stop", // KEYCODE_MEDIA_STOP
|
||||
87: "FastForward", // KEYCODE_MEDIA_NEXT
|
||||
88: "Rewind", // KEYCODE_MEDIA_PREVIOUS
|
||||
90: "FastForward", // KEYCODE_MEDIA_FAST_FORWARD
|
||||
89: "Rewind", // KEYCODE_MEDIA_REWIND
|
||||
};
|
||||
const key = keyMap[e.keyCode] || e.key;
|
||||
|
||||
|
|
@ -912,6 +1383,8 @@ function onKeyDown(e) {
|
|||
e.preventDefault(); break;
|
||||
case "Escape": case "Backspace": case "Stop":
|
||||
saveProgress();
|
||||
if (useNativePlayer) _cleanupNativePlayer();
|
||||
if (useDirectPlay) _cleanupDirectPlay();
|
||||
cleanupHLS();
|
||||
setTimeout(() => window.history.back(), 100);
|
||||
e.preventDefault(); break;
|
||||
|
|
@ -937,7 +1410,7 @@ function onKeyDown(e) {
|
|||
// === Watch-Progress speichern ===
|
||||
|
||||
function saveProgress(completed) {
|
||||
if (!cfg.videoId || !videoEl) return;
|
||||
if (!cfg.videoId || (!videoEl && !useDirectPlay && !useNativePlayer)) return;
|
||||
const dur = getDuration();
|
||||
// Bei completed: Position = Duration (garantiert ueber Schwelle)
|
||||
const pos = completed ? dur : getCurrentTime();
|
||||
|
|
@ -950,7 +1423,7 @@ function saveProgress(completed) {
|
|||
};
|
||||
if (completed) payload.completed = true;
|
||||
|
||||
fetch("/tv/api/watch-progress", {
|
||||
return fetch("/tv/api/watch-progress", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(payload),
|
||||
|
|
@ -958,8 +1431,35 @@ function saveProgress(completed) {
|
|||
}
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
saveProgress();
|
||||
cleanupHLS();
|
||||
// Fortschritt per sendBeacon speichern (zuverlaessig beim Seitenabbau)
|
||||
if (cfg.videoId && (videoEl || useDirectPlay || useNativePlayer)) {
|
||||
const dur = getDuration();
|
||||
const pos = getCurrentTime();
|
||||
if (pos >= 5) {
|
||||
navigator.sendBeacon("/tv/api/watch-progress",
|
||||
new Blob([JSON.stringify({
|
||||
video_id: cfg.videoId,
|
||||
position_sec: pos,
|
||||
duration_sec: dur,
|
||||
})], {type: "application/json"}));
|
||||
}
|
||||
}
|
||||
// VKNative bereinigen
|
||||
if (useNativePlayer && window.VKNative) {
|
||||
try { window.VKNative.stop(); } catch (e) { /* ignorieren */ }
|
||||
}
|
||||
// Legacy AVPlay bereinigen
|
||||
if (useDirectPlay && typeof AVPlayBridge !== "undefined") {
|
||||
AVPlayBridge.stop();
|
||||
}
|
||||
// HLS-Session per sendBeacon beenden (fetch wird beim Seitenabbau abgebrochen)
|
||||
if (hlsSessionId) {
|
||||
navigator.sendBeacon(`/tv/api/hls/${hlsSessionId}/stop`, "");
|
||||
}
|
||||
if (hlsInstance) {
|
||||
hlsInstance.destroy();
|
||||
hlsInstance = null;
|
||||
}
|
||||
});
|
||||
|
||||
// === Button-Status aktualisieren ===
|
||||
|
|
|
|||
442
video-konverter/app/static/tv/js/vknative-bridge.js
Normal file
442
video-konverter/app/static/tv/js/vknative-bridge.js
Normal 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");
|
||||
})();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}">▼</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 ? "▲" : "▼";
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue