stremiok 2.2.1 → 2.2.2

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
@@ -24,8 +24,9 @@ var ViewParts = {};
24
24
  var _modalPrevFocus = null;
25
25
  var _modalScope = null;
26
26
 
27
- // opts (optional): { noLabel, onNo, plain } — another label and an action for the other
28
- // button; plain: the confirm button not marked as destructive.
27
+ // opts (optional): { noLabel, onNo, plain, focusYes } — another label and an action for the
28
+ // other button; plain: the confirm button not marked as destructive; focusYes: focus starts on
29
+ // it (not on Cancel).
29
30
  function confirm(message, confirmLabel, onYes, opts) {
30
31
  opts = opts || {};
31
32
  // defensive: never stack modals
@@ -45,7 +46,7 @@ var ViewParts = {};
45
46
  overlay.appendChild(box);
46
47
  document.body.appendChild(overlay);
47
48
 
48
- showModal(overlay, no); // default to Cancel (safe)
49
+ showModal(overlay, opts.focusYes ? yes : no); // default to Cancel (safe)
49
50
  }
50
51
 
51
52
  // A QR code for the phone (an addon's link or setup page): the code holds the whole link;
@@ -511,10 +512,13 @@ var ViewParts = {};
511
512
  else if (m.key === 'discover') { item('tab-pill', 'discover', 'Discover', '🔭', function () { App.openDiscover(); }, tabbar); }
512
513
  else if (m.key === 'search') { item('hchip', 'search', 'Search', '🔍', function () { App.openSearch(); }, tabbar); }
513
514
  });
514
- head.appendChild(tabbar);
515
- item('hchip', 'settings', 'Settings', '⚙', function () { App.openSettings(); }, head);
516
-
517
- head.appendChild(U.el('div', 'spacer'));
515
+ // The items in one strip that scrolls sideways when they don't all fit (Focus keeps the
516
+ // focused one in view); the StremioK name outside it, at the right.
517
+ var strip = U.el('div', 'menu-scroll');
518
+ strip.appendChild(tabbar);
519
+ item('hchip', 'settings', 'Settings', '⚙', function () { App.openSettings(); }, strip);
520
+ head.appendChild(strip);
521
+ setTimeout(function () { showActiveItem(strip); }, 0); // (once it's on screen)
518
522
  var brand = U.el('div', 'brand');
519
523
  brand.innerHTML = 'Stremio<span class="dot">K</span>';
520
524
  if (Player.isPrivate && Player.isPrivate()) { brand.className += ' private'; } // (private viewing on: drawn as an outline)
@@ -522,6 +526,15 @@ var ViewParts = {};
522
526
  return head;
523
527
  }
524
528
 
529
+ // The page's own item scrolled into view (one picked by number, far to the right), with
530
+ // room for a neighbour on either side; focus may be on the page below.
531
+ function showActiveItem(strip) {
532
+ var a = strip.querySelector('.active'), w = strip.clientWidth, mx = 90;
533
+ if (!a || !w) { return; }
534
+ if (a.offsetLeft < strip.scrollLeft + mx) { strip.scrollLeft = Math.max(0, a.offsetLeft - mx); }
535
+ else if (a.offsetLeft + a.offsetWidth > strip.scrollLeft + w - mx) { strip.scrollLeft = a.offsetLeft + a.offsetWidth - w + mx; }
536
+ }
537
+
525
538
  // DOWN from the top menu returns to where focus was before going UP (if it's still
526
539
  // on screen), else the first focusable in the content area below the menu.
527
540
  function focusBelowTop(head) {
@@ -534,7 +547,7 @@ var ViewParts = {};
534
547
  var all = s.querySelectorAll('.focusable');
535
548
  for (var i = 0; i < all.length; i++) {
536
549
  var r = all[i].getBoundingClientRect();
537
- if (r.height > 0 && r.top > 80) { Focus.setFocus(all[i]); return true; }
550
+ if (r.height > 0 && r.top > 80 * U.zoom()) { Focus.setFocus(all[i]); return true; } // (below the menu: on screen, zoomed)
538
551
  }
539
552
  return false;
540
553
  }
@@ -639,17 +652,32 @@ var ViewParts = {};
639
652
  redrawGuideCards();
640
653
  }, 60000);
641
654
  }
655
+ // A hold-OK menu that waits for something first (a channel's schedule, a title's details):
656
+ // shown only if, when it comes, you're still where you held it: the card in the page, its
657
+ // screen not hidden (Settings or a title opened meanwhile; Home stays in the page, hidden),
658
+ // no player, no other menu up, and no hold since (that one's). -> { still(), latest() }
659
+ var _holdGen = 0;
660
+ function holdNow(card) {
661
+ var gen = ++_holdGen, screen = null;
662
+ for (var n = card; n && !screen; n = n.parentNode) { if (/\bscreen\b/.test(n.className || '')) { screen = n; } }
663
+ return {
664
+ latest: function () { return gen === _holdGen; },
665
+ still: function () {
666
+ return gen === _holdGen && !!card && document.documentElement.contains(card) && (!screen || !/\bhidden\b/.test(screen.className)) &&
667
+ !(typeof Player !== 'undefined' && Player.isActive()) && !isModalOpen();
668
+ }
669
+ };
670
+ }
642
671
  // Hold OK on a channel card: ▶ Watch now, ★ Add to / Remove from favorites, and its schedule
643
672
  // from 2 hours ago to tomorrow (what's on now marked, and picked); OK on a program says
644
673
  // what it is. A channel without a guide: the first two.
645
- var _schedGen = 0;
646
674
  function liveSchedule(meta, card) {
647
- var gen = ++_schedGen;
675
+ var hold = holdNow(card);
648
676
  U.spinner(true);
649
677
  EPG.schedule(meta.id).then(null, function () { return []; }).then(function (list) {
650
- if (gen !== _schedGen) { return; } // (held on another card since: its own)
678
+ if (!hold.latest()) { return; } // (held on another card since: its own)
651
679
  U.spinner(false);
652
- if (!document.documentElement.contains(card) || (typeof Player !== 'undefined' && Player.isActive())) { return; } // (left meanwhile)
680
+ if (!hold.still()) { return; } // (left meanwhile)
653
681
  var fav = IPTV.isFavorite(meta.id), t = Date.now() / 1000, at = 'play';
654
682
  var items = [['play', '▶ Watch now'], ['fav', fav ? '★ Remove from favorites' : '★ Add to favorites']];
655
683
  list.forEach(function (p, i) {
@@ -686,10 +714,12 @@ var ViewParts = {};
686
714
  // Playing, or mark it watched / unwatched (a series as a whole). The title's details
687
715
  // are fetched first (usually cached) so the choices match its current state.
688
716
  function posterActions(meta, type, card) {
717
+ var hold = holdNow(card);
689
718
  U.spinner(true);
690
719
  titleActions(meta, type).then(function (a) {
720
+ if (!hold.latest()) { return; } // (held on another card since: its own)
691
721
  U.spinner(false);
692
- pickList(a.name, a.items, null, a.run);
722
+ if (hold.still()) { pickList(a.name, a.items, null, a.run); } // (not over a screen opened meanwhile)
693
723
  });
694
724
  }
695
725
  // What a title's hold-OK menu offers, wherever it's held (a poster; the player's Cast & more,
package/js/view-detail.js CHANGED
@@ -203,6 +203,10 @@
203
203
  if (local) { resumeSources(startPlayback(local, p.id, p.name, how), p.id); return; }
204
204
  var st = { url: p.url, name: p.name, _headers: U.sanitizeHeaders(p.headers) || undefined,
205
205
  behaviorHints: (p.fileHints && typeof p.fileHints === 'object') ? p.fileHints : undefined }; // (its file: for subtitles made for it)
206
+ if (p.torrent && typeof p.torrent.infoHash === 'string') { // (a torrent: its link from the streaming server set now)
207
+ st = Stremio.torrentSource({ infoHash: p.torrent.infoHash, fileIdx: p.torrent.fileIdx, sources: p.torrent.sources, name: p.name, behaviorHints: st.behaviorHints });
208
+ if (!st.url) { U.toast('This one plays through a streaming server: Settings › Content › Streaming server', 6000); return; }
209
+ }
206
210
  if (/^https:\/\/www\.youtube\.com\/watch\?v=[A-Za-z0-9_-]{11}$/.test(p.url || '')) { st._yt = true; st.ytPage = p.url; } // (a YouTube video: through the service again)
207
211
  resumeSources(startPlayback(st, p.id, p.name, how), p.id);
208
212
  }
@@ -321,7 +325,7 @@
321
325
  if (!panel || (' ' + body.className + ' ').indexOf(' series-body ') === -1) { return; }
322
326
  var sr = s.getBoundingClientRect(), br = body.getBoundingClientRect();
323
327
  if (!(br.height > 0)) { return; }
324
- var top = Math.round(br.bottom - sr.top) + PANEL_GAP;
328
+ var top = Math.round((br.bottom - sr.top) / U.zoom()) + PANEL_GAP; // (measured on screen, zoomed)
325
329
  panel.style.top = Math.max(PANEL_MIN, Math.min(PANEL_MAX, top)) + 'px';
326
330
  }
327
331
 
@@ -724,7 +728,7 @@
724
728
  function fileStreams(v) {
725
729
  return v.streams.map(function (st) {
726
730
  var s = Object.assign({ name: fileLabel(v) }, st);
727
- return Stremio.youTubeSource ? Stremio.youTubeSource(s) : s;
731
+ return Stremio.torrentSource(Stremio.youTubeSource(s));
728
732
  });
729
733
  }
730
734
 
@@ -1395,6 +1399,8 @@
1395
1399
  uncached: function (a, b) { return (a.info.cache === -1 ? 1 : 0) - (b.info.cache === -1 ? 1 : 0); },
1396
1400
  kind: function (a, b) { return (KIND_SCORE[b.info.kind] || 0) - (KIND_SCORE[a.info.kind] || 0); },
1397
1401
  hdr: function (a, b) { return (b.info.hdr ? 1 : 0) - (a.info.hdr ? 1 : 0); },
1402
+ // (only two sources that both state theirs: the others keep their order)
1403
+ seeds: function (a, b) { return (a.info.seeds >= 0 && b.info.seeds >= 0) ? b.info.seeds - a.info.seeds : 0; },
1398
1404
  addon: function (a, b) { return (a.st._addonRank || 0) - (b.st._addonRank || 0); },
1399
1405
  // each source's place in its addon's own list (an addon's first ties with another's
1400
1406
  // first): a plain key, so it orders consistently wherever it sits among the criteria
@@ -1464,7 +1470,13 @@
1464
1470
  // again), as the player takes a source, or null. total: how many it could try in all.
1465
1471
  // avoid: [{ addon, tag }] — an addon's links through a debrid service that sent an account's
1466
1472
  // notice (access refused, a limit: the player's noticeOf): they'd say the same.
1473
+ // Torrent sources' links from the streaming server set now (Settings may have changed it since
1474
+ // they came: none when it's off).
1475
+ function serverNow(list) {
1476
+ (list || []).forEach(function (st) { if (st && st.infoHash) { Stremio.torrentSource(st); } });
1477
+ }
1467
1478
  function nextSourceFor(tried, avoid) {
1479
+ serverNow(_d && _d.streams);
1468
1480
  var rows = (_d && _d.streams) ? filterRows(rankRowsFor(_d.streams)) : [], seen = {}, all = []; // (within the filter: Addon ▾ / Resolution ▾)
1469
1481
  rows.forEach(function (r) {
1470
1482
  var st = r.st, id = Stremio.sourceId(st), dt = Stremio.debridOf ? Stremio.debridOf(st) : null;
@@ -1847,6 +1859,7 @@
1847
1859
  // before, 4K, 1080p, …) starts with its heading.
1848
1860
  var heading = streamsHeading();
1849
1861
  if (heading) { listScroll.appendChild(U.el('div', 'stream-for', heading)); }
1862
+ serverNow(_d.streams); // (a page kept from before: its torrents follow the streaming server set now)
1850
1863
  var list = _d.streams, keepAt = -1, rows = rankRowsFor(list), lastGroup = null;
1851
1864
  // Direct links only (Settings › Source ranking): the sources the TV can't play left out
1852
1865
  // (torrents, links it can't open); where-to-watch apps stay (they open)
@@ -1915,10 +1928,11 @@
1915
1928
  if (row && row.sized) { lines.push('📏 Outside your size limits: ' + row.sized + ' (Settings › Playback › Source ranking)'); }
1916
1929
  if (row && row.blocked) { lines.push('⚠ ' + row.blocked + ' isn’t allowed (Settings › Playback › Formats)'); }
1917
1930
  if (row && row.bad.length) { lines.push('⚠ Marked “Not working” on this TV: ' + row.bad.join(', ') + ' (Settings › Playback › Formats)'); }
1931
+ if (stream._torrent) { lines.push('🧲 A torrent, through your streaming server (Settings › Content › Streaming server)'); }
1918
1932
  if (opens) {
1919
1933
  lines.push('▶ OK opens ' + (opens.data ? 'it in ' : '') + opens.app.name);
1920
1934
  } else if (!playable) {
1921
- if (stream.infoHash) { lines.push('— torrent, needs a debrid/torrent client'); }
1935
+ if (stream.infoHash) { lines.push('— torrent: plays through a streaming server (Settings › Content › Streaming server)'); }
1922
1936
  else if (stream.externalUrl) {
1923
1937
  lines.push('— another app' + ((typeof TVApps !== 'undefined' && TVApps.available())
1924
1938
  ? ': add it in Settings › Content › Streaming apps to open it from here' : ' (not playable here)'));
@@ -1936,7 +1950,7 @@
1936
1950
  }
1937
1951
  node.__onselect = function () {
1938
1952
  if (opens) { openWhere(opens); return; }
1939
- if (!playable) { U.toast('This source can’t play directly on the TV.'); return; }
1953
+ if (!playable) { U.toast(stream.infoHash ? 'Torrents play through a streaming server: Settings › Content › Streaming server' : 'This source can’t play directly on the TV.', stream.infoHash ? 6000 : undefined); return; }
1940
1954
  startPlayback(stream, _d.streamId, _d.streamLabel || _d.meta.name, howHere());
1941
1955
  };
1942
1956
  return node;
@@ -1985,6 +1999,7 @@
1985
1999
  // The player's "Select source" list: each source with the request headers it needs
1986
2000
  // (IPTV), so switching to it sends its own (or none).
1987
2001
  function playableSources(list) {
2002
+ serverNow(list || _d.streams);
1988
2003
  return (list || _d.streams || []).filter(Stremio.isPlayable).map(function (st) {
1989
2004
  var sz = Stremio.streamSize(st);
1990
2005
  var desc = Stremio.streamDescription(st).replace(/\n/g, ' ');
@@ -2001,6 +2016,10 @@
2001
2016
 
2002
2017
  // how (optional): { fromStart } — Play from start (Continue Watching): from 0:00.
2003
2018
  function startPlayback(stream, videoId, label, how) {
2019
+ if (stream && stream.infoHash) { // (a torrent: its link from the streaming server set now)
2020
+ Stremio.torrentSource(stream);
2021
+ if (!stream.url) { U.toast('Torrents play through a streaming server: Settings › Content › Streaming server', 6000); return null; }
2022
+ }
2004
2023
  // VOD changes progress/watched state. Live playback does not, and keeping Home
2005
2024
  // untouched lets us restore the exact originating card and scroll position.
2006
2025
  if (!(_d && _d.exitToBack)) { markHomeDirty(); }
@@ -2015,8 +2034,10 @@
2015
2034
  var titleId = _d && _d.id, series = _d && _d.type === 'series', vtype = _d && _d.type;
2016
2035
  var remembers = !!(_d && !_d.live);
2017
2036
  // (also for another source picked in the player: what's learned is that one's)
2018
- function learnFor(st) {
2019
- var info = Stremio.streamInfo(st), fkeys = Stremio.formatKeys(info);
2037
+ // how.converted: played converted by the streaming server, so its formats aren't learned
2038
+ // (they didn't play on the TV); Played before and the kept source still are.
2039
+ function learnFor(st, how) {
2040
+ var info = Stremio.streamInfo(st), fkeys = (how && how.converted) ? [] : Stremio.formatKeys(info);
2020
2041
  return {
2021
2042
  // (private viewing: nothing about this playback remembered or forgotten — its own
2022
2043
  // flag, set when it started, see player-store; what plays on this TV is still learned)
@@ -2199,13 +2220,20 @@
2199
2220
  }
2200
2221
 
2201
2222
  // Load a specific episode's sources and auto-play the first playable one.
2223
+ // The next / previous episode (from the player): its sources asked for, the best like the
2224
+ // last one played. An answer once you've left the page, or asked for another episode since
2225
+ // (_epGen), does nothing: no playback, no page drawn, no spinner change, no note.
2226
+ var _epGen = 0;
2202
2227
  function playEpisode(v) {
2228
+ var still = pageNow(), gen = ++_epGen;
2229
+ function current() { return still() && gen === _epGen; }
2203
2230
  _d.mode = 'streams';
2204
2231
  _d.streamId = v.id;
2205
2232
  _d.season = (v.season === undefined ? 1 : v.season);
2206
2233
  _d.streamLabel = epName(v);
2207
2234
  U.spinner(true);
2208
2235
  Stremio.getStreams(addonType(), v.id).then(function (list) {
2236
+ if (!current()) { return; }
2209
2237
  _d.streams = sortStreams(Stremio.dedupeStreams(list));
2210
2238
  U.spinner(false);
2211
2239
  var pick = pickLikeLast(_d.id, _d.streams);
@@ -2215,7 +2243,7 @@
2215
2243
  return;
2216
2244
  }
2217
2245
  startPlayback(pick, v.id, _d.streamLabel);
2218
- }, function () { U.spinner(false); U.toast('Failed to load that episode'); });
2246
+ }, function () { if (!current()) { return; } U.spinner(false); U.toast('Failed to load that episode'); });
2219
2247
  }
2220
2248
 
2221
2249
  // ----- binge: the next episode plays a source like the one you've been watching -----
@@ -334,7 +334,7 @@
334
334
  }
335
335
  function rowHeight() {
336
336
  var els = _dv.cardEls, c = cols();
337
- return (els.length > c) ? (els[c].getBoundingClientRect().top - els[0].getBoundingClientRect().top) : 0;
337
+ return (els.length > c) ? (els[c].getBoundingClientRect().top - els[0].getBoundingClientRect().top) / U.zoom() : 0; // (in the grid's own pixels)
338
338
  }
339
339
  function drawGrid() {
340
340
  var grid = gridEl();
package/js/view-home.js CHANGED
@@ -164,18 +164,32 @@
164
164
  // reordered): reload just its rows; every other row keeps what it has, with no request.
165
165
  // url: the addon ('local:iptv' for IPTV); none: nothing to fetch (only the set / order
166
166
  // of rows changed). Calls before the reload runs add up.
167
+ // An addon Home had no rows from (IPTV read after your addons, a first playlist) is one too:
168
+ // its rows asked for, its tabs added to the menu when they come.
167
169
  var _reloadOnly = null; // { addon url: 1 } for the next reload, else a full one
168
- var _homeAddons = {}; // { addon url: 1 } the addons Home's last load had catalogs from
169
170
  function refreshAddonRows(url) {
170
171
  if (_homeCatalogs === null) { clearCatalogCache(); return; } // nothing loaded yet: a full load anyway
171
- // an addon Home didn't load rows from (the first playlist added: IPTV): a full load, its tab with it
172
- if (url && !_homeAddons[url]) { clearCatalogCache(); return; }
173
172
  _reloadOnly = _reloadOnly || {};
174
173
  if (url) { _reloadOnly[url] = 1; }
175
174
  _homeDirty = true;
176
175
  if (_current() === scr('home') && !isModalOpen() && !Player.isActive()) { loadHomeCatalogs(); }
177
176
  }
178
177
 
178
+ // Auto-refresh (Settings › Content › IPTV): every minute, whether the playlists are due —
179
+ // their oldest download (a saved copy's own date) and the last try both that long ago — then
180
+ // all of them and their guides read again, as Reload does, and their rows with them. Not
181
+ // while something plays or StremioK is in the background: at the next minute after.
182
+ var _iptvTriedAt = 0, _iptvReading = false;
183
+ function iptvRefreshTick() {
184
+ var h = Stremio.iptvRefreshEvery ? Stremio.iptvRefreshEvery() : 0;
185
+ if (!h || _iptvReading || document.hidden || Player.isActive()) { return; }
186
+ var due = h * 3600000, now = Date.now(), at = Stremio.iptvReadAt();
187
+ if (at === Infinity || now - at < due || now - _iptvTriedAt < due) { return; }
188
+ _iptvTriedAt = now; _iptvReading = true;
189
+ Stremio.iptvReload().then(function () { _iptvReading = false; refreshAddonRows('local:iptv'); }, function () { _iptvReading = false; });
190
+ }
191
+ setInterval(iptvRefreshTick, 60000);
192
+
179
193
  // The Home rows are also saved on the TV, trimmed to what a card and the details strip
180
194
  // use, so a launch shows the last rows at once and refreshes them in the background
181
195
  // (as the 15-minute refresh does). Saved rows are only used with the same addons.
@@ -1179,7 +1193,10 @@
1179
1193
  }
1180
1194
 
1181
1195
  // Switch the active home tab (from the shared top menu) and show it.
1182
- function setHomeTab(key) { _homeTab = key || _homeTab; _focusFirstCard = 'new'; }
1196
+ function setHomeTab(key) { _homeTab = key || _homeTab; _focusFirstCard = 'new'; _wantTab = null; }
1197
+ // A tab asked for whose rows are still coming (a first playlist's IPTV tab): shown when they
1198
+ // come, if Home is still on the tab it showed instead. { key, instead }
1199
+ var _wantTab = null;
1183
1200
 
1184
1201
  // prevFocus: a Home card to put focus back on after the rebuild (the card a title
1185
1202
  // was opened from). Otherwise the card currently focused on Home is kept, so rows
@@ -1206,7 +1223,9 @@
1206
1223
  // Keep the active tab valid for what's actually available.
1207
1224
  var avail = availableTabs();
1208
1225
  if (!_homeTab || !avail.some(function (t) { return t.key === _homeTab; })) {
1226
+ var asked = _homeTab;
1209
1227
  _homeTab = avail.length ? avail[0].key : 'movie';
1228
+ if (asked && (_homeLoading || _reloadOnly)) { _wantTab = { key: asked, instead: _homeTab }; }
1210
1229
  }
1211
1230
 
1212
1231
  s.appendChild(buildTopMenu(_homeTab));
@@ -1416,12 +1435,12 @@
1416
1435
  _homeLoading = false;
1417
1436
  _homeFirstLoad = false;
1418
1437
  if (first) { U.spinner(false); }
1419
- if (_reloadOnly && onHome()) { setTimeout(loadHomeCatalogs, 0); } // (rows asked for meanwhile: the IPTV ones, read after the addons)
1438
+ // (rows asked for meanwhile: the IPTV ones, read after the addons; unless the redraw
1439
+ // that follows has asked for them already)
1440
+ if (_reloadOnly && onHome()) { setTimeout(function () { if (_reloadOnly) { loadHomeCatalogs(); } }, 0); }
1420
1441
  }
1421
1442
  function onHome() { return _current() === s && !isModalOpen() && !Player.isActive(); }
1422
1443
  Stremio.catalogsForHome().then(function (cats) {
1423
- _homeAddons = {};
1424
- cats.forEach(function (cat) { if (cat.addon && cat.addon.url) { _homeAddons[cat.addon.url] = 1; } });
1425
1444
  var acc = cats.map(function (cat) { return copyCat({ name: cat.name, type: cat.type, yt: isYouTubeCat(cat) }, []); });
1426
1445
  var arrived = cats.map(function () { return false; }), shown = 0, partialTimer = null;
1427
1446
  // First load: publish each row as soon as it arrives, in its own place (a slower one above
@@ -1458,11 +1477,16 @@
1458
1477
  U.settleAll(tasks, 3).then(function () {
1459
1478
  _homeCatalogs = splitOtherCatalogs(acc).filter(function (c) { return c.metas && c.metas.length; });
1460
1479
  done();
1480
+ var want = _wantTab;
1481
+ _wantTab = null;
1482
+ if (want && _homeTab === want.instead && onHome() && availableTabs().some(function (t) { return t.key === want.key; })) {
1483
+ setHomeTab(want.key);
1484
+ }
1461
1485
  saveHomeCache();
1462
1486
  // The tab on screen is redrawn only if what it shows changed: redrawing rebuilds
1463
1487
  // every card (and reloads its artwork), which shows as a flicker. Continue
1464
1488
  // Watching doesn't come from catalogs at all. Other tabs rebuild when opened.
1465
- var same = (before !== null && shownSig() === before) || ownTabStill();
1489
+ var same = (before !== null && shownSig() === before && menuSig() === _renderedMenu) || ownTabStill();
1466
1490
  var keep = {};
1467
1491
  if (same && _tabRows[_homeTab]) { keep[_homeTab] = _tabRows[_homeTab]; }
1468
1492
  _tabRows = keep;
@@ -1473,6 +1497,7 @@
1473
1497
  });
1474
1498
  }, function () {
1475
1499
  done();
1500
+ _wantTab = null;
1476
1501
  if (first) { U.toast('Failed to load addons. Check network / Settings.'); }
1477
1502
  });
1478
1503
  }
@@ -193,9 +193,13 @@
193
193
  var bgs = { dark: 1, black: 1, gray: 1, white: 1 };
194
194
  return { accent: ACCENT_NAMES[accent] ? accent : 'indigo', bg: bgs[bg] ? bg : 'dark' };
195
195
  }
196
+ // Zoom: everything bigger (see U.setZoom), in percent.
197
+ var ZOOMS = [['100', '100%'], ['110', '110%'], ['120', '120%'], ['130', '130%'], ['140', '140%']];
198
+ function zoomPct() { return Stremio.getPrefs().zoom; }
196
199
  function applyAppearance() {
197
200
  var a = appearance();
198
201
  U.applyTheme(a.accent, a.bg);
202
+ U.setZoom(zoomPct() / 100);
199
203
  }
200
204
  function settingsAppearance(wrap) {
201
205
  var store = { get: appearance, set: function (cfg) {
@@ -207,6 +211,10 @@
207
211
  wrap.appendChild(U.el('h2', null, 'Appearance'));
208
212
  wrap.appendChild(subPickRow('Background', BACKGROUNDS, 'bg', store));
209
213
  wrap.appendChild(subPickRow('Accent color', accents, 'accent', store));
214
+ wrap.appendChild(subPickRow('Zoom', ZOOMS, 'zoom', {
215
+ get: function () { return { zoom: '' + zoomPct() }; },
216
+ set: function (cfg) { Stremio.setPref('zoom', +cfg.zoom); applyAppearance(); }
217
+ }));
210
218
  wrap.appendChild(subPickRow('Poster orientation', POSTER_SHAPES, 'posterShape', {
211
219
  get: function () { return { posterShape: Stremio.getPrefs().posterShape }; },
212
220
  set: function (cfg) { Stremio.setPref('posterShape', cfg.posterShape); markHomeDirty(); } // Home redraws on return
@@ -338,6 +346,7 @@
338
346
  var SETTINGS_GROUPS = {
339
347
  content: { title: 'Content', rows: [
340
348
  ['Addons', 'Catalog, stream & subtitle addons', 'addons'],
349
+ ['Streaming server', 'Stremio’s streaming server or TorrServer on your network: torrents, and videos the TV can’t play', 'server'],
341
350
  ['Integrations', 'API keys (OMDb, TMDB, MDBList), where-to-watch country', 'integrations'],
342
351
  ['IPTV', 'Manage M3U playlist URLs', 'iptv'],
343
352
  ['YouTube', 'Your YouTube channels, playlists and searches; YouTube in Search', 'youtube'],
@@ -407,6 +416,7 @@
407
416
  else if (_settingsSection === 'appshortcuts') { settingsAppShortcuts(wrap); }
408
417
  else if (_settingsSection === 'import') { settingsImport(wrap); }
409
418
  else if (_settingsSection === 'aiomanager') { settingsAiom(wrap); }
419
+ else if (_settingsSection === 'server') { settingsServer(wrap); }
410
420
  else if (_settingsSection === 'appearance') { settingsAppearance(wrap); }
411
421
  else if (_settingsSection === 'formats') { settingsFormats(wrap); }
412
422
  else { settingsMenu(wrap); }
@@ -449,7 +459,7 @@
449
459
  wrap.appendChild(U.el('h2', null, 'Settings'));
450
460
  wrap.appendChild(sectionRow('Content', 'Addons, API keys, IPTV playlists, streaming apps, importing a configuration file', 'content'));
451
461
  wrap.appendChild(sectionRow('Playback', 'Audio and subtitles, source ranking, feed sources, formats', 'playback'));
452
- wrap.appendChild(sectionRow('Appearance', 'Background, accent color, poster orientation, Home feeds, Search results', 'appearance'));
462
+ wrap.appendChild(sectionRow('Appearance', 'Background, accent color, zoom, poster orientation, Home feeds, Search results', 'appearance'));
453
463
  wrap.appendChild(sectionRow('Diagnostics', 'Logs, storage, installed apps; how StremioK was installed', 'diagnostics'));
454
464
  if (typeof YTPlay !== 'undefined' && YTPlay.installMode && YTPlay.installMode() === 'tizenbrew') { wrap.appendChild(U.el('div', 'hint', TIZENBREW_NOTE)); }
455
465
  focusBack(wrap);
@@ -972,6 +982,60 @@
972
982
  }
973
983
  });
974
984
  }
985
+ // Streaming server: Stremio's or TorrServer, on your network (see Stremio.server). Picking one
986
+ // asks for its address; Test says whether it answers.
987
+ var SERVERS = [['', 'Off'], ['stremio', 'Stremio server'], ['torrserver', 'TorrServer']];
988
+ var SERVER_PORT = { stremio: 11470, torrserver: 8090 };
989
+ var TORRENT_WAITS = [['45', '45 seconds'], ['60', '1 minute'], ['120', '2 minutes'], ['180', '3 minutes'], ['300', '5 minutes']];
990
+ function askServerAddress(kind, url) {
991
+ var eg = 'http://192.168.0.10:' + SERVER_PORT[kind];
992
+ App.openEntry({
993
+ title: (kind === 'stremio' ? 'Stremio server' : 'TorrServer') + ': address',
994
+ hint: 'Its address on your network, like ' + eg + '. RETURN cancels.',
995
+ value: url || 'http://', placeholder: eg, phone: 'server',
996
+ validate: function (v) { return Stremio.serverAddressProblem(v); },
997
+ onConfirm: function (v) { Stremio.setServer({ kind: kind, url: v }); App.back(); renderSettings(); }
998
+ });
999
+ }
1000
+ function settingsServer(wrap) {
1001
+ wrap.appendChild(U.el('h2', null, 'Streaming server'));
1002
+ wrap.appendChild(U.el('div', 'hint', 'Torrent-only sources play through it; Stremio’s can also convert a video the TV can’t play.'));
1003
+ var sv = Stremio.server();
1004
+ wrap.appendChild(subPickRow('Server', SERVERS, 'kind', {
1005
+ get: function () { return { kind: Stremio.server().kind }; },
1006
+ set: function (cfg) {
1007
+ var cur = Stremio.server();
1008
+ if (!cfg.kind) { Stremio.setServer({ kind: '' }); renderSettings(); return; }
1009
+ askServerAddress(cfg.kind, cur.kind === cfg.kind ? cur.url : '');
1010
+ }
1011
+ }));
1012
+ if (!sv.kind) { return; }
1013
+ var addr = U.el('div', 'addon-item focusable cfg-row');
1014
+ addr.appendChild(U.el('div', 'a-name', 'Address: ' + sv.url));
1015
+ addr.appendChild(U.el('div', 'a-url', 'Press OK to change'));
1016
+ addr.__onselect = function () { askServerAddress(sv.kind, sv.url); };
1017
+ wrap.appendChild(addr);
1018
+ var test = U.el('div', 'addon-item focusable cfg-row'), said = U.el('div', 'a-url', 'Press OK to see whether it answers');
1019
+ test.appendChild(U.el('div', 'a-name', 'Test'));
1020
+ test.appendChild(said);
1021
+ test.__onselect = function () {
1022
+ said.textContent = 'Asking…';
1023
+ Stremio.testServer().then(function (r) { said.textContent = (r.ok ? '✓ ' : '✗ ') + r.text; });
1024
+ };
1025
+ wrap.appendChild(test);
1026
+ wrap.appendChild(subPickRow('Wait for a torrent to start', TORRENT_WAITS, 'wait', {
1027
+ get: function () { return { wait: '' + Stremio.server().wait }; },
1028
+ set: function (cfg) { Stremio.setServer({ kind: Stremio.server().kind, wait: +cfg.wait }); }
1029
+ }));
1030
+ if (sv.kind === 'stremio') {
1031
+ wrap.appendChild(subPickRow('Convert videos the TV can’t play', [['on', 'On'], ['off', 'Off']], 'convert', {
1032
+ get: function () { return { convert: Stremio.server().convert ? 'on' : 'off' }; },
1033
+ set: function (cfg) { Stremio.setServer({ kind: 'stremio', convert: cfg.convert === 'on' }); }
1034
+ }));
1035
+ }
1036
+ }
1037
+
1038
+ var IPTV_EVERY = [['0', 'Off'], ['1', 'Every hour'], ['3', 'Every 3 hours'], ['6', 'Every 6 hours'], ['12', 'Every 12 hours']];
975
1039
  function settingsIptv(wrap) {
976
1040
  wrap.appendChild(U.el('h2', null, 'IPTV'));
977
1041
  var playlists = Stremio.iptvGetPlaylists ? Stremio.iptvGetPlaylists() : [];
@@ -1058,6 +1122,10 @@
1058
1122
  xt.appendChild(U.el('div', 'a-url', 'Its server, username and password: its channels and TV guide added'));
1059
1123
  xt.__onselect = addXtream;
1060
1124
  wrap.appendChild(xt);
1125
+ if (playlists.length && Stremio.iptvRefreshEvery) { // (all of them, with their guides, as Reload does)
1126
+ wrap.appendChild(subPickRow('Auto-refresh', IPTV_EVERY, 'every',
1127
+ { get: function () { return { every: String(Stremio.iptvRefreshEvery()) }; }, set: function (c) { Stremio.iptvSetRefreshEvery(+c.every); } }));
1128
+ }
1061
1129
 
1062
1130
  wrap.appendChild(U.el('div', 'hint',
1063
1131
  'Channels are grouped into IPTV and Radio rows on Home. ▲ / ▼ set the playlists\' order: a channel in two playlists comes from the first.'));
package/js/views.js CHANGED
@@ -14,7 +14,7 @@ var Views = (function (V) {
14
14
  renderLive: V.renderLive,
15
15
  renderSearch: V.renderSearch, searchFor: function (q) { V.searchFor(q); }, renderDiscover: V.renderDiscover, renderEntry: V.renderEntry, backInDetail: V.backInDetail,
16
16
  homeDirty: V.homeDirty, refreshHomeIfStale: V.refreshHomeIfStale, alertNewEpisodes: V.alertNewEpisodes, previewTiles: V.previewTiles, withShowInfo: V.withShowInfo,
17
- isModalOpen: V.isModalOpen, closeModal: V.closeModal, moveKey: V.moveKey, availableTabs: V.availableTabs,
17
+ isModalOpen: V.isModalOpen, closeModal: V.closeModal, confirm: V.confirm, moveKey: V.moveKey, availableTabs: V.availableTabs,
18
18
  menuItems: V.menuItems, menuOrder: V.menuOrder, moveMenuItem: V.moveMenuItem
19
19
  };
20
20
  })(ViewParts);
package/js/ytplay.js CHANGED
@@ -65,14 +65,15 @@ var YTPlay = (function () {
65
65
  // { fix, sure, why, url, cc: [{ ch, lang }] (its closed captions), ccSure }. force: the relay
66
66
  // at once (the channel's already known to need it). h: the channel's own User-Agent /
67
67
  // Referer ({ ua, ref }). opts (with force): { fix: false (the sound as it is), cc: the caption
68
- // channels to add as subtitle tracks }.
69
- function liveOpen(url, h, force, opts) {
68
+ // channels to add as subtitle tracks }. cancel (a U.cancelToken): the look stopped, the
69
+ // service's downloads with it (another channel, the player closed).
70
+ function liveOpen(url, h, force, opts, cancel) {
70
71
  if (MODE === 'browser') { return Promise.reject(new Error('no service')); }
71
72
  var cc = opts && opts.cc ? opts.cc.map(function (c) { return c.ch + (c.lang ? '-' + c.lang : ''); }).join(',') : '';
72
73
  return ensureService().then(entryAuth).then(function (a) {
73
74
  var q = '/live/open?u=' + encodeURIComponent(url) + (h && h.ua ? '&ua=' + encodeURIComponent(h.ua) : '') + (h && h.ref ? '&ref=' + encodeURIComponent(h.ref) : '') + (force ? '&force=1' : '') +
74
75
  (force && opts && opts.fix === false ? '&fix=0' : '') + (force && cc ? '&cc=' + encodeURIComponent(cc) : '');
75
- return U.getJSON(PROXY + q + a.q, force ? 5000 : 30000, 65536, null, a.h);
76
+ return U.getJSON(PROXY + q + a.q, force ? 5000 : 30000, 65536, cancel || null, a.h);
76
77
  });
77
78
  }
78
79
  // An HLS stream's quality list, read by the service with the stream's own User-Agent /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stremiok",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "StremioK streaming client for Tizen 3.0+ Samsung TVs (TizenBrew app module).",
5
5
  "packageType": "app",
6
6
  "appName": "StremioK",
@@ -28,6 +28,7 @@
28
28
  "MediaTrackNext",
29
29
  "ChannelUp",
30
30
  "ChannelDown",
31
+ "Guide",
31
32
  "ColorF0Red",
32
33
  "ColorF1Green",
33
34
  "ColorF2Yellow",
package/service/index.js CHANGED
@@ -1589,6 +1589,29 @@ function openPart(fs, part, append, cb) {
1589
1589
  });
1590
1590
  });
1591
1591
  }
1592
+ // A partial download's version tag (ETag / Last-Modified), for resuming it after a restart: kept
1593
+ // in StremioK's own folder by the partial file's path (DL_VALIDATORS at most, the oldest dropped),
1594
+ // never on the drive, where a prepared file could be a link elsewhere (read: its contents sent
1595
+ // as If-Range; written: overwritten). None to keep it in (TizenBrew): downloads are closed there.
1596
+ var DL_VALIDATORS = 64;
1597
+ function dlValFile() { var d = tokenDir(); return d ? d + '/dl-validators.json' : null; }
1598
+ function dlValAll() {
1599
+ var f = dlValFile(), o = null;
1600
+ if (!f) { return {}; }
1601
+ try { o = JSON.parse(require('fs').readFileSync(f, 'utf8')); } catch (e) { o = null; }
1602
+ return o && typeof o === 'object' && !Array.isArray(o) ? o : {};
1603
+ }
1604
+ function dlValGet(part) { var v = dlValAll()[part]; return typeof v === 'string' ? v.slice(0, 300) : ''; }
1605
+ function dlValSet(part, v) {
1606
+ var f = dlValFile();
1607
+ if (!f) { return; }
1608
+ var all = dlValAll();
1609
+ delete all[part];
1610
+ if (v) { all[part] = String(v).slice(0, 300); }
1611
+ var keys = Object.keys(all);
1612
+ while (keys.length > DL_VALIDATORS) { delete all[keys.shift()]; }
1613
+ try { require('fs').writeFileSync(f, JSON.stringify(all), { mode: 384 }); } catch (e) { log('download version not kept: ' + (e && (e.code || e.message))); }
1614
+ }
1592
1615
  function dlRun(e) {
1593
1616
  var fs = require('fs'), part = e.dir + '/' + e.name + '.part';
1594
1617
  e.state = 'downloading'; e.err = '';
@@ -1601,17 +1624,17 @@ function dlRun(e) {
1601
1624
  fs.mkdir(e.dir, function () {
1602
1625
  fsRealOk(e.dir, function (bad) {
1603
1626
  if (bad) { dlEnd(e, 'failed', 'the folder: ' + bad.message); return; }
1604
- fs.stat(part, function (se, st) {
1627
+ fs.lstat(part, function (se, st) { // (not through a link: a partial file is a regular one, see openPart)
1628
+ if (!se && !st.isFile()) { dlEnd(e, 'failed', 'the partial file isn\'t a regular file'); return; }
1605
1629
  var have = se ? 0 : st.size;
1606
1630
  if (!have || e.answered) { get(e.url, 0, have); return; } // (this one's own, from this run: picked up as it was)
1607
- fs.readFile(part + '.v', 'utf8', function (ve, v) { // (a partial file from before a restart: resumed only with its validator)
1608
- e.validator = ve ? '' : String(v).trim().slice(0, 300);
1609
- if (e.validator) { get(e.url, 0, have); } else { dropPart(function () { get(e.url, 0, 0); }); }
1610
- });
1631
+ e.validator = dlValGet(part); // (a partial file from before a restart: resumed only with its version kept)
1632
+ if (e.validator) { get(e.url, 0, have); } else { dropPart(function () { get(e.url, 0, 0); }); }
1611
1633
  });
1612
1634
  });
1613
1635
  });
1614
- function dropPart(then) { fs.unlink(part + '.v', function () { fs.unlink(part, function () { then(); }); }); }
1636
+ // (….part.v: where 2.1.17–2.2.1 kept the version, removed; a link removed, not what it points to)
1637
+ function dropPart(then) { dlValSet(part, ''); fs.unlink(part + '.v', function () { fs.unlink(part, function () { then(); }); }); }
1615
1638
  function get(u, hops, have) {
1616
1639
  if (e.state !== 'downloading') { return; }
1617
1640
  var pu = urllib.parse(u), mod = pu.protocol === 'https:' ? https : http;
@@ -1647,7 +1670,7 @@ function dlRun(e) {
1647
1670
  if (!resumed) { // (asked again on a resume: If-Range; kept beside the partial file, for one after a restart)
1648
1671
  var et = resp.headers.etag;
1649
1672
  e.validator = et && !/^W\//.test(et) ? et : (resp.headers['last-modified'] || '');
1650
- fs.writeFile(part + '.v', e.validator, { mode: 384 }, function () {});
1673
+ dlValSet(part, e.validator);
1651
1674
  }
1652
1675
  if (isPlaylistAnswer(resp.headers['content-type'], '')) { resp.resume(); closed = true; dlEnd(e, 'failed', 'a streamed playlist (HLS or DASH), not a file: it can\'t be downloaded'); return; }
1653
1676
  var total = 0, cr = /\/(\d+)\s*$/.exec(resp.headers['content-range'] || '');
@@ -1715,7 +1738,7 @@ function dlRun(e) {
1715
1738
  fs.rename(part, final, function (re) {
1716
1739
  if (re) { dlEnd(e, 'failed', 'couldn\'t finish the file: ' + (re.code || re.message)); return; }
1717
1740
  e.name = final.split('/').pop(); e.tries = 0;
1718
- fs.unlink(part + '.v', function () {});
1741
+ dlValSet(part, ''); fs.unlink(part + '.v', function () {});
1719
1742
  dlEnd(e, 'done');
1720
1743
  });
1721
1744
  });
@@ -3057,7 +3080,8 @@ function epgControl(req, res, path, query) {
3057
3080
  var ENTRY_PORT = PORT + 10, ENTRY_MS = 5 * 60 * 1000, ENTRY_IDLE_MS = 30000, ENTRY_MAX = 8192;
3058
3081
  var ENTRY_OPEN_MAX = 2, ENTRY_OPENS = 12, ENTRY_CONNECTIONS = 8, ENTRY_VISIT_MS = 20000, ENTRY_QUIET_MS = 10000;
3059
3082
  var ENTRY_TITLES = { link: 'Play a link on StremioK', addon: 'Add an addon to StremioK', playlist: 'Add an IPTV playlist to StremioK', config: 'Import a configuration file into StremioK',
3060
- search: 'Search on StremioK', youtube: 'Add a YouTube catalog to StremioK', aiom: 'Your AIOManager address for StremioK', aiomkey: 'Your AIOManager API key for StremioK' };
3083
+ search: 'Search on StremioK', youtube: 'Add a YouTube catalog to StremioK', aiom: 'Your AIOManager address for StremioK', aiomkey: 'Your AIOManager API key for StremioK',
3084
+ server: 'Your streaming server’s address for StremioK' };
3061
3085
  var ENTRY_TEXT = { search: 'Words to search for', youtube: 'A channel, a playlist link, or words to search YouTube for', aiomkey: 'Your account API key' }; // (plain text, not a link)
3062
3086
  var _entries = Object.create(null), _entryServer = null, _entrySockets = [], _entryOpened = [];
3063
3087
  function lanAddress() {