Add Samsung Tizen TV web client.

Focus-friendly VMC shell for Tizen with live TV and playback against the master API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-07 19:16:30 +02:00
parent 252bce838b
commit a0311c031e
14 changed files with 4168 additions and 0 deletions

12
apps/tizen-tv/README.md Normal file
View file

@ -0,0 +1,12 @@
# Media Cluster — Samsung Tizen 1.2.0
Cinematic UI (Netflix-achtig), Tizen-veilige CSS. IPTV werkt hetzelfde, wel visueel strakker.
## Install
`apps/android-tv/dist/vonas-mediacluster-tizen-1.2.0.wgt` via Apps2Samsung → Custom WGT.
```powershell
cd apps/tizen-tv
.\package.ps1
```

View file

@ -0,0 +1,79 @@
# Samsung Tizen — bouwconstraints (officiële docs)
Bronnen (raadpleeg bij elke grotere change):
- [Web Engine Specifications](https://developer.samsung.com/smarttv/develop/specifications/web-engine-specifications.html)
- [General Specifications](https://developer.samsung.com/smarttv/develop/specifications/general-specifications.html)
- [AVPlay playback](https://developer.samsung.com/smarttv/develop/guides/multimedia/media-playback/using-avplay.html)
- [W3C/HTML5 API References](https://developer.samsung.com/smarttv/develop/api-references/w3chtml5-api-references.html)
- [Web API References](https://developer.samsung.com/smarttv/develop/api-references/web-api-references.html)
- [TV Device setup / Developer Mode](https://developer.samsung.com/smarttv/develop/getting-started/using-sdk/tv-device.html)
## Doelplatform
We mikken op **Tizen 5.5+ (2020, Chromium ~M69)** als ondergrens, met voorkeurstest op nieuwere sets.
TV-webengine wordt **niet** geüpdatet na aankoopjaar — feature-matrix per jaargang geldt.
| Jaar | Tizen | Chromium (indicatief) |
|------|-------|------------------------|
| 2020 | 5.5 | M69 |
| 2021 | 6.0 | M76 |
| 2022 | 6.5 | M85 |
| 2023 | 7.0 | M94 |
| 2024 | 8.0 | M108 |
## CSS — vermijden tot bewezen op target
Volgens Samsung Web Engine Specs (check matrix vóór gebruik):
| Feature | Let op |
|---------|--------|
| `inset` | **Nee** op oudere engines (o.a. tot ~Tizen 6.x/7.x in matrix als No) → gebruik `top/right/bottom/left` |
| `gap` (flex) | Beperkt / pas later breed → liever margins |
| CSS Grid | Pas later volledig → liever absolute/float/inline-block/flex zonder gap |
| `aspect-ratio`, `dvh`, `:is()`, `:where()`, `:focus-visible` | Modern — niet aannemen |
| `object-fit` | Ja vanaf Tizen 3+ volgens matrix; poster-covers toch liever `background-size: cover` in vast kader + `overflow: hidden` |
Veilig: flexbox (zonder gap), transforms, transitions (basis), `border-radius`, `box-shadow`, absolute layouts, `background-size/position`.
## JavaScript
- ES5-stijl is het veiligst voor breed bereik; ES6 (Promise, arrow, let/const) checken in de Samsung ES6-tabel voor jouw minimumjaar.
- **Geen** aanname van Node/browser globals: altijd `window` of IIFE `(function (global) { ... })(window)`.
- `fetch`: polyfill/XHR achter de hand houden voor oudere engines.
- Geen emoji als enige UI-signaal (font/glyph-support wisselt).
## AVPlay (productie-playback)
Volgens Samsung AVPlay guide:
1. Privilege: `http://developer.samsung.com/privilege/avplay` in `config.xml`
2. Script: `$WEBAPIS/webapis/webapis.js` (lazy laden mag)
3. State machine: `open` → `setDisplayRect` / listener / DRM → `prepare`/`prepareAsync` → `play`
4. `prepare` niet op UI-thread blokkeren → **`prepareAsync`**
5. Object `type="application/avplayer"` alleen tonen tijdens playback (anders fullscreen zwart vlak)
6. Netwerk: `access origin` + internet privilege; HTTPS/certificaten/host bereikbaarheid checken bij `PLAYER_ERROR_CONNECT_FAILED`
7. HTML5 `<video>` als fallback voor progressive streams mag, AVPlay blijft primaire TV-path
## config.xml checklist
- `tizen:profile name="tv-samsung"`
- `required_version` realistisch (2.3/2.4 ok voor breed)
- Privileges: `internet`, `tv.inputdevice`, `avplay`, `network.public`
- `<access origin="*" subdomains="true"/>` of strakkere origins
- `.wgt` = zip met **forward-slash** paden, `config.xml` in root
## Werkwijze (verplicht bij Tizen-wijzigingen)
1. Feature opzoeken in **Web Engine Specifications** voor minimum Tizen-jaar.
2. Bij playback: AVPlay-guide + state table volgen.
3. Op echte TV (Apps2Samsung) valideren — emulator ≠ hardware.
4. Bij twijfel: polyfill of oudere syntax, geen “moderne default”.
## Bekende valkuilen (dit project)
- `inset: 0` → leeg/zwart scherm (containers 0×0)
- AVPlay `<object>` in DOM terwijl UI “hidden” → zwart overlay
- `global.McApp` zonder `global`-parameter → script stopt, `McApp=undefined`
- `PLAYER_ERROR_CONNECT_FAILED` → vaak node/HTTPS/netwerk, niet alleen player-API
- `Failed to register session on node` → master↔node websocket ACK, niet Tizen-UI

36
apps/tizen-tv/package.ps1 Normal file
View file

@ -0,0 +1,36 @@
# Bouwt een .wgt voor Apps2Samsung sideload (ZIP met forward-slash paden).
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$root = $PSScriptRoot
$www = Resolve-Path (Join-Path $root "www")
$config = Join-Path $www "config.xml"
if (-not (Test-Path $config)) { throw "config.xml ontbreekt in www/" }
[xml]$xml = Get-Content $config
$version = $xml.widget.version
if (-not $version) { $version = "1.0.0" }
$dist = Join-Path $root "..\android-tv\dist"
New-Item -ItemType Directory -Force -Path $dist | Out-Null
$outPath = Join-Path $dist "vonas-mediacluster-tizen-$version.wgt"
if (Test-Path $outPath) { Remove-Item $outPath -Force }
$fs = [IO.File]::Open($outPath, [IO.FileMode]::Create)
$zip = New-Object IO.Compression.ZipArchive($fs, [IO.Compression.ZipArchiveMode]::Create)
try {
Get-ChildItem $www -Recurse -File | ForEach-Object {
$rel = $_.FullName.Substring($www.Path.Length).TrimStart("\").Replace("\", "/")
[IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$zip, $_.FullName, $rel, [IO.Compression.CompressionLevel]::Optimal
) | Out-Null
}
} finally {
$zip.Dispose()
$fs.Dispose()
}
$size = [math]::Round((Get-Item $outPath).Length / 1KB, 1)
Write-Host "OK: $outPath ($size KB)"
Write-Host "Installeer via Apps2Samsung → Custom WGT/TPK."

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<widget xmlns:tizen="http://tizen.org/ns/widgets" xmlns="http://www.w3.org/ns/widgets" id="http://vonas.nl/mediacluster" version="1.3.6" viewmodes="maximized">
<tizen:application id="VonasMC001.MediaCluster" package="VonasMC001" required_version="2.3"/>
<content src="index.html"/>
<feature name="http://tizen.org/feature/screen.size.normal.1080.1920"/>
<icon src="icon.png"/>
<name>VMC</name>
<tizen:profile name="tv-samsung"/>
<tizen:privilege name="http://tizen.org/privilege/internet"/>
<tizen:privilege name="http://tizen.org/privilege/tv.inputdevice"/>
<tizen:privilege name="http://developer.samsung.com/privilege/avplay"/>
<tizen:privilege name="http://developer.samsung.com/privilege/network.public"/>
<access origin="*" subdomains="true"></access>
<tizen:metadata key="http://samsung.com/tv/metadata/devel.api.version" value="2.4"/>
<tizen:setting screen-orientation="landscape" context-menu="disable" background-support="disable" encryption="disable" install-location="auto" hwkey-event="enable"/>
</widget>

File diff suppressed because it is too large Load diff

BIN
apps/tizen-tv/www/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080, initial-scale=1.0, user-scalable=no" />
<title>VMC</title>
<link rel="stylesheet" href="css/app.css" />
<style type="text/css">
html, body { margin:0; width:100%; height:100%; background:#0B0B0B; color:#fff; font-family:Arial,sans-serif; }
#boot-banner {
position:absolute; top:0; left:0; right:0; bottom:0;
background:#0B0B0B; color:#E50914; font-size:48px; font-weight:900;
text-align:center; padding-top:38%; z-index:9999; letter-spacing:0.08em;
}
</style>
</head>
<body style="margin:0;background:#0B0B0B;color:#fff;">
<div id="boot-banner">VMC</div>
<div id="app">
<section id="screen-pair" class="screen">
<div class="pair-card">
<div class="brand">VMC</div>
<h1>Koppel deze TV</h1>
<p class="muted">Open de link op je telefoon of computer en vul de code in.</p>
<div id="pair-code" class="pair-code">····</div>
<p id="pair-link" class="pair-link"></p>
<p id="pair-status" class="muted">Bezig met starten…</p>
<button class="btn focusable" id="pair-refresh">Nieuwe code</button>
</div>
</section>
<section id="screen-profiles" class="screen hidden">
<div class="profiles-wrap">
<h1>Wie kijkt er?</h1>
<p class="muted">Kies een profiel</p>
<div id="profile-grid" class="profile-grid"></div>
<div id="pin-panel" class="pin-panel hidden">
<h2 id="pin-title">PIN</h2>
<div id="pin-dots" class="pin-dots"></div>
<div class="numpad" id="pin-pad"></div>
<button class="btn focusable" id="pin-cancel">Annuleer</button>
</div>
</div>
</section>
<section id="screen-main" class="screen hidden">
<nav id="topnav" class="topnav overlay">
<div class="nav-brand">VMC</div>
<button class="nav-tab focusable active" data-tab="home">Home<span class="nav-underline"></span></button>
<button class="nav-tab focusable" data-tab="movies">Films<span class="nav-underline"></span></button>
<button class="nav-tab focusable" data-tab="series">Series<span class="nav-underline"></span></button>
<button class="nav-tab focusable hidden" data-tab="tv" id="nav-tv">TV<span class="nav-underline"></span></button>
<button class="nav-tab focusable" data-tab="search">Zoeken<span class="nav-underline"></span></button>
<button class="nav-tab focusable" data-tab="settings">Instellingen<span class="nav-underline"></span></button>
<button class="nav-profile focusable" id="nav-profile" title="Profiel wisselen"></button>
</nav>
<div id="main-body" class="main-body home-mode">
<div id="tab-home" class="tab-pane"></div>
<div id="tab-movies" class="tab-pane hidden"></div>
<div id="tab-series" class="tab-pane hidden"></div>
<div id="tab-tv" class="tab-pane hidden"></div>
<div id="tab-search" class="tab-pane hidden"></div>
<div id="tab-settings" class="tab-pane hidden"></div>
</div>
</section>
<section id="screen-detail" class="screen hidden">
<div id="detail-bg" class="detail-bg"></div>
<div class="detail-shade"></div>
<div class="detail-body">
<button class="btn focusable" id="detail-back">Terug</button>
<div id="detail-logo" class="hero-logo hidden"></div>
<h1 id="detail-title"></h1>
<div id="detail-meta" class="hero-meta"></div>
<p id="detail-overview" class="detail-overview"></p>
<div id="detail-actions" class="hero-actions"></div>
<div id="detail-episodes" class="episodes"></div>
</div>
</section>
<section id="screen-player" class="screen hidden">
<div id="av-object-wrap" class="av-wrap"></div>
<video id="html-player" class="html-player hidden"></video>
<div id="player-subs" class="player-subs hidden"></div>
<div id="player-ui" class="player-ui">
<div class="player-chrome">
<div id="player-title" class="player-title"></div>
<div class="player-bar">
<div id="player-progress" class="player-progress"><div id="player-fill"></div></div>
<div class="player-time-row">
<span id="player-time" class="player-time"></span>
</div>
</div>
<div class="player-controls" id="player-controls">
<button type="button" class="pctrl focusable" id="btn-play" title="Pauze">Pauze</button>
<button type="button" class="pctrl focusable" id="btn-rew" title="10s terug">-10s</button>
<button type="button" class="pctrl focusable" id="btn-ff" title="10s vooruit">+10s</button>
<span class="pctrl-spacer"></span>
<div class="player-actions" id="player-actions">
<button type="button" class="pctrl text focusable" id="btn-audio">Audio</button>
<button type="button" class="pctrl text focusable" id="btn-subs">Ondertiteling</button>
</div>
</div>
</div>
</div>
<div id="player-menu" class="player-menu hidden">
<div class="player-menu-panel">
<div id="player-menu-title" class="player-menu-title">Audio</div>
<div id="player-menu-list" class="player-menu-list"></div>
<button type="button" class="btn focusable" id="player-menu-close">Sluiten</button>
</div>
</div>
<div id="player-error" class="player-error hidden"></div>
</section>
<div id="toast" class="toast hidden"></div>
<div id="loading" class="loading hidden"><div class="spinner"></div></div>
<div id="fatal" class="fatal hidden"></div>
</div>
<script src="js/polyfill.js"></script>
<script src="js/api.js"></script>
<script src="js/player.js"></script>
<script src="js/focus.js"></script>
<script src="js/live.js"></script>
<script src="js/app.js"></script>
<script>
setTimeout(function () {
var b = document.getElementById("boot-banner");
if (!b) return;
if (b.style.display === "none" || b.style.visibility === "hidden") return;
b.style.background = "#12080a";
b.style.color = "#ffffff";
b.style.paddingTop = "28%";
b.style.fontSize = "22px";
b.style.letterSpacing = "0";
b.innerHTML =
"App start niet automatisch.<br/>McApi=" +
(typeof window.McApi) +
" McApp=" +
(typeof window.McApp) +
" McLive=" +
(typeof window.McLive) +
"<br/>Herinstalleer of stuur deze tekst door.";
}, 2500);
</script>
</body>
</html>

163
apps/tizen-tv/www/js/api.js Normal file
View file

@ -0,0 +1,163 @@
(function (global) {
var BASE = "https://master.vonas.nl";
var TOKEN_KEY = "mc_refresh_token";
var PROFILE_KEY = "mc_profile";
var FAV_KEY = "mc_iptv_favs";
function store() {
try {
return localStorage;
} catch (e) {
return {
_d: {},
getItem: function (k) { return this._d[k] || null; },
setItem: function (k, v) { this._d[k] = String(v); },
removeItem: function (k) { delete this._d[k]; },
};
}
}
var ls = store();
function getToken() { return ls.getItem(TOKEN_KEY); }
function setToken(token) {
if (token) ls.setItem(TOKEN_KEY, token);
else ls.removeItem(TOKEN_KEY);
}
function getProfileCache() {
try { return JSON.parse(ls.getItem(PROFILE_KEY) || "null"); } catch (e) { return null; }
}
function setProfileCache(profile) {
if (profile) ls.setItem(PROFILE_KEY, JSON.stringify(profile));
else ls.removeItem(PROFILE_KEY);
}
function getFavs() {
try { return JSON.parse(ls.getItem(FAV_KEY) || "[]"); } catch (e) { return []; }
}
function setFavs(ids) { ls.setItem(FAV_KEY, JSON.stringify(ids || [])); }
function toggleFav(streamId) {
var id = String(streamId);
var favs = getFavs();
var i = favs.indexOf(id);
if (i >= 0) favs.splice(i, 1);
else favs.push(id);
setFavs(favs);
return favs.indexOf(id) >= 0;
}
function isFav(streamId) { return getFavs().indexOf(String(streamId)) >= 0; }
function errMessage(body, status) {
if (body && body.error && body.error.message) return body.error.message;
if (body && body.error && body.error.code) return body.error.code;
return "HTTP " + status;
}
function request(path, opts) {
opts = opts || {};
var headers = opts.headers || {};
headers["Content-Type"] = "application/json";
var token = getToken();
if (token && !opts.noAuth) headers.Authorization = "Bearer " + token;
return fetch(BASE + path, {
method: opts.method || "GET",
headers: headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
}).then(function (res) {
return res.text().then(function (text) {
var body = null;
try { body = text ? JSON.parse(text) : null; } catch (e) { body = { raw: text }; }
if (!res.ok) {
var err = new Error(errMessage(body, res.status));
err.status = res.status;
err.code = body && body.error && body.error.code;
err.body = body;
throw err;
}
return body;
});
});
}
global.McApi = {
BASE: BASE,
getToken: getToken,
setToken: setToken,
getProfileCache: getProfileCache,
setProfileCache: setProfileCache,
getFavs: getFavs,
toggleFav: toggleFav,
isFav: isFav,
clearSession: function () { setToken(null); setProfileCache(null); },
deviceCode: function () {
return request("/api/v1/client/device/code", {
method: "POST", noAuth: true,
body: { deviceName: "Samsung Tizen", platform: "tizen" },
});
},
devicePoll: function (code) {
return request("/api/v1/client/device/poll?code=" + encodeURIComponent(code), { noAuth: true });
},
me: function () { return request("/api/v1/client/me"); },
listProfiles: function () { return request("/api/v1/client/profiles"); },
selectProfile: function (id, pin) {
var body = {};
if (pin) body.pin = pin;
return request("/api/v1/client/profiles/" + encodeURIComponent(id) + "/select", { method: "POST", body: body });
},
clearProfile: function () {
return request("/api/v1/client/profiles/clear", { method: "POST", body: {} });
},
home: function () { return request("/api/v1/client/home"); },
search: function (q) {
return request("/api/v1/client/search?q=" + encodeURIComponent(q) + "&take=60");
},
catalog: function (id, skip, take) {
skip = skip || 0; take = take || 60;
return request(
"/api/v1/client/catalog/" + encodeURIComponent(id) +
"?skip=" + skip + "&take=" + take
);
},
movie: function (id) { return request("/api/v1/client/movies/" + encodeURIComponent(id)); },
series: function (id) { return request("/api/v1/client/series/" + encodeURIComponent(id)); },
play: function (mediaFileId) {
return request("/api/v1/client/play", { method: "POST", body: { mediaFileId: mediaFileId } });
},
progress: function (payload) {
return request("/api/v1/client/progress", { method: "POST", body: payload });
},
dismissContinue: function (progressId) {
return request("/api/v1/client/progress/" + encodeURIComponent(progressId) + "/dismiss", {
method: "POST", body: {},
});
},
iptvCategories: function () { return request("/api/v1/client/iptv/categories"); },
iptvChannels: function (categoryId, includeEpg) {
var params = [];
if (categoryId) params.push("categoryId=" + encodeURIComponent(categoryId));
if (includeEpg) params.push("epg=1");
var q = params.length ? "?" + params.join("&") : "";
return request("/api/v1/client/iptv/channels" + q);
},
iptvPlay: function (streamId) {
return request("/api/v1/client/iptv/channels/" + streamId + "/play");
},
iptvEpg: function (streamId) {
return request("/api/v1/client/iptv/channels/" + streamId + "/epg");
},
searchSubtitles: function (mediaFileId, languages) {
languages = languages || "nl,en";
return request(
"/api/v1/client/subtitles/search?mediaFileId=" +
encodeURIComponent(mediaFileId) +
"&languages=" +
encodeURIComponent(languages)
);
},
fetchSubtitle: function (fileId, source) {
return request("/api/v1/client/subtitles/fetch", {
method: "POST",
body: { fileId: fileId, source: source || "org" },
});
},
};
})(window);

1346
apps/tizen-tv/www/js/app.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,100 @@
(function (global) {
var keys = {
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40,
ENTER: 13,
BACK: 10009,
ESC: 27,
PLAY: 415,
PAUSE: 19,
PLAYPAUSE: 10252,
FF: 417,
REW: 412,
STOP: 413,
};
function visibleFocusables(root) {
root = root || document;
var nodes = root.querySelectorAll(".focusable, .tile, .profile-tile, .ep, .num-key, .btn");
var out = [];
for (var i = 0; i < nodes.length; i++) {
var el = nodes[i];
if (el.disabled) continue;
if (el.classList.contains("hidden")) continue;
if (el.closest && el.closest(".hidden")) continue;
var r = el.getBoundingClientRect();
if (r.width < 2 || r.height < 2) continue;
out.push(el);
}
return out;
}
function center(el) {
var r = el.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2, r: r };
}
function move(direction) {
var active = document.activeElement;
var list = visibleFocusables();
if (!list.length) return;
if (!active || list.indexOf(active) < 0) {
list[0].focus();
return;
}
var a = center(active);
var best = null;
var bestScore = Infinity;
for (var i = 0; i < list.length; i++) {
var el = list[i];
if (el === active) continue;
var b = center(el);
var dx = b.x - a.x;
var dy = b.y - a.y;
var ok = false;
if (direction === "left") ok = dx < -10 && Math.abs(dy) < Math.abs(dx) * 1.6;
if (direction === "right") ok = dx > 10 && Math.abs(dy) < Math.abs(dx) * 1.6;
if (direction === "up") ok = dy < -10 && Math.abs(dx) < Math.abs(dy) * 1.8;
if (direction === "down") ok = dy > 10 && Math.abs(dx) < Math.abs(dy) * 1.8;
if (!ok) continue;
var score = Math.abs(dx) + Math.abs(dy) * 1.2;
if (score < bestScore) {
bestScore = score;
best = el;
}
}
if (best) {
best.focus();
if (best.scrollIntoView) best.scrollIntoView({ block: "nearest", inline: "nearest" });
}
}
function registerKeys() {
try {
if (global.tizen && tizen.tvinputdevice) {
var supported = tizen.tvinputdevice.getSupportedKeys() || [];
var names = ["MediaPlay", "MediaPause", "MediaPlayPause", "MediaFastForward", "MediaRewind", "MediaStop"];
for (var i = 0; i < supported.length; i++) {
if (names.indexOf(supported[i].name) >= 0) {
try {
tizen.tvinputdevice.registerKey(supported[i].name);
} catch (e) {}
}
}
}
} catch (e) {}
}
global.McFocus = {
keys: keys,
move: move,
focusFirst: function (root) {
var list = visibleFocusables(root);
if (list[0]) list[0].focus();
},
registerKeys: registerKeys,
};
})(window);

View file

@ -0,0 +1,366 @@
(function (global) {
var state = {
categories: [],
channels: [],
categoryId: "__all__",
selected: null,
playList: [],
playIndex: -1,
mounted: false,
focusInList: false,
};
function $(id) { return document.getElementById(id); }
function toast(msg) {
if (global.McApp && McApp.toast) McApp.toast(msg);
}
function showLoading(on) {
if (global.McApp && McApp.showLoading) McApp.showLoading(on);
}
function escapeHtml(s) {
return String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function safeUrl(u) {
return String(u || "").replace(/'/g, "%27");
}
function categoryList() {
var all = [{ id: "__all__", name: "Alles", channelCount: 0 }].concat(state.categories || []);
var favs = McApi.getFavs();
if (favs.length) {
all.splice(1, 0, { id: "__fav__", name: "Favorieten", channelCount: favs.length });
}
return all;
}
/** Bouw categoriechips één keer; update alleen active + behoud focus. */
function renderCategories(opts) {
opts = opts || {};
var el = $("tv-cats");
if (!el) return;
var all = categoryList();
var activeId = state.categoryId || "__all__";
var prevFocusId = null;
var ae = document.activeElement;
if (ae && ae.getAttribute && ae.getAttribute("data-cat-id")) {
prevFocusId = ae.getAttribute("data-cat-id");
}
el.innerHTML = "";
var focusTarget = null;
all.forEach(function (c) {
var b = document.createElement("button");
b.className = "cat-chip focusable" + (activeId === c.id ? " active" : "");
b.type = "button";
b.setAttribute("data-cat-id", c.id);
b.innerHTML =
escapeHtml(c.name) +
(c.channelCount ? " (" + c.channelCount + ")" : "") +
'<span class="chip-line"></span>';
b.addEventListener("click", function () {
if (state.categoryId === c.id) {
b.focus();
return;
}
state.categoryId = c.id;
state.focusInList = false;
setCategoryActive(c.id);
loadChannels(c.id, { keepCategoryFocus: true, focusChipId: c.id });
});
b.addEventListener("focus", function () {
state.focusInList = false;
});
el.appendChild(b);
if (c.id === (opts.focusChipId || prevFocusId || activeId)) {
focusTarget = b;
}
});
if (opts.autoFocus || opts.keepCategoryFocus) {
setTimeout(function () {
if (focusTarget) focusTarget.focus();
}, 30);
}
}
function setCategoryActive(id) {
var chips = document.querySelectorAll("#tv-cats .cat-chip");
for (var i = 0; i < chips.length; i++) {
var chip = chips[i];
if (chip.getAttribute("data-cat-id") === id) chip.classList.add("active");
else chip.classList.remove("active");
}
}
function renderChannels(channels, opts) {
opts = opts || {};
state.channels = channels || [];
var list = $("tv-list");
var detail = $("tv-detail");
if (!list) return;
list.innerHTML = "";
if (!state.channels.length) {
list.innerHTML = '<div class="muted" style="padding:28px">Geen zenders in deze categorie</div>';
if (detail) {
detail.innerHTML = '<p class="muted">Geen zenders</p>';
}
return;
}
state.channels.forEach(function (ch, idx) {
var row = document.createElement("button");
row.className = "ch-row focusable";
row.type = "button";
row.setAttribute("data-idx", String(idx));
var now = ch.nowTitle || "";
var fav = McApi.isFav(ch.streamId);
var logo = ch.logoUrl
? '<div class="ch-logo"><div class="ch-logo-img" style="background-image:url(\'' +
safeUrl(ch.logoUrl) +
"')\"></div></div>"
: '<div class="ch-logo"><div class="ch-logo-img"></div></div>';
row.innerHTML =
'<span class="ch-bar"></span>' +
logo +
'<div class="ch-meta">' +
'<div class="ch-name">' +
(fav ? "* " : "") +
escapeHtml(ch.name) +
"</div>" +
'<div class="ch-epg">' +
escapeHtml(now ? "Nu · " + now : "Geen programma-info") +
"</div></div>";
row.addEventListener("click", function () {
selectChannel(ch, idx);
});
row.addEventListener("focus", function () {
state.focusInList = true;
selectChannel(ch, idx, { skipRebuildActions: true });
});
row.addEventListener("keydown", function (e) {
if ((e.keyCode || e.which) === 13) playChannel(ch, idx);
});
list.appendChild(row);
});
var keepIdx = 0;
if (state.selected) {
for (var i = 0; i < state.channels.length; i++) {
if (state.channels[i].streamId === state.selected.streamId) {
keepIdx = i;
break;
}
}
}
selectChannel(state.channels[keepIdx], keepIdx, { skipRebuildActions: false });
if (opts.focusFirstChannel) {
var first = list.querySelector(".ch-row");
if (first) setTimeout(function () { first.focus(); }, 40);
}
}
function paintDetail(ch, idx) {
var detail = $("tv-detail");
if (!detail || !ch) return;
var logoBlock = ch.logoUrl
? '<div class="tv-detail-logo"><div class="tv-detail-logo-img" style="background-image:url(\'' +
safeUrl(ch.logoUrl) +
"')\"></div></div>"
: "";
detail.innerHTML =
logoBlock +
"<h2>" + escapeHtml(ch.name) + "</h2>" +
'<div class="epg-card">' +
'<div class="epg-label">Nu</div>' +
'<div class="epg-title" id="tv-epg-now">' +
escapeHtml(ch.nowTitle || "Geen EPG beschikbaar") +
"</div></div>" +
'<div class="epg-card">' +
'<div class="epg-label">Straks</div>' +
'<div class="epg-title" id="tv-epg-next">' +
escapeHtml(ch.nextTitle || "—") +
"</div></div>" +
'<div class="tv-actions">' +
'<button class="btn primary focusable" id="tv-play">Kijken</button>' +
'<button class="btn focusable" id="tv-fav">' +
(McApi.isFav(ch.streamId) ? "* Favoriet" : "Favoriet") +
"</button></div>" +
'<p class="tv-hint">OK = kijken · Favoriet toevoegen met Favoriet</p>';
var playBtn = $("tv-play");
var favBtn = $("tv-fav");
if (playBtn) playBtn.onclick = function () { playChannel(ch, idx); };
if (favBtn) {
favBtn.onclick = function () {
McApi.toggleFav(ch.streamId);
renderCategories({ keepCategoryFocus: true, focusChipId: state.categoryId });
loadChannels(state.categoryId || "__all__", {
keepCategoryFocus: true,
focusChipId: state.categoryId,
});
};
}
}
function selectChannel(ch, idx, opts) {
opts = opts || {};
state.selected = ch;
state.playIndex = idx;
var rows = document.querySelectorAll("#tv-list .ch-row");
for (var i = 0; i < rows.length; i++) {
if (i === idx) rows[i].classList.add("active");
else rows[i].classList.remove("active");
}
paintDetail(ch, idx);
McApi.iptvEpg(ch.streamId).then(function (epg) {
if (!state.selected || state.selected.streamId !== ch.streamId) return;
ch.nowTitle = epg.now && epg.now.title;
ch.nextTitle = epg.next && epg.next.title;
var nowEl = $("tv-epg-now");
var nextEl = $("tv-epg-next");
if (nowEl) nowEl.textContent = ch.nowTitle || "Geen EPG beschikbaar";
if (nextEl) nextEl.textContent = ch.nextTitle || "—";
var row = document.querySelector('#tv-list .ch-row[data-idx="' + idx + '"] .ch-epg');
if (row) {
row.textContent = ch.nowTitle ? "Nu · " + ch.nowTitle : "Geen programma-info";
}
}).catch(function () {});
}
function playChannel(ch, idx) {
if (!ch) return;
showLoading(true);
McApi.iptvPlay(ch.streamId)
.then(function (info) {
showLoading(false);
state.playList = state.channels.slice();
state.playIndex = idx;
if (global.McApp && McApp.openLivePlayer) {
McApp.openLivePlayer({
title: info.name || ch.name,
url: info.streamUrl,
fallbackUrl: info.fallbackStreamUrl,
channels: state.playList,
index: idx,
});
}
})
.catch(function (e) {
showLoading(false);
toast(e.message || "Live starten mislukt");
});
}
function loadChannels(categoryId, opts) {
opts = opts || {};
var id = categoryId || "__all__";
state.categoryId = id;
setCategoryActive(id);
showLoading(true);
var req;
if (id === "__fav__") {
req = McApi.iptvChannels(null, true).then(function (res) {
var all = (res && res.channels) || res || [];
return (all || []).filter(function (c) { return McApi.isFav(c.streamId); });
});
} else if (id === "__all__") {
req = McApi.iptvChannels(null, true).then(function (res) {
return (res && res.channels) || res || [];
});
} else {
req = McApi.iptvChannels(id, true).then(function (res) {
return (res && res.channels) || res || [];
});
}
req
.then(function (channels) {
showLoading(false);
renderChannels(channels, {
focusFirstChannel: !!opts.focusFirstChannel,
});
if (opts.keepCategoryFocus) {
var chip =
document.querySelector('#tv-cats .cat-chip[data-cat-id="' + (opts.focusChipId || id) + '"]');
if (chip) {
setTimeout(function () { chip.focus(); }, 40);
}
}
})
.catch(function (e) {
showLoading(false);
toast(e.message || "Zenders laden mislukt");
if (opts.keepCategoryFocus) {
var chip =
document.querySelector('#tv-cats .cat-chip[data-cat-id="' + (opts.focusChipId || id) + '"]');
if (chip) chip.focus();
}
});
}
function mount(container) {
state.mounted = true;
state.focusInList = false;
container.innerHTML =
'<div class="tv-layout">' +
'<div id="tv-cats" class="tv-cats"></div>' +
'<div class="tv-body">' +
'<div id="tv-list" class="tv-list"></div>' +
'<div id="tv-detail" class="tv-detail"><p class="muted">Kies een zender om te kijken</p></div>' +
"</div></div>";
if (state.categories.length) {
var id = state.categoryId || "__all__";
renderCategories({ autoFocus: true, focusChipId: id });
loadChannels(id, { keepCategoryFocus: true, focusChipId: id });
return;
}
state.categoryId = "__all__";
state.selected = null;
showLoading(true);
McApi.iptvCategories()
.then(function (res) {
state.categories = (res && res.categories) || res || [];
renderCategories({ autoFocus: true, focusChipId: "__all__" });
loadChannels("__all__", { keepCategoryFocus: true, focusChipId: "__all__" });
})
.catch(function (e) {
showLoading(false);
container.innerHTML =
'<p class="muted" style="padding:48px">' +
escapeHtml(e.message || "Live TV niet beschikbaar") +
"</p>";
});
}
function zap(delta) {
if (!state.playList.length) return null;
var next = state.playIndex + delta;
if (next < 0) next = state.playList.length - 1;
if (next >= state.playList.length) next = 0;
var ch = state.playList[next];
state.playIndex = next;
return ch;
}
global.McLive = {
mount: mount,
zap: zap,
playChannel: playChannel,
getPlayState: function () {
return { list: state.playList, index: state.playIndex };
},
};
})(window);

View file

@ -0,0 +1,764 @@
(function (global) {
var usingAvplay = false;
var htmlVideo = null;
var listeners = {};
var hideTimer = null;
var tickTimer = null;
var webapisLoaded = false;
var currentUrl = "";
var currentTitle = "";
var externalCues = [];
var externalLabel = "";
var silentText = false;
var lastAvSubText = "";
var playSession = 0;
var fallbackOnce = false;
function $(id) {
return document.getElementById(id);
}
function hostOf(url) {
try {
var m = String(url).match(/^https?:\/\/([^\/]+)/i);
return m ? m[1] : "?";
} catch (e) {
return "?";
}
}
function loadWebapis(cb) {
if (webapisLoaded || (global.webapis && webapis.avplay)) {
webapisLoaded = true;
cb(true);
return;
}
var s = document.createElement("script");
s.src = "$WEBAPIS/webapis/webapis.js";
s.onload = function () {
webapisLoaded = true;
cb(!!(global.webapis && webapis.avplay));
};
s.onerror = function () {
cb(false);
};
document.head.appendChild(s);
}
function hasAvplay() {
return !!(global.webapis && webapis.avplay);
}
function ensureAvObject() {
var wrap = $("av-object-wrap");
if (!wrap) return null;
wrap.style.display = "block";
wrap.style.position = "absolute";
wrap.style.left = "0";
wrap.style.top = "0";
wrap.style.width = "100%";
wrap.style.height = "100%";
var obj = document.getElementById("av-player");
if (!obj) {
obj = document.createElement("object");
obj.id = "av-player";
obj.type = "application/avplayer";
obj.setAttribute(
"style",
"position:absolute;left:0;top:0;width:1920px;height:1080px;"
);
wrap.appendChild(obj);
}
return obj;
}
function showUi(show, opts) {
opts = opts || {};
var ui = $("player-ui");
if (!ui) return;
if (show) ui.classList.remove("fade");
else ui.classList.add("fade");
clearTimeout(hideTimer);
if (show) {
if (opts.focusPlay) {
var playBtn = $("btn-play");
if (playBtn) setTimeout(function () { playBtn.focus(); }, 30);
}
hideTimer = setTimeout(function () {
if ($("player-menu") && !$("player-menu").classList.contains("hidden")) return;
var ae = document.activeElement;
if (ae && ae.className && String(ae.className).indexOf("pctrl") >= 0) {
/* focus in controls: niet auto-verbergen */
return;
}
ui.classList.add("fade");
}, 6000);
}
}
function isUiVisible() {
var ui = $("player-ui");
return !!(ui && !ui.classList.contains("fade"));
}
function setPlayingIcon(playing) {
var btn = $("btn-play");
if (!btn) return;
/* Geen fancy unicode — ontbreekt vaak op Tizen-fonts */
btn.textContent = playing ? "Pauze" : "Play";
}
function safeAvClose() {
if (!hasAvplay()) return;
try {
var st = "";
try { st = webapis.avplay.getState(); } catch (e0) { st = ""; }
if (st === "PLAYING" || st === "PAUSED" || st === "READY") {
try { webapis.avplay.stop(); } catch (e1) {}
}
if (st && st !== "NONE") {
try { webapis.avplay.close(); } catch (e2) {}
}
} catch (e) {
try { webapis.avplay.close(); } catch (e3) {}
}
}
function destroyAvObject() {
safeAvClose();
var wrap = $("av-object-wrap");
if (wrap) {
wrap.innerHTML = "";
wrap.style.display = "none";
}
}
function formatTime(ms) {
if (!ms || ms < 0) return "0:00";
var s = Math.floor(ms / 1000);
var m = Math.floor(s / 60);
var h = Math.floor(m / 60);
s = s % 60;
m = m % 60;
function two(n) { return n < 10 ? "0" + n : String(n); }
if (h > 0) return h + ":" + two(m) + ":" + two(s);
return m + ":" + two(s);
}
function getPositionMs() {
try {
if (usingAvplay) return webapis.avplay.getCurrentTime() || 0;
if (htmlVideo) return (htmlVideo.currentTime || 0) * 1000;
} catch (e) {}
return 0;
}
function getDurationMs() {
try {
if (usingAvplay) return webapis.avplay.getDuration() || 0;
if (htmlVideo) return (htmlVideo.duration || 0) * 1000;
} catch (e) {}
return 0;
}
function setSubOverlay(text) {
var el = $("player-subs");
if (!el) return;
if (!text || silentText) {
el.classList.add("hidden");
el.textContent = "";
return;
}
el.textContent = text;
el.classList.remove("hidden");
}
function updateExternalCue() {
if (!externalCues.length || silentText) {
if (!lastAvSubText) setSubOverlay("");
return;
}
var pos = getPositionMs();
var text = "";
for (var i = 0; i < externalCues.length; i++) {
var c = externalCues[i];
if (pos >= c.start && pos < c.end) {
text = c.text;
break;
}
}
setSubOverlay(text);
}
function updateProgress() {
var pos = getPositionMs();
var dur = getDurationMs();
var fill = $("player-fill");
var time = $("player-time");
if (fill && dur > 0) fill.style.width = Math.min(100, (pos / dur) * 100) + "%";
if (time) time.textContent = formatTime(pos) + " / " + formatTime(dur);
updateExternalCue();
}
function startTick() {
stopTick();
tickTimer = setInterval(updateProgress, 400);
}
function stopTick() {
if (tickTimer) clearInterval(tickTimer);
tickTimer = null;
}
function setError(msg) {
var el = $("player-error");
if (!el) return;
if (msg) {
el.textContent = msg;
el.classList.remove("hidden");
} else {
el.classList.add("hidden");
}
}
function clearExternal() {
externalCues = [];
externalLabel = "";
lastAvSubText = "";
setSubOverlay("");
}
function stop() {
playSession += 1;
fallbackOnce = false;
stopTick();
setError(null);
clearExternal();
silentText = false;
usingAvplay = false;
destroyAvObject();
if (htmlVideo) {
try {
htmlVideo.onplaying = null;
htmlVideo.onended = null;
htmlVideo.onerror = null;
htmlVideo.removeAttribute("src");
htmlVideo.load();
} catch (e) {}
htmlVideo.classList.add("hidden");
}
}
function playWithHtml(url, note) {
var session = playSession;
usingAvplay = false;
setError(null);
htmlVideo = $("html-player");
if (!htmlVideo) {
setError("Geen HTML5-speler beschikbaar");
return;
}
htmlVideo.classList.remove("hidden");
htmlVideo.setAttribute(
"style",
"position:absolute;left:0;top:0;width:100%;height:100%;background:#000;"
);
try {
htmlVideo.onplaying = null;
htmlVideo.onended = null;
htmlVideo.onerror = null;
} catch (e0) {}
htmlVideo.onplaying = function () {
if (session !== playSession) return;
setError(null);
setPlayingIcon(true);
};
htmlVideo.onended = function () {
if (session !== playSession) return;
if (listeners.onEnded) listeners.onEnded();
};
htmlVideo.onerror = function () {
if (session !== playSession) return;
setError("Kan stream niet openen.\nHost: " + hostOf(url));
};
function tryPlay(attempt) {
if (session !== playSession) return;
try {
if (!htmlVideo.src || htmlVideo.src.indexOf(url.split("?")[0]) < 0) {
htmlVideo.src = url;
}
var p = htmlVideo.play();
if (p && p.catch) {
p.catch(function (e) {
if (session !== playSession) return;
var msg = (e && e.message) || String(e);
if (/interrupted|AbortError/i.test(msg) && attempt < 3) {
setTimeout(function () { tryPlay(attempt + 1); }, 300 * attempt);
return;
}
setError(
"Afspelen mislukt op deze TV.\nHost: " +
hostOf(url) +
(note ? "\n(" + note + ")" : "")
);
});
}
startTick();
} catch (e) {
if (attempt < 3) {
setTimeout(function () { tryPlay(attempt + 1); }, 300);
} else {
setError("HTML5 crash: " + (e.message || e));
}
}
}
setTimeout(function () { tryPlay(1); }, 50);
}
function fallbackHtml(url, reason) {
if (fallbackOnce) return;
fallbackOnce = true;
/* AVPlay-callbacks doden + decoder vrijgeven vóór HTML5 */
playSession += 1;
var session = playSession;
usingAvplay = false;
destroyAvObject();
setError(null);
setTimeout(function () {
if (session !== playSession) return;
playWithHtml(url, reason || null);
}, 450);
}
function playWithAvplay(url) {
var session = playSession;
fallbackOnce = false;
usingAvplay = true;
var hv = $("html-player");
if (hv) {
try {
hv.onplaying = null;
hv.onended = null;
hv.onerror = null;
hv.removeAttribute("src");
hv.load();
} catch (eH) {}
hv.classList.add("hidden");
}
ensureAvObject();
setTimeout(function () {
if (session !== playSession) return;
try {
safeAvClose();
} catch (eClose) {}
setTimeout(function () {
if (session !== playSession) return;
try {
webapis.avplay.open(url);
try {
webapis.avplay.setDisplayMethod("PLAYER_DISPLAY_MODE_AUTO_ASPECT_RATIO");
} catch (e1) {}
webapis.avplay.setDisplayRect(0, 0, 1920, 1080);
try {
webapis.avplay.setStreamingProperty(
"USER_AGENT",
"Mozilla/5.0 (SMART-TV; Linux; Tizen 6.0) AppleWebKit/537.36"
);
} catch (e2) {}
webapis.avplay.setListener({
onbufferingstart: function () {},
onbufferingprogress: function () {},
onbufferingcomplete: function () {},
oncurrentplaytime: function () {
if (session !== playSession) return;
updateProgress();
},
onsubtitlechange: function (duration, text) {
if (session !== playSession) return;
if (externalCues.length) return;
var s = text;
if (Object.prototype.toString.call(text) === "[object Array]") {
s = text.join("\n");
} else if (text && typeof text === "object") {
s = text.text || text.Data || text.data || "";
}
lastAvSubText = String(s || "").replace(/<[^>]+>/g, "");
if (!silentText) setSubOverlay(lastAvSubText);
},
onstreamcompleted: function () {
if (session !== playSession) return;
stop();
if (listeners.onEnded) listeners.onEnded();
},
onerror: function (type) {
if (session !== playSession) return;
fallbackHtml(url, String(type || "error"));
if (listeners.onError) listeners.onError(type);
},
});
webapis.avplay.prepareAsync(
function () {
if (session !== playSession) return;
try {
var st = "";
try { st = webapis.avplay.getState(); } catch (eS) {}
if (st !== "READY" && st !== "PAUSED" && st !== "PLAYING") {
fallbackHtml(url, "AVPlay state " + st);
return;
}
webapis.avplay.play();
startTick();
setError(null);
setPlayingIcon(true);
} catch (ePlay) {
fallbackHtml(url, "play");
}
},
function (e) {
if (session !== playSession) return;
fallbackHtml(url, e && (e.message || e.name) ? String(e.message || e.name) : "prepare");
}
);
} catch (e) {
if (session !== playSession) return;
fallbackHtml(url, e.message || String(e));
}
}, 150);
}, 80);
}
function playUrl(url, title) {
stop();
playSession += 1;
fallbackOnce = false;
currentUrl = url;
currentTitle = title || "";
$("player-title").textContent = currentTitle;
setError(null);
setPlayingIcon(true);
showUi(true, { focusPlay: true });
if (/^http:\/\//i.test(url)) {
playWithHtml(url, null);
return;
}
loadWebapis(function (ok) {
if (ok && hasAvplay()) playWithAvplay(url);
else playWithHtml(url, null);
});
}
function parseExtra(extra) {
if (!extra) return {};
if (typeof extra === "object") return extra;
try { return JSON.parse(extra); } catch (e) { return { raw: String(extra) }; }
}
function langLabel(code) {
var c = String(code || "").trim().toLowerCase();
if (!c || c === "und" || c === "unknown" || c === "null") return "";
if (c === "nl" || c === "dut" || c === "nld") return "Nederlands";
if (c === "en" || c === "eng") return "Engels";
if (c === "de" || c === "ger" || c === "deu") return "Duits";
if (c === "fr" || c === "fre" || c === "fra") return "Frans";
if (c === "es" || c === "spa") return "Spaans";
if (c === "it" || c === "ita") return "Italiaans";
if (c === "pt" || c === "por") return "Portugees";
if (c === "pl" || c === "pol") return "Pools";
if (c === "ru" || c === "rus") return "Russisch";
if (c === "ja" || c === "jpn") return "Japans";
if (c === "zh" || c === "chi" || c === "zho") return "Chinees";
return c.toUpperCase();
}
function isJunkLang(v) {
var s = String(v || "").trim().toLowerCase();
if (!s) return true;
if (s.indexOf("/") >= 0) return true; /* text/plain, application/x-subrip */
if (s.indexOf("text") === 0) return true;
if (s === "plain" || s === "utf-8" || s === "utf8") return true;
if (s === "und" || s === "unknown" || s === "null" || s === "undefined") return true;
if (/^(srt|ass|ssa|vtt|sub|idx|pgs|hdmv)$/.test(s)) return true;
return false;
}
function pickLangFromExtra(extra) {
var keys = [
"track_lang",
"language",
"lang",
"language_code",
"Language",
"lang_code",
];
for (var i = 0; i < keys.length; i++) {
var v = extra[keys[i]];
if (v && typeof v === "object") v = v.code || v.name || "";
if (!isJunkLang(v)) return String(v).trim();
}
return "";
}
function trackLabel(type, index, extra) {
var lang = pickLangFromExtra(extra);
var nice = langLabel(lang);
var channels = extra.channels || extra.audio_channels || "";
if (typeof channels === "object") channels = "";
var base =
nice ||
(type === "AUDIO" ? "Audio " + (index + 1) : "Ondertitel " + (index + 1));
if (channels && String(channels).match(/^\d+$/)) base += " · " + channels + "ch";
return base;
}
function listAvTracks(type) {
var out = [];
if (!usingAvplay || !hasAvplay()) return out;
try {
var info = webapis.avplay.getTotalTrackInfo() || [];
for (var i = 0; i < info.length; i++) {
var t = info[i];
if (!t) continue;
var tType = String(t.type || "").toUpperCase();
if (tType !== type) continue;
var idx = typeof t.index === "number" ? t.index : i;
var extra = parseExtra(t.extra_info);
out.push({
type: type,
index: idx,
label: trackLabel(type, idx, extra),
lang: extra.track_lang || extra.language || "",
});
}
} catch (e) {}
return out;
}
function listHtmlAudio() {
var out = [];
if (!htmlVideo) return out;
var tracks = htmlVideo.audioTracks;
if (!tracks || !tracks.length) return out;
for (var i = 0; i < tracks.length; i++) {
var t = tracks[i];
var nice = langLabel(t.language || "") || t.label || ("Audio " + (i + 1));
out.push({ type: "AUDIO", index: i, label: nice, lang: t.language || "" });
}
return out;
}
function listHtmlText() {
var out = [];
if (!htmlVideo) return out;
var tracks = htmlVideo.textTracks;
if (!tracks || !tracks.length) return out;
for (var i = 0; i < tracks.length; i++) {
var t = tracks[i];
var kind = String(t.kind || "").toLowerCase();
if (kind && kind !== "subtitles" && kind !== "captions") continue;
var nice = langLabel(t.language || "") || t.label || ("Ondertitel " + (i + 1));
out.push({ type: "TEXT", index: i, label: nice, lang: t.language || "" });
}
return out;
}
function listAudioTracks() {
return usingAvplay ? listAvTracks("AUDIO") : listHtmlAudio();
}
function listTextTracks() {
var embedded = usingAvplay ? listAvTracks("TEXT") : listHtmlText();
if (externalCues.length) {
embedded = embedded.concat([{
type: "EXTERNAL",
index: -1,
label: externalLabel || "OpenSubtitles",
lang: "",
}]);
}
return embedded;
}
function selectAudio(index) {
try {
if (usingAvplay) {
webapis.avplay.setSelectTrack("AUDIO", index);
} else if (htmlVideo && htmlVideo.audioTracks) {
for (var i = 0; i < htmlVideo.audioTracks.length; i++) {
htmlVideo.audioTracks[i].enabled = i === index;
}
}
} catch (e) {}
}
function disableText() {
silentText = true;
lastAvSubText = "";
setSubOverlay("");
try {
if (usingAvplay) {
webapis.avplay.setSilentSubtitle(true);
} else if (htmlVideo && htmlVideo.textTracks) {
for (var i = 0; i < htmlVideo.textTracks.length; i++) {
htmlVideo.textTracks[i].mode = "disabled";
}
}
} catch (e) {}
}
function selectText(index) {
silentText = false;
try {
if (usingAvplay) {
webapis.avplay.setSilentSubtitle(false);
webapis.avplay.setSelectTrack("TEXT", index);
} else if (htmlVideo && htmlVideo.textTracks) {
for (var i = 0; i < htmlVideo.textTracks.length; i++) {
htmlVideo.textTracks[i].mode = i === index ? "showing" : "disabled";
}
}
} catch (e) {}
}
function parseTimeToMs(raw) {
var s = String(raw).trim().replace(",", ".");
var parts = s.split(":");
if (parts.length < 3) return 0;
var h = parseInt(parts[0], 10) || 0;
var m = parseInt(parts[1], 10) || 0;
var sec = parseFloat(parts[2]) || 0;
return Math.round(((h * 60 + m) * 60 + sec) * 1000);
}
function parseSrt(text) {
var blocks = String(text).replace(/\r/g, "").split(/\n\n+/);
var cues = [];
for (var i = 0; i < blocks.length; i++) {
var lines = blocks[i].split("\n").filter(function (l) { return l.length; });
if (lines.length < 2) continue;
var timeLine = lines[0].indexOf("-->") >= 0 ? lines[0] : lines[1];
if (!timeLine || timeLine.indexOf("-->") < 0) continue;
var times = timeLine.split("-->");
var start = parseTimeToMs(times[0]);
var end = parseTimeToMs(times[1]);
var bodyStart = lines[0].indexOf("-->") >= 0 ? 1 : 2;
var body = lines.slice(bodyStart).join("\n").replace(/<[^>]+>/g, "");
if (end > start && body) cues.push({ start: start, end: end, text: body });
}
return cues;
}
function parseVtt(text) {
var cleaned = String(text).replace(/\r/g, "");
if (cleaned.indexOf("WEBVTT") === 0) {
cleaned = cleaned.replace(/^WEBVTT[^\n]*\n+/, "");
}
return parseSrt(cleaned);
}
function b64ToText(b64) {
try {
var bin = atob(b64);
try {
return decodeURIComponent(escape(bin));
} catch (e1) {
return bin;
}
} catch (e) {
return "";
}
}
function applyExternalSubtitle(payload) {
var text = b64ToText(payload.contentBase64 || "");
if (!text) throw new Error("Lege ondertitel");
var format = String(payload.format || "srt").toLowerCase();
var cues = format === "vtt" ? parseVtt(text) : parseSrt(text);
if (!cues.length) throw new Error("Kon ondertitel niet lezen");
externalCues = cues;
externalLabel = payload.label || payload.fileName || "OpenSubtitles";
silentText = false;
lastAvSubText = "";
try {
if (usingAvplay) webapis.avplay.setSilentSubtitle(true);
else if (htmlVideo && htmlVideo.textTracks) {
for (var i = 0; i < htmlVideo.textTracks.length; i++) {
htmlVideo.textTracks[i].mode = "disabled";
}
}
} catch (e) {}
updateExternalCue();
return cues.length;
}
function togglePause() {
showUi(true);
try {
if (usingAvplay) {
var state = webapis.avplay.getState();
if (state === "PLAYING") {
webapis.avplay.pause();
setPlayingIcon(false);
} else if (state === "PAUSED") {
webapis.avplay.play();
setPlayingIcon(true);
}
} else if (htmlVideo) {
if (htmlVideo.paused) {
htmlVideo.play();
setPlayingIcon(true);
} else {
htmlVideo.pause();
setPlayingIcon(false);
}
}
} catch (e) {}
}
function seek(deltaMs) {
showUi(true);
try {
if (usingAvplay) {
var pos = webapis.avplay.getCurrentTime() + deltaMs;
if (pos < 0) pos = 0;
webapis.avplay.seekTo(pos);
} else if (htmlVideo) {
htmlVideo.currentTime = Math.max(0, htmlVideo.currentTime + deltaMs / 1000);
}
} catch (e) {}
updateProgress();
}
global.McPlayer = {
playUrl: playUrl,
stop: stop,
togglePause: togglePause,
seek: seek,
showUi: showUi,
isUiVisible: isUiVisible,
setPlayingIcon: setPlayingIcon,
getPositionMs: getPositionMs,
getDurationMs: getDurationMs,
listAudioTracks: listAudioTracks,
listTextTracks: listTextTracks,
selectAudio: selectAudio,
selectText: selectText,
disableText: disableText,
applyExternalSubtitle: applyExternalSubtitle,
langLabel: langLabel,
on: function (name, fn) {
listeners[name] = fn;
},
};
})(window);

View file

@ -0,0 +1,59 @@
/* Oudere Tizen-browsers: fetch + pad helpers */
(function (global) {
if (typeof global.fetch !== "function") {
global.fetch = function (url, opts) {
opts = opts || {};
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(opts.method || "GET", url, true);
var headers = opts.headers || {};
for (var k in headers) {
if (headers.hasOwnProperty(k)) xhr.setRequestHeader(k, headers[k]);
}
xhr.onload = function () {
var body = xhr.responseText;
resolve({
ok: xhr.status >= 200 && xhr.status < 300,
status: xhr.status,
text: function () {
return Promise.resolve(body);
},
json: function () {
return Promise.resolve(JSON.parse(body || "null"));
},
});
};
xhr.onerror = function () {
reject(new Error("Netwerkfout"));
};
xhr.send(opts.body || null);
});
};
}
if (!Element.prototype.closest) {
Element.prototype.closest = function (sel) {
var el = this;
while (el && el.nodeType === 1) {
if (el.matches ? el.matches(sel) : el.msMatchesSelector(sel)) return el;
el = el.parentElement;
}
return null;
};
}
if (!Element.prototype.matches) {
Element.prototype.matches =
Element.prototype.msMatchesSelector ||
Element.prototype.webkitMatchesSelector;
}
if (typeof Promise === "undefined") {
var fatal = document.getElementById("fatal");
if (fatal) {
fatal.classList.remove("hidden");
fatal.textContent =
"Deze TV ondersteunt geen moderne JavaScript (Promise ontbreekt).";
}
}
})(window);