stremiok 2.2.0 → 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/CHANGELOG.md +37 -0
- package/README.md +8 -6
- package/config.xml +1 -1
- package/css/style.css +11 -8
- package/index.html +33 -33
- package/js/app.js +24 -22
- package/js/focus.js +5 -5
- package/js/iptv.js +15 -2
- package/js/player-core.js +71 -9
- package/js/player-lang.js +66 -5
- package/js/player-store.js +4 -3
- package/js/player-subs.js +67 -13
- package/js/stremio.js +154 -23
- package/js/util.js +55 -13
- package/js/view-core.js +43 -13
- package/js/view-detail.js +36 -8
- package/js/view-discover.js +1 -1
- package/js/view-guide.js +41 -2
- package/js/view-home.js +33 -8
- package/js/view-settings.js +208 -57
- package/js/views.js +2 -2
- package/js/ytplay.js +4 -3
- package/package.json +2 -1
- package/service/index.js +33 -9
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
|
-
|
|
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(
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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 (
|
|
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: ' + (
|
|
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 (
|
|
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 + ': ' + (
|
|
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,
|
|
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
|
-
|
|
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 '
|
|
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
|
|
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('
|
|
525
|
-
xhr.onerror = function () { if (!done) { done = true; reject(logged(
|
|
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
|
-
|
|
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,
|
|
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) {
|
|
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
|
})();
|
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
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
|
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 (
|
|
678
|
+
if (!hold.latest()) { return; } // (held on another card since: its own)
|
|
651
679
|
U.spinner(false);
|
|
652
|
-
if (!
|
|
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,
|