Focus-friendly VMC shell for Tizen with live TV and playback against the master API. Co-authored-by: Cursor <cursoragent@cursor.com>
366 lines
12 KiB
JavaScript
366 lines
12 KiB
JavaScript
(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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
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);
|