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:
parent
00d8f6b982
commit
956b7b9ac8
15 changed files with 153 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) ===
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue