stremiok 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/config.xml +1 -1
- package/css/style.css +10 -2
- package/index.html +31 -31
- package/js/downloads.js +17 -5
- package/js/hubpreview.js +15 -5
- package/js/iptv.js +4 -4
- package/js/player-controls.js +5 -0
- package/js/player-core.js +92 -19
- package/js/player-lang.js +1 -1
- package/js/player-quality.js +15 -7
- package/js/player-store.js +7 -3
- package/js/player-subs.js +7 -3
- package/js/player.js +1 -1
- package/js/stremio-account.js +3 -0
- package/js/stremio.js +87 -22
- package/js/trakt.js +60 -9
- package/js/util.js +17 -2
- package/js/view-core.js +11 -0
- package/js/view-detail.js +5 -4
- package/js/view-home.js +27 -10
- package/js/view-settings.js +67 -45
- package/js/youtube.js +1 -3
- package/js/ytplay.js +10 -1
- package/package.json +1 -1
- package/service/index.js +280 -74
- package/service/preview.js +14 -4
package/README.md
CHANGED
|
@@ -75,8 +75,10 @@ a direct HTTP(S)/HLS/DASH URL, and this app embeds no torrent or debrid engine.
|
|
|
75
75
|
|
|
76
76
|
The **IPTV** feature likewise hosts no content: you supply your own M3U playlist URLs. For
|
|
77
77
|
YouTube entries, the bundled service finds the video's stream; the video itself comes
|
|
78
|
-
straight from YouTube.
|
|
79
|
-
drive.
|
|
78
|
+
straight from YouTube. No video or audio is kept: everything plays straight from its source,
|
|
79
|
+
unless you download it to your own USB drive. What StremioK does keep, on the TV, is your
|
|
80
|
+
progress, settings and copies that let it start faster (see
|
|
81
|
+
[Privacy and data](docs/PRIVACY.md#what-stays-on-the-tv)).
|
|
80
82
|
|
|
81
83
|
## Known limitations
|
|
82
84
|
|
package/config.xml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
2
|
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
|
|
3
|
-
id="http://stremiok.tizen/StremioKTizen" version="2.
|
|
3
|
+
id="http://stremiok.tizen/StremioKTizen" version="2.1.0" viewmodes="maximized">
|
|
4
4
|
<tizen:application id="StremioK01.StremioK" package="StremioK01" required_version="3.0"/>
|
|
5
5
|
<content src="index.html"/>
|
|
6
6
|
<feature name="http://tizen.org/feature/screen.size.all"/>
|
package/css/style.css
CHANGED
|
@@ -40,7 +40,7 @@ html, body {
|
|
|
40
40
|
.header .spacer { flex: 1; }
|
|
41
41
|
.header-title { font-size: 36px; font-weight: bold; }
|
|
42
42
|
/* Search and Settings: pills like the tabs (the top menu fits every tab, Local included, at 1920 px) */
|
|
43
|
-
.header .hchip { display: inline-flex; align-items: center; font-size: 22px; padding: 8px
|
|
43
|
+
.header .hchip { display: inline-flex; align-items: center; font-size: 22px; padding: 8px 9px; margin-right: 5px; border-radius: 28px; background: #1a1f2b; color: #aeb6c4; border: 4px solid transparent; white-space: nowrap; }
|
|
44
44
|
.header .hchip.focusable.focused { background: #818cf8; color: #0e1117; border-color: #fff; opacity: 1; }
|
|
45
45
|
|
|
46
46
|
/* --- Rows --- */
|
|
@@ -335,6 +335,13 @@ html, body {
|
|
|
335
335
|
.playlist-row .playlist-item { flex: 1; margin-bottom: 0; }
|
|
336
336
|
.feed-off .playlist-item { opacity: 0.5; }
|
|
337
337
|
.playlist-item .addon-said:empty { display: none; }
|
|
338
|
+
/* an addon's logo (Settings › Content › Addons): its initial until the logo has loaded, then the logo over it */
|
|
339
|
+
.addon-item.has-logo { position: relative; padding-left: 92px; min-height: 56px; }
|
|
340
|
+
.addon-logo { position: absolute; left: 18px; top: 16px; width: 56px; height: 56px; border-radius: 8px; overflow: hidden; background: #0d1119; color: #8b93a5; font-size: 26px; font-weight: bold; line-height: 56px; text-align: center; word-break: normal; }
|
|
341
|
+
.addon-logo img { position: absolute; left: 0; top: 0; width: 100%; height: 100%; object-fit: contain; visibility: hidden; }
|
|
342
|
+
.addon-logo:before { content: attr(data-initial); }
|
|
343
|
+
.addon-logo.loaded:before { visibility: hidden; }
|
|
344
|
+
.addon-logo.loaded img { visibility: visible; }
|
|
338
345
|
/* list picker (single choice) */
|
|
339
346
|
.pick-box { width: 720px; text-align: left; }
|
|
340
347
|
.pick-box .modal-msg { text-align: left; margin-bottom: 20px; }
|
|
@@ -367,12 +374,13 @@ html, body {
|
|
|
367
374
|
|
|
368
375
|
/* Home top row: tab pills + Search + Settings on the left, StremioK logo on the right */
|
|
369
376
|
.home-header .tabbar-inline { display: flex; align-items: center; }
|
|
370
|
-
.tab-pill { display: inline-flex; align-items: center; font-size: 22px; padding: 8px
|
|
377
|
+
.tab-pill { display: inline-flex; align-items: center; font-size: 22px; padding: 8px 9px; margin-right: 5px; border-radius: 28px; background: #1a1f2b; color: #aeb6c4; border: 4px solid transparent; white-space: nowrap; }
|
|
371
378
|
.tab-ic { margin-left: 6px; font-size: 22px; }
|
|
372
379
|
.tab-pill.active { background: #2a3346; color: #fff; }
|
|
373
380
|
.tab-pill.focusable.focused { background: #818cf8; color: #0e1117; border-color: #fff; }
|
|
374
381
|
.home-header .hchip.active { background: #2a3346; color: #fff; opacity: 1; }
|
|
375
382
|
.row-empty { margin: 50px 60px; font-size: 26px; color: #8b93a3; }
|
|
383
|
+
.row-loading .row-empty { margin: 10px 60px 26px 60px; font-size: 22px; }
|
|
376
384
|
|
|
377
385
|
/* --- Subtitle fonts (bundled; SIL Open Font License, see css/fonts/OFL-*.txt) ---
|
|
378
386
|
Latin, Latin-extended and Cyrillic subsets; unicode-range makes the webview
|
package/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=1920, height=1080, user-scalable=no">
|
|
6
6
|
<title>StremioK</title>
|
|
7
|
-
<link rel="stylesheet" href="css/style.css?v=2.
|
|
7
|
+
<link rel="stylesheet" href="css/style.css?v=2.1.0">
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
10
10
|
<!-- CSS-applied sentinel (kept off-screen); app.js waits for its width before rendering -->
|
|
@@ -52,35 +52,35 @@
|
|
|
52
52
|
|
|
53
53
|
<!-- Load order matters: polyfills -> util -> iptv -> data -> input -> tvapps -> player parts -> player -> ytplay -> view parts -> views -> app -->
|
|
54
54
|
<!-- ?v= busts the TV webview cache on every release (keep in sync with package.json) -->
|
|
55
|
-
<script src="js/polyfills.js?v=2.
|
|
56
|
-
<script src="js/util.js?v=2.
|
|
57
|
-
<script src="js/iptv.js?v=2.
|
|
58
|
-
<script src="js/youtube.js?v=2.
|
|
59
|
-
<script src="js/stremio.js?v=2.
|
|
60
|
-
<script src="js/trakt.js?v=2.
|
|
61
|
-
<script src="js/keys.js?v=2.
|
|
62
|
-
<script src="js/focus.js?v=2.
|
|
63
|
-
<script src="js/tvapps.js?v=2.
|
|
64
|
-
<script src="js/player-core.js?v=2.
|
|
65
|
-
<script src="js/player-store.js?v=2.
|
|
66
|
-
<script src="js/player-lang.js?v=2.
|
|
67
|
-
<script src="js/player-quality.js?v=2.
|
|
68
|
-
<script src="js/player-subs.js?v=2.
|
|
69
|
-
<script src="js/player-audio.js?v=2.
|
|
70
|
-
<script src="js/player-controls.js?v=2.
|
|
71
|
-
<script src="js/player.js?v=2.
|
|
72
|
-
<script src="js/ytplay.js?v=2.
|
|
73
|
-
<script src="js/local.js?v=2.
|
|
74
|
-
<script src="js/downloads.js?v=2.
|
|
75
|
-
<script src="js/stremio-account.js?v=2.
|
|
76
|
-
<script src="js/view-core.js?v=2.
|
|
77
|
-
<script src="js/view-home.js?v=2.
|
|
78
|
-
<script src="js/view-detail.js?v=2.
|
|
79
|
-
<script src="js/view-search.js?v=2.
|
|
80
|
-
<script src="js/view-settings.js?v=2.
|
|
81
|
-
<script src="js/view-discover.js?v=2.
|
|
82
|
-
<script src="js/views.js?v=2.
|
|
83
|
-
<script src="js/app.js?v=2.
|
|
84
|
-
<script src="js/hubpreview.js?v=2.
|
|
55
|
+
<script src="js/polyfills.js?v=2.1.0"></script>
|
|
56
|
+
<script src="js/util.js?v=2.1.0"></script>
|
|
57
|
+
<script src="js/iptv.js?v=2.1.0"></script>
|
|
58
|
+
<script src="js/youtube.js?v=2.1.0"></script>
|
|
59
|
+
<script src="js/stremio.js?v=2.1.0"></script>
|
|
60
|
+
<script src="js/trakt.js?v=2.1.0"></script>
|
|
61
|
+
<script src="js/keys.js?v=2.1.0"></script>
|
|
62
|
+
<script src="js/focus.js?v=2.1.0"></script>
|
|
63
|
+
<script src="js/tvapps.js?v=2.1.0"></script>
|
|
64
|
+
<script src="js/player-core.js?v=2.1.0"></script>
|
|
65
|
+
<script src="js/player-store.js?v=2.1.0"></script>
|
|
66
|
+
<script src="js/player-lang.js?v=2.1.0"></script>
|
|
67
|
+
<script src="js/player-quality.js?v=2.1.0"></script>
|
|
68
|
+
<script src="js/player-subs.js?v=2.1.0"></script>
|
|
69
|
+
<script src="js/player-audio.js?v=2.1.0"></script>
|
|
70
|
+
<script src="js/player-controls.js?v=2.1.0"></script>
|
|
71
|
+
<script src="js/player.js?v=2.1.0"></script>
|
|
72
|
+
<script src="js/ytplay.js?v=2.1.0"></script>
|
|
73
|
+
<script src="js/local.js?v=2.1.0"></script>
|
|
74
|
+
<script src="js/downloads.js?v=2.1.0"></script>
|
|
75
|
+
<script src="js/stremio-account.js?v=2.1.0"></script>
|
|
76
|
+
<script src="js/view-core.js?v=2.1.0"></script>
|
|
77
|
+
<script src="js/view-home.js?v=2.1.0"></script>
|
|
78
|
+
<script src="js/view-detail.js?v=2.1.0"></script>
|
|
79
|
+
<script src="js/view-search.js?v=2.1.0"></script>
|
|
80
|
+
<script src="js/view-settings.js?v=2.1.0"></script>
|
|
81
|
+
<script src="js/view-discover.js?v=2.1.0"></script>
|
|
82
|
+
<script src="js/views.js?v=2.1.0"></script>
|
|
83
|
+
<script src="js/app.js?v=2.1.0"></script>
|
|
84
|
+
<script src="js/hubpreview.js?v=2.1.0"></script>
|
|
85
85
|
</body>
|
|
86
86
|
</html>
|
package/js/downloads.js
CHANGED
|
@@ -13,7 +13,8 @@ var Downloads = (function () {
|
|
|
13
13
|
return typeof YTPlay !== 'undefined' && YTPlay.installMode() === 'wgt' && typeof LocalMedia !== 'undefined' && LocalMedia.present && LocalMedia.present();
|
|
14
14
|
}
|
|
15
15
|
function kept() { var l = U.store.get(DL_KEY, []); return Array.isArray(l) ? l : []; }
|
|
16
|
-
|
|
16
|
+
var _keptJson = null; // (what was last written: the list polled every few seconds is written only when it changed)
|
|
17
|
+
function keep(l) { var v = l.slice(-MAX), j = JSON.stringify(v); if (j === _keptJson) { return; } _keptJson = j; U.store.set(DL_KEY, v); }
|
|
17
18
|
function keyOf(x) { return x.dir + '/' + x.name; }
|
|
18
19
|
// The same file, renamed or not ("Film.mkv", "Film (k3x9).mkv"): its folder and its name's stem.
|
|
19
20
|
function sameFile(y, k) {
|
|
@@ -55,11 +56,13 @@ var Downloads = (function () {
|
|
|
55
56
|
});
|
|
56
57
|
_synced = true;
|
|
57
58
|
emit();
|
|
58
|
-
return U.settleAll(again.map(function (k) { return function () { return send(k); }; }), 1).then(function () { return _items; });
|
|
59
|
+
return U.settleAll(again.map(function (k) { return function () { return send(k, true); }; }), 1).then(function () { return _items; });
|
|
59
60
|
}, function () { return _items; });
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
// again: the service no longer knows it (it restarted): it checks the drive first, so one it had
|
|
63
|
+
// finished isn't downloaded a second time.
|
|
64
|
+
function send(k, again) {
|
|
65
|
+
return ask('/fsdl/add?' + qs({ dir: k.dir, name: k.name, url: k.url, ua: k.ua, ref: k.ref, size: k.size, title: k.title, again: again ? '1' : '' }), 20000).then(function (x) {
|
|
63
66
|
var mine = kept();
|
|
64
67
|
mine.forEach(function (y) { if (keyOf(y) === keyOf(k)) { y.id = x.id; y.state = x.state; y.err = ''; } });
|
|
65
68
|
keep(mine);
|
|
@@ -129,6 +132,14 @@ var Downloads = (function () {
|
|
|
129
132
|
|
|
130
133
|
// ---- starting one, from a title's source (view-detail's source menu) ----
|
|
131
134
|
var EXT = /\.(mkv|mp4|m4v|avi|ts|webm|mov|wmv|mpe?g)$/i;
|
|
135
|
+
// Only a file can be downloaded: a link (http/https) that isn't a streamed playlist (HLS
|
|
136
|
+
// .m3u8, DASH .mpd), which would save just the list, not the video. (One whose link doesn't
|
|
137
|
+
// say is checked by StremioK's service, by what the server answers.)
|
|
138
|
+
function isFileLink(stream) {
|
|
139
|
+
var u = '' + ((stream && stream.url) || ''), path = u.split(/[?#]/)[0], query = u.split('#')[0].split('?')[1] || '';
|
|
140
|
+
if (!/^https?:\/\//i.test(u)) { return false; }
|
|
141
|
+
return !/\.(m3u8?|mpd)$/i.test(path) && !/(^|&)(type|format|ext|output)=(m3u8|hls|mpd|dash)(&|$)/i.test(query);
|
|
142
|
+
}
|
|
132
143
|
function fileNameFor(stream, label) {
|
|
133
144
|
var bh = stream.behaviorHints || {}, path = ('' + (stream.url || '')).split(/[?#]/)[0];
|
|
134
145
|
var named = typeof bh.filename === 'string' && EXT.test(bh.filename) ? bh.filename : '';
|
|
@@ -150,6 +161,7 @@ var Downloads = (function () {
|
|
|
150
161
|
// ui: { pick(title, options, onPick), confirm(msg, ok, fn) } (the views' popups)
|
|
151
162
|
function start(stream, t, ui) {
|
|
152
163
|
if (!available()) { U.toast('Plug a USB drive into the TV first'); return; }
|
|
164
|
+
if (!isFileLink(stream)) { U.toast('This source is a streamed playlist (HLS or DASH), not a file: it can’t be downloaded. Pick another source.', 6000); return; }
|
|
153
165
|
var name = fileNameFor(stream, t.label), bytes = +((stream.behaviorHints || {}).videoSize) || 0;
|
|
154
166
|
spaces().then(function (list) {
|
|
155
167
|
if (!list.length) { U.toast('No USB drive'); return; }
|
|
@@ -202,6 +214,6 @@ var Downloads = (function () {
|
|
|
202
214
|
}, function () {});
|
|
203
215
|
}
|
|
204
216
|
|
|
205
|
-
return { available: available, items: items, sync: sync, watch: watch, onChange: onChange, add: add, cancel: cancel, clear: clear, retry: retry,
|
|
217
|
+
return { available: available, isFileLink: isFileLink, items: items, sync: sync, watch: watch, onChange: onChange, add: add, cancel: cancel, clear: clear, retry: retry,
|
|
206
218
|
start: start, fileNameFor: fileNameFor, busy: busy, synced: function () { return _synced; } };
|
|
207
219
|
})();
|
package/js/hubpreview.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
var HubPreview = (function () {
|
|
9
9
|
'use strict';
|
|
10
10
|
var SERVICE = 'StremioK01.service', EVERY_MS = 10 * 60 * 1000, SENT_KEY = 'stremiok.homepreview';
|
|
11
|
+
// Sent counts only once the preview service says the TV took them (SENT_KEY); a try is kept
|
|
12
|
+
// apart (_tried), for the 10 minutes between sends; a failure is tried again later (RETRY_MS).
|
|
13
|
+
var RETRY_MS = [60000, 300000, 900000], _tried = null, _fails = 0;
|
|
11
14
|
var _timer = null;
|
|
12
15
|
// Tizen 4 and later: the 2017 TV (Tizen 3) shows no preview for it, so there it's left out
|
|
13
16
|
// (no setting, nothing sent).
|
|
@@ -28,15 +31,22 @@ var HubPreview = (function () {
|
|
|
28
31
|
function send(now) {
|
|
29
32
|
if (!available()) { return; }
|
|
30
33
|
var json = JSON.stringify(payload()), last = U.store.get(SENT_KEY, null) || {}, t = Date.now();
|
|
31
|
-
if (!now && json === last.json) { return; }
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
if (!now && json === last.json) { return; } // (the TV has these already)
|
|
35
|
+
var lastTry = Math.max(last.at || 0, (_tried && _tried.at) || 0);
|
|
36
|
+
if (!now && t - lastTry < EVERY_MS) { later(lastTry + EVERY_MS - t); return; }
|
|
37
|
+
_tried = { json: json, at: t };
|
|
38
|
+
function failed(why) {
|
|
39
|
+
U.log('preview', 'Home bar preview not sent: ' + why);
|
|
40
|
+
if (_fails < RETRY_MS.length) { var wait = RETRY_MS[_fails++]; _tried = null; later(wait); } // (then as usual: a change, 10 minutes apart)
|
|
41
|
+
}
|
|
34
42
|
try {
|
|
35
43
|
tizen.application.launchAppControl(
|
|
36
44
|
new tizen.ApplicationControl('http://tizen.org/appcontrol/operation/pick', null, 'image/jpeg', null,
|
|
37
45
|
[new tizen.ApplicationControlData('caller', ['ForegroundApp']), new tizen.ApplicationControlData('tiles', [json])]),
|
|
38
|
-
SERVICE, function () {}, function (e) {
|
|
39
|
-
|
|
46
|
+
SERVICE, function () {}, function (e) { failed((e && e.message) || e); },
|
|
47
|
+
{ onsuccess: function () { U.store.set(SENT_KEY, { json: json, at: t }); _fails = 0; },
|
|
48
|
+
onfailure: function () { failed('the TV didn’t take them'); } });
|
|
49
|
+
} catch (e) { failed(e && e.message); }
|
|
40
50
|
}
|
|
41
51
|
function later(ms) {
|
|
42
52
|
if (_timer) { clearTimeout(_timer); }
|
package/js/iptv.js
CHANGED
|
@@ -75,9 +75,8 @@ var IPTV = (function () {
|
|
|
75
75
|
// Up (-1) or down (1): a channel in two playlists comes from the first, and rows are
|
|
76
76
|
// in playlist order.
|
|
77
77
|
function movePlaylist(id, dir) {
|
|
78
|
-
var list = getPlaylists()
|
|
79
|
-
if (
|
|
80
|
-
var t = list[i]; list[i] = list[j]; list[j] = t;
|
|
78
|
+
var list = getPlaylists();
|
|
79
|
+
if (!U.moveIn(list, find(list, id), dir)) { return false; } // (one place, or to the top / bottom)
|
|
81
80
|
savePlaylists(list);
|
|
82
81
|
return true;
|
|
83
82
|
}
|
|
@@ -368,6 +367,7 @@ var IPTV = (function () {
|
|
|
368
367
|
_progressFns.forEach(function (fn) { try { fn(total); } catch (e) {} });
|
|
369
368
|
}
|
|
370
369
|
function onProgress(fn) { _progressFns.push(fn); }
|
|
370
|
+
function isReady() { return !!_cache; } // (the playlists read: synthAddon has their channels)
|
|
371
371
|
|
|
372
372
|
// Each playlist is fetched once and saved (the big store). Passing a playlist id
|
|
373
373
|
// forces only that entry to refresh; true refreshes every configured playlist.
|
|
@@ -478,7 +478,7 @@ var IPTV = (function () {
|
|
|
478
478
|
getPlaylists: getPlaylists, addPlaylist: addPlaylist, updatePlaylist: updatePlaylist,
|
|
479
479
|
removePlaylist: removePlaylist, renamePlaylist: renamePlaylist, setPlaylistOff: setPlaylistOff,
|
|
480
480
|
movePlaylist: movePlaylist, label: labelOf, isEnabled: isEnabled, reset: reset, status: status,
|
|
481
|
-
load: load, reload: reload, catalog: catalog, meta: meta, streams: streams, synthAddon: synthAddon,
|
|
481
|
+
load: load, reload: reload, catalog: catalog, meta: meta, streams: streams, synthAddon: synthAddon, isReady: isReady,
|
|
482
482
|
onProgress: onProgress
|
|
483
483
|
};
|
|
484
484
|
})();
|
package/js/player-controls.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
function infoLang() { return P.infoLang.apply(null, arguments); } // lang
|
|
23
23
|
function trackInfo() { return P.trackInfo.apply(null, arguments); } // lang
|
|
24
24
|
function hasQualities() { return P.hasQualities.apply(null, arguments); } // quality
|
|
25
|
+
function qualityUnread() { return P.qualityUnread ? P.qualityUnread() : false; } // quality
|
|
25
26
|
function qualityCount() { return P.qualityCount.apply(null, arguments); } // quality
|
|
26
27
|
function qualityLabel() { return P.qualityLabel.apply(null, arguments); } // quality
|
|
27
28
|
function qualityMenu() { return P.qualityMenu.apply(null, arguments); } // quality
|
|
@@ -627,6 +628,8 @@
|
|
|
627
628
|
}
|
|
628
629
|
if (hasQualities()) {
|
|
629
630
|
items.push({ label: 'Video quality', detail: qualityLabel(), run: function () { pushMenu(qualityMenu()); } });
|
|
631
|
+
} else if (qualityUnread()) {
|
|
632
|
+
items.push({ label: 'Video quality', detail: 'Auto: this stream’s qualities couldn’t be read', run: function () { U.toast('This stream’s qualities couldn’t be read: it plays on Auto', 4000); } });
|
|
630
633
|
}
|
|
631
634
|
if (chapters().length) {
|
|
632
635
|
var ci = chapterAt(S.cur);
|
|
@@ -683,6 +686,7 @@
|
|
|
683
686
|
function canDownload() {
|
|
684
687
|
var c = S.ctx, st = c && c.stream;
|
|
685
688
|
return !!(st && c.downloadAs && !S.isLive && !c.trailer && !c.yt && !c.localFile && !st._localFile && !st._yt && /^https?:\/\//i.test(st.url || '') &&
|
|
689
|
+
(typeof Downloads === 'undefined' || !Downloads.isFileLink || Downloads.isFileLink(st)) &&
|
|
686
690
|
typeof Downloads !== 'undefined' && Downloads.available());
|
|
687
691
|
}
|
|
688
692
|
function downloadPlaying() {
|
|
@@ -807,6 +811,7 @@
|
|
|
807
811
|
}
|
|
808
812
|
lines.push(['Subtitles', subtitleSummary()]);
|
|
809
813
|
if (hasQualities()) { lines.push(['Quality', qualityLabel() + ' (' + qualityCount() + ' available)']); }
|
|
814
|
+
else if (qualityUnread()) { lines.push(['Quality', 'Auto (this stream’s qualities couldn’t be read)']); }
|
|
810
815
|
if (useAV) {
|
|
811
816
|
try {
|
|
812
817
|
var bw = +avplay.getStreamingProperty('CURRENT_BANDWIDTH');
|
package/js/player-core.js
CHANGED
|
@@ -58,7 +58,8 @@ var PlayerParts = {};
|
|
|
58
58
|
dur: 0, // seconds
|
|
59
59
|
cur: 0, // seconds
|
|
60
60
|
isLive: false, // live stream (sports/IPTV): no fixed duration
|
|
61
|
-
playStarted: false, // this AVPlay attempt
|
|
61
|
+
playStarted: false, // this AVPlay attempt was prepared (opened, ready to play)
|
|
62
|
+
playing: false, // ...and actually played: its position moved for a second (see oncurrentplaytime)
|
|
62
63
|
// seek robustness (AVPlay can error if you seek mid-buffer)
|
|
63
64
|
buffering: false,
|
|
64
65
|
pendingSeek: null,
|
|
@@ -156,8 +157,14 @@ var PlayerParts = {};
|
|
|
156
157
|
|
|
157
158
|
function isActive() { return S.active; }
|
|
158
159
|
|
|
159
|
-
|
|
160
|
+
// fromTicker: the 5-second save while it plays: skipped when nothing moved since the last one
|
|
161
|
+
// (paused); a stop, a leave or a jump always saves.
|
|
162
|
+
var _savedSig = '';
|
|
163
|
+
function saveProgress(fromTicker) {
|
|
160
164
|
if (!S.ctx || !S.dur || S.isLive || S.ctx.trailer || S.ctx.private || isNotice()) { return; } // (a notice clip, private viewing: nothing saved, nothing removed)
|
|
165
|
+
var sig = progressKey(S.ctx) + '|' + Math.floor(S.cur) + '|' + Math.floor(S.dur);
|
|
166
|
+
if (fromTicker && sig === _savedSig) { return; }
|
|
167
|
+
_savedSig = sig;
|
|
161
168
|
// Past the credits the episode is watched: its entry was removed and the next one
|
|
162
169
|
// queued (handleCredits). Saving again would put it back next to the next episode.
|
|
163
170
|
if (_creditsHandled) { return; }
|
|
@@ -376,7 +383,8 @@ var PlayerParts = {};
|
|
|
376
383
|
resetQuality(ctx);
|
|
377
384
|
resetControls();
|
|
378
385
|
_stall = { t: -1, since: 0, bufSince: 0, recoveries: [] };
|
|
379
|
-
|
|
386
|
+
recoReset();
|
|
387
|
+
_prefetchedNext = false; S.playStarted = false; S.playing = false;
|
|
380
388
|
_segments = null; _creditsSec = null; _creditsHandled = false; _segmentsFor = null;
|
|
381
389
|
S.buffering = false; S.pendingSeek = null; S.lastSeekAt = 0; S.settleFrom = 0; _seekRecovering = false; _jumpUndone = null; _pausedOnce = false;
|
|
382
390
|
updateHint();
|
|
@@ -509,7 +517,7 @@ var PlayerParts = {};
|
|
|
509
517
|
url = (S.ctx && S.ctx._relay && S.ctx._relayFor === url) ? S.ctx._relay : opened(url); // (a live channel's sound repacked: see liveCheck)
|
|
510
518
|
var token = ++_playToken;
|
|
511
519
|
objEl.className = 'active';
|
|
512
|
-
S.playStarted = false;
|
|
520
|
+
S.playStarted = false; S.playing = false;
|
|
513
521
|
_lastTickMs = null; _playedMs = 0;
|
|
514
522
|
_tick = null; _pausedOnce = false; // (a new open: the jump watch starts over, see checkJump)
|
|
515
523
|
subsBeforeOpen(); // a built-in subtitle track is selected again once this attempt plays
|
|
@@ -564,6 +572,7 @@ var PlayerParts = {};
|
|
|
564
572
|
// Played, not just opened: a file can open and then fail (or show nothing)
|
|
565
573
|
if (_lastTickMs !== null && ms > _lastTickMs && ms - _lastTickMs < 1500) { _playedMs += ms - _lastTickMs; }
|
|
566
574
|
_lastTickMs = ms;
|
|
575
|
+
if (!S.playing && _playedMs >= 1000) { S.playing = true; _reco.tries = 0; } // (started: playing, not just opened; a reconnect's tries start over)
|
|
567
576
|
if (_playedMs >= 2000 && !isNotice()) { reportStart(true); } // (a notice clip proves nothing about the format)
|
|
568
577
|
audioOnTick();
|
|
569
578
|
subsOnTick();
|
|
@@ -586,11 +595,15 @@ var PlayerParts = {};
|
|
|
586
595
|
setTimeout(function () { _seekRecovering = false; }, 4000);
|
|
587
596
|
return;
|
|
588
597
|
}
|
|
589
|
-
//
|
|
590
|
-
if (
|
|
598
|
+
// A live channel that played: its connection dropped → opened again (liveDropped).
|
|
599
|
+
if (S.playing && liveReconnects()) { U.log('player', 'Playback error: ' + err + ' (' + where(S.ctx.url) + ')'); liveDropped('Connection lost'); return; }
|
|
600
|
+
// A reconnect's try that didn't play → the next try.
|
|
601
|
+
if (!S.playing && _reco.tries) { if (!relayFailed()) { liveDropped('Couldn’t reconnect'); } return; }
|
|
602
|
+
// Failed before it ever played (opened, maybe, but nothing shown) → retry / step down / Auto.
|
|
603
|
+
if (!S.playing && (relayFailed() || tryFallback())) { return; }
|
|
591
604
|
reportStart(false, err); // (unless it had already played)
|
|
592
605
|
U.log('player', 'Playback error: ' + err + ' (' + where(S.ctx && S.ctx.url) + ')');
|
|
593
|
-
if (!S.
|
|
606
|
+
if (!S.playing) { failedStart('Playback error: ' + err); return; }
|
|
594
607
|
U.toast('Playback error: ' + err);
|
|
595
608
|
exitLater();
|
|
596
609
|
}
|
|
@@ -625,6 +638,7 @@ var PlayerParts = {};
|
|
|
625
638
|
if (!S.active || token !== _playToken) { return; }
|
|
626
639
|
U.spinner(false);
|
|
627
640
|
if (relayFailed()) { return; }
|
|
641
|
+
if (_reco.tries) { liveDropped('Couldn’t reconnect'); return; } // (a reconnect's try: the next one)
|
|
628
642
|
// Prepare failed → retry / step down / Auto.
|
|
629
643
|
if (tryFallback()) { return; }
|
|
630
644
|
U.log('player', 'Could not open: ' + (err && (err.name || err.message || err)) + ' (' + where(url) + ')');
|
|
@@ -710,7 +724,7 @@ var PlayerParts = {};
|
|
|
710
724
|
_ticker = setInterval(function () {
|
|
711
725
|
if (inBackground()) { return; } // (saved on the way there; nothing to watch until it's back)
|
|
712
726
|
if (useAV) { try { S.cur = avplay.getCurrentTime() / 1000; } catch (e) {} onProgress(); checkStall(); }
|
|
713
|
-
saveProgress();
|
|
727
|
+
saveProgress(true);
|
|
714
728
|
}, 5000);
|
|
715
729
|
}
|
|
716
730
|
|
|
@@ -723,8 +737,8 @@ var PlayerParts = {};
|
|
|
723
737
|
return !!S.ctx && !S.ctx.yt && !S.ctx.trailer && !S.isLive && (S.ctx.type === 'movie' || S.ctx.type === 'series') && S.dur > 0 && S.dur < NOTICE_MAX_S;
|
|
724
738
|
}
|
|
725
739
|
function onEnded() {
|
|
726
|
-
// a live channel whose stream ended (its server stopped it): opened again
|
|
727
|
-
if (
|
|
740
|
+
// a live channel whose stream ended (its server stopped it): opened again (liveDropped)
|
|
741
|
+
if (liveReconnects()) { liveDropped('The stream ended'); return; }
|
|
728
742
|
if (isNotice()) { // (back to its sources: another one, or this one later)
|
|
729
743
|
U.toast('That source isn\'t ready yet (the debrid service is still getting it): try another, or this one later', 6000);
|
|
730
744
|
exit();
|
|
@@ -834,6 +848,13 @@ var PlayerParts = {};
|
|
|
834
848
|
if (_creditsHandled || !S.ctx || S.ctx.trailer || isNotice()) { return; }
|
|
835
849
|
_creditsHandled = true;
|
|
836
850
|
if (S.ctx.private) { return; } // (private viewing: not marked watched, nothing queued)
|
|
851
|
+
// With Trakt: sent as a finish (Trakt's own way, which also clears a paused place it
|
|
852
|
+
// kept for it), not as a separate "watched"
|
|
853
|
+
if (S.dur > 0 && typeof Trakt !== 'undefined' && Trakt.onStopped && Trakt.isConnected && Trakt.isConnected()) {
|
|
854
|
+
markFinished(true);
|
|
855
|
+
Trakt.onStopped(S.ctx.type, S.ctx.id, S.cur, S.dur, S.ctx.name);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
837
858
|
markFinished(false);
|
|
838
859
|
}
|
|
839
860
|
// Watched: off Continue Watching, the next episode queued. quiet: Trakt's been told another
|
|
@@ -937,7 +958,7 @@ var PlayerParts = {};
|
|
|
937
958
|
var NEXT_TRIES = 3, AV_START_MS = 45000, _openedAt = 0;
|
|
938
959
|
function nextSourceOnFail(notice) {
|
|
939
960
|
var c = S.ctx;
|
|
940
|
-
if (!c || !S.active || S.
|
|
961
|
+
if (!c || !S.active || S.playing || S.isLive || c.trailer || c.yt || c.localFile || !c.stream || typeof c.nextSource !== 'function') { return false; }
|
|
941
962
|
if (typeof Stremio === 'undefined' || !Stremio.getPrefs || !Stremio.getPrefs().nextOnFail) { return false; }
|
|
942
963
|
var tried = (c._tried || []).concat([Stremio.sourceId(c.stream)]);
|
|
943
964
|
if (tried.length > NEXT_TRIES) { return false; }
|
|
@@ -1000,11 +1021,17 @@ var PlayerParts = {};
|
|
|
1000
1021
|
else { n.text = svc + ' couldn’t give this file (' + addon + ': “' + kind.replace(/_/g, ' ') + '”)'; }
|
|
1001
1022
|
return n;
|
|
1002
1023
|
}
|
|
1003
|
-
// Not
|
|
1004
|
-
// next source
|
|
1024
|
+
// Not playing after AV_START_MS (no answer from the server, or opened and nothing shown): the
|
|
1025
|
+
// next source when there's one; else it stops and says so (not while you've paused it).
|
|
1005
1026
|
function startCheck() {
|
|
1006
|
-
if (!S.active || S.
|
|
1007
|
-
|
|
1027
|
+
if (!S.active || S.playing || !useAV || !_openedAt || Date.now() - _openedAt < AV_START_MS) { return; }
|
|
1028
|
+
if (_reco.tries) { liveDropped('Couldn’t reconnect'); return; } // (a reconnect's try: the next one)
|
|
1029
|
+
if (nextSourceOnFail()) { return; }
|
|
1030
|
+
if (S.playStarted && isPaused()) { return; }
|
|
1031
|
+
U.log('player', 'Didn’t start: nothing played in ' + (AV_START_MS / 1000) + ' s (' + where(S.ctx && S.ctx.url) + ')');
|
|
1032
|
+
U.spinner(false);
|
|
1033
|
+
U.toast('Didn’t start: nothing played in ' + (AV_START_MS / 1000) + ' seconds. Try another source.', 6000);
|
|
1034
|
+
exitLater();
|
|
1008
1035
|
}
|
|
1009
1036
|
P.startCheck = startCheck;
|
|
1010
1037
|
|
|
@@ -1077,14 +1104,15 @@ var PlayerParts = {};
|
|
|
1077
1104
|
var now = Date.now();
|
|
1078
1105
|
if (S.buffering) {
|
|
1079
1106
|
if (!_stall.bufSince) { _stall.bufSince = now; }
|
|
1080
|
-
else if (now - _stall.bufSince > BUFFER_STALL_SECS * 1000) { recoverStall('Stream stopped loading
|
|
1107
|
+
else if (now - _stall.bufSince > BUFFER_STALL_SECS * 1000) { recoverStall('Stream stopped loading'); }
|
|
1081
1108
|
return;
|
|
1082
1109
|
}
|
|
1083
1110
|
_stall.bufSince = 0;
|
|
1084
1111
|
if (isPaused() || S.cur !== _stall.t) { _stall.t = S.cur; _stall.since = now; return; }
|
|
1085
|
-
if (_stall.since && now - _stall.since > STALL_SECS * 1000) { recoverStall('Playback stalled
|
|
1112
|
+
if (_stall.since && now - _stall.since > STALL_SECS * 1000) { recoverStall('Playback stalled'); }
|
|
1086
1113
|
}
|
|
1087
|
-
function recoverStall(
|
|
1114
|
+
function recoverStall(why) {
|
|
1115
|
+
if (liveReconnects()) { liveDropped(why); return; }
|
|
1088
1116
|
var now = Date.now();
|
|
1089
1117
|
_stall.recoveries = _stall.recoveries.filter(function (t) { return now - t < STALL_WINDOW_MS; });
|
|
1090
1118
|
if (_stall.recoveries.length >= STALL_MAX) {
|
|
@@ -1094,7 +1122,51 @@ var PlayerParts = {};
|
|
|
1094
1122
|
return;
|
|
1095
1123
|
}
|
|
1096
1124
|
_stall.recoveries.push(now);
|
|
1097
|
-
reopen(
|
|
1125
|
+
reopen(why + ' — reloading…');
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// ---------- a live channel that drops ----------
|
|
1129
|
+
// A live channel that stops (an error once it played, its connection lost; frozen, stuck
|
|
1130
|
+
// loading, or ended by its server) is opened again at the live edge, as Settings › Playback ›
|
|
1131
|
+
// Seeking & buffering › Reconnect live channels says: Off (the player closes), A few times
|
|
1132
|
+
// (STALL_MAX within STALL_WINDOW_MS, as any stall) or Keep trying (no limit). A try that
|
|
1133
|
+
// doesn't play is followed by another, each a little later (RECO_WAIT_S); BACK stops it.
|
|
1134
|
+
// Not for a trailer or YouTube (a stall there reloads as a video's does).
|
|
1135
|
+
var RECO_WAIT_S = [0, 5, 10, 20, 40, 60], _reco = { tries: 0, timer: null };
|
|
1136
|
+
function liveReconnects() { return S.isLive && !!S.ctx && !S.ctx.trailer && !S.ctx.yt; }
|
|
1137
|
+
function recoReset() {
|
|
1138
|
+
if (_reco.timer) { clearTimeout(_reco.timer); }
|
|
1139
|
+
_reco = { tries: 0, timer: null };
|
|
1140
|
+
}
|
|
1141
|
+
function liveDropped(why) {
|
|
1142
|
+
if (_reco.timer) { return; } // (a try already waiting)
|
|
1143
|
+
var mode = P.playerCfg().reconnect, now = Date.now();
|
|
1144
|
+
if (mode === 'off') {
|
|
1145
|
+
U.log('player', 'Live channel: ' + why + '; reconnecting is off');
|
|
1146
|
+
U.spinner(false); U.toast(why + ': the channel stopped (reconnecting is off)', 5000);
|
|
1147
|
+
exitLater();
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
_stall.recoveries = _stall.recoveries.filter(function (t) { return now - t < STALL_WINDOW_MS; });
|
|
1151
|
+
if (mode !== 'always' && _stall.recoveries.length >= STALL_MAX) {
|
|
1152
|
+
U.log('player', 'Live channel: ' + why + '; dropped ' + STALL_MAX + ' times in ' + (STALL_WINDOW_MS / 60000) + ' minutes, given up');
|
|
1153
|
+
U.spinner(false); U.toast('This channel keeps dropping — try it again later', 5000);
|
|
1154
|
+
exit();
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
_stall.recoveries.push(now);
|
|
1158
|
+
var n = ++_reco.tries, wait = RECO_WAIT_S[Math.min(n - 1, RECO_WAIT_S.length - 1)] * 1000, ctx = S.ctx;
|
|
1159
|
+
var msg = why + ' — reconnecting' + (n > 1 ? ' (try ' + n + ')' : '') + '…';
|
|
1160
|
+
U.log('player', 'Live channel: ' + why + ', reconnecting (try ' + n + (wait ? ', in ' + (wait / 1000) + ' s' : '') + ')');
|
|
1161
|
+
if (!wait) { reopen(msg); return; }
|
|
1162
|
+
U.spinner(true, msg);
|
|
1163
|
+
function due() {
|
|
1164
|
+
_reco.timer = null;
|
|
1165
|
+
if (!S.active || S.ctx !== ctx) { return; }
|
|
1166
|
+
if (inBackground()) { _reco.timer = setTimeout(due, 5000); return; } // (back first)
|
|
1167
|
+
reopen(msg);
|
|
1168
|
+
}
|
|
1169
|
+
_reco.timer = setTimeout(due, wait);
|
|
1098
1170
|
}
|
|
1099
1171
|
// The stream opened again in place: VOD at the same position, live at the edge.
|
|
1100
1172
|
function reopen(message) {
|
|
@@ -1126,6 +1198,7 @@ var PlayerParts = {};
|
|
|
1126
1198
|
closeControls();
|
|
1127
1199
|
screenSaver(true);
|
|
1128
1200
|
_playToken++; // invalidate AVPlay/HTML5 callbacks before stop/close emits any
|
|
1201
|
+
recoReset();
|
|
1129
1202
|
if (_ticker) { clearInterval(_ticker); _ticker = null; }
|
|
1130
1203
|
if (_osdTimer) { clearTimeout(_osdTimer); _osdTimer = null; }
|
|
1131
1204
|
hideOSD();
|
package/js/player-lang.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
var info = tr && tr.extra_info;
|
|
11
11
|
if (typeof info === 'string') {
|
|
12
12
|
try { info = JSON.parse(info); } catch (e) {
|
|
13
|
-
var out =
|
|
13
|
+
var out = Object.create(null), re = /["']?([A-Za-z_]+)["']?\s*[:=]\s*["']?([^"',}]*)/g, m; // (no inherited names)
|
|
14
14
|
while ((m = re.exec(info))) { out[m[1]] = m[2].trim(); }
|
|
15
15
|
info = out;
|
|
16
16
|
}
|
package/js/player-quality.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
function resetQuality(ctx) {
|
|
27
27
|
_variants = []; _qualityBw = null; _ytRetried = false; _switchNote = null;
|
|
28
28
|
_userPicked = false; _autoRetried = false; _startNote = '';
|
|
29
|
-
loadVariants(ctx.variantUrl || ctx.url, ctx.initialQualityHeight || 0, !!ctx.variantUrl);
|
|
29
|
+
loadVariants(ctx.variantUrl || ctx.url, ctx.initialQualityHeight || 0, !!ctx.variantUrl, ctx._headers);
|
|
30
30
|
}
|
|
31
31
|
// Playback started: a switch is done; say where a fallback landed.
|
|
32
32
|
function qualityOnStart() {
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
function qualityHeight() { var v = variantByBw(_qualityBw); return v ? v.h : 0; }
|
|
39
39
|
function qualityCount() { return _variants.length; }
|
|
40
40
|
function hasQualities() { return _variants.length > 1; }
|
|
41
|
+
var _unread = false; // an HLS stream whose quality list couldn't be read
|
|
42
|
+
function qualityUnread() { return _unread && !_variants.length; }
|
|
41
43
|
|
|
42
44
|
// Auto lets AVPlay adapt to bandwidth; an explicit pick caps the ladder via ADAPTIVE_INFO.
|
|
43
45
|
// Two cap strategies, because YouTube-style HLS breaks the usual one:
|
|
@@ -187,8 +189,11 @@
|
|
|
187
189
|
var u = '' + (url || ''), path = u.split('#')[0].split('?')[0], query = u.split('#')[0].split('?')[1] || '';
|
|
188
190
|
return /\.m3u8$/i.test(path) || /(^|&)(type|format|ext|output)=m3u8(&|$)/i.test(query);
|
|
189
191
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
+
// h: the stream's own User-Agent / Referer ({ ua, ref }): the list is then read through
|
|
193
|
+
// StremioK's service, which can send them (as the TV's player does); a web page can't.
|
|
194
|
+
function getHlsMaster(url, h) {
|
|
195
|
+
var viaService = !!(h && (h.ua || h.ref) && typeof YTPlay !== 'undefined' && YTPlay.hlsMaster && YTPlay.installMode && YTPlay.installMode() !== 'browser');
|
|
196
|
+
return (viaService ? YTPlay.hlsMaster(url, h) : U.getText(url, 8000, HLS_MASTER_MAX)).then(function (text) {
|
|
192
197
|
var t = ('' + (text || '')).replace(/^\uFEFF/, '').replace(/^\s+/, '');
|
|
193
198
|
if (t.indexOf('#EXTM3U') !== 0) { throw new Error('not an HLS playlist'); }
|
|
194
199
|
return t;
|
|
@@ -205,12 +210,13 @@
|
|
|
205
210
|
CLASSES.forEach(function (c) { if (Math.abs(c - t) <= c * 0.06) { best = c; } });
|
|
206
211
|
return best;
|
|
207
212
|
}
|
|
208
|
-
function loadVariants(url, initialHeight, isMaster) {
|
|
213
|
+
function loadVariants(url, initialHeight, isMaster, headers) {
|
|
209
214
|
var tok = ++_variantsToken; // a list arriving after you've moved on is for the old stream
|
|
210
215
|
_variants = [];
|
|
211
216
|
_sepAudio = false;
|
|
217
|
+
_unread = false;
|
|
212
218
|
if (!url || !(isMaster || isHlsUrl(url))) { return; }
|
|
213
|
-
getHlsMaster(url).then(function (text) {
|
|
219
|
+
getHlsMaster(url, headers).then(function (text) {
|
|
214
220
|
if (tok !== _variantsToken) { return; }
|
|
215
221
|
var t = '' + text;
|
|
216
222
|
_sepAudio = t.indexOf('#EXT-X-MEDIA:TYPE=AUDIO') !== -1 || /#EXT-X-STREAM-INF[^\r\n]*[,:]AUDIO=/.test(t);
|
|
@@ -239,7 +245,9 @@
|
|
|
239
245
|
}
|
|
240
246
|
}
|
|
241
247
|
}
|
|
242
|
-
}, function () {
|
|
248
|
+
}, function (e) { // (couldn't be read: said so, not "one quality"; an answer that isn't a playlist at all: no list, as before)
|
|
249
|
+
if (tok === _variantsToken) { _variants = []; _sepAudio = false; _unread = !/not an HLS playlist/.test((e && e.message) || ''); }
|
|
250
|
+
});
|
|
243
251
|
}
|
|
244
252
|
|
|
245
253
|
// Switch to a different source mid-playback, keeping position (VOD): a playback of its
|
|
@@ -312,7 +320,7 @@
|
|
|
312
320
|
|
|
313
321
|
// ----- for the other parts (and js/player.js) -----
|
|
314
322
|
P.resetQuality = resetQuality; P.qualityOnStart = qualityOnStart; P.clearStartNote = clearStartNote;
|
|
315
|
-
P.qualityHeight = qualityHeight; P.qualityCount = qualityCount; P.hasQualities = hasQualities;
|
|
323
|
+
P.qualityHeight = qualityHeight; P.qualityCount = qualityCount; P.hasQualities = hasQualities; P.qualityUnread = qualityUnread;
|
|
316
324
|
P.adaptiveInfo = adaptiveInfo; P.tryFallback = tryFallback; P.switchSource = switchSource;
|
|
317
325
|
P.qualityLabel = qualityLabel; P.qualityMenu = qualityMenu;
|
|
318
326
|
P.hasYtLanguages = hasYtLanguages; P.ytLanguageLabel = ytLanguageLabel; P.ytLanguageMenu = ytLanguageMenu;
|