stremiok 2.1.0 β†’ 2.2.1

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/stremio.js CHANGED
@@ -175,7 +175,8 @@ var Stremio = (function () {
175
175
  posterRatings: p.posterRatings === true, // Settings > Appearance: IMDb rating on cards
176
176
  homePreview: p.homePreview !== false, // Settings > Appearance: Continue Watching on the TV's home bar (js/hubpreview.js)
177
177
  homePreviewOrder: p.homePreviewOrder === 'watching' ? 'watching' : 'recent', // ...its tiles: the latest first, or as in Watching
178
- hiddenTabs: wordList(p.hiddenTabs, 20, 20), // Settings > Appearance > Home tabs
178
+ hiddenTabs: wordList(p.hiddenTabs, 20, 20), // Settings > Appearance > Menu: tabs hidden
179
+ menuOrder: wordList(p.menuOrder, 20, 20), // ...and the menu's order (see ViewParts.menuOrder)
179
180
  hideWords: wordList(p.hideWords, 30, 40), // Settings > Source ranking: sources with these words at the bottom
180
181
  watchRegion: /^[A-Z]{2}$/.test(p.watchRegion || '') ? p.watchRegion : '' }; // Settings > Integrations ('' = automatic)
181
182
  }
@@ -481,7 +482,18 @@ var Stremio = (function () {
481
482
  if (_hasIptv) { IPTV.removePlaylist(id); }
482
483
  configChanged();
483
484
  }
485
+ function iptvAddXtream(base, user, pass) { // (as any playlist added: the addons' list made again)
486
+ var id = _hasIptv ? IPTV.addXtream(base, user, pass) : false;
487
+ configChanged();
488
+ return id;
489
+ }
490
+ function iptvSetXtreamFormat(id, fmt) {
491
+ var changed = _hasIptv ? IPTV.setXtreamFormat(id, fmt) : false;
492
+ configChanged();
493
+ return changed;
494
+ }
484
495
  function iptvRenamePlaylist(id, name) { return _hasIptv ? IPTV.renamePlaylist(id, name) : false; }
496
+ function iptvSetPlaylistGuide(id, url) { return _hasIptv && IPTV.setPlaylistGuide ? IPTV.setPlaylistGuide(id, url) : false; }
485
497
  function iptvSetPlaylistOff(id, off) { var ok = _hasIptv ? IPTV.setPlaylistOff(id, off) : false; configChanged(); return ok; }
486
498
  function iptvMovePlaylist(id, dir) { var ok = _hasIptv ? IPTV.movePlaylist(id, dir) : false; configChanged(); return ok; }
487
499
  function iptvLabel(id) { return _hasIptv ? IPTV.label(id) : ''; }
@@ -685,7 +697,8 @@ var Stremio = (function () {
685
697
  // ---- Feed lists: order + show / hide, as the user sets them ----
686
698
  // Home feeds (the Home rows: every catalog) and search sources (the Search rows:
687
699
  // every catalog that can search) each keep their own { order: [keys], hidden: [keys] }.
688
- // Anything new (an addon added later) goes after the ordered ones, in addon order.
700
+ // Anything new (an addon added later) goes after the ordered ones, in addon order, but for a
701
+ // catalog that asks to come first (def._first: IPTV's Favorites) until you place it.
689
702
  var FEEDS_KEY = 'stremiok.feeds', SEARCH_FEEDS_KEY = 'stremiok.searchfeeds';
690
703
  function catalogKey(cat) {
691
704
  var aid = (cat.addon.manifest && cat.addon.manifest.id) || cat.addon.url;
@@ -698,8 +711,8 @@ var Stremio = (function () {
698
711
  var pos = {};
699
712
  cfg().order.forEach(function (k, i) { pos[k] = i; });
700
713
  return items.map(function (c, i) { return { c: c, i: i, k: keyOf(c) }; }).sort(function (a, b) {
701
- var pa = (pos[a.k] !== undefined) ? pos[a.k] : (100000 + a.i);
702
- var pb = (pos[b.k] !== undefined) ? pos[b.k] : (100000 + b.i);
714
+ var pa = (pos[a.k] !== undefined) ? pos[a.k] : (a.c && a.c.def && a.c.def._first) ? -1 : (100000 + a.i);
715
+ var pb = (pos[b.k] !== undefined) ? pos[b.k] : (b.c && b.c.def && b.c.def._first) ? -1 : (100000 + b.i);
703
716
  return pa - pb;
704
717
  }).map(function (x) { return x.c; });
705
718
  }
@@ -1455,6 +1468,57 @@ var Stremio = (function () {
1455
1468
  return tmdbGet('/' + kind + '/' + tmdbId + '/external_ids', '', cancel).then(function (r) { return (r && r.imdb_id) || null; });
1456
1469
  }
1457
1470
 
1471
+ // A film known only by a TV guide's program (the Guide's guide-only channels): { title (as
1472
+ // shown, Spanish say), orig (its original title), year } -> Promise of { type, id (IMDb), name,
1473
+ // poster } when exactly one film has that name (and that year, when known), else null.
1474
+ // TMDB with your key (the original title, else the shown one in Spanish), else Cinemeta
1475
+ // (original titles only).
1476
+ function plainTitle(s) { // ("El SeΓ±or de los Anillos:" -> "elsenordelosanillos")
1477
+ var t = ('' + (s || '')).toLowerCase();
1478
+ if (t.normalize) { try { t = t.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } catch (e) {} }
1479
+ return t.replace(/[^a-z0-9]+/g, '');
1480
+ }
1481
+ function findFilm(p, cancel) {
1482
+ var year = +p.year || 0;
1483
+ function near(y) { return !year || (y && Math.abs(y - year) <= 1); }
1484
+ function one(list, names) {
1485
+ var want = names.map(plainTitle).filter(Boolean), hits = (list || []).filter(function (r) {
1486
+ return near(r.year) && want.some(function (w) { return w === plainTitle(r.name) || w === plainTitle(r.orig); });
1487
+ });
1488
+ return hits.length === 1 ? hits[0] : null;
1489
+ }
1490
+ function tmdb(query, spanish) {
1491
+ var extra = '&query=' + encodeURIComponent(query) + (year ? '&year=' + year : '') + (spanish ? '&language=es-AR' : '');
1492
+ return tmdbGet('/search/movie', extra, cancel).then(function (r) {
1493
+ return ((r && r.results) || []).map(function (x) {
1494
+ return { tmdbId: x.id, name: x.title || '', orig: x.original_title || '', year: +('' + (x.release_date || '')).slice(0, 4) || 0, poster: tmdbImg(x.poster_path, 'w342') };
1495
+ });
1496
+ }, function () { return []; });
1497
+ }
1498
+ function viaTmdb() {
1499
+ if (!tmdbKey()) { return Promise.resolve(null); }
1500
+ var first = p.orig ? tmdb(p.orig, false).then(function (l) { return one(l, [p.orig]); }) : Promise.resolve(null);
1501
+ return first.then(function (hit) {
1502
+ return hit || (p.title ? tmdb(p.title, true).then(function (l) { return one(l, [p.title]); }) : null);
1503
+ }).then(function (hit) {
1504
+ if (!hit) { return null; }
1505
+ return tmdbImdbId('movie', hit.tmdbId, cancel).then(function (imdb) {
1506
+ return imdb ? { type: 'movie', id: imdb, name: hit.name || p.title, poster: hit.poster } : null;
1507
+ }, function () { return null; });
1508
+ });
1509
+ }
1510
+ function viaCinemeta() {
1511
+ if (!p.orig) { return Promise.resolve(null); }
1512
+ return U.getJSON('https://v3-cinemeta.strem.io/catalog/movie/top/search=' + encodeURIComponent(p.orig) + '.json', 10000, null, cancel).then(function (r) {
1513
+ var hit = one(((r && r.metas) || []).map(function (m) {
1514
+ return { id: m.id, name: m.name || '', year: parseInt(m.releaseInfo || m.year || '', 10) || 0, poster: m.poster || '' };
1515
+ }), [p.orig]);
1516
+ return hit && /^tt\d+$/.test(hit.id) ? { type: 'movie', id: hit.id, name: p.title || hit.name, poster: hit.poster } : null;
1517
+ }, function () { return null; });
1518
+ }
1519
+ return viaTmdb().then(function (hit) { return hit || viaCinemeta(); });
1520
+ }
1521
+
1458
1522
  // ---- MDBList ratings (Settings > Integrations > MDBList) ----
1459
1523
  // -> [{ key, label, text }] in a fixed order, only the ones the title has.
1460
1524
  var MDB_SOURCES = [['imdb', 'IMDb'], ['tomatoes', 'Rotten Tomatoes'], ['popcorn', 'Audience'],
@@ -2012,7 +2076,7 @@ var Stremio = (function () {
2012
2076
  getIntroSegments: getIntroSegments,
2013
2077
  tmdbValidate: tmdbValidate, mdblistValidate: mdblistValidate, omdbValidate: omdbValidate,
2014
2078
  tmdbFindTvId: tmdbFindTvId, tmdbSeasonEpisodes: tmdbSeasonEpisodes, tmdbImg: tmdbImg,
2015
- tmdbTitleExtras: tmdbTitleExtras, tmdbExtrasById: tmdbExtrasById, tmdbEpisodeCast: tmdbEpisodeCast, tmdbEpisodeTrailer: tmdbEpisodeTrailer, setHideWords: setHideWords, hideWordHit: hideWordHit, normalizeAddonUrl: normalizeManifestUrl, debridOf: debridOf, addonOrigin: addonOrigin, setAddonOrigin: setAddonOrigin, parseSetupList: parseSetupList, applySetupEntries: applySetupEntries, sizeLimitHit: sizeLimitHit, setSizeLimit: setSizeLimit, gbText: gbText, runtimeMinutes: runtimeMinutes, tmdbPeople: tmdbPeople, tmdbPerson: tmdbPerson, tmdbBrowse: tmdbBrowse, tmdbTitleTrailers: tmdbTitleTrailers, tmdbProviders: tmdbProviders, tmdbProvidersKept: tmdbProvidersKept, tmdbGenres: tmdbGenres, tmdbWhereToWatch: tmdbWhereToWatch, watchRegion: watchRegion, autoWatchRegion: autoWatchRegion, tmdbImdbId: tmdbImdbId, hasTmdb: function () { return !!tmdbKey(); },
2079
+ tmdbTitleExtras: tmdbTitleExtras, tmdbExtrasById: tmdbExtrasById, tmdbEpisodeCast: tmdbEpisodeCast, tmdbEpisodeTrailer: tmdbEpisodeTrailer, setHideWords: setHideWords, hideWordHit: hideWordHit, normalizeAddonUrl: normalizeManifestUrl, debridOf: debridOf, addonOrigin: addonOrigin, setAddonOrigin: setAddonOrigin, parseSetupList: parseSetupList, applySetupEntries: applySetupEntries, sizeLimitHit: sizeLimitHit, setSizeLimit: setSizeLimit, gbText: gbText, runtimeMinutes: runtimeMinutes, tmdbPeople: tmdbPeople, tmdbPerson: tmdbPerson, tmdbBrowse: tmdbBrowse, tmdbTitleTrailers: tmdbTitleTrailers, tmdbProviders: tmdbProviders, tmdbProvidersKept: tmdbProvidersKept, tmdbGenres: tmdbGenres, tmdbWhereToWatch: tmdbWhereToWatch, watchRegion: watchRegion, autoWatchRegion: autoWatchRegion, tmdbImdbId: tmdbImdbId, findFilm: findFilm, hasTmdb: function () { return !!tmdbKey(); },
2016
2080
  mdblistRatings: mdblistRatings,
2017
2081
  omdbSeasonRatings: omdbSeasonRatings,
2018
2082
  loadAddons: loadAddons, listCatalogs: listCatalogs, getCatalog: getCatalog,
@@ -2042,7 +2106,7 @@ var Stremio = (function () {
2042
2106
  importSetupList: importSetupList, savedManifest: savedManifest, isAddonOff: isAddonOff, setAddonOff: setAddonOff, onIptvReady: onIptvReady, streamListenerCount: function (type, id) { var e = _streamCache[type + ':' + id]; return e ? e.listeners.length : 0; },
2043
2107
  iptvGetPlaylists: iptvGetPlaylists, iptvAddPlaylist: iptvAddPlaylist,
2044
2108
  iptvUpdatePlaylist: iptvUpdatePlaylist, iptvRemovePlaylist: iptvRemovePlaylist,
2045
- iptvRenamePlaylist: iptvRenamePlaylist, iptvSetPlaylistOff: iptvSetPlaylistOff, iptvMovePlaylist: iptvMovePlaylist, iptvLabel: iptvLabel,
2109
+ iptvRenamePlaylist: iptvRenamePlaylist, iptvSetPlaylistGuide: iptvSetPlaylistGuide, iptvAddXtream: iptvAddXtream, iptvSetXtreamFormat: iptvSetXtreamFormat, iptvSetPlaylistOff: iptvSetPlaylistOff, iptvMovePlaylist: iptvMovePlaylist, iptvLabel: iptvLabel,
2046
2110
  iptvLoad: iptvLoad, iptvStatus: iptvStatus, iptvReload: iptvReload
2047
2111
  };
2048
2112
  })();
package/js/trakt.js CHANGED
@@ -375,6 +375,7 @@ var Trakt = (function () {
375
375
  function next() {
376
376
  return authed('GET', path + (path.indexOf('?') === -1 ? '?' : '&') + 'page=' + page + '&limit=' + perPage, null).then(function (r) {
377
377
  live(g);
378
+ if (!Array.isArray(r.data)) { var bad = new Error('Trakt sent a page that couldn\'t be read (' + path.split('?')[0] + ', page ' + page + ')'); bad.status = r.status || 0; throw bad; } // (not the last page: the refresh fails, the copy kept)
378
379
  var items = list(r.data, perPage), first = items.length ? JSON.stringify(items[0]) : '';
379
380
  if (page > 1 && first && first === before) { return out.slice(0, max); }
380
381
  before = first;
package/js/util.js CHANGED
@@ -430,15 +430,20 @@ var U = (function () {
430
430
  /* GET via XHR with a timeout. Returns a Promise of the response text.
431
431
  maxBytes: give up as soon as more than that has arrived ("Too large (over …)");
432
432
  TEXT_MAX when not given. */
433
- // A failed download, in Diagnostics. During a private playback, only its server: its link (a
434
- // title's name can be in it) isn't kept, in the log or the copy saved for the next launch.
433
+ // A failed download, in Diagnostics. One asked during a private playback (when it started:
434
+ // it may fail after the playback ended) says only that, what kind of failure, and its
435
+ // server: not its link nor what the server answered (a title's name can be in either), in
436
+ // the log or the copy saved for the next launch.
435
437
  function privateNow() { try { return typeof Player !== 'undefined' && !!Player.privateNow && Player.privateNow(); } catch (e) { return false; } }
436
- function logged(err) {
438
+ function logged(err, priv) {
437
439
  var msg = '' + err.message;
438
- if (privateNow()) { msg = msg.replace(/(https?:\/\/[^\/\s?#]+)[^\s]*/g, '$1/…'); }
440
+ if (priv || privateNow()) {
441
+ var kind = /^(HTTP \d+|Timeout|Network error|Too large|Bad JSON|Redirected into the home network)/.exec(msg), host = /(https?:\/\/[^\/\s?#]+)/.exec(msg);
442
+ msg = 'Private viewing: a download' + (host ? ' from ' + host[1] : '') + ' failed' + (kind ? ' (' + kind[1] + ')' : '');
443
+ }
439
444
  log('network', msg); err.logged = true; return err;
440
445
  }
441
- function noteNetwork(err) { return logged(err); }
446
+ function noteNetwork(err, priv) { return logged(err, priv); }
442
447
  // What a server said when it refused (an error answer's own message, e.g. AIOStreams'
443
448
  // { error: { message: "Invalid stream expression … Expected :" } }, or short plain text),
444
449
  // on one line, links in it masked, a long one kept to its start and end (where the what
@@ -464,8 +469,10 @@ var U = (function () {
464
469
  // quiet: a failure isn't noted in Diagnostics (a check that's expected to fail at times, like
465
470
  // the YouTube service's while it starts; the caller says so if it matters).
466
471
  // headers (optional): { name: value } sent with it.
467
- function getText(url, timeoutMs, maxBytes, cancel, quiet, headers) {
468
- function logged(err) { return quiet ? err : noteNetwork(err); }
472
+ // body (optional): sent as a POST (text), else it's a GET.
473
+ function getText(url, timeoutMs, maxBytes, cancel, quiet, headers, body) {
474
+ var priv = privateNow(); // (asked during a private playback: logged as one, however late it fails)
475
+ function logged(err) { return quiet ? err : noteNetwork(err, priv); }
469
476
  maxBytes = maxBytes || TEXT_MAX;
470
477
  return new Promise(function (resolveP, rejectP) {
471
478
  if (cancel && cancel.cancelled) { rejectP(cancelledError(url)); return; } // (nothing started)
@@ -482,7 +489,7 @@ var U = (function () {
482
489
  });
483
490
  }
484
491
  try {
485
- xhr.open('GET', url, true);
492
+ xhr.open(body == null ? 'GET' : 'POST', url, true);
486
493
  if (headers) { Object.keys(headers).forEach(function (h) { xhr.setRequestHeader(h, headers[h]); }); }
487
494
  } catch (e) { reject(e); return; }
488
495
  xhr.timeout = timeoutMs || 15000;
@@ -516,7 +523,7 @@ var U = (function () {
516
523
  };
517
524
  xhr.ontimeout = function () { if (!done) { done = true; reject(logged(new Error('Timeout: ' + redactUrl(url)))); } };
518
525
  xhr.onerror = function () { if (!done) { done = true; reject(logged(new Error('Network error: ' + redactUrl(url)))); } };
519
- try { xhr.send(); } catch (e3) { reject(e3); }
526
+ try { if (body == null) { xhr.send(); } else { xhr.send('' + body); } } catch (e3) { reject(e3); }
520
527
  });
521
528
  }
522
529
 
@@ -557,10 +564,13 @@ var U = (function () {
557
564
 
558
565
  /* GET JSON via XHR with a timeout. Returns a Promise of the parsed body (JSON_MAX
559
566
  unless maxBytes says otherwise). cancel, headers: see getText. */
560
- function getJSON(url, timeoutMs, maxBytes, cancel, headers) {
561
- return getText(url, timeoutMs, maxBytes || JSON_MAX, cancel, false, headers).then(function (text) {
567
+ // A POST (body: text), its answer JSON.
568
+ function postJSON(url, body, timeoutMs, maxBytes, headers) { return getJSON(url, timeoutMs, maxBytes, null, headers, body); }
569
+ function getJSON(url, timeoutMs, maxBytes, cancel, headers, body) {
570
+ var priv = privateNow(); // (asked during a private playback: its answer unreadable later is logged as one too)
571
+ return getText(url, timeoutMs, maxBytes || JSON_MAX, cancel, false, headers, body).then(function (text) {
562
572
  try { return JSON.parse(text); }
563
- catch (e) { throw logged(new Error('Bad JSON from ' + redactUrl(url))); }
573
+ catch (e) { throw logged(new Error('Bad JSON from ' + redactUrl(url)), priv); }
564
574
  });
565
575
  }
566
576
 
@@ -1491,7 +1501,8 @@ var U = (function () {
1491
1501
  }
1492
1502
 
1493
1503
  return {
1494
- byId: byId, el: el, clear: clear, moveIn: moveIn, getJSON: getJSON, getText: getText, redactUrl: redactUrl, problem: problem, problemDetail: problemDetail, listOf: listOf, lru: lru, cachedJSON: cachedJSON,
1504
+ byId: byId, el: el, clear: clear, moveIn: moveIn, getJSON: getJSON, getText: getText,
1505
+ postJSON: postJSON, redactUrl: redactUrl, problem: problem, problemDetail: problemDetail, listOf: listOf, lru: lru, cachedJSON: cachedJSON,
1495
1506
  parseUrl: parseUrl, urlProblem: urlProblem, isPlaylistUrl: isPlaylistUrl, isManifestUrl: isManifestUrl,
1496
1507
  isPrivateHost: isPrivateHost, urlIsPrivate: urlIsPrivate, setLocalPolicy: setLocalPolicy, sanitizeHeaders: sanitizeHeaders, drawable: drawable,
1497
1508
  log: log, logEntries: logEntries, clearLog: clearLog, bigStore: bigStore, diskInfo: diskInfo,
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;
package/js/view-detail.js CHANGED
@@ -2039,7 +2039,7 @@
2039
2039
  if (_d) { _d.direct = false; _d.directList = false; _d.directFailed = false; } // (only this once: played again from the list, back to the list)
2040
2040
  var onExit = function (how) {
2041
2041
  // (after CH+/CHβˆ’, back on the card of the channel you ended on)
2042
- if (exitToBack) { App.returnFromLive(_d.channels ? _d.channels.list[_d.channels.idx].card : null); return; }
2042
+ if (exitToBack) { var ch = _d.channels ? _d.channels.list[_d.channels.idx] : null; App.returnFromLive(ch ? ch.card : null, ch ? ch.meta.id : null); return; }
2043
2043
  // played straight away (a single YouTube video): back where it was opened from
2044
2044
  if (direct && !page.directFailed && _d === page) { App.back(); return; } // (Home redrawn if Continue Watching changed)
2045
2045
  // a channel's video played straight away: back on the channel's videos, on it