stremiok 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/README.md +9 -9
- package/config.xml +1 -1
- package/css/style.css +27 -0
- package/index.html +34 -31
- package/js/app.js +19 -7
- package/js/epg.js +320 -0
- package/js/iptv.js +198 -25
- package/js/keys.js +2 -0
- package/js/player-controls.js +50 -8
- package/js/player-core.js +112 -12
- package/js/player-store.js +21 -1
- package/js/player-subs.js +38 -8
- package/js/stremio.js +70 -6
- package/js/trakt.js +1 -0
- package/js/util.js +24 -13
- package/js/view-core.js +128 -55
- package/js/view-guide.js +332 -0
- package/js/view-home.js +148 -33
- package/js/view-settings.js +103 -35
- package/js/views.js +3 -2
- package/js/ytplay.js +53 -5
- package/package.json +2 -1
- package/service/index.js +863 -46
package/js/view-core.js
CHANGED
|
@@ -8,6 +8,7 @@ var ViewParts = {};
|
|
|
8
8
|
|
|
9
9
|
// ----- from the other parts -----
|
|
10
10
|
function availableTabs() { return V.availableTabs.apply(null, arguments); } // home
|
|
11
|
+
function menuItems() { return V.menuItems.apply(null, arguments); } // home
|
|
11
12
|
function clearCatalogCache() { return V.clearCatalogCache.apply(null, arguments); } // home
|
|
12
13
|
function refreshAddonRows() { return V.refreshAddonRows.apply(null, arguments); } // home
|
|
13
14
|
function epCode() { return V.epCode.apply(null, arguments); } // detail
|
|
@@ -271,6 +272,16 @@ var ViewParts = {};
|
|
|
271
272
|
var rename = U.el('div', 'pill focusable modal-btn', 'Rename');
|
|
272
273
|
var onoff = U.el('div', 'pill focusable modal-btn', playlist.off ? 'Turn on' : 'Turn off');
|
|
273
274
|
var edit = U.el('div', 'pill focusable modal-btn', 'Edit link');
|
|
275
|
+
var guide = U.el('div', 'pill focusable modal-btn', 'Guide link');
|
|
276
|
+
var fmt = IPTV.xtreamFormat ? IPTV.xtreamFormat(playlist.id) : ''; // (an Xtream account: MPEG-TS or HLS)
|
|
277
|
+
var format = U.el('div', 'pill focusable modal-btn', 'Format: ' + (fmt === 'hls' ? 'HLS' : 'MPEG-TS'));
|
|
278
|
+
format.__onselect = function () {
|
|
279
|
+
closeModal();
|
|
280
|
+
Stremio.iptvSetXtreamFormat(playlist.id, fmt === 'hls' ? 'ts' : 'hls');
|
|
281
|
+
refreshAddonRows('local:iptv');
|
|
282
|
+
U.toast('Its channels now play as ' + (fmt === 'hls' ? 'MPEG-TS' : 'HLS') + ' (reading the account again…)', 4000);
|
|
283
|
+
renderSettings();
|
|
284
|
+
};
|
|
274
285
|
var reload = U.el('div', 'pill focusable modal-btn', 'Reload');
|
|
275
286
|
var remove = U.el('div', 'pill focusable modal-btn danger', 'Remove');
|
|
276
287
|
|
|
@@ -312,6 +323,20 @@ var ViewParts = {};
|
|
|
312
323
|
}
|
|
313
324
|
});
|
|
314
325
|
};
|
|
326
|
+
guide.__onselect = function () { // its TV guide: yours, or the playlist's own (empty)
|
|
327
|
+
closeModal();
|
|
328
|
+
App.openEntry({
|
|
329
|
+
title: 'TV guide link', phone: 'playlist',
|
|
330
|
+
hint: 'An XMLTV guide (.xml or .xml.gz), then Confirm. Empty uses the playlist’s own (the one it names, or an Xtream playlist’s). RETURN cancels.',
|
|
331
|
+
value: playlist.guide || '', placeholder: 'https://example.com/guide.xml.gz',
|
|
332
|
+
validate: function (v) { return ('' + v).trim() ? (U.urlProblem(v) || null) : null; },
|
|
333
|
+
onConfirm: function (v) {
|
|
334
|
+
Stremio.iptvSetPlaylistGuide(playlist.id, v);
|
|
335
|
+
U.toast(('' + v).trim() ? 'Guide link set: reading it…' : 'Back to the playlist’s own guide', 3000);
|
|
336
|
+
App.back(); renderSettings();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
};
|
|
315
340
|
reload.__onselect = function () {
|
|
316
341
|
closeModal();
|
|
317
342
|
U.toast('Reloading playlist…', 4000);
|
|
@@ -334,6 +359,8 @@ var ViewParts = {};
|
|
|
334
359
|
row.appendChild(rename);
|
|
335
360
|
row.appendChild(onoff);
|
|
336
361
|
row.appendChild(edit);
|
|
362
|
+
if (!playlist.off && typeof EPG !== 'undefined' && EPG.available && EPG.available()) { row.appendChild(guide); }
|
|
363
|
+
if (!playlist.off && fmt) { row.appendChild(format); }
|
|
337
364
|
if (!playlist.off) { row.appendChild(reload); }
|
|
338
365
|
row.appendChild(remove);
|
|
339
366
|
box.appendChild(row);
|
|
@@ -373,33 +400,6 @@ var ViewParts = {};
|
|
|
373
400
|
showModal(overlay, pref);
|
|
374
401
|
}
|
|
375
402
|
|
|
376
|
-
// Switches in a popup (Settings › Appearance › Home tabs): OK on one turns it on or off and
|
|
377
|
-
// the list stays open (BACK closes it). items: [[key, label, on]]; words: { on, off } (the
|
|
378
|
-
// state shown after each label); onToggle(key, on).
|
|
379
|
-
function toggleList(title, items, words, onToggle) {
|
|
380
|
-
var existing = document.querySelector('.modal-overlay');
|
|
381
|
-
if (existing) { existing.parentNode.removeChild(existing); }
|
|
382
|
-
var overlay = U.el('div', 'modal-overlay');
|
|
383
|
-
var box = U.el('div', 'modal-box pick-box');
|
|
384
|
-
box.appendChild(U.el('div', 'modal-msg', title));
|
|
385
|
-
var listEl = U.el('div', 'pick-list'), first = null;
|
|
386
|
-
items.forEach(function (opt) {
|
|
387
|
-
var on = !!opt[2], it = U.el('div', 'pick-item focusable');
|
|
388
|
-
function draw() {
|
|
389
|
-
it.textContent = opt[1] + ': ' + (on ? words.on : words.off);
|
|
390
|
-
if (on) { it.classList.add('sel'); } else { it.classList.remove('sel'); }
|
|
391
|
-
}
|
|
392
|
-
draw();
|
|
393
|
-
it.__onselect = function () { on = !on; draw(); onToggle(opt[0], on); };
|
|
394
|
-
if (!first) { first = it; }
|
|
395
|
-
listEl.appendChild(it);
|
|
396
|
-
});
|
|
397
|
-
box.appendChild(listEl);
|
|
398
|
-
overlay.appendChild(box);
|
|
399
|
-
document.body.appendChild(overlay);
|
|
400
|
-
showModal(overlay, first);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
403
|
// Ticks in a popup (what to import): nothing ticked at first; OK on one ticks or unticks it,
|
|
404
404
|
// Select all ticks every one that can be (again: none); the button counts what's ticked.
|
|
405
405
|
// items: [{ key, label, have (already yours: shown as such, can't be ticked), note }];
|
|
@@ -482,7 +482,8 @@ var ViewParts = {};
|
|
|
482
482
|
}
|
|
483
483
|
|
|
484
484
|
// The shared top menu, used on Home / Search / Settings so they look and navigate
|
|
485
|
-
// the same:
|
|
485
|
+
// the same: Watching, then the tabs, Discover and Search in your order (menuItems), then
|
|
486
|
+
// Settings, on the left; StremioK on the right.
|
|
486
487
|
// `active` is the key of the current view: a tab key, 'search' or 'settings'.
|
|
487
488
|
function buildTopMenu(active) {
|
|
488
489
|
var head = U.el('div', 'header home-header');
|
|
@@ -495,34 +496,23 @@ var ViewParts = {};
|
|
|
495
496
|
};
|
|
496
497
|
function down() { return focusBelowTop(head); }
|
|
497
498
|
var tabbar = U.el('div', 'tabbar-inline');
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
499
|
+
// a pill: a Home tab, Discover (browse any catalog in full, its own screen), Search;
|
|
500
|
+
// Settings a chip like them (name, then icon), after them
|
|
501
|
+
function item(cls, key, label, icon, open, into) {
|
|
502
|
+
var p = U.el('div', cls + ' focusable' + (key === active ? ' active' : ''));
|
|
503
|
+
p.appendChild(document.createTextNode(label));
|
|
504
|
+
p.appendChild(U.el('span', 'tab-ic', icon));
|
|
505
|
+
p.__onselect = open;
|
|
506
|
+
p.__navDOWN = down;
|
|
507
|
+
into.appendChild(p);
|
|
508
|
+
}
|
|
509
|
+
menuItems().forEach(function (m) {
|
|
510
|
+
if (m.tab) { item('tab-pill', m.key, m.tab.label, m.tab.icon, function () { App.goHome(m.key); }, tabbar); }
|
|
511
|
+
else if (m.key === 'discover') { item('tab-pill', 'discover', 'Discover', '🔭', function () { App.openDiscover(); }, tabbar); }
|
|
512
|
+
else if (m.key === 'search') { item('hchip', 'search', 'Search', '🔍', function () { App.openSearch(); }, tabbar); }
|
|
505
513
|
});
|
|
506
|
-
// Discover: browse any catalog in full, by genre / year (its own screen)
|
|
507
|
-
var disc = U.el('div', 'tab-pill focusable' + (active === 'discover' ? ' active' : ''));
|
|
508
|
-
disc.appendChild(document.createTextNode('Discover'));
|
|
509
|
-
disc.appendChild(U.el('span', 'tab-ic', '🔭'));
|
|
510
|
-
disc.__onselect = function () { App.openDiscover(); };
|
|
511
|
-
disc.__navDOWN = down;
|
|
512
|
-
tabbar.appendChild(disc);
|
|
513
514
|
head.appendChild(tabbar);
|
|
514
|
-
|
|
515
|
-
// Search and Settings: pills like the tabs (name, then icon), after them
|
|
516
|
-
function chip(key, label, icon, open) {
|
|
517
|
-
var c = U.el('div', 'hchip focusable' + (active === key ? ' active' : ''));
|
|
518
|
-
c.appendChild(document.createTextNode(label));
|
|
519
|
-
c.appendChild(U.el('span', 'tab-ic', icon));
|
|
520
|
-
c.__onselect = open;
|
|
521
|
-
c.__navDOWN = down;
|
|
522
|
-
head.appendChild(c);
|
|
523
|
-
}
|
|
524
|
-
chip('search', 'Search', '🔍', function () { App.openSearch(); });
|
|
525
|
-
chip('settings', 'Settings', '⚙', function () { App.openSettings(); });
|
|
515
|
+
item('hchip', 'settings', 'Settings', '⚙', function () { App.openSettings(); }, head);
|
|
526
516
|
|
|
527
517
|
head.appendChild(U.el('div', 'spacer'));
|
|
528
518
|
var brand = U.el('div', 'brand');
|
|
@@ -579,6 +569,10 @@ var ViewParts = {};
|
|
|
579
569
|
if (rating > 0 && rating <= 10 && Stremio.getPrefs().posterRatings) { c.appendChild(U.el('div', 'card-rating', '★ ' + rating.toFixed(1))); } // (a series: marked watched whole, see setTitleWatched / the title page)
|
|
580
570
|
if (kind === 'movie' || kind === 'series') { c.__onlongpress = function () { posterActions(meta, kind, c); }; }
|
|
581
571
|
else if (youTubeList(meta.id)) { c.__onlongpress = function () { addYouTubeCatalog(meta); }; } // (a channel / playlist: to your YouTube)
|
|
572
|
+
else if (/^iptv:/.test(meta.id || '') && typeof EPG !== 'undefined') { // an IPTV channel: what's on, and its schedule
|
|
573
|
+
guideOnCard(c, meta.id);
|
|
574
|
+
c.__onlongpress = function () { liveSchedule(meta, c); };
|
|
575
|
+
}
|
|
582
576
|
c.__onselect = function () {
|
|
583
577
|
var t = titleType(meta.type || fallbackType);
|
|
584
578
|
// Live TV catalogs (anything that isn't movies/series) skip the info page and
|
|
@@ -597,6 +591,86 @@ var ViewParts = {};
|
|
|
597
591
|
return c;
|
|
598
592
|
}
|
|
599
593
|
|
|
594
|
+
// ---- TV guide (js/epg.js) on IPTV channel cards ----
|
|
595
|
+
// What's on now, under the channel's name, with how far along it is; drawn again when the
|
|
596
|
+
// guide brings more (the cards still on a page; the most recent GUIDE_CARDS).
|
|
597
|
+
var _guideCards = [], GUIDE_CARDS = 3000;
|
|
598
|
+
function guideOnCard(card, id) {
|
|
599
|
+
drawGuide(card, id);
|
|
600
|
+
_guideCards.push(card);
|
|
601
|
+
if (_guideCards.length > GUIDE_CARDS) { _guideCards = _guideCards.slice(-GUIDE_CARDS); }
|
|
602
|
+
}
|
|
603
|
+
function drawGuide(card, id) {
|
|
604
|
+
guideLogo(card, id);
|
|
605
|
+
var g = EPG.now(id), old = card.querySelector('.card-epg');
|
|
606
|
+
if (!g || !g.now) { if (old) { card.removeChild(old); } return; }
|
|
607
|
+
var t = Date.now() / 1000, pct = Math.max(0, Math.min(100, Math.round((t - g.now.start) / (g.now.stop - g.now.start) * 100)));
|
|
608
|
+
var box = old || U.el('div', 'card-epg');
|
|
609
|
+
U.clear(box);
|
|
610
|
+
box.appendChild(U.el('div', 'card-epg-t', g.now.title));
|
|
611
|
+
var bar = U.el('div', 'card-epg-bar'), fill = U.el('i', 'card-epg-fill');
|
|
612
|
+
fill.setAttribute('style', 'width: ' + pct + '%');
|
|
613
|
+
bar.appendChild(fill);
|
|
614
|
+
box.appendChild(bar);
|
|
615
|
+
if (!old) { card.appendChild(box); }
|
|
616
|
+
}
|
|
617
|
+
// A channel card without a picture (no tvg-logo): its guide's logo, once it's known; the
|
|
618
|
+
// name stays until it has loaded.
|
|
619
|
+
function guideLogo(card, id) {
|
|
620
|
+
var src = !card.__guideLogo && !card.querySelector('img.poster') && EPG.logo(id);
|
|
621
|
+
if (!src) { return; }
|
|
622
|
+
card.__guideLogo = true;
|
|
623
|
+
var img = U.el('img', 'poster'), fb = card.querySelector('.fallback');
|
|
624
|
+
img.style.visibility = 'hidden';
|
|
625
|
+
img.onload = function () { img.style.visibility = ''; if (fb && fb.parentNode) { fb.parentNode.removeChild(fb); } };
|
|
626
|
+
img.onerror = function () { if (img.parentNode) { img.parentNode.removeChild(img); } };
|
|
627
|
+
card.insertBefore(img, card.firstChild);
|
|
628
|
+
U.deferImage(img, U.cardImage(src, 'landscape'));
|
|
629
|
+
}
|
|
630
|
+
function redrawGuideCards() {
|
|
631
|
+
_guideCards = _guideCards.filter(function (c) { return document.documentElement.contains(c); });
|
|
632
|
+
_guideCards.forEach(function (c) { drawGuide(c, c.getAttribute('data-id')); });
|
|
633
|
+
}
|
|
634
|
+
if (typeof EPG !== 'undefined') {
|
|
635
|
+
EPG.onChange(redrawGuideCards);
|
|
636
|
+
// every minute: the cards' progress bars (and what's on, once it changed) along with the time
|
|
637
|
+
setInterval(function () {
|
|
638
|
+
if (!_guideCards.length || document.hidden || (typeof Player !== 'undefined' && Player.isActive())) { return; }
|
|
639
|
+
redrawGuideCards();
|
|
640
|
+
}, 60000);
|
|
641
|
+
}
|
|
642
|
+
// Hold OK on a channel card: ▶ Watch now, ★ Add to / Remove from favorites, and its schedule
|
|
643
|
+
// from 2 hours ago to tomorrow (what's on now marked, and picked); OK on a program says
|
|
644
|
+
// what it is. A channel without a guide: the first two.
|
|
645
|
+
var _schedGen = 0;
|
|
646
|
+
function liveSchedule(meta, card) {
|
|
647
|
+
var gen = ++_schedGen;
|
|
648
|
+
U.spinner(true);
|
|
649
|
+
EPG.schedule(meta.id).then(null, function () { return []; }).then(function (list) {
|
|
650
|
+
if (gen !== _schedGen) { return; } // (held on another card since: its own)
|
|
651
|
+
U.spinner(false);
|
|
652
|
+
if (!document.documentElement.contains(card) || (typeof Player !== 'undefined' && Player.isActive())) { return; } // (left meanwhile)
|
|
653
|
+
var fav = IPTV.isFavorite(meta.id), t = Date.now() / 1000, at = 'play';
|
|
654
|
+
var items = [['play', '▶ Watch now'], ['fav', fav ? '★ Remove from favorites' : '★ Add to favorites']];
|
|
655
|
+
list.forEach(function (p, i) {
|
|
656
|
+
var on = p.start <= t && t < p.stop;
|
|
657
|
+
if (on) { at = 'p' + i; }
|
|
658
|
+
items.push(['p' + i, (on ? '● ' : '') + EPG.dayTime(p.start) + ' ' + p.title]);
|
|
659
|
+
});
|
|
660
|
+
pickList((meta.name || '') + (list.length ? ' · TV guide' : ''), items, at, function (v) {
|
|
661
|
+
if (v === 'play') { card.__onselect(); return; }
|
|
662
|
+
if (v === 'fav') {
|
|
663
|
+
IPTV.setFavorite(meta.id, !fav);
|
|
664
|
+
U.toast(fav ? 'Removed from favorites' : 'Added to favorites: ★ Favorites, first on the IPTV tab', 3000);
|
|
665
|
+
refreshAddonRows('local:iptv');
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
var p = list[+v.slice(1)];
|
|
669
|
+
if (p) { U.toast(EPG.dayTime(p.start) + '–' + EPG.hhmm(p.stop) + ' ' + p.title + (p.desc ? ': ' + p.desc : ''), 8000); }
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
600
674
|
// The channels of the row a live card sits in, for CH+/CH− while it plays.
|
|
601
675
|
function rowChannels(card) {
|
|
602
676
|
var sib = card.parentNode ? card.parentNode.children : [card], list = [], idx = 0;
|
|
@@ -750,7 +824,6 @@ var ViewParts = {};
|
|
|
750
824
|
V.inNowPlaying = inNowPlaying;
|
|
751
825
|
V.onModalClose = onModalClose;
|
|
752
826
|
V.pickList = pickList;
|
|
753
|
-
V.toggleList = toggleList;
|
|
754
827
|
V.pickMany = pickMany;
|
|
755
828
|
V.playlistActions = playlistActions;
|
|
756
829
|
V.posterCard = posterCard;
|
package/js/view-guide.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/* ViewParts: the Guide tab: the IPTV channels' TV guide as a grid (js/epg.js).
|
|
2
|
+
A row a channel that has a guide (in the IPTV tab's order, favorites first), across SPAN from
|
|
3
|
+
the current half hour, a line at the time now. Only ROWS rows are drawn at a time: ↑ / ↓ past
|
|
4
|
+
them move the window along, through every channel; programs are read for the rows drawn and
|
|
5
|
+
the AHEAD after them (a row not read yet says so until they come). Each program knows its
|
|
6
|
+
neighbors (Focus's __go hooks): ← / → the program before / after it (the hours move along past
|
|
7
|
+
an edge, never back before the current half hour for one still on), ↑ / ↓ the channel above /
|
|
8
|
+
below, at the same time. Home's details strip describes the focused program; OK on what's on
|
|
9
|
+
now plays the channel, on another says what it is. A guide-only channel's (tvg-type="guide":
|
|
10
|
+
watched elsewhere) any program: the film looked up in your addons (findInAddons). */
|
|
11
|
+
(function (V) {
|
|
12
|
+
'use strict';
|
|
13
|
+
function scr() { return V.scr.apply(null, arguments); } // core
|
|
14
|
+
|
|
15
|
+
var SPAN = 150 * 60, STEP = 30 * 60, ROWS = 10, AHEAD = 10;
|
|
16
|
+
var _g = null; // the grid on screen: { el, start, chans, lists: { id: programs }, asked: { id: 1 }, top, nodes: [row: cells], box }
|
|
17
|
+
function floorStep(t) { return Math.floor(t / STEP) * STEP; }
|
|
18
|
+
function pct(x) { return Math.round(x * 10000) / 100; }
|
|
19
|
+
|
|
20
|
+
// Drawn again soon after (Home redrawn, the tab picked again): from what the grid before had
|
|
21
|
+
// read (REUSE_MS), the same channels in the same window and hours, focus where it was;
|
|
22
|
+
// no "Reading…" between, nothing asked again.
|
|
23
|
+
var REUSE_MS = 5 * 60 * 1000;
|
|
24
|
+
function guideGrid(rows) {
|
|
25
|
+
stopFinding(); // (a lookup from the grid drawn before: stopped)
|
|
26
|
+
var now = Date.now() / 1000, chans = EPG.guided(), was = _g;
|
|
27
|
+
var g = _g = { el: rows, start: floorStep(now), live: true, chans: chans, lists: {}, asked: {}, failed: {}, got: {}, top: 0, nodes: [], at: Date.now(), gen: EPG.generation ? EPG.generation() : 0 }; // (live: the hours show the present, and follow the clock: tick)
|
|
28
|
+
if (!chans.length) { rows.appendChild(U.el('div', 'row-empty', 'No TV guide yet (Settings › Content › IPTV › Guide link).')); return; }
|
|
29
|
+
if (was && was.at && Date.now() - was.at < REUSE_MS && was.gen === g.gen && sameChans(was, chans)) {
|
|
30
|
+
var f = Focus.current(), want = (f && was.box && was.box.contains(f) && f.__chan) ? (f.__loading ? { id: f.__chan, t: f.__t } : { id: f.__chan, start: f.__p.start }) : null;
|
|
31
|
+
g.lists = was.lists; g.asked = was.asked; g.failed = was.failed || {}; g.got = was.got || {}; g.at = was.at; g.top = was.top;
|
|
32
|
+
g.chans = chans.filter(function (c) { return !(c.id in g.lists) || g.lists[c.id].length; });
|
|
33
|
+
if (!was.live) { g.start = was.start; g.live = false; }
|
|
34
|
+
var n = draw(g, want), cur = Focus.current(); // (focus: back on the cell it was on; else as when first drawn)
|
|
35
|
+
if (n && (want || !cur || !document.documentElement.contains(cur) || /\b(tab-pill|hchip)\b/.test(cur.className || ''))) { Focus.setScope(scr('home'), n); }
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
rows.appendChild(U.el('div', 'row-empty guide-loading', 'Reading the TV guide…'));
|
|
39
|
+
ensure(g).then(function () {
|
|
40
|
+
if (_g !== g || !document.documentElement.contains(rows)) { return; }
|
|
41
|
+
var first = draw(g);
|
|
42
|
+
var cur = Focus.current(); // (focus still where the tab was picked: onto what's on now)
|
|
43
|
+
if (first && (!cur || !document.documentElement.contains(cur) || /\b(tab-pill|hchip)\b/.test(cur.className || '') || rows.contains(cur))) { Focus.setScope(scr('home'), first); }
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sameChans(was, chans) { // (the same channels as the grid before: none added, none gone)
|
|
48
|
+
var ids = {}, n = 0;
|
|
49
|
+
was.chans.forEach(function (c) { ids[c.id] = 1; });
|
|
50
|
+
Object.keys(was.lists).forEach(function (id) { if (!ids[id] && !was.lists[id].length) { ids[id] = 1; } }); // (dropped for having nothing)
|
|
51
|
+
for (var i = 0; i < chans.length; i++) { if (!ids[chans[i].id]) { return false; } n++; }
|
|
52
|
+
return n >= was.chans.length;
|
|
53
|
+
}
|
|
54
|
+
// The programs of the rows drawn and the AHEAD after them, those not asked for yet (from 2
|
|
55
|
+
// hours ago to 30 ahead: the hours move without asking again). A channel with nothing at all
|
|
56
|
+
// in that time is dropped once read. Resolves once they're in.
|
|
57
|
+
// What's kept: the programs of the channels within KEEP rows of the window, read in the last
|
|
58
|
+
// KEEP_MS (older, or further off: let go, read again if they're come back to); all of it,
|
|
59
|
+
// once the guides changed (EPG's generation).
|
|
60
|
+
var KEEP = 40, KEEP_MS = 2 * 3600 * 1000;
|
|
61
|
+
function prune(g) {
|
|
62
|
+
if (EPG.generation && g.gen !== EPG.generation()) { g.gen = EPG.generation(); g.lists = {}; g.asked = {}; g.failed = {}; g.got = {}; return; }
|
|
63
|
+
var near = {}, now = Date.now();
|
|
64
|
+
g.chans.slice(Math.max(0, g.top - KEEP), g.top + ROWS + AHEAD + KEEP).forEach(function (c) { near[c.id] = 1; });
|
|
65
|
+
Object.keys(g.lists).forEach(function (id) {
|
|
66
|
+
if (!g.lists[id].length) { return; } // (known to have nothing: kept, it's what keeps it off the grid)
|
|
67
|
+
if (!near[id] || now - (g.got[id] || 0) > KEEP_MS) { delete g.lists[id]; delete g.asked[id]; delete g.got[id]; }
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function ensure(g) {
|
|
71
|
+
prune(g);
|
|
72
|
+
var ids = g.chans.slice(g.top, g.top + ROWS + AHEAD).filter(function (c) { return !g.asked[c.id]; }).map(function (c) { g.asked[c.id] = 1; return c.id; });
|
|
73
|
+
if (!ids.length) { return Promise.resolve(false); }
|
|
74
|
+
var now = Date.now() / 1000;
|
|
75
|
+
return EPG.range(ids, now - 2 * 3600, now + 30 * 3600).then(function (lists) {
|
|
76
|
+
var unknown = {};
|
|
77
|
+
((lists && lists._unknown) || []).forEach(function (id) { unknown[id] = 1; });
|
|
78
|
+
ids.forEach(function (id) {
|
|
79
|
+
if (unknown[id]) { delete g.asked[id]; g.failed[id] = 1; return; } // (not answered: kept, asked again)
|
|
80
|
+
delete g.failed[id];
|
|
81
|
+
g.lists[id] = (lists && lists[id]) || [];
|
|
82
|
+
g.got[id] = Date.now();
|
|
83
|
+
});
|
|
84
|
+
g.chans = g.chans.filter(function (c) { return !(c.id in g.lists) || g.lists[c.id].length; });
|
|
85
|
+
g.top = Math.max(0, Math.min(g.top, g.chans.length - ROWS));
|
|
86
|
+
if (Object.keys(unknown).length) { retryLater(g); }
|
|
87
|
+
return true;
|
|
88
|
+
}, function () { ids.forEach(function (id) { delete g.asked[id]; g.failed[id] = 1; }); retryLater(g); return true; });
|
|
89
|
+
}
|
|
90
|
+
// Channels whose programs didn't come (the service didn't answer, or not all of it): asked
|
|
91
|
+
// again RETRY_MS later, while the grid is still on screen; their rows say so meanwhile.
|
|
92
|
+
var RETRY_MS = 30000;
|
|
93
|
+
function retryLater(g) {
|
|
94
|
+
if (g.retry) { return; }
|
|
95
|
+
g.retry = setTimeout(function () {
|
|
96
|
+
g.retry = null;
|
|
97
|
+
if (_g !== g || !g.box || !document.documentElement.contains(g.box)) { return; }
|
|
98
|
+
ensure(g).then(function (got) { if (got) { refresh(g); } });
|
|
99
|
+
}, RETRY_MS);
|
|
100
|
+
}
|
|
101
|
+
// Rows read after the grid was drawn: drawn again, focus kept where it was (a "Loading…" row's:
|
|
102
|
+
// that row, at the same time).
|
|
103
|
+
function refresh(g) {
|
|
104
|
+
if (_g !== g || !g.box || !document.documentElement.contains(g.box)) { return; }
|
|
105
|
+
var cur = Focus.current(), mine = cur && g.box.contains(cur), want = null;
|
|
106
|
+
if (mine && cur.__p) { want = cur.__loading ? { id: cur.__chan, t: cur.__t } : { id: cur.__chan, start: cur.__p.start }; }
|
|
107
|
+
var n = draw(g, want);
|
|
108
|
+
if (mine && n) { Focus.setScope(scr('home'), n); }
|
|
109
|
+
}
|
|
110
|
+
// The guides changed (EPG's generation) while the grid is on screen: what it had let go, the
|
|
111
|
+
// rows on screen read again.
|
|
112
|
+
if (typeof EPG !== 'undefined' && EPG.onChange) {
|
|
113
|
+
EPG.onChange(function () {
|
|
114
|
+
var g = _g;
|
|
115
|
+
if (!g || !g.box || !document.documentElement.contains(g.box) || !EPG.generation || g.gen === EPG.generation()) { return; }
|
|
116
|
+
ensure(g).then(function (got) { if (got) { refresh(g); } });
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// The window moved to show row r (↑ / ↓): the rows to read next asked for, and drawn again once in.
|
|
120
|
+
function showRow(g, r) {
|
|
121
|
+
if (r < g.top) { g.top = r; } else if (r >= g.top + ROWS) { g.top = r - ROWS + 1; }
|
|
122
|
+
ensure(g).then(function (got) { if (got) { refresh(g); } });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The grid for g.start, rows g.top to g.top + ROWS: the hours, then a row a channel. Returns
|
|
126
|
+
// the cell wanted: { id, start } that program, { id, t } that channel's at time t; else
|
|
127
|
+
// what's on now on the first channel drawn.
|
|
128
|
+
function draw(g, want) {
|
|
129
|
+
var old = g.el.querySelector('.guide'), loading = g.el.querySelector('.guide-loading');
|
|
130
|
+
if (loading) { loading.parentNode.removeChild(loading); }
|
|
131
|
+
var box = U.el('div', 'guide'), now = Date.now() / 1000, end = g.start + SPAN, found = null, first = null;
|
|
132
|
+
var head = U.el('div', 'guide-head'), hours = U.el('div', 'guide-hours');
|
|
133
|
+
head.appendChild(U.el('div', 'guide-corner', EPG.dayTime(g.start).length > 5 ? EPG.dayTime(g.start).split(' ')[0] : 'Today'));
|
|
134
|
+
for (var t = g.start; t < end; t += STEP) {
|
|
135
|
+
var mark = U.el('div', 'guide-time', EPG.hhmm(t));
|
|
136
|
+
mark.setAttribute('style', 'left: ' + pct((t - g.start) / SPAN) + '%');
|
|
137
|
+
hours.appendChild(mark);
|
|
138
|
+
}
|
|
139
|
+
head.appendChild(hours);
|
|
140
|
+
box.appendChild(head);
|
|
141
|
+
g.nodes = [];
|
|
142
|
+
var any = false;
|
|
143
|
+
g.chans.slice(g.top, g.top + ROWS).forEach(function (c, k) {
|
|
144
|
+
var r = g.top + k, row = U.el('div', 'guide-row'), track = U.el('div', 'guide-track'), nodes = [], onNode = null;
|
|
145
|
+
var name = U.el('div', 'guide-ch'), logo = c.logo || EPG.logo(c.id);
|
|
146
|
+
if (logo && U.cardImage(logo, 'logo')) {
|
|
147
|
+
var img = U.el('img', 'guide-ch-logo');
|
|
148
|
+
img.onerror = function () { if (img.parentNode) { img.parentNode.removeChild(img); } };
|
|
149
|
+
name.appendChild(img);
|
|
150
|
+
U.deferImage(img, U.cardImage(logo, 'logo'));
|
|
151
|
+
}
|
|
152
|
+
name.appendChild(document.createTextNode(c.name));
|
|
153
|
+
row.appendChild(name);
|
|
154
|
+
function hooks(n, i) {
|
|
155
|
+
n.__row = r; n.__chan = c.id;
|
|
156
|
+
n.__goLEFT = function () { return i < 0 ? false : step(g, r, i, -1); };
|
|
157
|
+
n.__goRIGHT = function () { return i < 0 ? false : step(g, r, i, 1); };
|
|
158
|
+
n.__goUP = function () { return vertical(g, r, -1, n); };
|
|
159
|
+
n.__goDOWN = function () { return vertical(g, r, 1, n); };
|
|
160
|
+
}
|
|
161
|
+
if (!(c.id in g.lists)) { // (not read yet: says so, and can be passed through)
|
|
162
|
+
var wait = U.el('div', 'guide-prog guide-wait focusable', g.failed[c.id] ? 'Its TV guide didn’t come: asked again shortly' : 'Loading…');
|
|
163
|
+
wait.setAttribute('style', 'left: 0%; width: 100%');
|
|
164
|
+
wait.__p = { start: g.start, stop: end, title: '' }; wait.__loading = true;
|
|
165
|
+
wait.__t = want && want.id === c.id && typeof want.t === 'number' ? want.t : Math.max(now, g.start);
|
|
166
|
+
wait.__meta = { name: c.name, releaseInfo: 'Reading its TV guide…' };
|
|
167
|
+
hooks(wait, -1);
|
|
168
|
+
track.appendChild(wait);
|
|
169
|
+
nodes.push(wait);
|
|
170
|
+
if (want && want.id === c.id) { found = wait; }
|
|
171
|
+
} else {
|
|
172
|
+
g.lists[c.id].forEach(function (p, i) {
|
|
173
|
+
if (p.stop <= g.start || p.start >= end) { return; }
|
|
174
|
+
var on = p.start <= now && now < p.stop, a = Math.max(p.start, g.start), b = Math.min(p.stop, end);
|
|
175
|
+
var n = U.el('div', 'guide-prog focusable' + (on ? ' now' : ''), p.title);
|
|
176
|
+
n.setAttribute('style', 'left: ' + pct((a - g.start) / SPAN) + '%; width: ' + pct((b - a) / SPAN) + '%');
|
|
177
|
+
n.__meta = { name: p.title, releaseInfo: c.name + ' · ' + EPG.dayTime(p.start) + '–' + EPG.hhmm(p.stop) + (c.guideOnly ? ' · OK: find it in your addons' : ''), description: p.desc, picture: p.picture };
|
|
178
|
+
n.__p = p; n.__i = i;
|
|
179
|
+
n.__onselect = function () { pick(g, r, p); };
|
|
180
|
+
hooks(n, i);
|
|
181
|
+
track.appendChild(n);
|
|
182
|
+
nodes.push(n);
|
|
183
|
+
any = true;
|
|
184
|
+
if (want && want.id === c.id && want.start === p.start) { found = n; }
|
|
185
|
+
if (on) { onNode = n; }
|
|
186
|
+
});
|
|
187
|
+
if (want && want.id === c.id && typeof want.t === 'number' && !found) { found = at(nodes, want.t, g.start) || null; } // (a time, 0 too)
|
|
188
|
+
}
|
|
189
|
+
if (k === 0) { first = onNode || nodes[0] || null; }
|
|
190
|
+
if (now >= g.start && now < end) {
|
|
191
|
+
var line = U.el('div', 'guide-nowline');
|
|
192
|
+
line.setAttribute('style', 'left: ' + pct((now - g.start) / SPAN) + '%');
|
|
193
|
+
track.appendChild(line);
|
|
194
|
+
}
|
|
195
|
+
row.appendChild(track);
|
|
196
|
+
box.appendChild(row);
|
|
197
|
+
g.nodes[r] = nodes;
|
|
198
|
+
});
|
|
199
|
+
if (!any && g.chans.slice(g.top, g.top + ROWS).every(function (c) { return c.id in g.lists; })) {
|
|
200
|
+
box.appendChild(U.el('div', 'row-empty guide-empty', 'Nothing on any channel in these hours.'));
|
|
201
|
+
}
|
|
202
|
+
if (old) { g.el.removeChild(old); } // (the grid is all the tab has)
|
|
203
|
+
g.el.appendChild(box);
|
|
204
|
+
g.box = box;
|
|
205
|
+
if (!_ticker) { _ticker = setInterval(tick, 60000); }
|
|
206
|
+
return found || first || (g.nodes[g.top] && g.nodes[g.top][0]) || null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Every minute, while the grid is on screen: the line at the time now moved along, and what's
|
|
210
|
+
// on now marked again (nothing drawn again: focus and the cells stay). At a new half hour, a
|
|
211
|
+
// grid showing the present moves its hours along (follow). Stops once it's gone.
|
|
212
|
+
var _ticker = null;
|
|
213
|
+
function tick() {
|
|
214
|
+
var g = _g;
|
|
215
|
+
if (!g || !g.box || !document.documentElement.contains(g.box)) { clearInterval(_ticker); _ticker = null; return; }
|
|
216
|
+
if (/\bhidden\b/.test(scr('home').className) || document.hidden) { return; }
|
|
217
|
+
var now = Date.now() / 1000, end = g.start + SPAN;
|
|
218
|
+
if (g.live && g.start < floorStep(now)) { follow(g, now); return; }
|
|
219
|
+
var lines = g.box.querySelectorAll('.guide-nowline'); // (a loop: a NodeList's forEach is newer than the 2017 TV's browser)
|
|
220
|
+
for (var i = 0; i < lines.length; i++) {
|
|
221
|
+
if (now >= g.start && now < end) { lines[i].setAttribute('style', 'left: ' + pct((now - g.start) / SPAN) + '%'); } else { lines[i].setAttribute('style', 'display: none'); }
|
|
222
|
+
}
|
|
223
|
+
(g.nodes || []).forEach(function (nodes) {
|
|
224
|
+
nodes.forEach(function (n) { var on = n.__p.start <= now && now < n.__p.stop; if (on !== /\bnow\b/.test(n.className)) { n.className = on ? n.className + ' now' : n.className.replace(/\s*\bnow\b/, ''); } });
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
// The current half hour first again: focus on the program it was on, or (it has ended) what's
|
|
228
|
+
// on now on that channel.
|
|
229
|
+
function follow(g, now) {
|
|
230
|
+
var start = floorStep(now), f = Focus.current(), mine = !!(f && g.box.contains(f)), want = null;
|
|
231
|
+
if (mine && f.__chan) { want = (f.__p && !f.__loading && f.__p.stop > start) ? { id: f.__chan, start: f.__p.start } : { id: f.__chan, t: now }; }
|
|
232
|
+
g.start = start;
|
|
233
|
+
var n = draw(g, want);
|
|
234
|
+
if (mine && n) { Focus.setScope(scr('home'), n); }
|
|
235
|
+
}
|
|
236
|
+
// ← / →: the program before / after this one on its channel; past an edge, the hours move
|
|
237
|
+
// to it (for one still on, never before the current half hour).
|
|
238
|
+
function step(g, r, i, dir) {
|
|
239
|
+
var c = g.chans[r], list = g.lists[c.id] || [], p = list[i + dir];
|
|
240
|
+
if (!p) { return false; }
|
|
241
|
+
if (p.stop <= g.start || p.start >= g.start + SPAN) {
|
|
242
|
+
var now = Date.now() / 1000;
|
|
243
|
+
g.start = dir > 0 ? Math.max(floorStep(p.start) - 2 * STEP, floorStep(now))
|
|
244
|
+
: (p.stop <= now ? floorStep(p.start) : Math.max(floorStep(p.start), floorStep(now)));
|
|
245
|
+
g.live = g.start === floorStep(now); // (moved away from the present: stays put)
|
|
246
|
+
return draw(g, { id: c.id, start: p.start });
|
|
247
|
+
}
|
|
248
|
+
return find(g, r, p);
|
|
249
|
+
}
|
|
250
|
+
// ↑ / ↓: the next channel up / down with something in these hours (one with nothing in them
|
|
251
|
+
// is passed; one not read yet is stopped at: it says so); from the top row, on up to the menu.
|
|
252
|
+
// Past the rows drawn, the window moves along to it.
|
|
253
|
+
function vertical(g, r, dir, from) {
|
|
254
|
+
var end = g.start + SPAN, t = from.__loading ? from.__t : Math.max(from.__p.start, g.start);
|
|
255
|
+
for (var k = r + dir; k >= 0 && k < g.chans.length; k += dir) {
|
|
256
|
+
var c = g.chans[k], list = g.lists[c.id];
|
|
257
|
+
var has = !list || list.some(function (p) { return p.stop > g.start && p.start < end; });
|
|
258
|
+
if (!has) { continue; }
|
|
259
|
+
if (k >= g.top && k < g.top + ROWS) { return at(g.nodes[k] || [], t, g.start) || false; }
|
|
260
|
+
showRow(g, k);
|
|
261
|
+
return draw(g, { id: c.id, t: t }) || false;
|
|
262
|
+
}
|
|
263
|
+
return dir < 0 ? undefined : false;
|
|
264
|
+
}
|
|
265
|
+
// In a row's cells: the program on at time t (or the window's start, for one that began
|
|
266
|
+
// before it); else the nearest.
|
|
267
|
+
function at(nodes, t, start) {
|
|
268
|
+
var best = null, gap = Infinity;
|
|
269
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
270
|
+
var p = nodes[i].__p;
|
|
271
|
+
if (p.start <= t && t < p.stop) { return nodes[i]; }
|
|
272
|
+
var d = Math.abs(Math.max(p.start, start) - t);
|
|
273
|
+
if (d < gap) { gap = d; best = nodes[i]; }
|
|
274
|
+
}
|
|
275
|
+
return best;
|
|
276
|
+
}
|
|
277
|
+
function find(g, r, p) {
|
|
278
|
+
var nodes = g.nodes[r] || [];
|
|
279
|
+
for (var i = 0; i < nodes.length; i++) { if (nodes[i].__p === p) { return nodes[i]; } }
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
// OK: what's on now plays the channel; another program says what it is.
|
|
283
|
+
// Played, CH+ / CH− go through the Guide's channels; each one's card (where BACK lands, on
|
|
284
|
+
// the channel you end on) is what's on now on it.
|
|
285
|
+
function pick(g, r, p) {
|
|
286
|
+
var c = g.chans[r], now = Date.now() / 1000;
|
|
287
|
+
if (c.guideOnly) { findInAddons(p); return; }
|
|
288
|
+
if (p.start <= now && now < p.stop) {
|
|
289
|
+
var list = [], idx = 0;
|
|
290
|
+
g.chans.forEach(function (ch, i) { // (every channel: a cell only for the rows drawn, where BACK lands)
|
|
291
|
+
if (ch.guideOnly) { return; } // (nothing to play)
|
|
292
|
+
var nodes = g.nodes[i] || [], card = nodes[0] || null;
|
|
293
|
+
for (var j = 0; j < nodes.length; j++) { if (nodes[j].__p.start <= now && now < nodes[j].__p.stop) { card = nodes[j]; break; } }
|
|
294
|
+
if (i === r) { idx = list.length; }
|
|
295
|
+
list.push({ meta: { id: ch.id, type: ch.type, name: ch.name, poster: ch.logo || '', logo: ch.logo || '', posterShape: 'landscape' }, card: card });
|
|
296
|
+
});
|
|
297
|
+
App.openLive(c.type, c.id, list[idx].meta, { list: list, idx: idx });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
U.toast(EPG.dayTime(p.start) + '–' + EPG.hhmm(p.stop) + ' ' + c.name + ' · ' + p.title + (p.desc ? ': ' + p.desc : '') +
|
|
301
|
+
(p.stop <= now ? ' (already shown)' : ''), 8000);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// A guide-only channel's program: a film found by the guide's original title and year (else
|
|
305
|
+
// its title) opens its page with your addons' sources, as Discover does; a show, or a film
|
|
306
|
+
// without one sure match, opens Search with its name.
|
|
307
|
+
// Each pick stops the lookup before it (its answer ignored, its spinner gone); an answer that
|
|
308
|
+
// comes once the Guide was left (another tab, or drawn again) opens nothing.
|
|
309
|
+
var _finding = 0, _findCancel = null;
|
|
310
|
+
function stopFinding() {
|
|
311
|
+
_finding++;
|
|
312
|
+
if (_findCancel) { _findCancel.cancel(); _findCancel = null; U.spinner(false); }
|
|
313
|
+
}
|
|
314
|
+
function onGuide(grid) { return _g === grid && !!grid.box && document.documentElement.contains(grid.box) && !/\bhidden\b/.test(scr('home').className); }
|
|
315
|
+
function findInAddons(p) {
|
|
316
|
+
stopFinding();
|
|
317
|
+
function search() { App.openSearch(); Views.searchFor(p.title); }
|
|
318
|
+
if (p.kind === 'series') { search(); return; }
|
|
319
|
+
var gen = _finding, grid = _g, cancel = _findCancel = U.cancelToken();
|
|
320
|
+
U.spinner(true, 'Looking for “' + p.title + '”…');
|
|
321
|
+
function done(hit) {
|
|
322
|
+
if (gen !== _finding) { return; }
|
|
323
|
+
_findCancel = null;
|
|
324
|
+
U.spinner(false);
|
|
325
|
+
if (!onGuide(grid)) { return; }
|
|
326
|
+
if (hit) { App.openDetail(hit.type, hit.id, { name: hit.name, poster: hit.poster, type: hit.type }); } else { search(); }
|
|
327
|
+
}
|
|
328
|
+
Stremio.findFilm({ title: p.title, orig: p.orig, year: p.year }, cancel).then(done, function () { done(null); });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
V.guideGrid = guideGrid;
|
|
332
|
+
})(ViewParts);
|