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/player-subs.js CHANGED
@@ -48,7 +48,7 @@
48
48
 
49
49
  // A new playback: no subtitles yet.
50
50
  function resetSubs() {
51
- _subGen++;
51
+ _subGen++; stopSubLoad();
52
52
  _subTracks = []; _cues = null; _subOn = false; _subLabel = ''; _subTrackUrl = '';
53
53
  _subDelay = 0; _subTrackId = null; _embSub = null; _embLang = ''; _embWant = null;
54
54
  _embDelay = 0; _embQueue = []; _embNudge = null;
@@ -285,12 +285,52 @@
285
285
  // Each choice (a track, Off, a built-in one) is a new one: a download still on its way
286
286
  // for an earlier choice is dropped when it comes, without a word (_subGen).
287
287
  var _subGen = 0;
288
+ // A subtitle file's time to arrive: some servers build it when asked (12 s, seen from an
289
+ // OpenSubtitles proxy); the video plays on meanwhile.
290
+ var SUB_FILE_MS = 20000;
291
+ // The download under way (_subLoad: { url, ctx, cancel, timer, done }): the same track picked
292
+ // again while it comes waits for it (it used to be asked for again, what was on its way
293
+ // dropped: slow servers took several picks); another choice stops it, its connection freed.
294
+ // The files downloaded (the last SUB_FILES_MAX, while StremioK runs) come back at once.
295
+ var _subLoad = null, SUB_FILES_MAX = 8, _subFiles = []; // ([{ url, text }], newest last)
296
+ function subFile(url) { for (var i = 0; i < _subFiles.length; i++) { if (_subFiles[i].url === url) { return _subFiles[i].text; } } return null; }
297
+ function keepSubFile(url, text) {
298
+ _subFiles = _subFiles.filter(function (f) { return f.url !== url; });
299
+ _subFiles.push({ url: url, text: text });
300
+ if (_subFiles.length > SUB_FILES_MAX) { _subFiles.shift(); }
301
+ }
302
+ function stopSubLoad() {
303
+ var l = _subLoad;
304
+ _subLoad = null;
305
+ if (l) { clearInterval(l.timer); l.cancel.cancel(); }
306
+ }
307
+ // A subtitle file: from those kept, else downloaded (the same one shared by whoever asks).
308
+ function subText(track, cancel) {
309
+ var kept = subFile(track.url);
310
+ if (kept !== null) { return Promise.resolve(kept); }
311
+ return U.getText(track.url, SUB_FILE_MS, undefined, cancel || null, false, track.headers || null).then(function (text) { keepSubFile(track.url, text); return text; }); // (a USB file's: the service's header where there's no token)
312
+ }
288
313
  function loadTrack(track, onDone) {
289
314
  if (!U.parseUrl(track.url)) { U.toast('That subtitle track has no usable link'); return; } // (http(s) only)
290
- U.toast('Loading subtitles…', 1500);
315
+ var l = _subLoad;
316
+ if (l && l.url === track.url && l.ctx === S.ctx) { // (still coming: waited for, not asked for again)
317
+ if (onDone) { l.done.push(onDone); }
318
+ U.toast('Still loading subtitles… ' + l.secs + ' s', 1500);
319
+ return;
320
+ }
321
+ stopSubLoad();
291
322
  var forCtx = S.ctx, gen = ++_subGen;
292
323
  function stale() { return !S.active || S.ctx !== forCtx || gen !== _subGen; } // (another playback, or another choice, meanwhile)
293
- U.getText(track.url, 15000, undefined, null, false, track.headers || null).then(function (text) { // (a USB file's: the service's header where there's no token)
324
+ var cancel = U.cancelToken(), mine = { url: track.url, ctx: forCtx, cancel: cancel, secs: 0, done: onDone ? [onDone] : [], timer: null };
325
+ if (subFile(track.url) === null) { // (a download: how long it's been, every second, till it's in)
326
+ _subLoad = mine;
327
+ U.toast('Loading subtitles…', 1500);
328
+ mine.timer = setInterval(function () { mine.secs++; if (_subLoad === mine) { U.toast('Loading subtitles… ' + mine.secs + ' s', 1500); } }, 1000);
329
+ }
330
+ function finished() { clearInterval(mine.timer); if (_subLoad === mine) { _subLoad = null; } }
331
+ onDone = function () { mine.done.forEach(function (f) { try { f(); } catch (e) {} }); };
332
+ subText(track, cancel).then(function (text) {
333
+ finished();
294
334
  if (stale()) { return; }
295
335
  var cues = parseCues(text);
296
336
  if (!cues.length) { U.toast('Could not parse subtitles'); return; }
@@ -299,8 +339,8 @@
299
339
  _subTrackId = (track.id != null) ? track.id : null; _subDelay = 0;
300
340
  renderSubs();
301
341
  U.toast('Subtitles: ' + (track.lang || 'on'));
302
- if (onDone) { onDone(); }
303
- }, function () { if (!stale()) { U.toast('Failed to download subtitles'); } });
342
+ onDone();
343
+ }, function () { finished(); if (!stale()) { U.toast('Failed to download subtitles'); } });
304
344
  }
305
345
 
306
346
  // ---------- the second subtitle ----------
@@ -323,7 +363,7 @@
323
363
  var track = _subTracks.filter(function (t) { return t.url && langMatches(want, t.lang); })[0];
324
364
  if (!track || (_cues2 && _sub2Label === track.url)) { if (!track) { secondOff(); } return; }
325
365
  var forCtx = S.ctx, gen = ++_sub2Gen;
326
- U.getText(track.url, 15000, undefined, null, false, track.headers || null).then(function (text) {
366
+ subText(track).then(function (text) {
327
367
  if (!S.active || S.ctx !== forCtx || gen !== _sub2Gen) { return; }
328
368
  var cues = parseCues(text);
329
369
  if (!cues.length) { return; }
@@ -374,7 +414,7 @@
374
414
  }
375
415
 
376
416
  function subsOff() {
377
- _subGen++;
417
+ _subGen++; stopSubLoad(); // (a download under way for a track: stopped)
378
418
  _subOn = false; _cues = null; _subLabel = ''; _subTrackUrl = ''; _subTrackId = null; _subDelay = 0;
379
419
  embOff();
380
420
  S.subsEl.className = 'hidden'; U.clear(S.subsEl);
package/js/stremio.js CHANGED
@@ -162,6 +162,7 @@ var Stremio = (function () {
162
162
  function getPrefs() {
163
163
  var p = U.store.get(PREFS_KEY, {}) || {};
164
164
  return { posterShape: p.posterShape || 'portrait', accent: p.accent || 'indigo', bg: p.bg || 'dark',
165
+ zoom: [110, 120, 130, 140].indexOf(p.zoom) !== -1 ? p.zoom : 100, // Settings > Appearance: everything bigger (percent)
165
166
  formatsAuto: p.formatsAuto !== false, // Settings > Formats: mark "Not working" on its own
166
167
  allowLocal: p.allowLocal === true, // Settings > Addons: home-network links from internet addons
167
168
  addonWait: ADDON_WAITS.indexOf(p.addonWait) !== -1 ? p.addonWait : 0, // Settings > Addons: seconds to wait for an addon (0: Normal)
@@ -289,7 +290,7 @@ var Stremio = (function () {
289
290
  var APP_FIELDS = {};
290
291
  ['_addon', '_addonPos', '_addonRank', '_also', '_cardImage', '_posterAlt', '_from', '_headers', '_keepStream', '_playlistId', '_reported', '_yt', '_file',
291
292
  '_group', '_local', '_localSubs', '_tmdbWhere', '_trakt', '_dupes', '_restoreKey', '_current', '_autoFocus', '_epFocus', '_focusNp', '_focusPill',
292
- '_focusSeason', '_focusTrailer', '_earlier', '_localFile', 'stale', 'ytPage'].forEach(function (k) { APP_FIELDS[k] = 1; });
293
+ '_focusSeason', '_focusTrailer', '_earlier', '_localFile', '_torrent', '_pickFile', 'stale', 'ytPage'].forEach(function (k) { APP_FIELDS[k] = 1; });
293
294
  function fieldSet(list) { var o = {}; list.forEach(function (k) { o[k] = 1; }); return o; }
294
295
  var STR_FIELDS = fieldSet(['id', 'type', 'name', 'title', 'description', 'poster', 'background', 'logo', 'thumbnail', 'banner', 'url', 'externalUrl', 'ytId',
295
296
  'infoHash', 'releaseInfo', 'released', 'imdb_id', 'lang', 'filename', 'bingeGroup', 'posterShape', 'runtime']);
@@ -498,6 +499,9 @@ var Stremio = (function () {
498
499
  function iptvMovePlaylist(id, dir) { var ok = _hasIptv ? IPTV.movePlaylist(id, dir) : false; configChanged(); return ok; }
499
500
  function iptvLabel(id) { return _hasIptv ? IPTV.label(id) : ''; }
500
501
  function iptvLoad() { return _hasIptv ? IPTV.load() : Promise.resolve(null); }
502
+ function iptvRefreshEvery() { return _hasIptv && IPTV.refreshEvery ? IPTV.refreshEvery() : 0; }
503
+ function iptvSetRefreshEvery(h) { if (_hasIptv && IPTV.setRefreshEvery) { IPTV.setRefreshEvery(h); } }
504
+ function iptvReadAt() { return _hasIptv && IPTV.readAt ? IPTV.readAt() : Infinity; }
501
505
  function iptvStatus(id) { return _hasIptv ? IPTV.status(id) : { error: 'IPTV unavailable' }; }
502
506
  // Force a re-fetch of one playlist (Settings → Reload) and rebuild the addon list.
503
507
  function iptvReload(id) {
@@ -777,17 +781,24 @@ var Stremio = (function () {
777
781
  ['uncached', 'Not yet cached last', 'sources an addon marks as not cached on your debrid service yet (they can’t start right away)'],
778
782
  ['kind', 'Release type', 'remux > Blu-ray > web > unknown > TV rip, CAM rips far below'],
779
783
  ['hdr', 'HDR before SDR', 'HDR10 / HDR10+ / Dolby Vision first'],
784
+ ['seeds', 'Seeders (high to low)', 'torrent sources with more people sharing them first, by the count they state (Torrentio’s 👤 123); the others keep their order'],
780
785
  ['addon', 'Feed sources order', 'your order of the addons (at the bottom of this page)'],
781
786
  ['within', 'Each addon\'s own order', 'the order an addon lists its sources in (its first, then its second…); right after Feed sources order: each addon\'s list as it gives it'],
782
787
  ['cached', 'Cached before unknown', 'sources an addon marks as cached ahead of ones with no mark']
783
788
  ];
784
789
  var RANK_OFF_DEFAULT = ['cached'];
790
+ var RANK_AFTER = { seeds: 'hdr' }; // (added in 2.2.1.13)
785
791
  function getRanking() {
786
792
  var r = U.store.get(RANKING_KEY, null) || {}, known = {}, order = [];
787
793
  RANK_CRITERIA.forEach(function (c) { known[c[0]] = c; });
788
794
  (r.order || []).forEach(function (k) { if (known[k] && order.indexOf(k) === -1) { order.push(k); } });
789
- // one missing from a saved order goes where it is by default (Resolution: first)
790
- RANK_CRITERIA.forEach(function (c, i) { if (order.indexOf(c[0]) === -1) { order.splice(Math.min(i, order.length), 0, c[0]); } });
795
+ // one missing from a saved order goes where it is by default (Resolution: first), or right
796
+ // after the one it follows (RANK_AFTER: Seeders after HDR, wherever you moved that)
797
+ RANK_CRITERIA.forEach(function (c, i) {
798
+ if (order.indexOf(c[0]) !== -1) { return; }
799
+ var after = RANK_AFTER[c[0]] ? order.indexOf(RANK_AFTER[c[0]]) : -1;
800
+ order.splice(after !== -1 ? after + 1 : Math.min(i, order.length), 0, c[0]);
801
+ });
791
802
  var off = r.off || RANK_OFF_DEFAULT;
792
803
  return { played: r.played !== false, criteria: order.map(function (k) {
793
804
  return { key: k, name: known[k][1], sub: known[k][2], on: off.indexOf(k) === -1 };
@@ -962,7 +973,7 @@ var Stremio = (function () {
962
973
  return function () {
963
974
  var url = a.base + '/stream/' + encodeURIComponent(type) + '/' + encodeURIComponent(id) + '.json';
964
975
  return U.getJSON(url, addonMs(15000)).then(function (res) {
965
- var arr = U.listOf(fromAddon(a, res, 'sources').streams, MAX_STREAMS).map(youTubeSource), said = notes.length;
976
+ var arr = U.listOf(fromAddon(a, res, 'sources').streams, MAX_STREAMS).map(youTubeSource).map(torrentSource), said = notes.length;
966
977
  arr = arr.filter(function (s) { // (an addon's notices: what it removed and why, its errors; not sources)
967
978
  if (!isNotice(s)) { return true; }
968
979
  notes.push({ addon: a.manifest.name || 'Addon', title: ('' + (s.name || '')).replace(/\s+/g, ' ').trim().slice(0, 120),
@@ -1019,14 +1030,14 @@ var Stremio = (function () {
1019
1030
  return e.promise;
1020
1031
  }
1021
1032
 
1022
- function isTimeout(err) { return /^Timeout/.test((err && err.message) || ''); }
1033
+ function isTimeout(err) { return /^(Timeout|Timed out)/.test((err && err.message) || ''); }
1023
1034
  // Why an addon's sources didn't come, in a few words (its link, and any token in it, left out):
1024
1035
  // "refused (HTTP 429 · rate_limited)", "didn't answer in time", "couldn't be reached"…
1025
1036
  function whyNone(err) {
1026
1037
  var m = String((err && err.message) || ''), h = /^HTTP (\d{3}) for \S+(?::\s+(.+))?$/.exec(m); // (its message after ": ", not the link's "://")
1027
1038
  if (h) { return 'refused (HTTP ' + h[1] + (h[2] ? ' · ' + h[2].replace(/https?:\/\/\S+/g, '…').slice(0, 80) : '') + ')'; }
1028
1039
  if (isTimeout(err)) { return 'didn’t answer in time'; }
1029
- if (/^Network error/.test(m)) { return 'couldn’t be reached'; }
1040
+ if (/^(No answer|Network error)/.test(m)) { return 'couldn’t be reached'; }
1030
1041
  if (/^Too large/.test(m)) { return 'sent too much (' + (/\(([^)]+)\)/.exec(m) || ['', 'too large'])[1] + ')'; }
1031
1042
  return 'failed (' + m.replace(/https?:\/\/\S+/g, '…').slice(0, 80) + ')';
1032
1043
  }
@@ -1066,6 +1077,7 @@ var Stremio = (function () {
1066
1077
  if (!st || typeof st.url !== 'string' || !/^https?:\/\//i.test(st.url) || st._localFile || !st._addon) { return; }
1067
1078
  var copy = {};
1068
1079
  Object.keys(st).forEach(function (k) { if (KEPT_SKIP.indexOf(k) === -1) { copy[k] = st[k]; } });
1080
+ if (st._torrent) { delete copy.url; delete copy._pickFile; } // (its link: made again from the streaming server set then)
1069
1081
  var all = keptAll(), key = type + ':' + id, sid = sourceId(st);
1070
1082
  var list = (Array.isArray(all[key]) ? all[key] : []).filter(function (x) { return x && x.st && sourceId(x.st) !== sid; });
1071
1083
  list.unshift({ at: Date.now(), st: copy });
@@ -1091,6 +1103,7 @@ var Stremio = (function () {
1091
1103
  var s = {};
1092
1104
  Object.keys(x.st).forEach(function (k) { s[k] = x.st[k]; });
1093
1105
  s._earlier = x.at || 1; s._addonRank = rank; s._addonPos = n;
1106
+ if (s._torrent) { torrentSource(s); } // (none with no server set: not playable)
1094
1107
  return s;
1095
1108
  });
1096
1109
  }
@@ -1144,7 +1157,7 @@ var Stremio = (function () {
1144
1157
  var tasks = providers.map(function (a) {
1145
1158
  return function () {
1146
1159
  var url = a.base + '/subtitles/' + encodeURIComponent(type) + '/' + encodeURIComponent(id) + (args.length ? '/' + args.join('&') : '') + '.json';
1147
- return U.getJSON(url, addonMs(12000)).then(function (res) {
1160
+ return U.getJSON(url, addonMs(15000)).then(function (res) { // (15 s: a subtitle addon may fetch them from elsewhere first)
1148
1161
  var arr = U.listOf(fromAddon(a, res, 'subtitles').subtitles, MAX_SUBTITLES);
1149
1162
  arr.forEach(function (s) { s._addon = a.manifest.name || 'Addon'; });
1150
1163
  return arr;
@@ -1618,14 +1631,23 @@ var Stremio = (function () {
1618
1631
  // GET https://api.theintrodb.org/v4/media?imdb_id=tt..[&season=&episode=][&duration_ms=]
1619
1632
  // (v4: answers keyed by the show's TheTVDB id, episodes in TheTVDB's default order; the
1620
1633
  // segments as v3 gave them. v2 and v3 stop on 18 Jan 2027.)
1634
+ // A skip-times lookup that gave nothing: why, for its line in Diagnostics (the lookups aren't
1635
+ // noted on their own). A 404 is the source having nothing for it (TheIntroDB: "no
1636
+ // submissions for this media"); anything else, why it didn't answer.
1637
+ function segMiss(err) {
1638
+ if (/^HTTP 404\b/.test((err && err.message) || '')) { return { _why: 'nothing' }; }
1639
+ var p = U.problem(err);
1640
+ return { _why: /^no answer/.test(p) ? p : 'no answer (' + p + ')' }; // ("no answer (connection refused…)" once, not twice)
1641
+ }
1642
+ function segWhy(r, has) { return r && r._why ? r._why : !r ? 'no answer' : has.length ? has.join(', ') : 'nothing'; }
1621
1643
  function theIntroDb(q, durationMs) {
1622
1644
  var url = 'https://api.theintrodb.org/v4/media?imdb_id=' + encodeURIComponent(q.imdb);
1623
1645
  if (q.ep) { url += '&season=' + encodeURIComponent(q.ep.season) + '&episode=' + encodeURIComponent(q.ep.episode); }
1624
1646
  if (durationMs && durationMs > 0) { url += '&duration_ms=' + Math.floor(durationMs); }
1625
- return U.getJSON(url, 10000).then(function (r) {
1647
+ return U.getJSON(url, 10000, 0, null, null, null, true).then(function (r) {
1626
1648
  if (!r) { return null; }
1627
1649
  return { intro: segsOf(r.intro), recap: segsOf(r.recap), credits: segsOf(r.credits), preview: segsOf(r.preview) };
1628
- }, function () { return null; });
1650
+ }, segMiss);
1629
1651
  }
1630
1652
  // GET https://api.skipdb.tv/api/segments?imdb_id=tt..[&season=&episode=][&duration=<sec>]
1631
1653
  // -> { segments: { intro, recap, outro, preview } }, each the best match or null.
@@ -1633,12 +1655,12 @@ var Stremio = (function () {
1633
1655
  var url = 'https://api.skipdb.tv/api/segments?imdb_id=' + encodeURIComponent(q.imdb);
1634
1656
  if (q.ep) { url += '&season=' + encodeURIComponent(q.ep.season) + '&episode=' + encodeURIComponent(q.ep.episode); }
1635
1657
  if (durationMs && durationMs > 0) { url += '&duration=' + Math.round(durationMs / 1000); }
1636
- return U.getJSON(url, 10000).then(function (r) {
1658
+ return U.getJSON(url, 10000, 0, null, null, null, true).then(function (r) {
1637
1659
  var sg = r && r.segments;
1638
1660
  if (!sg || typeof sg !== 'object') { return null; }
1639
1661
  function one(x) { return segsOf(x ? [x] : []); }
1640
1662
  return { intro: one(sg.intro), recap: one(sg.recap), credits: one(sg.outro), preview: one(sg.preview) };
1641
- }, function () { return null; });
1663
+ }, segMiss);
1642
1664
  }
1643
1665
  // GET https://api.introdb.app/segments?imdb_id=tt..(&season=&episode= | &is_movie=true)
1644
1666
  // -> { intro, recap, outro, post_credits }, each one segment or null (a scene after the
@@ -1646,11 +1668,11 @@ var Stremio = (function () {
1646
1668
  function introDb(q) {
1647
1669
  var url = 'https://api.introdb.app/segments?imdb_id=' + encodeURIComponent(q.imdb) +
1648
1670
  (q.ep ? '&season=' + encodeURIComponent(q.ep.season) + '&episode=' + encodeURIComponent(q.ep.episode) : '&is_movie=true');
1649
- return U.getJSON(url, 10000).then(function (r) {
1671
+ return U.getJSON(url, 10000, 0, null, null, null, true).then(function (r) {
1650
1672
  if (!r || typeof r !== 'object') { return null; }
1651
1673
  function one(x) { return segsOf(x ? [x] : []); }
1652
1674
  return { intro: one(r.intro), recap: one(r.recap), credits: one(r.outro), preview: [] };
1653
- }, function () { return null; });
1675
+ }, segMiss);
1654
1676
  }
1655
1677
  // GET https://api.aniskip.com/v2/skip-times/<MyAnimeList id>/<episode>?types[]=…&episodeLength=<s>
1656
1678
  // -> { found, results: [{ interval: { startTime, endTime }, skipType, episodeLength }] }: the
@@ -1684,7 +1706,7 @@ var Stremio = (function () {
1684
1706
  if (!mal) { return null; }
1685
1707
  var len = durationMs > 0 ? Math.round(durationMs / 1000) : 0;
1686
1708
  var url = 'https://api.aniskip.com/v2/skip-times/' + mal + '/' + ep + '?types[]=op&types[]=ed&types[]=recap&types[]=mixed-op&types[]=mixed-ed&episodeLength=' + len;
1687
- return U.getJSON(url, 10000).then(function (r) {
1709
+ return U.getJSON(url, 10000, 0, null, null, null, true).then(function (r) {
1688
1710
  if (!r || typeof r !== 'object') { return null; }
1689
1711
  var list = U.listOf(r.results, 50, function (v) { return v.interval && typeof v.interval.startTime === 'number' && typeof v.interval.endTime === 'number' && v.interval.endTime > v.interval.startTime; });
1690
1712
  function best(types) {
@@ -1694,7 +1716,7 @@ var Stremio = (function () {
1694
1716
  return [{ start: Math.max(0, c[0].interval.startTime), end: c[0].interval.endTime }];
1695
1717
  }
1696
1718
  return { intro: best(['op', 'mixed-op']), recap: best(['recap']), credits: best(['ed', 'mixed-ed']), preview: [], from: { intro: 'AniSkip', recap: 'AniSkip', credits: 'AniSkip' } };
1697
- }, function () { return null; });
1719
+ }, segMiss);
1698
1720
  });
1699
1721
  }
1700
1722
  var SEG_SOURCES = [['TheIntroDB', theIntroDb], ['SkipDB', skipDb], ['IntroDB', introDb]];
@@ -1711,9 +1733,10 @@ var Stremio = (function () {
1711
1733
  // reached), from the IMDb-based sources by the show's IMDb id, season and episode.
1712
1734
  function animeSegments(type, a, durationMs, asked, hints) {
1713
1735
  return animeIds(a, hints).then(function (ids) {
1714
- return aniSkip(ids.mal, a.ep, durationMs).then(function (r) {
1736
+ return aniSkip(ids.mal, a.ep, durationMs).then(function (got) {
1737
+ var r = got && got._why ? null : got; // (it gave nothing: why, in its line)
1715
1738
  var has = r ? SEG_KINDS.filter(function (k) { return r[k].length; }) : [];
1716
- if (asked) { asked.push('AniSkip: ' + (!r ? 'no answer' : has.length ? has.join(', ') : 'nothing')); }
1739
+ if (asked) { asked.push('AniSkip: ' + segWhy(got, has)); }
1717
1740
  var out = has.length ? r : null;
1718
1741
  if (!ids.imdb || has.length === SEG_KINDS.length - 1) { return out; } // (all AniSkip has: intro, recap, credits)
1719
1742
  var q = { imdb: ids.imdb, ep: (type === 'movie' || ids.movie) ? null : { season: ids.season, episode: a.ep } };
@@ -1729,9 +1752,10 @@ var Stremio = (function () {
1729
1752
  var missing = SEG_KINDS.filter(function (k) { return !(out && out[k] && out[k].length); });
1730
1753
  if (!missing.length || i >= SEG_SOURCES.length) { return Promise.resolve(out); }
1731
1754
  var name = SEG_SOURCES[i][0];
1732
- return SEG_SOURCES[i][1](q, durationMs).then(function (more) {
1755
+ return SEG_SOURCES[i][1](q, durationMs).then(function (got) {
1756
+ var more = got && got._why ? null : got; // (it gave nothing: why, in its line)
1733
1757
  var has = more ? SEG_KINDS.filter(function (k) { return more[k].length; }) : [];
1734
- if (asked) { asked.push((i === 0 && note ? note : '') + name + ': ' + (!more ? 'no answer' : has.length ? has.join(', ') : 'nothing')); }
1758
+ if (asked) { asked.push((i === 0 && note ? note : '') + name + ': ' + segWhy(got, has)); }
1735
1759
  // (TheIntroDB knowing the title, even with nothing for it: an answer; from the
1736
1760
  // others, only the segments they have)
1737
1761
  if (more && (i === 0 || has.length)) {
@@ -1836,8 +1860,105 @@ var Stremio = (function () {
1836
1860
  return s;
1837
1861
  }
1838
1862
 
1863
+ // ---- Streaming server (Settings › Content › Streaming server) ----
1864
+ // Stremio's streaming server or a TorrServer on your network. A torrent-only source (infoHash,
1865
+ // fileIdx) becomes a link on it (torrentSource), as Stremio's own apps make it; with Stremio's,
1866
+ // a source the TV refused can be converted by it (convertUrl: the player asks once).
1867
+ // { kind: '' (off) | 'stremio' | 'torrserver', url, convert }
1868
+ var SERVER_KEY = 'stremiok.server', SERVER_KINDS = { stremio: 1, torrserver: 1 }, TORRENT_WAITS = [45, 60, 120, 180, 300];
1869
+ function server() {
1870
+ var x = U.store.get(SERVER_KEY, {}) || {};
1871
+ return { kind: SERVER_KINDS[x.kind] ? x.kind : '', url: typeof x.url === 'string' ? x.url : '', convert: x.convert === true, // (convert: off unless chosen, see convertUrl)
1872
+ wait: TORRENT_WAITS.indexOf(x.wait) !== -1 ? x.wait : 120 }; // (seconds a torrent has to start, see the player's startCheck)
1873
+ }
1874
+ // An address as typed ("192.168.0.10:11470/"): http:// unless given, no trailing slash.
1875
+ function serverAddress(v) {
1876
+ v = ('' + (v || '')).trim().replace(/\/+$/, '');
1877
+ if (v && !/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) { v = 'http://' + v; }
1878
+ return v;
1879
+ }
1880
+ function serverAddressProblem(v) { return U.urlProblem(serverAddress(v)) || null; }
1881
+ // Returns null, or what's wrong with the address (nothing changed then). The sources found
1882
+ // so far are asked for again (their links change).
1883
+ function setServer(cfg) {
1884
+ var cur = server(), kind = SERVER_KINDS[cfg.kind] ? cfg.kind : '';
1885
+ var url = cfg.url !== undefined ? serverAddress(cfg.url) : cur.url;
1886
+ if (kind && U.urlProblem(url)) { return U.urlProblem(url); }
1887
+ U.store.set(SERVER_KEY, { kind: kind, url: url, convert: cfg.convert !== undefined ? cfg.convert === true : cur.convert,
1888
+ wait: TORRENT_WAITS.indexOf(cfg.wait) !== -1 ? cfg.wait : cur.wait });
1889
+ var kept = _loaded; configChanged(); _loaded = kept;
1890
+ return null;
1891
+ }
1892
+ // Does it answer? { ok, text } — Stremio's /settings (its version in it), TorrServer's /echo.
1893
+ function testServer() {
1894
+ var sv = server();
1895
+ if (!sv.kind || !sv.url) { return Promise.resolve({ ok: false, text: 'No server set' }); }
1896
+ var p = sv.kind === 'stremio'
1897
+ ? U.getJSON(sv.url + '/settings', 8000).then(function (r) {
1898
+ if (!r || typeof r !== 'object') { throw new Error('Not a Stremio streaming server'); }
1899
+ var v = r.values && r.values.serverVersion;
1900
+ return 'Stremio server answers' + (v ? ' (' + ('' + v).slice(0, 20) + ')' : '');
1901
+ })
1902
+ : U.getText(sv.url + '/echo', 8000).then(function (t) { return 'TorrServer answers (' + ('' + t).trim().slice(0, 30) + ')'; });
1903
+ return p.then(function (text) { return { ok: true, text: text }; },
1904
+ function (e) { return { ok: false, text: 'The server didn’t answer: ' + U.problem(e) }; });
1905
+ }
1906
+ // Made again each time it's asked (a page kept from before, just before playing): from the
1907
+ // server set now, or no link when there's none.
1908
+ var HASH = /^[0-9a-f]{40}$/i;
1909
+ function torrentSource(s) {
1910
+ var sv = server();
1911
+ if (s && s._torrent) { delete s.url; delete s._torrent; delete s._pickFile; }
1912
+ if (!s || s.url || !sv.kind || !sv.url || typeof s.infoHash !== 'string' || !HASH.test(s.infoHash)) { return s; }
1913
+ var h = s.infoHash.toLowerCase(), idx = /^\d{1,6}$/.test('' + s.fileIdx) ? +s.fileIdx : -1;
1914
+ var src = (Array.isArray(s.sources) ? s.sources : []).filter(function (x) { return typeof x === 'string' && /^(tracker|dht):/.test(x); }).slice(0, 20);
1915
+ if (sv.kind === 'stremio') { // (-1: the server takes the torrent's largest file)
1916
+ s.url = sv.url + '/' + h + '/' + idx + (src.length ? '?' + src.map(function (x) { return 'tr=' + encodeURIComponent(x); }).join('&') : '');
1917
+ } else { // (TorrServer: files counted from 1, its trackers in a magnet; without a file, torrentFile)
1918
+ var tr = src.filter(function (x) { return x.indexOf('tracker:') === 0; }).map(function (x) { return '&tr=' + encodeURIComponent(x.slice(8)); });
1919
+ s.url = sv.url + '/stream/play?link=' + encodeURIComponent(tr.length ? 'magnet:?xt=urn:btih:' + h + tr.join('') : h) + (idx >= 0 ? '&index=' + (idx + 1) : '') + '&play';
1920
+ if (idx < 0) { s._pickFile = true; }
1921
+ }
1922
+ s._torrent = true;
1923
+ return s;
1924
+ }
1925
+ // TorrServer, a torrent that doesn't say which of its files (_pickFile): its file list (&stat,
1926
+ // once it has the torrent's details), then the episode's file (S01E02, 1x02), a film's largest
1927
+ // video. Its link with that file; as it was when the list doesn't come (one file plays anyway);
1928
+ // null when the episode isn't in it (another episode isn't played in its place).
1929
+ var VIDEO_FILE = /\.(mkv|mp4|m4v|avi|mov|wmv|ts|m2ts|webm|mpe?g|flv)$/i;
1930
+ function torrentFile(st, season, episode, cancel) {
1931
+ var sv = server(), m = /[?&]link=([^&]+)/.exec(st.url || '');
1932
+ if (sv.kind !== 'torrserver' || !m) { return Promise.resolve(st.url); }
1933
+ return U.getJSON(sv.url + '/stream/?link=' + m[1] + '&stat', 60000, undefined, cancel).then(function (r) {
1934
+ var files = U.listOf(r && r.file_stats, 5000).filter(function (f) { return f && typeof f.id === 'number' && VIDEO_FILE.test('' + f.path); });
1935
+ var pick = null, ep = (season && episode) ? new RegExp('(^|[^0-9a-z])(s0*' + season + '[ ._-]*e0*' + episode + '|0*' + season + 'x0*' + episode + ')([^0-9]|$)', 'i') : null;
1936
+ if (ep) {
1937
+ pick = files.filter(function (f) { return ep.test(f.path); })[0] || null;
1938
+ if (!pick && files.length) { return null; }
1939
+ }
1940
+ if (!pick) { files.forEach(function (f) { if (!pick || (+f.length || 0) > (+pick.length || 0)) { pick = f; } }); }
1941
+ return pick ? st.url.replace(/&play$/, '&index=' + pick.id + '&play') : st.url;
1942
+ }, function (e) { U.log('sources', 'The streaming server didn’t list the torrent’s files: ' + U.problem(e)); return st.url; });
1943
+ }
1944
+ // The server's HLS of a link, for what the TV plays (H.264 / HEVC video; AAC, AC-3, E-AC-3,
1945
+ // MP3 audio, 6 channels): the rest it converts. Null when there's no converting to ask for.
1946
+ // Off unless chosen: what it sends (fragmented-MP4 HLS, version 7) the 2017 TV's player
1947
+ // doesn't open (Stremio's own Tizen app doesn't convert either).
1948
+ var CONVERT_VIDEO = ['h264', 'hevc'], CONVERT_AUDIO = ['aac', 'ac3', 'eac3', 'mp3'];
1949
+ function convertUrl(url) {
1950
+ var sv = server();
1951
+ if (sv.kind !== 'stremio' || !sv.convert || !sv.url || !/^https?:\/\//i.test(url || '') || url.indexOf(sv.url + '/hlsv2/') === 0) { return null; }
1952
+ var id = '';
1953
+ while (id.length < 16) { id += Math.floor(Math.random() * 16).toString(16); }
1954
+ return sv.url + '/hlsv2/' + id + '/master.m3u8?mediaURL=' + encodeURIComponent(url) +
1955
+ CONVERT_VIDEO.map(function (c) { return '&videoCodecs=' + c; }).join('') +
1956
+ CONVERT_AUDIO.map(function (c) { return '&audioCodecs=' + c; }).join('') + '&maxAudioChannels=6';
1957
+ }
1958
+
1839
1959
  // A stream is directly playable on the TV only if it exposes an http(s) URL.
1840
- // Torrent (infoHash) / external-only streams need an engine this app doesn't embed.
1960
+ // Torrent (infoHash) / external-only streams need an engine this app doesn't embed (or a
1961
+ // streaming server: torrentSource gives them one).
1841
1962
  function isPlayable(stream) {
1842
1963
  if (typeof LocalMedia !== 'undefined' && LocalMedia.isLocalStream(stream)) { return true; } // (a file on a USB drive: only one StremioK made)
1843
1964
  return !!(stream && typeof stream.url === 'string' && /^https?:\/\//i.test(stream.url));
@@ -1904,12 +2025,21 @@ var Stremio = (function () {
1904
2025
  if (!m) { return 0; }
1905
2026
  return parseFloat(m[1].replace(',', '.')) * ({ TB: 1099511627776, GB: 1073741824, MB: 1048576 })[m[2].toUpperCase()];
1906
2027
  }
2028
+ // The seeders a torrent source states: Torrentio's and MediaFusion's 👤 123, or "Seeders: 123".
2029
+ // -1: none stated.
2030
+ var SEEDS = /(?:\uD83D\uDC64|\uD83D\uDC65)\s*(\d[\d,.]*)|\bseed(?:er)?s?\s*[:=]\s*(\d[\d,.]*)/i;
2031
+ function seedersOf(t) {
2032
+ var m = SEEDS.exec(t);
2033
+ if (!m) { return -1; }
2034
+ var n = parseInt((m[1] || m[2]).replace(/[,.]/g, ''), 10);
2035
+ return isFinite(n) ? n : -1;
2036
+ }
1907
2037
  function streamInfo(stream) {
1908
2038
  var bh = stream.behaviorHints || {};
1909
2039
  var path = '';
1910
2040
  try { path = decodeURIComponent(('' + (stream.url || '')).split('?')[0]); } catch (e) { path = '' + (stream.url || ''); }
1911
2041
  var t = [stream.name, stream.title, stream.description, bh.filename, path.split('/').pop()].join(' \n ');
1912
- var i = { res: 0, codec: '', bits10: RX.bits10.test(t), hdr: '', audio: [], kind: '', cache: 0, container: '', size: sizeBytes(stream) };
2042
+ var i = { res: 0, codec: '', bits10: RX.bits10.test(t), hdr: '', audio: [], kind: '', cache: 0, container: '', size: sizeBytes(stream), seeds: seedersOf(t) };
1913
2043
  i.res = RX.r2160.test(t) ? 2160 : RX.r1440.test(t) ? 1440 : RX.r1080.test(t) ? 1080 : RX.r720.test(t) ? 720 : RX.r480.test(t) ? 480 : 0;
1914
2044
  var dv = RX.dv.test(t), hdr = RX.hdr.test(t), hdr10p = RX.hdr10p.test(t);
1915
2045
  i.hdr = dv ? ((hdr || hdr10p) ? 'dv' : 'dv-only') : hdr10p ? 'hdr10p' : hdr ? 'hdr' : '';
@@ -2098,7 +2228,8 @@ var Stremio = (function () {
2098
2228
  moveSearchFeed: function (key, dir) { return searchFeeds.move(key, dir); },
2099
2229
  toggleSearchFeed: function (key) { searchFeeds.toggle(key); },
2100
2230
  isSearchFeedHidden: function (key) { return searchFeeds.isHidden(key); },
2101
- isPlayable: isPlayable, youTubeSource: youTubeSource, streamLabel: streamLabel, streamName: streamName,
2231
+ isPlayable: isPlayable, youTubeSource: youTubeSource, torrentSource: torrentSource,
2232
+ keptSources: keptSources, torrentFile: torrentFile, server: server, setServer: setServer, testServer: testServer, convertUrl: convertUrl, serverAddressProblem: serverAddressProblem, streamLabel: streamLabel, streamName: streamName,
2102
2233
  streamSize: streamSize, streamDescription: streamDescription,
2103
2234
  streamInfo: streamInfo, formatTags: formatTags, formatKeys: formatKeys, recordFormats: recordFormats, setFormat: setFormat,
2104
2235
  resetFormats: resetFormats, badFormats: badFormats, formatList: formatList,
@@ -2106,7 +2237,7 @@ var Stremio = (function () {
2106
2237
  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; },
2107
2238
  iptvGetPlaylists: iptvGetPlaylists, iptvAddPlaylist: iptvAddPlaylist,
2108
2239
  iptvUpdatePlaylist: iptvUpdatePlaylist, iptvRemovePlaylist: iptvRemovePlaylist,
2109
- iptvRenamePlaylist: iptvRenamePlaylist, iptvSetPlaylistGuide: iptvSetPlaylistGuide, iptvAddXtream: iptvAddXtream, iptvSetXtreamFormat: iptvSetXtreamFormat, iptvSetPlaylistOff: iptvSetPlaylistOff, iptvMovePlaylist: iptvMovePlaylist, iptvLabel: iptvLabel,
2240
+ iptvRenamePlaylist: iptvRenamePlaylist, iptvSetPlaylistGuide: iptvSetPlaylistGuide, iptvAddXtream: iptvAddXtream, iptvSetXtreamFormat: iptvSetXtreamFormat, iptvRefreshEvery: iptvRefreshEvery, iptvSetRefreshEvery: iptvSetRefreshEvery, iptvReadAt: iptvReadAt, iptvSetPlaylistOff: iptvSetPlaylistOff, iptvMovePlaylist: iptvMovePlaylist, iptvLabel: iptvLabel,
2110
2241
  iptvLoad: iptvLoad, iptvStatus: iptvStatus, iptvReload: iptvReload
2111
2242
  };
2112
2243
  })();
package/js/util.js CHANGED
@@ -33,14 +33,30 @@ var U = (function () {
33
33
  try { d = decodeURIComponent(('' + k).replace(/\+/g, ' ')); } catch (e) {}
34
34
  return SECRET_PARAM.test(d.trim());
35
35
  }
36
- function maskParams(str) {
36
+ // A link inside a link's value (a converted video's mediaURL=…): decoded (up to 3 times), masked
37
+ // the same way (a link in it too, 3 deep at most), and shown encoded as it was. Null: not one.
38
+ var LINK_START = /^[a-z][a-z0-9+.-]*:\/\//i;
39
+ function innerLink(v, depth) {
40
+ var d = v, n = 0;
41
+ while (n < 3 && !LINK_START.test(d) && /%[0-9a-f]{2}/i.test(d)) {
42
+ try { d = decodeURIComponent(d.replace(/\+/g, ' ')); } catch (e) { return null; }
43
+ n++;
44
+ }
45
+ if (!n || !LINK_START.test(d)) { return null; }
46
+ var r = depth < 3 ? redactUrl(d, depth + 1) : d.replace(/[?#].*$/, '?•••');
47
+ for (var i = 0; i < n; i++) { r = encodeURIComponent(r).replace(/%E2%80%A2/g, '•'); }
48
+ return r;
49
+ }
50
+ function maskParams(str, depth) {
37
51
  return str.split('&').map(function (kv) {
38
52
  var eq = kv.indexOf('='), k = eq === -1 ? kv : kv.slice(0, eq);
39
53
  if (eq !== -1 && secretName(k)) { return k + '=•••'; }
54
+ var inner = eq !== -1 ? innerLink(kv.slice(eq + 1), depth || 0) : null;
55
+ if (inner !== null) { return k + '=' + inner; }
40
56
  return (eq !== -1 && looksLikeToken(kv.slice(eq + 1))) ? k + '=•••' : kv;
41
57
  }).join('&');
42
58
  }
43
- function redactUrl(url) {
59
+ function redactUrl(url, depth) {
44
60
  var u = '' + (url || ''), m = u.match(/^([a-z][a-z0-9+.-]*:\/\/)([^\/?#]*)([^?#]*)(\?[^#]*)?(#.*)?$/i);
45
61
  if (!m) { return u; }
46
62
  var auth = m[2].lastIndexOf('@');
@@ -52,9 +68,9 @@ var U = (function () {
52
68
  if (xt !== -1 && (j === xt + 1 || j === xt + 2)) { return '•••'; }
53
69
  return looksLikeToken(seg) ? '•••' : seg;
54
70
  });
55
- var query = m[4] ? '?' + maskParams(m[4].slice(1)) : '';
71
+ var query = m[4] ? '?' + maskParams(m[4].slice(1), depth) : '';
56
72
  var frag = m[5] ? m[5].slice(1) : null;
57
- if (frag !== null) { frag = '#' + (frag.indexOf('=') !== -1 ? maskParams(frag) : looksLikeToken(frag) ? '•••' : frag); }
73
+ if (frag !== null) { frag = '#' + (frag.indexOf('=') !== -1 ? maskParams(frag, depth) : looksLikeToken(frag) ? '•••' : frag); }
58
74
  return m[1] + host + segs.join('/') + query + (frag || '');
59
75
  }
60
76
 
@@ -398,11 +414,13 @@ var U = (function () {
398
414
  var big = m.match(/^Too large \((over [^)]+)\)/);
399
415
  if (big) { return 'too large (' + big[1] + ')'; }
400
416
  if (/^Bad JSON/.test(m)) { return 'not valid JSON'; }
417
+ var late = m.match(/^Timed out after (\d+ s)/);
418
+ if (late) { return 'timed out after ' + late[1]; }
401
419
  if (/^Timeout/.test(m)) { return 'timed out'; }
402
- if (/^Network error/.test(m)) { return 'network error'; }
420
+ if (/^(No answer|Network error)/.test(m)) { return 'no answer (connection refused, blocked or certificate)'; }
403
421
  if (/^Redirected into the home network/.test(m)) { return 'redirected into the home network'; }
404
422
  var st = m.match(/^HTTP (\d+)/);
405
- if (st) { return st[1] === '0' ? 'no answer (blocked?)' : 'HTTP ' + st[1]; }
423
+ if (st) { return st[1] === '0' ? 'no answer (connection refused, blocked or certificate)' : 'HTTP ' + st[1]; }
406
424
  return m.split(':')[0] || 'failed';
407
425
  }
408
426
 
@@ -438,7 +456,7 @@ var U = (function () {
438
456
  function logged(err, priv) {
439
457
  var msg = '' + err.message;
440
458
  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);
459
+ var kind = /^(HTTP \d+|Timed out after \d+ s|Timeout|No answer|Network error|Too large|Bad JSON|Redirected into the home network)/.exec(msg), host = /(https?:\/\/[^\/\s?#]+)/.exec(msg);
442
460
  msg = 'Private viewing: a download' + (host ? ' from ' + host[1] : '') + ' failed' + (kind ? ' (' + kind[1] + ')' : '');
443
461
  }
444
462
  log('network', msg); err.logged = true; return err;
@@ -513,6 +531,11 @@ var U = (function () {
513
531
  reject(logged(new Error('Redirected into the home network: ' + redactUrl(url) + ' -> ' + redactUrl(landed))));
514
532
  return;
515
533
  }
534
+ if (xhr.status === 0) { // (no answer: the TV's browser says so first, then whether it timed out or failed)
535
+ done = false;
536
+ setTimeout(function () { if (!done) { done = true; reject(logged(noAnswer(url))); } }, 0);
537
+ return;
538
+ }
516
539
  if (xhr.status >= 200 && xhr.status < 300) { resolve(body); }
517
540
  else {
518
541
  var err = new Error('HTTP ' + xhr.status + ' for ' + redactUrl(url));
@@ -521,12 +544,15 @@ var U = (function () {
521
544
  reject(logged(err));
522
545
  }
523
546
  };
524
- xhr.ontimeout = function () { if (!done) { done = true; reject(logged(new Error('Timeout: ' + redactUrl(url)))); } };
525
- xhr.onerror = function () { if (!done) { done = true; reject(logged(new Error('Network error: ' + redactUrl(url)))); } };
547
+ xhr.ontimeout = function () { if (!done) { done = true; reject(logged(new Error('Timed out after ' + Math.round(xhr.timeout / 1000) + ' s: ' + redactUrl(url)))); } };
548
+ xhr.onerror = function () { if (!done) { done = true; reject(logged(noAnswer(url))); } };
526
549
  try { if (body == null) { xhr.send(); } else { xhr.send('' + body); } } catch (e3) { reject(e3); }
527
550
  });
528
551
  }
529
552
 
553
+ // No answer at all: the connection refused or dropped, blocked, a certificate the TV doesn't
554
+ // trust (its browser says only "status 0").
555
+ function noAnswer(url) { return new Error('No answer (connection refused, blocked or certificate): ' + redactUrl(url)); }
530
556
  function cancelledError(url) { var e = new Error('Cancelled: ' + redactUrl(url)); e.cancelled = true; return e; }
531
557
  // Downloads for one screen (or one job), stopped together: getText / getJSON(url, …, token).
532
558
  function cancelToken() {
@@ -566,11 +592,12 @@ var U = (function () {
566
592
  unless maxBytes says otherwise). cancel, headers: see getText. */
567
593
  // A POST (body: text), its answer JSON.
568
594
  function postJSON(url, body, timeoutMs, maxBytes, headers) { return getJSON(url, timeoutMs, maxBytes, null, headers, body); }
569
- function getJSON(url, timeoutMs, maxBytes, cancel, headers, body) {
595
+ // quiet: not noted in Diagnostics (the caller says what came of it, e.g. a skip-times lookup).
596
+ function getJSON(url, timeoutMs, maxBytes, cancel, headers, body, quiet) {
570
597
  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) {
598
+ return getText(url, timeoutMs, maxBytes || JSON_MAX, cancel, !!quiet, headers, body).then(function (text) {
572
599
  try { return JSON.parse(text); }
573
- catch (e) { throw logged(new Error('Bad JSON from ' + redactUrl(url)), priv); }
600
+ catch (e) { var bad = new Error('Bad JSON from ' + redactUrl(url)); throw quiet ? bad : logged(bad, priv); }
574
601
  });
575
602
  }
576
603
 
@@ -1309,6 +1336,21 @@ var U = (function () {
1309
1336
  'filter: drop-shadow(0 0 2px rgba(0,0,0,0.85)) drop-shadow(0 0 6px rgba(0,0,0,0.45)) }\n' +
1310
1337
  '#player-osd, #sub-menu, #player-bar, #skip-prompt, #stream-info, #cast-strip, #yt-links, #yt-card, #toast, #hold-hint, #spinner { color: #e6e9ef }';
1311
1338
 
1339
+ // Zoom (Settings › Appearance), 1 to 1.4: the page laid out on 1920×1080 divided by it and
1340
+ // scaled up from the top left to fill the screen (a transform: what's measured on screen,
1341
+ // getBoundingClientRect, is scaled; scrolling, offsets and styles aren't: divide by zoom()).
1342
+ var _zoom = 1;
1343
+ function zoom() { return _zoom; }
1344
+ function setZoom(z) {
1345
+ _zoom = (z > 1 && z <= 1.4) ? z : 1;
1346
+ var s = document.body && document.body.style, on = _zoom !== 1;
1347
+ if (!s) { return; }
1348
+ s.width = on ? Math.ceil(1920 / _zoom) + 'px' : '';
1349
+ s.height = on ? Math.ceil(1080 / _zoom) + 'px' : '';
1350
+ s.webkitTransform = s.transform = on ? 'scale(' + _zoom + ')' : '';
1351
+ s.webkitTransformOrigin = s.transformOrigin = on ? '0 0' : '';
1352
+ }
1353
+
1312
1354
  // bg: 'dark' (the stylesheet's own), 'black', 'gray' or 'white' (the last two with dark text)
1313
1355
  function applyTheme(accent, bg) {
1314
1356
  if (bg === true) { bg = 'black'; }
@@ -1509,6 +1551,6 @@ var U = (function () {
1509
1551
  store: store, toast: toast, spinner: spinner,
1510
1552
  deferImage: deferImage, deferBg: deferBg, cardImage: cardImage, prioritizeImages: prioritizeImages, requeueImages: requeueImages,
1511
1553
  fmtTime: fmtTime, dayLabel: dayLabel, settleAll: settleAll, cancelToken: cancelToken, isHidden: isHidden, whenVisible: whenVisible, crumb: crumb, sessionStart: sessionStart, sessionState: sessionState, mark: mark, startupMarks: startupMarks, loadScript: loadScript, qrModules: qrModules, qrCanvas: qrCanvas, qrScale: qrScale,
1512
- applyTheme: applyTheme, accentColor: accentColor, ACCENT_KEYS: Object.keys(ACCENTS)
1554
+ applyTheme: applyTheme, zoom: zoom, setZoom: setZoom, accentColor: accentColor, ACCENT_KEYS: Object.keys(ACCENTS)
1513
1555
  };
1514
1556
  })();