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.WebView
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.preference.PreferenceManager
@ -24,6 +27,13 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
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)
// Server-URL aus Intent oder Preferences
@ -93,6 +103,17 @@ class MainActivity : AppCompatActivity() {
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() {
super.onPause()
// ExoPlayer pausieren wenn App in den Hintergrund geht

View file

@ -2,8 +2,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>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowFullscreen">true</item>
</style>
</resources>

View file

@ -524,10 +524,10 @@
var avEl = document.getElementById("avplayer");
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) {
_iframe.style.pointerEvents = "none";
_iframe.style.opacity = "0";
}
// AVPlay oeffnen
@ -655,10 +655,9 @@
var avEl = document.getElementById("avplayer");
if (avEl) avEl.style.display = "block";
// iframe deaktivieren
// iframe: Pointer deaktivieren (D-Pad kommt per postMessage)
if (_iframe) {
_iframe.style.pointerEvents = "none";
_iframe.style.opacity = "0";
}
// AVPlay mit HLS-URL oeffnen
@ -758,7 +757,6 @@
// iframe wieder aktivieren
if (_iframe) {
_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")
settings["tv"]["force_transcode"] = (
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(
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,
"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_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"]
if "stream_quality" in data:
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:
await auth_service.update_client_settings(
client_id, **client_kwargs)

View file

@ -76,12 +76,19 @@ class AuthService:
DEFAULT 'stereo',
stream_quality ENUM('uhd','hd','sd','low')
DEFAULT 'hd',
audio_compressor BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
) 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)
await cur.execute("""
CREATE TABLE IF NOT EXISTS tv_watchlist (
@ -575,7 +582,7 @@ class AuthService:
pool = await self._get_pool()
if not pool:
return False
allowed = {"name", "sound_mode", "stream_quality"}
allowed = {"name", "sound_mode", "stream_quality", "audio_compressor"}
updates = []
values = []
for key, val in kwargs.items():

View file

@ -194,6 +194,12 @@ class HLSSessionManager:
# Audio-Transcoding noetig?
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
if self._tv_setting("force_transcode", False):
needs_video_transcode = True
@ -346,8 +352,18 @@ class HLSSessionManager:
if needs_audio_transcode:
bitrate = {1: "96k", 2: "192k"}.get(
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),
"-b:a", bitrate]
if af_parts:
cmd += ["-af", ",".join(af_parts)]
else:
cmd += ["-c:a", "copy"]

View file

@ -948,6 +948,24 @@ a { color: var(--accent); text-decoration: none; }
background: #000;
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 {
position: fixed;
inset: 0;

View file

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

View file

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

View file

@ -42,6 +42,10 @@ let nativePlayTimeout = null; // Master-Timeout fuer VKNative-Start
// Legacy AVPlay Direct-Play State (Rueckwaerts-Kompatibilitaet)
let useDirectPlay = false; // Alter AVPlayBridge-Pfad aktiv?
// Audio-Kompressor (DynamicsCompressorNode)
let _audioCtx = null;
let _audioCompressorSetup = false;
/**
* Player initialisieren
* @param {Object} opts - Konfiguration
@ -300,6 +304,10 @@ function hideLoading() {
function onPlaying() {
hideLoading();
hlsRetryCount = 0; // Reset bei erfolgreichem Start
// Audio-Kompressor aktivieren (nur bei Browser-Wiedergabe, nicht VKNative)
if (cfg.audioCompressor && !useNativePlayer && videoEl) {
_setupAudioCompressor(videoEl);
}
}
function onVideoError() {
@ -569,6 +577,7 @@ async function _tryNativeDirectPlay(startPosSec) {
}
useNativePlayer = true;
document.body.classList.add("vknative-playing");
console.info("[Player] VKNative.play() aufrufen...");
var ok = window.VKNative.play(directUrl, info, {
seekMs: Math.floor(startPosSec * 1000)
@ -603,6 +612,7 @@ function _cleanupNativePlayer() {
}
useNativePlayer = false;
nativePlayStarted = false;
document.body.classList.remove("vknative-playing");
// Callbacks entfernen
window._vkOnReady = null;
window._vkOnTimeUpdate = null;
@ -629,6 +639,7 @@ function _nativeFallbackToHLS(startPosSec) {
// Kein VKNative HLS (Tizen etc.) -> WebView-HLS
useNativePlayer = false;
nativePlayStarted = false;
document.body.classList.remove("vknative-playing");
if (videoEl) videoEl.style.display = "";
var infoReady = loadVideoInfo();
startHLSStream(startPosSec);
@ -713,6 +724,7 @@ async function _startNativeHLS(startPosSec) {
// ExoPlayer HLS starten
useNativePlayer = true;
document.body.classList.add("vknative-playing");
var ok = window.VKNative.playHLS(playlistUrl, {});
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) ===
/**

View file

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

View file

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

View file

@ -186,6 +186,12 @@
<option value="low" {% if client.stream_quality == 'low' %}selected{% endif %}>{{ t('player.quality_low') }}</option>
</select>
</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>
{% endif %}

View file

@ -59,6 +59,30 @@
</div>
</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 -->
<fieldset>
<legend>Watch-Status</legend>