feat: VideoKonverter v5.6 - Player-Overlay, Immersive Fullscreen, Audio-Normalisierung

Tizen TV: Transparenter iframe-Overlay statt opacity:0 - Player-Controls
(Progress-Bar, Buttons, Popup-Menue) jetzt sichtbar ueber dem AVPlay-Video.
CSS-Klasse "vknative-playing" macht Hintergruende transparent, AVPlay-Video
scheint durch den iframe hindurch.

Android App: Immersive Sticky Fullscreen mit WindowInsetsControllerCompat.
Status- und Navigationsleiste komplett versteckt, per Swipe vom Rand
temporaer einblendbar.

Audio-Normalisierung (3 Stufen):
- Server-seitig: EBU R128 loudnorm (I=-14 LUFS) im HLS-Transcoding
- Server-seitig: dynaudnorm (dynamische Szenen-Anpassung) im HLS-Transcoding
- Client-seitig: DynamicsCompressorNode im Browser-Player
Alle Optionen konfigurierbar: loudnorm/dynaudnorm im TV Admin-Center,
Audio-Kompressor pro Client in den Einstellungen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-10 21:07:04 +01:00
parent 00d8f6b982
commit 956b7b9ac8
15 changed files with 153 additions and 10 deletions

View file

@ -8,6 +8,9 @@ import android.webkit.CookieManager
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
@ -24,6 +27,13 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Immersive Fullscreen: Status- und Navigationsleiste verstecken
WindowCompat.setDecorFitsSystemWindows(window, false)
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
// Server-URL aus Intent oder Preferences // Server-URL aus Intent oder Preferences
@ -93,6 +103,17 @@ class MainActivity : AppCompatActivity() {
return super.onKeyDown(keyCode, event) return super.onKeyDown(keyCode, event)
} }
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
// Immersive Mode nach Focus-Wechsel neu setzen (Android setzt es zurueck)
if (hasFocus) {
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
// ExoPlayer pausieren wenn App in den Hintergrund geht // ExoPlayer pausieren wenn App in den Hintergrund geht

View file

@ -2,8 +2,8 @@
<resources> <resources>
<style name="Theme.VideoKonverter" parent="Theme.AppCompat.NoActionBar"> <style name="Theme.VideoKonverter" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">@android:color/black</item> <item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/black</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/black</item> <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowFullscreen">false</item> <item name="android:windowFullscreen">true</item>
</style> </style>
</resources> </resources>

View file

@ -524,10 +524,10 @@
var avEl = document.getElementById("avplayer"); var avEl = document.getElementById("avplayer");
if (avEl) avEl.style.display = "block"; if (avEl) avEl.style.display = "block";
// iframe deaktivieren (keine Events abfangen) // iframe: Pointer deaktivieren (D-Pad kommt per postMessage)
// Opacity bleibt 1 → transparenter Hintergrund im iframe zeigt AVPlay-Video durch
if (_iframe) { if (_iframe) {
_iframe.style.pointerEvents = "none"; _iframe.style.pointerEvents = "none";
_iframe.style.opacity = "0";
} }
// AVPlay oeffnen // AVPlay oeffnen
@ -655,10 +655,9 @@
var avEl = document.getElementById("avplayer"); var avEl = document.getElementById("avplayer");
if (avEl) avEl.style.display = "block"; if (avEl) avEl.style.display = "block";
// iframe deaktivieren // iframe: Pointer deaktivieren (D-Pad kommt per postMessage)
if (_iframe) { if (_iframe) {
_iframe.style.pointerEvents = "none"; _iframe.style.pointerEvents = "none";
_iframe.style.opacity = "0";
} }
// AVPlay mit HLS-URL oeffnen // AVPlay mit HLS-URL oeffnen
@ -758,7 +757,6 @@
// iframe wieder aktivieren // iframe wieder aktivieren
if (_iframe) { if (_iframe) {
_iframe.style.pointerEvents = "auto"; _iframe.style.pointerEvents = "auto";
_iframe.style.opacity = "1";
} }
} }

View file

@ -156,6 +156,10 @@ def setup_page_routes(app: web.Application, config: Config,
data.get("pause_batch_on_stream") == "on") data.get("pause_batch_on_stream") == "on")
settings["tv"]["force_transcode"] = ( settings["tv"]["force_transcode"] = (
data.get("force_transcode") == "on") data.get("force_transcode") == "on")
settings["tv"]["audio_loudnorm"] = (
data.get("audio_loudnorm") == "on")
settings["tv"]["audio_dynaudnorm"] = (
data.get("audio_dynaudnorm") == "on")
settings["tv"]["watched_threshold_pct"] = int( settings["tv"]["watched_threshold_pct"] = int(
data.get("watched_threshold_pct", 90)) data.get("watched_threshold_pct", 90))

View file

@ -963,6 +963,7 @@ def setup_tv_routes(app: web.Application, config: Config,
"series_detail_url": series_detail_url, "series_detail_url": series_detail_url,
"client_sound_mode": client.get("sound_mode", "stereo") if client else "stereo", "client_sound_mode": client.get("sound_mode", "stereo") if client else "stereo",
"client_stream_quality": client.get("stream_quality", "hd") if client else "hd", "client_stream_quality": client.get("stream_quality", "hd") if client else "hd",
"client_audio_compressor": client.get("audio_compressor", False) if client else False,
} }
) )
@ -1300,6 +1301,12 @@ def setup_tv_routes(app: web.Application, config: Config,
client_kwargs["sound_mode"] = data["sound_mode"] client_kwargs["sound_mode"] = data["sound_mode"]
if "stream_quality" in data: if "stream_quality" in data:
client_kwargs["stream_quality"] = data["stream_quality"] client_kwargs["stream_quality"] = data["stream_quality"]
# audio_compressor: Checkbox-Handling
if "audio_compressor" in data:
client_kwargs["audio_compressor"] = (
data["audio_compressor"] == "on")
elif not is_ajax:
client_kwargs["audio_compressor"] = False
if client_kwargs: if client_kwargs:
await auth_service.update_client_settings( await auth_service.update_client_settings(
client_id, **client_kwargs) client_id, **client_kwargs)

View file

@ -76,12 +76,19 @@ class AuthService:
DEFAULT 'stereo', DEFAULT 'stereo',
stream_quality ENUM('uhd','hd','sd','low') stream_quality ENUM('uhd','hd','sd','low')
DEFAULT 'hd', DEFAULT 'hd',
audio_compressor BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""") """)
# Spalte audio_compressor hinzufuegen (Migration fuer bestehende DBs)
await cur.execute("""
ALTER TABLE tv_clients
ADD COLUMN IF NOT EXISTS audio_compressor BOOLEAN DEFAULT FALSE
""")
# Merkliste (Watchlist) # Merkliste (Watchlist)
await cur.execute(""" await cur.execute("""
CREATE TABLE IF NOT EXISTS tv_watchlist ( CREATE TABLE IF NOT EXISTS tv_watchlist (
@ -575,7 +582,7 @@ class AuthService:
pool = await self._get_pool() pool = await self._get_pool()
if not pool: if not pool:
return False return False
allowed = {"name", "sound_mode", "stream_quality"} allowed = {"name", "sound_mode", "stream_quality", "audio_compressor"}
updates = [] updates = []
values = [] values = []
for key, val in kwargs.items(): for key, val in kwargs.items():

View file

@ -194,6 +194,12 @@ class HLSSessionManager:
# Audio-Transcoding noetig? # Audio-Transcoding noetig?
needs_audio_transcode = audio_codec not in BROWSER_AUDIO_CODECS needs_audio_transcode = audio_codec not in BROWSER_AUDIO_CODECS
# Audio-Normalisierung erzwingt Transcoding
audio_loudnorm = self._tv_setting("audio_loudnorm", False)
audio_dynaudnorm = self._tv_setting("audio_dynaudnorm", False)
if audio_loudnorm or audio_dynaudnorm:
needs_audio_transcode = True
# Force-Transcode: Immer transcodieren fuer maximale Kompatibilitaet # Force-Transcode: Immer transcodieren fuer maximale Kompatibilitaet
if self._tv_setting("force_transcode", False): if self._tv_setting("force_transcode", False):
needs_video_transcode = True needs_video_transcode = True
@ -346,8 +352,18 @@ class HLSSessionManager:
if needs_audio_transcode: if needs_audio_transcode:
bitrate = {1: "96k", 2: "192k"}.get( bitrate = {1: "96k", 2: "192k"}.get(
out_channels, f"{out_channels * 64}k") out_channels, f"{out_channels * 64}k")
# Audio-Filter aufbauen (loudnorm, dynaudnorm)
af_parts = []
audio_loudnorm = self._tv_setting("audio_loudnorm", False)
audio_dynaudnorm = self._tv_setting("audio_dynaudnorm", False)
if audio_loudnorm:
af_parts.append("loudnorm=I=-14:LRA=7:TP=-1")
if audio_dynaudnorm:
af_parts.append("dynaudnorm=f=250:g=15:p=0.95")
cmd += ["-c:a", "aac", "-ac", str(out_channels), cmd += ["-c:a", "aac", "-ac", str(out_channels),
"-b:a", bitrate] "-b:a", bitrate]
if af_parts:
cmd += ["-af", ",".join(af_parts)]
else: else:
cmd += ["-c:a", "copy"] cmd += ["-c:a", "copy"]

View file

@ -948,6 +948,24 @@ a { color: var(--accent); text-decoration: none; }
background: #000; background: #000;
overflow: hidden; overflow: hidden;
} }
/* VKNative (Tizen AVPlay): Transparenter Hintergrund damit
das AVPlay-Video (Hardware-Layer unter HTML) durchscheint.
Controls behalten ihre Gradient-Hintergruende. */
body.vknative-playing {
background: transparent !important;
}
body.vknative-playing .player-wrapper {
background: transparent !important;
}
body.vknative-playing #player-video {
display: none !important;
}
body.vknative-playing .player-loading {
background: rgba(0, 0, 0, 0.6) !important;
}
body.vknative-playing .player-body {
background: transparent !important;
}
.player-wrapper { .player-wrapper {
position: fixed; position: fixed;
inset: 0; inset: 0;

View file

@ -129,6 +129,7 @@
"sound_surround": "Surround (5.1/7.1)", "sound_surround": "Surround (5.1/7.1)",
"sound_original": "Original", "sound_original": "Original",
"stream_quality": "Standard-Qualität", "stream_quality": "Standard-Qualität",
"audio_compressor": "Audio-Kompression (gleicht Lautstärke-Schwankungen aus)",
"on": "An", "on": "An",
"off": "Aus" "off": "Aus"
}, },

View file

@ -129,6 +129,7 @@
"sound_surround": "Surround (5.1/7.1)", "sound_surround": "Surround (5.1/7.1)",
"sound_original": "Original", "sound_original": "Original",
"stream_quality": "Default Quality", "stream_quality": "Default Quality",
"audio_compressor": "Audio Compression (levels out volume differences)",
"on": "On", "on": "On",
"off": "Off" "off": "Off"
}, },

View file

@ -42,6 +42,10 @@ let nativePlayTimeout = null; // Master-Timeout fuer VKNative-Start
// Legacy AVPlay Direct-Play State (Rueckwaerts-Kompatibilitaet) // Legacy AVPlay Direct-Play State (Rueckwaerts-Kompatibilitaet)
let useDirectPlay = false; // Alter AVPlayBridge-Pfad aktiv? let useDirectPlay = false; // Alter AVPlayBridge-Pfad aktiv?
// Audio-Kompressor (DynamicsCompressorNode)
let _audioCtx = null;
let _audioCompressorSetup = false;
/** /**
* Player initialisieren * Player initialisieren
* @param {Object} opts - Konfiguration * @param {Object} opts - Konfiguration
@ -300,6 +304,10 @@ function hideLoading() {
function onPlaying() { function onPlaying() {
hideLoading(); hideLoading();
hlsRetryCount = 0; // Reset bei erfolgreichem Start hlsRetryCount = 0; // Reset bei erfolgreichem Start
// Audio-Kompressor aktivieren (nur bei Browser-Wiedergabe, nicht VKNative)
if (cfg.audioCompressor && !useNativePlayer && videoEl) {
_setupAudioCompressor(videoEl);
}
} }
function onVideoError() { function onVideoError() {
@ -569,6 +577,7 @@ async function _tryNativeDirectPlay(startPosSec) {
} }
useNativePlayer = true; useNativePlayer = true;
document.body.classList.add("vknative-playing");
console.info("[Player] VKNative.play() aufrufen..."); console.info("[Player] VKNative.play() aufrufen...");
var ok = window.VKNative.play(directUrl, info, { var ok = window.VKNative.play(directUrl, info, {
seekMs: Math.floor(startPosSec * 1000) seekMs: Math.floor(startPosSec * 1000)
@ -603,6 +612,7 @@ function _cleanupNativePlayer() {
} }
useNativePlayer = false; useNativePlayer = false;
nativePlayStarted = false; nativePlayStarted = false;
document.body.classList.remove("vknative-playing");
// Callbacks entfernen // Callbacks entfernen
window._vkOnReady = null; window._vkOnReady = null;
window._vkOnTimeUpdate = null; window._vkOnTimeUpdate = null;
@ -629,6 +639,7 @@ function _nativeFallbackToHLS(startPosSec) {
// Kein VKNative HLS (Tizen etc.) -> WebView-HLS // Kein VKNative HLS (Tizen etc.) -> WebView-HLS
useNativePlayer = false; useNativePlayer = false;
nativePlayStarted = false; nativePlayStarted = false;
document.body.classList.remove("vknative-playing");
if (videoEl) videoEl.style.display = ""; if (videoEl) videoEl.style.display = "";
var infoReady = loadVideoInfo(); var infoReady = loadVideoInfo();
startHLSStream(startPosSec); startHLSStream(startPosSec);
@ -713,6 +724,7 @@ async function _startNativeHLS(startPosSec) {
// ExoPlayer HLS starten // ExoPlayer HLS starten
useNativePlayer = true; useNativePlayer = true;
document.body.classList.add("vknative-playing");
var ok = window.VKNative.playHLS(playlistUrl, {}); var ok = window.VKNative.playHLS(playlistUrl, {});
console.info("[Player] VKNative.playHLS() Ergebnis: " + ok); console.info("[Player] VKNative.playHLS() Ergebnis: " + ok);
@ -733,6 +745,33 @@ async function _startNativeHLS(startPosSec) {
} }
} }
// === Audio-Kompressor (Client-seitige Lautstaerke-Nivellierung) ===
/**
* DynamicsCompressorNode: Reduziert Lautstaerke-Schwankungen im Browser.
* Laute Passagen (Explosionen) werden komprimiert, leise (Dialoge) angehoben.
* Funktioniert nur bei <video>-Element (nicht bei VKNative/AVPlay).
*/
function _setupAudioCompressor(el) {
if (_audioCompressorSetup || !el) return;
try {
_audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var source = _audioCtx.createMediaElementSource(el);
var compressor = _audioCtx.createDynamicsCompressor();
compressor.threshold.value = -30; // Ab -30dB komprimieren
compressor.knee.value = 20; // Weicher Uebergang
compressor.ratio.value = 4; // 4:1 Kompression
compressor.attack.value = 0.005; // 5ms Ansprechzeit
compressor.release.value = 0.1; // 100ms Abklingzeit
source.connect(compressor);
compressor.connect(_audioCtx.destination);
_audioCompressorSetup = true;
console.info("[Player] Audio-Kompressor aktiviert (DynamicsCompressor)");
} catch (e) {
console.warn("[Player] Audio-Kompressor nicht verfuegbar:", e.message || e);
}
}
// === Browser Direct-Play (MP4 mit Range-Requests) === // === Browser Direct-Play (MP4 mit Range-Requests) ===
/** /**

View file

@ -4,7 +4,7 @@
* Kein Offline-Caching noetig (Streaming braucht Netzwerk) * Kein Offline-Caching noetig (Streaming braucht Netzwerk)
*/ */
const CACHE_NAME = "vk-tv-v15"; const CACHE_NAME = "vk-tv-v16";
const STATIC_ASSETS = [ const STATIC_ASSETS = [
"/static/tv/css/tv.css", "/static/tv/css/tv.css",
"/static/tv/js/tv.js", "/static/tv/js/tv.js",

View file

@ -106,6 +106,7 @@
subtitlesEnabled: {{ 'true' if user.subtitles_enabled else 'false' }}, subtitlesEnabled: {{ 'true' if user.subtitles_enabled else 'false' }},
soundMode: "{{ client_sound_mode or 'stereo' }}", soundMode: "{{ client_sound_mode or 'stereo' }}",
streamQuality: "{{ client_stream_quality or 'hd' }}", streamQuality: "{{ client_stream_quality or 'hd' }}",
audioCompressor: {{ 'true' if client_audio_compressor else 'false' }},
seriesDetailUrl: "{{ series_detail_url or '' }}", seriesDetailUrl: "{{ series_detail_url or '' }}",
}); });
</script> </script>

View file

@ -186,6 +186,12 @@
<option value="low" {% if client.stream_quality == 'low' %}selected{% endif %}>{{ t('player.quality_low') }}</option> <option value="low" {% if client.stream_quality == 'low' %}selected{% endif %}>{{ t('player.quality_low') }}</option>
</select> </select>
</label> </label>
<label class="settings-check">
<input type="checkbox" name="audio_compressor"
{% if client.audio_compressor %}checked{% endif %}
data-focusable>
{{ t('settings.audio_compressor') }}
</label>
</fieldset> </fieldset>
{% endif %} {% endif %}

View file

@ -59,6 +59,30 @@
</div> </div>
</fieldset> </fieldset>
<!-- Audio-Normalisierung -->
<fieldset>
<legend>Audio-Normalisierung</legend>
<div class="form-grid">
<div class="form-group">
<label>
<input type="checkbox" name="audio_loudnorm" id="audio_loudnorm"
{% if tv.audio_loudnorm | default(false) %}checked{% endif %}>
EBU R128 Loudnorm (Lautstaerke-Normalisierung)
</label>
<span class="text-muted" style="font-size:0.8rem">Normalisiert Audio auf -14 LUFS (Broadcast-Standard). Alle Filme/Serien haben die gleiche Grundlautstaerke. Nur bei HLS-Streaming aktiv.</span>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="audio_dynaudnorm" id="audio_dynaudnorm"
{% if tv.audio_dynaudnorm | default(false) %}checked{% endif %}>
Dynamische Audio-Normalisierung (dynaudnorm)
</label>
<span class="text-muted" style="font-size:0.8rem">Passt Lautstaerke Szene fuer Szene an. Leise Dialoge werden lauter, Explosionen leiser. Ergaenzt Loudnorm. Nur bei HLS-Streaming aktiv.</span>
</div>
</div>
</fieldset>
<!-- Watch-Status --> <!-- Watch-Status -->
<fieldset> <fieldset>
<legend>Watch-Status</legend> <legend>Watch-Status</legend>