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/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: tab pills + Search + Settings on the left, StremioK on the right.
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
- availableTabs().forEach(function (t) {
499
- var pill = U.el('div', 'tab-pill focusable' + (t.key === active ? ' active' : ''));
500
- pill.appendChild(document.createTextNode(t.label));
501
- pill.appendChild(U.el('span', 'tab-ic', t.icon));
502
- pill.__onselect = function () { App.goHome(t.key); };
503
- pill.__navDOWN = down;
504
- tabbar.appendChild(pill);
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;
@@ -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);