stremiok 2.0.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/trakt.js CHANGED
@@ -92,7 +92,9 @@ var Trakt = (function () {
92
92
  try { data = text ? JSON.parse(text) : null; } catch (e2) { bad = true; }
93
93
  var retry = 0;
94
94
  try { retry = parseInt(xhr.getResponseHeader('Retry-After'), 10) || 0; } catch (e5) {}
95
- if (xhr.status >= 200 && xhr.status < 300) { resolve({ status: xhr.status, data: data, bad: bad }); }
95
+ var pages = 0, limit = 0; // (a list sent a page at a time: how many pages, how many a page)
96
+ try { pages = parseInt(xhr.getResponseHeader('X-Pagination-Page-Count'), 10) || 0; limit = parseInt(xhr.getResponseHeader('X-Pagination-Limit'), 10) || 0; } catch (e6) {}
97
+ if (xhr.status >= 200 && xhr.status < 300) { resolve({ status: xhr.status, data: data, bad: bad, pages: pages, limit: limit }); }
96
98
  else { reject({ status: xhr.status, data: data, retryAfter: retry }); }
97
99
  };
98
100
  xhr.ontimeout = function () { if (!done) { done = true; reject({ status: 0, timeout: true }); } };
@@ -338,19 +340,67 @@ var Trakt = (function () {
338
340
  return out;
339
341
  }
340
342
  function isWatched(type, id) { return watchedAt(type, id) > 0; }
343
+ // Watched past a place kept (Trakt's paused one, or this TV's): a movie watched since; an
344
+ // episode watched since, or a later one of the show (on any device). A place kept after
345
+ // those were watched (watching again) still stands. e: { type, id, updated }.
346
+ var _byShow = null, _byShowFor = null; // { imdb: [[season, episode, when]] }, built from _watched when it changes
347
+ function byShow() {
348
+ if (_byShowFor === _watched) { return _byShow; }
349
+ var m = {};
350
+ Object.keys(_watched).forEach(function (k) {
351
+ var p = /^series:(tt\d+):(\d+):(\d+)$/.exec(k), v = _watched[k];
352
+ if (p && typeof v === 'number' && v > 0) { (m[p[1]] = m[p[1]] || []).push([+p[2], +p[3], v]); }
353
+ });
354
+ _byShow = m; _byShowFor = _watched;
355
+ return m;
356
+ }
357
+ function watchedPast(e) {
358
+ var since = (e && e.updated) || 0;
359
+ if (!e || !since) { return false; }
360
+ if (e.type === 'movie') { return watchedAt('movie', e.id) > since; }
361
+ var p = /^(tt\d+):(\d+):(\d+)$/.exec('' + e.id);
362
+ if (e.type !== 'series' || !p) { return false; }
363
+ var sn = +p[2], en = +p[3];
364
+ return (byShow()[p[1]] || []).some(function (w) { return w[2] > since && (w[0] > sn || (w[0] === sn && w[1] >= en)); });
365
+ }
341
366
 
367
+ // Trakt's watched lists come a page at a time (since July 2026), and shows carry their
368
+ // episodes only when asked (extended=progress). Every page is read: up to the count Trakt
369
+ // gives, or, without one, until a page is shorter than its page size (Trakt's, or the one
370
+ // asked); PAGES_MAX at most, and no more items than max. A page that repeats the one
371
+ // before (a server that ignores the page asked) ends it too, and isn't kept twice.
372
+ var PAGES_MAX = 100;
373
+ function allPages(path, perPage, max, g) {
374
+ var out = [], page = 1, before = '';
375
+ function next() {
376
+ return authed('GET', path + (path.indexOf('?') === -1 ? '?' : '&') + 'page=' + page + '&limit=' + perPage, null).then(function (r) {
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)
379
+ var items = list(r.data, perPage), first = items.length ? JSON.stringify(items[0]) : '';
380
+ if (page > 1 && first && first === before) { return out.slice(0, max); }
381
+ before = first;
382
+ out = out.concat(items);
383
+ var last = !items.length || out.length >= max || page >= PAGES_MAX ||
384
+ (r.pages ? page >= r.pages : items.length < (r.limit || perPage));
385
+ if (last) { return out.slice(0, max); }
386
+ page++;
387
+ return next();
388
+ });
389
+ }
390
+ return next();
391
+ }
342
392
  var SHOWS_MAX = 5000, SEASONS_MAX = 200, EPISODES_MAX = 1000;
343
393
  function fetchWatched(g) {
344
394
  var map = {};
345
- return authed('GET', '/sync/watched/movies', null).then(function (r) {
346
- list(r.data, 20000).forEach(function (m) {
395
+ return allPages('/sync/watched/movies', 250, 20000, g).then(function (movies) {
396
+ movies.forEach(function (m) {
347
397
  var imdb = m && imdbOf(m.movie && m.movie.ids);
348
398
  if (imdb) { map['movie:' + imdb] = when(m.last_watched_at) || 1; }
349
399
  });
350
- return authed('GET', '/sync/watched/shows', null);
351
- }).then(function (r) {
400
+ return allPages('/sync/watched/shows?extended=progress', 100, SHOWS_MAX, g);
401
+ }).then(function (all) {
352
402
  var shows = [];
353
- list(r.data, SHOWS_MAX).forEach(function (sh) {
403
+ all.forEach(function (sh) {
354
404
  var show = sh && sh.show, imdb = show && imdbOf(show.ids);
355
405
  if (!imdb) { return; }
356
406
  var trakt = num(show.ids.trakt, 1e10), at = when(sh.last_watched_at);
@@ -472,13 +522,15 @@ var Trakt = (function () {
472
522
 
473
523
  // ---- playback (Continue Watching) cache ----
474
524
  // Trakt's paused titles, but not one you removed from Continue Watching here, unless Trakt
475
- // has newer progress for it since (you watched more of it, here or elsewhere).
525
+ // has newer progress for it since (you watched more of it, here or elsewhere), nor one
526
+ // you've watched past since (watchedPast: Trakt keeps a paused place it's been told of in
527
+ // other ways, a history entry, and the show's next episode would wait behind it).
476
528
  // And the shows you're watching (Trakt's Up next): their next episode, placed by when you
477
529
  // last watched the show, unless Trakt has a paused episode of it already.
478
530
  function continueList() {
479
531
  var gone = U.store.get(DISMISSED_KEY, {}) || {};
480
532
  function kept(e) { var at = gone[e.type + ':' + e.id]; return !at || (e.updated || 0) > at; }
481
- var list = (U.store.get(PLAYBACK_KEY, []) || []).filter(kept), paused = {};
533
+ var list = (U.store.get(PLAYBACK_KEY, []) || []).filter(function (e) { return kept(e) && !watchedPast(e); }), paused = {};
482
534
  list.forEach(function (e) { if (e.type === 'series') { paused[('' + e.id).split(':')[0]] = true; } });
483
535
  (U.store.get(UPNEXT_KEY, []) || []).forEach(function (u) {
484
536
  if (!u || !u.imdb || paused[u.imdb]) { return; }
@@ -837,7 +889,7 @@ var Trakt = (function () {
837
889
  return {
838
890
  hasCreds: hasCreds, isConnected: isConnected, connectStatus: connectStatus,
839
891
  startDeviceFlow: startDeviceFlow, disconnect: disconnect, refreshCaches: refreshCaches,
840
- isWatched: isWatched, watchedAt: watchedAt, watchedSince: watchedSince, continueList: continueList, dismiss: dismiss, synthAddon: synthAddon,
892
+ isWatched: isWatched, watchedAt: watchedAt, watchedSince: watchedSince, watchedPast: watchedPast, continueList: continueList, dismiss: dismiss, synthAddon: synthAddon,
841
893
  onWatched: onWatched, onMarked: onMarked, onProgress: onProgress, onStopped: onStopped, refreshSoon: refreshSoon, rate: rate, myRating: myRating,
842
894
  retryFailed: retryFailed, discardFailed: discardFailed, sendNow: sendNow, flush: flushOutbox, noteEpisodes: noteEpisodes
843
895
  };
package/js/util.js CHANGED
@@ -410,6 +410,14 @@ var U = (function () {
410
410
  are dropped), and at most `max` of them. Addons and APIs are outside our control:
411
411
  a list that's a string, or has nulls in it, or runs to a million items, must not
412
412
  stop the app. */
413
+ // Moves list[i] in place: dir -1 / 1 one step, -Infinity / Infinity to the top / bottom
414
+ // (the others keep their order). False when it can't move (not found, already there).
415
+ function moveIn(list, i, dir) {
416
+ var j = dir === -Infinity ? 0 : dir === Infinity ? list.length - 1 : i + dir;
417
+ if (i < 0 || i >= list.length || j < 0 || j >= list.length || j === i) { return false; }
418
+ list.splice(j, 0, list.splice(i, 1)[0]);
419
+ return true;
420
+ }
413
421
  function listOf(x, max, keep) { // keep(item): an extra test, applied before counting
414
422
  if (!Array.isArray(x)) { return []; }
415
423
  var out = [];
@@ -422,8 +430,20 @@ var U = (function () {
422
430
  /* GET via XHR with a timeout. Returns a Promise of the response text.
423
431
  maxBytes: give up as soon as more than that has arrived ("Too large (over …)");
424
432
  TEXT_MAX when not given. */
425
- function logged(err) { log('network', err.message); err.logged = true; return err; }
426
- function noteNetwork(err) { return logged(err); }
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.
437
+ function privateNow() { try { return typeof Player !== 'undefined' && !!Player.privateNow && Player.privateNow(); } catch (e) { return false; } }
438
+ function logged(err, priv) {
439
+ var msg = '' + err.message;
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
+ }
444
+ log('network', msg); err.logged = true; return err;
445
+ }
446
+ function noteNetwork(err, priv) { return logged(err, priv); }
427
447
  // What a server said when it refused (an error answer's own message, e.g. AIOStreams'
428
448
  // { error: { message: "Invalid stream expression … Expected :" } }, or short plain text),
429
449
  // on one line, links in it masked, a long one kept to its start and end (where the what
@@ -449,8 +469,10 @@ var U = (function () {
449
469
  // quiet: a failure isn't noted in Diagnostics (a check that's expected to fail at times, like
450
470
  // the YouTube service's while it starts; the caller says so if it matters).
451
471
  // headers (optional): { name: value } sent with it.
452
- function getText(url, timeoutMs, maxBytes, cancel, quiet, headers) {
453
- 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); }
454
476
  maxBytes = maxBytes || TEXT_MAX;
455
477
  return new Promise(function (resolveP, rejectP) {
456
478
  if (cancel && cancel.cancelled) { rejectP(cancelledError(url)); return; } // (nothing started)
@@ -467,7 +489,7 @@ var U = (function () {
467
489
  });
468
490
  }
469
491
  try {
470
- xhr.open('GET', url, true);
492
+ xhr.open(body == null ? 'GET' : 'POST', url, true);
471
493
  if (headers) { Object.keys(headers).forEach(function (h) { xhr.setRequestHeader(h, headers[h]); }); }
472
494
  } catch (e) { reject(e); return; }
473
495
  xhr.timeout = timeoutMs || 15000;
@@ -501,7 +523,7 @@ var U = (function () {
501
523
  };
502
524
  xhr.ontimeout = function () { if (!done) { done = true; reject(logged(new Error('Timeout: ' + redactUrl(url)))); } };
503
525
  xhr.onerror = function () { if (!done) { done = true; reject(logged(new Error('Network error: ' + redactUrl(url)))); } };
504
- try { xhr.send(); } catch (e3) { reject(e3); }
526
+ try { if (body == null) { xhr.send(); } else { xhr.send('' + body); } } catch (e3) { reject(e3); }
505
527
  });
506
528
  }
507
529
 
@@ -542,10 +564,13 @@ var U = (function () {
542
564
 
543
565
  /* GET JSON via XHR with a timeout. Returns a Promise of the parsed body (JSON_MAX
544
566
  unless maxBytes says otherwise). cancel, headers: see getText. */
545
- function getJSON(url, timeoutMs, maxBytes, cancel, headers) {
546
- 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) {
547
572
  try { return JSON.parse(text); }
548
- catch (e) { throw logged(new Error('Bad JSON from ' + redactUrl(url))); }
573
+ catch (e) { throw logged(new Error('Bad JSON from ' + redactUrl(url)), priv); }
549
574
  });
550
575
  }
551
576
 
@@ -1476,7 +1501,8 @@ var U = (function () {
1476
1501
  }
1477
1502
 
1478
1503
  return {
1479
- byId: byId, el: el, clear: clear, 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,
1480
1506
  parseUrl: parseUrl, urlProblem: urlProblem, isPlaylistUrl: isPlaylistUrl, isManifestUrl: isManifestUrl,
1481
1507
  isPrivateHost: isPrivateHost, urlIsPrivate: urlIsPrivate, setLocalPolicy: setLocalPolicy, sanitizeHeaders: sanitizeHeaders, drawable: drawable,
1482
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
@@ -191,6 +192,16 @@ var ViewParts = {};
191
192
  var cancel = U.el('div', 'pill focusable modal-btn', 'Cancel');
192
193
  var refresh = U.el('div', 'pill focusable modal-btn', 'Refresh');
193
194
  var remove = U.el('div', 'pill focusable modal-btn danger', 'Remove');
195
+ // Turn off / on: kept in your list, not asked for anything while it's off (a backup)
196
+ var isOff = !!(Stremio.isAddonOff && Stremio.isAddonOff(url));
197
+ var onoff = U.el('div', 'pill focusable modal-btn', isOff ? 'Turn on' : 'Turn off');
198
+ onoff.__onselect = function () {
199
+ closeModal();
200
+ Stremio.setAddonOff(url, !isOff);
201
+ refreshAddonRows(); // (its rows come or go; nothing else is fetched)
202
+ U.toast(isOff ? 'β€œ' + name + '” is on' : 'β€œ' + name + '” is off: not asked for anything until you turn it on', 4000);
203
+ renderSettings();
204
+ };
194
205
 
195
206
  cancel.__onselect = function () {
196
207
  closeModal();
@@ -237,6 +248,7 @@ var ViewParts = {};
237
248
  }
238
249
  row.appendChild(cancel);
239
250
  row.appendChild(refresh);
251
+ row.appendChild(onoff);
240
252
  if (configure) { row.appendChild(configure); }
241
253
  row.appendChild(share);
242
254
  row.appendChild(remove);
@@ -260,6 +272,16 @@ var ViewParts = {};
260
272
  var rename = U.el('div', 'pill focusable modal-btn', 'Rename');
261
273
  var onoff = U.el('div', 'pill focusable modal-btn', playlist.off ? 'Turn on' : 'Turn off');
262
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
+ };
263
285
  var reload = U.el('div', 'pill focusable modal-btn', 'Reload');
264
286
  var remove = U.el('div', 'pill focusable modal-btn danger', 'Remove');
265
287
 
@@ -301,6 +323,20 @@ var ViewParts = {};
301
323
  }
302
324
  });
303
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
+ };
304
340
  reload.__onselect = function () {
305
341
  closeModal();
306
342
  U.toast('Reloading playlist…', 4000);
@@ -323,6 +359,8 @@ var ViewParts = {};
323
359
  row.appendChild(rename);
324
360
  row.appendChild(onoff);
325
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); }
326
364
  if (!playlist.off) { row.appendChild(reload); }
327
365
  row.appendChild(remove);
328
366
  box.appendChild(row);
@@ -362,33 +400,6 @@ var ViewParts = {};
362
400
  showModal(overlay, pref);
363
401
  }
364
402
 
365
- // Switches in a popup (Settings β€Ί Appearance β€Ί Home tabs): OK on one turns it on or off and
366
- // the list stays open (BACK closes it). items: [[key, label, on]]; words: { on, off } (the
367
- // state shown after each label); onToggle(key, on).
368
- function toggleList(title, items, words, onToggle) {
369
- var existing = document.querySelector('.modal-overlay');
370
- if (existing) { existing.parentNode.removeChild(existing); }
371
- var overlay = U.el('div', 'modal-overlay');
372
- var box = U.el('div', 'modal-box pick-box');
373
- box.appendChild(U.el('div', 'modal-msg', title));
374
- var listEl = U.el('div', 'pick-list'), first = null;
375
- items.forEach(function (opt) {
376
- var on = !!opt[2], it = U.el('div', 'pick-item focusable');
377
- function draw() {
378
- it.textContent = opt[1] + ': ' + (on ? words.on : words.off);
379
- if (on) { it.classList.add('sel'); } else { it.classList.remove('sel'); }
380
- }
381
- draw();
382
- it.__onselect = function () { on = !on; draw(); onToggle(opt[0], on); };
383
- if (!first) { first = it; }
384
- listEl.appendChild(it);
385
- });
386
- box.appendChild(listEl);
387
- overlay.appendChild(box);
388
- document.body.appendChild(overlay);
389
- showModal(overlay, first);
390
- }
391
-
392
403
  // Ticks in a popup (what to import): nothing ticked at first; OK on one ticks or unticks it,
393
404
  // Select all ticks every one that can be (again: none); the button counts what's ticked.
394
405
  // items: [{ key, label, have (already yours: shown as such, can't be ticked), note }];
@@ -471,7 +482,8 @@ var ViewParts = {};
471
482
  }
472
483
 
473
484
  // The shared top menu, used on Home / Search / Settings so they look and navigate
474
- // 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.
475
487
  // `active` is the key of the current view: a tab key, 'search' or 'settings'.
476
488
  function buildTopMenu(active) {
477
489
  var head = U.el('div', 'header home-header');
@@ -484,34 +496,23 @@ var ViewParts = {};
484
496
  };
485
497
  function down() { return focusBelowTop(head); }
486
498
  var tabbar = U.el('div', 'tabbar-inline');
487
- availableTabs().forEach(function (t) {
488
- var pill = U.el('div', 'tab-pill focusable' + (t.key === active ? ' active' : ''));
489
- pill.appendChild(document.createTextNode(t.label));
490
- pill.appendChild(U.el('span', 'tab-ic', t.icon));
491
- pill.__onselect = function () { App.goHome(t.key); };
492
- pill.__navDOWN = down;
493
- 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); }
494
513
  });
495
- // Discover: browse any catalog in full, by genre / year (its own screen)
496
- var disc = U.el('div', 'tab-pill focusable' + (active === 'discover' ? ' active' : ''));
497
- disc.appendChild(document.createTextNode('Discover'));
498
- disc.appendChild(U.el('span', 'tab-ic', 'πŸ”­'));
499
- disc.__onselect = function () { App.openDiscover(); };
500
- disc.__navDOWN = down;
501
- tabbar.appendChild(disc);
502
514
  head.appendChild(tabbar);
503
-
504
- // Search and Settings: pills like the tabs (name, then icon), after them
505
- function chip(key, label, icon, open) {
506
- var c = U.el('div', 'hchip focusable' + (active === key ? ' active' : ''));
507
- c.appendChild(document.createTextNode(label));
508
- c.appendChild(U.el('span', 'tab-ic', icon));
509
- c.__onselect = open;
510
- c.__navDOWN = down;
511
- head.appendChild(c);
512
- }
513
- chip('search', 'Search', 'πŸ”', function () { App.openSearch(); });
514
- chip('settings', 'Settings', 'βš™', function () { App.openSettings(); });
515
+ item('hchip', 'settings', 'Settings', 'βš™', function () { App.openSettings(); }, head);
515
516
 
516
517
  head.appendChild(U.el('div', 'spacer'));
517
518
  var brand = U.el('div', 'brand');
@@ -568,6 +569,10 @@ var ViewParts = {};
568
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)
569
570
  if (kind === 'movie' || kind === 'series') { c.__onlongpress = function () { posterActions(meta, kind, c); }; }
570
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
+ }
571
576
  c.__onselect = function () {
572
577
  var t = titleType(meta.type || fallbackType);
573
578
  // Live TV catalogs (anything that isn't movies/series) skip the info page and
@@ -586,6 +591,86 @@ var ViewParts = {};
586
591
  return c;
587
592
  }
588
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
+
589
674
  // The channels of the row a live card sits in, for CH+/CHβˆ’ while it plays.
590
675
  function rowChannels(card) {
591
676
  var sib = card.parentNode ? card.parentNode.children : [card], list = [], idx = 0;
@@ -739,7 +824,6 @@ var ViewParts = {};
739
824
  V.inNowPlaying = inNowPlaying;
740
825
  V.onModalClose = onModalClose;
741
826
  V.pickList = pickList;
742
- V.toggleList = toggleList;
743
827
  V.pickMany = pickMany;
744
828
  V.playlistActions = playlistActions;
745
829
  V.posterCard = posterCard;
package/js/view-detail.js CHANGED
@@ -62,10 +62,11 @@
62
62
  U.spinner(false);
63
63
  _d.streams = list;
64
64
  var playable = list.filter(Stremio.isPlayable);
65
- if (!playable.length) { // (and why, in Settings β€Ί Diagnostics)
65
+ if (!playable.length) { // (and why: on screen, each addon's reason; in Settings β€Ί Diagnostics)
66
+ var why = list.length ? '' : (Stremio.streamNotes ? Stremio.streamNotes(type, id) : []).slice(0, 3).map(function (n) { return n.addon + ' ' + n.title; }).join('; ');
66
67
  U.log('sources', 'No playable source for β€œ' + ((_d.meta && _d.meta.name) || id) + '” (' + type + ' ' + id + '): ' +
67
- (list.length ? list.length + ' source(s), none playable here' : 'no addon sent any'));
68
- U.toast('No playable source for this channel'); App.back(); return;
68
+ (list.length ? list.length + ' source(s), none playable here' : 'no addon sent any') + (why ? ' (' + why + ')' : ''));
69
+ U.toast('No playable source for this channel' + (why ? ': ' + why : ''), why ? 6000 : 3000); App.back(); return;
69
70
  }
70
71
  if (playable.length === 1) {
71
72
  startPlayback(playable[0], id, _d.streamLabel || _d.meta.name || 'Live');
@@ -1566,7 +1567,7 @@
1566
1567
  // offer, it's asked at once.
1567
1568
  function sourceMenu(stream) {
1568
1569
  var canStart = !howHere() && savedPlace(_d.streamId);
1569
- var canDownload = typeof Downloads !== 'undefined' && Downloads.available() && /^https?:\/\//i.test(stream.url || '') && !stream._localFile && !stream._yt;
1570
+ var canDownload = typeof Downloads !== 'undefined' && Downloads.available() && (Downloads.isFileLink ? Downloads.isFileLink(stream) : /^https?:\/\//i.test(stream.url || '')) && !stream._localFile && !stream._yt;
1570
1571
  if (!canStart && !canDownload) { toggleUnsuitable(stream); return; }
1571
1572
  var unsuit = isUnsuitable(_d.id, stream), opts = [];
1572
1573
  if (canStart) { opts.push(['start', 'Play from the start']); }