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>
340 lines
11 KiB
JavaScript
340 lines
11 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|
|
},
|
|
};
|