stremiok 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/README.md +10 -8
- package/config.xml +1 -1
- package/css/style.css +37 -2
- package/index.html +34 -31
- package/js/app.js +19 -7
- package/js/downloads.js +17 -5
- package/js/epg.js +320 -0
- package/js/hubpreview.js +15 -5
- package/js/iptv.js +202 -29
- package/js/keys.js +2 -0
- package/js/player-controls.js +55 -8
- package/js/player-core.js +204 -31
- package/js/player-lang.js +1 -1
- package/js/player-quality.js +15 -7
- package/js/player-store.js +28 -4
- package/js/player-subs.js +45 -11
- package/js/player.js +1 -1
- package/js/stremio-account.js +3 -0
- package/js/stremio.js +157 -28
- package/js/trakt.js +61 -9
- package/js/util.js +36 -10
- package/js/view-core.js +139 -55
- package/js/view-detail.js +5 -4
- package/js/view-guide.js +332 -0
- package/js/view-home.js +168 -36
- package/js/view-settings.js +170 -80
- package/js/views.js +3 -2
- package/js/youtube.js +1 -3
- package/js/ytplay.js +62 -5
- package/package.json +2 -1
- package/service/index.js +1131 -108
- package/service/preview.js +14 -4
package/js/iptv.js
CHANGED
|
@@ -29,6 +29,7 @@ var IPTV = (function () {
|
|
|
29
29
|
var o = { id: '' + p.id, url: cleanUrl(p.url) };
|
|
30
30
|
if (typeof p.name === 'string' && p.name.trim()) { o.name = p.name.trim().slice(0, NAME_MAX); }
|
|
31
31
|
if (p.off === true) { o.off = true; }
|
|
32
|
+
if (typeof p.guide === 'string' && U.parseUrl(p.guide)) { o.guide = p.guide; } // (its TV guide, yours: see EPG.guideUrl)
|
|
32
33
|
return o;
|
|
33
34
|
});
|
|
34
35
|
}
|
|
@@ -64,6 +65,17 @@ var IPTV = (function () {
|
|
|
64
65
|
savePlaylists(list, true);
|
|
65
66
|
return true;
|
|
66
67
|
}
|
|
68
|
+
// Its TV guide: a link of yours (an XMLTV file), or '' for the one it names / an Xtream
|
|
69
|
+
// playlist's own (EPG.guideUrl). false: not a link.
|
|
70
|
+
function setPlaylistGuide(id, url) {
|
|
71
|
+
var list = getPlaylists(), i = find(list, id);
|
|
72
|
+
url = ('' + (url || '')).trim();
|
|
73
|
+
if (i === -1 || (url && !checkedUrl(url))) { return false; }
|
|
74
|
+
if (url) { list[i].guide = checkedUrl(url); } else { delete list[i].guide; }
|
|
75
|
+
savePlaylists(list, true);
|
|
76
|
+
if (typeof EPG !== 'undefined' && EPG.reset) { EPG.reset(); }
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
67
79
|
// Off: kept (with its saved copy), but its channels aren't read or shown.
|
|
68
80
|
function setPlaylistOff(id, off) {
|
|
69
81
|
var list = getPlaylists(), i = find(list, id);
|
|
@@ -75,9 +87,8 @@ var IPTV = (function () {
|
|
|
75
87
|
// Up (-1) or down (1): a channel in two playlists comes from the first, and rows are
|
|
76
88
|
// in playlist order.
|
|
77
89
|
function movePlaylist(id, dir) {
|
|
78
|
-
var list = getPlaylists()
|
|
79
|
-
if (
|
|
80
|
-
var t = list[i]; list[i] = list[j]; list[j] = t;
|
|
90
|
+
var list = getPlaylists();
|
|
91
|
+
if (!U.moveIn(list, find(list, id), dir)) { return false; } // (one place, or to the top / bottom)
|
|
81
92
|
savePlaylists(list);
|
|
82
93
|
return true;
|
|
83
94
|
}
|
|
@@ -122,6 +133,58 @@ var IPTV = (function () {
|
|
|
122
133
|
return id;
|
|
123
134
|
}
|
|
124
135
|
|
|
136
|
+
// ---- An Xtream account: its server, username and password ----
|
|
137
|
+
// The server as typed ("tv.example.net:8080": http:// when it has no scheme; a page or a
|
|
138
|
+
// .php after it left out), or a whole Xtream link pasted there (its username and password
|
|
139
|
+
// too): { base, user, pass } or null.
|
|
140
|
+
function xtreamParts(server) {
|
|
141
|
+
var s = ('' + (server || '')).trim();
|
|
142
|
+
if (!s) { return null; }
|
|
143
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) { s = 'http://' + s.replace(/^\/+/, ''); }
|
|
144
|
+
var u = U.parseUrl(s);
|
|
145
|
+
if (!u) { return null; }
|
|
146
|
+
function param(name) { var m = new RegExp('(?:^|&)' + name + '=([^&#]*)').exec(u.query || ''); try { return m ? decodeURIComponent(m[1].replace(/\+/g, ' ')) : ''; } catch (e) { return ''; } }
|
|
147
|
+
var path = (u.path || '/').replace(/\/[^\/]*\.php$/i, '').replace(/\/+$/, '');
|
|
148
|
+
return { base: u.scheme + '://' + u.host + (u.port ? ':' + u.port : '') + path, user: param('username'), pass: param('password') };
|
|
149
|
+
}
|
|
150
|
+
// Its channels as MPEG-TS (output=ts): servers' own HLS tends to stall a few minutes in;
|
|
151
|
+
// Format (setXtreamFormat) switches an account to HLS (output=m3u8) and back.
|
|
152
|
+
function xtreamUrl(base, user, pass) {
|
|
153
|
+
return base + '/get.php?username=' + encodeURIComponent(user) + '&password=' + encodeURIComponent(pass) + '&type=m3u_plus&output=ts';
|
|
154
|
+
}
|
|
155
|
+
function isXtream(url) { var u = U.parseUrl(url); return !!(u && /\/get\.php$/i.test(u.path) && /(?:^|&)username=/.test(u.query) && /(?:^|&)password=/.test(u.query)); }
|
|
156
|
+
// An Xtream account's format: 'ts' or 'hls' ('' for a playlist that isn't one).
|
|
157
|
+
function xtreamFormat(id) {
|
|
158
|
+
var list = getPlaylists(), i = find(list, id);
|
|
159
|
+
if (i === -1 || !isXtream(list[i].url)) { return ''; }
|
|
160
|
+
return /(?:[?&])output=(m3u8|hls)(?:&|$)/i.test(list[i].url) ? 'hls' : 'ts';
|
|
161
|
+
}
|
|
162
|
+
function setXtreamFormat(id, fmt) {
|
|
163
|
+
var list = getPlaylists(), i = find(list, id);
|
|
164
|
+
if (i === -1 || !isXtream(list[i].url)) { return false; }
|
|
165
|
+
var out = fmt === 'hls' ? 'm3u8' : 'ts', u = list[i].url;
|
|
166
|
+
u = /([?&])output=[^&#]*/i.test(u) ? u.replace(/([?&])output=[^&#]*/i, '$1output=' + out) : u + '&output=' + out;
|
|
167
|
+
return updatePlaylist(id, u);
|
|
168
|
+
}
|
|
169
|
+
// Checked with the server (player_api.php): resolves 'ok', 'refused' (a wrong login, an
|
|
170
|
+
// account expired or banned: { refused, status }) or 'unreachable'.
|
|
171
|
+
function xtreamCheck(base, user, pass) {
|
|
172
|
+
return U.getJSON(base + '/player_api.php?username=' + encodeURIComponent(user) + '&password=' + encodeURIComponent(pass), 15000, 1048576).then(function (j) {
|
|
173
|
+
var ui = j && j.user_info;
|
|
174
|
+
return (ui && +ui.auth === 1 && !/^(expired|banned|disabled)$/i.test('' + (ui.status || ''))) ? { ok: true } : { refused: true, status: ui && ui.status ? '' + ui.status : '' };
|
|
175
|
+
}, function () { return { unreachable: true }; });
|
|
176
|
+
}
|
|
177
|
+
// Added as its get.php link, named after its server ("Xtream · tv.example.net"). Its id.
|
|
178
|
+
function addXtream(base, user, pass) {
|
|
179
|
+
var id = addPlaylist(xtreamUrl(base, user, pass));
|
|
180
|
+
var u = U.parseUrl(base);
|
|
181
|
+
if (id && u) {
|
|
182
|
+
var list = getPlaylists(), i = find(list, id);
|
|
183
|
+
if (i !== -1 && !list[i].name) { list[i].name = freeName('Xtream · ' + u.host, list.filter(function (p) { return p.id !== id; })); savePlaylists(list, true); }
|
|
184
|
+
}
|
|
185
|
+
return id;
|
|
186
|
+
}
|
|
187
|
+
|
|
125
188
|
function updatePlaylist(id, url) {
|
|
126
189
|
url = checkedUrl(url);
|
|
127
190
|
if (!url) { return false; }
|
|
@@ -145,8 +208,8 @@ var IPTV = (function () {
|
|
|
145
208
|
function isEnabled() { return getPlaylists().some(function (p) { return !p.off; }); }
|
|
146
209
|
// The playlists' revision: changed (added, edited, removed, switched off, moved, reloaded),
|
|
147
210
|
// a load still under way for the old ones keeps nothing, and they're read as they are now.
|
|
148
|
-
var _rev = 0, _pending = null;
|
|
149
|
-
function reset() { _cache = null; _rev++; }
|
|
211
|
+
var _rev = 0, _pending = null, _ytEnds = {}; // (_ytEnds: Xtream link -> where it led, youTubeEnd)
|
|
212
|
+
function reset() { _cache = null; _rev++; _ytEnds = {}; }
|
|
150
213
|
|
|
151
214
|
// Playlist text is cached per playlist (one key each), in the big store (IndexedDB:
|
|
152
215
|
// far more room than localStorage, which keeps settings and progress). A playlist that
|
|
@@ -178,6 +241,13 @@ var IPTV = (function () {
|
|
|
178
241
|
function idOf(s, exact) { return slug(s) + '-' + hash(exact === undefined ? s : exact); }
|
|
179
242
|
|
|
180
243
|
function attr(line, re) { var m = line.match(re); return m ? m[1] : ''; }
|
|
244
|
+
// A header a playlist gives a channel, by name: its User-Agent or Referer (the only two the
|
|
245
|
+
// TV's player takes: any other is left out). Checked when played (U.sanitizeHeaders).
|
|
246
|
+
function headerInto(c, name, value) {
|
|
247
|
+
var n = ('' + name).trim().toLowerCase(), v = ('' + (value || '')).trim();
|
|
248
|
+
if (!v) { return; }
|
|
249
|
+
if (n === 'user-agent') { c.ua = v; } else if (n === 'referer' || n === 'referrer') { c.ref = v; }
|
|
250
|
+
}
|
|
181
251
|
|
|
182
252
|
function truthy(v) { return /^(?:1|true|yes)$/i.test(('' + (v || '')).trim()); }
|
|
183
253
|
|
|
@@ -187,11 +257,23 @@ var IPTV = (function () {
|
|
|
187
257
|
|
|
188
258
|
// Returns [{ id, type, name, logo, group, tvgId, ua, ref, url, _yt }]
|
|
189
259
|
// The M3U reader, a line at a time (so a big playlist can be read in chunks, see
|
|
190
|
-
// parseM3UAsync). max: channels kept; any beyond are only counted (dropped).
|
|
260
|
+
// parseM3UAsync). max: channels kept; any beyond are only counted (dropped). guides: the TV
|
|
261
|
+
// guides it names (url-tvg / x-tvg-url / tvg-url, several split by commas).
|
|
191
262
|
function m3uReader(max) {
|
|
192
|
-
var r = { channels: [], dropped: 0, title: '' }, cur = null, seen = {};
|
|
263
|
+
var r = { channels: [], dropped: 0, title: '', guides: [] }, cur = null, seen = {};
|
|
264
|
+
function add(c) {
|
|
265
|
+
var base = 'iptv:' + idOf(c.tvgId || c.name);
|
|
266
|
+
var id = base, n = 2;
|
|
267
|
+
while (seen[id]) { id = base + '-' + n; n++; }
|
|
268
|
+
seen[id] = 1; c.id = id;
|
|
269
|
+
r.channels.push(c);
|
|
270
|
+
}
|
|
193
271
|
r.line = function (raw) {
|
|
194
272
|
var s = raw.replace(/^\s+/, '');
|
|
273
|
+
if (s.indexOf('#EXTM3U') === 0 && !r.guides.length) {
|
|
274
|
+
r.guides = attr(s, /(?:^|\s)(?:url-tvg|x-tvg-url|tvg-url)="([^"]{1,4000})"/i).split(',').map(function (u) { return u.trim(); })
|
|
275
|
+
.filter(function (u) { return /^https?:\/\//i.test(u); }).slice(0, 5);
|
|
276
|
+
}
|
|
195
277
|
if (!r.title && s.indexOf('#EXTM3U') === 0) { // the playlist's own name, if it has one
|
|
196
278
|
r.title = attr(s, /(?:^|\s)(?:x-playlist-name|playlist-name|x-tvg-name)="([^"]{1,200})"/i).trim().slice(0, NAME_MAX);
|
|
197
279
|
} else if (!r.title && s.indexOf('#PLAYLIST:') === 0) {
|
|
@@ -206,18 +288,37 @@ var IPTV = (function () {
|
|
|
206
288
|
group: group,
|
|
207
289
|
tvgId: attr(s, /tvg-id="([^"]*)"/i),
|
|
208
290
|
ua: attr(s, /http-user-agent="([^"]*)"/i),
|
|
209
|
-
ref: attr(s, /http-
|
|
291
|
+
ref: attr(s, /http-referr?er="([^"]*)"/i), // (both spellings)
|
|
210
292
|
radio: truthy(attr(s, /(?:^|\s)radio="([^"]*)"/i)) || /^radio$/i.test(kind),
|
|
211
293
|
sport: /^sports?$/i.test(kind), // (StremioK's own mark: the Sports tab)
|
|
212
294
|
url: ''
|
|
213
295
|
};
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
296
|
+
if (/^guide$/i.test(kind)) { // (StremioK's own mark: a channel watched elsewhere, kept for its TV guide alone; a link after it is ignored)
|
|
297
|
+
if (max && r.channels.length >= max) { r.dropped++; cur = null; return; }
|
|
298
|
+
cur.guideOnly = true; cur.radio = false; cur.type = 'tv';
|
|
299
|
+
if (!cur.group) { cur.group = 'TV guide'; }
|
|
300
|
+
add(cur);
|
|
301
|
+
cur = null;
|
|
302
|
+
}
|
|
303
|
+
} else if (/^#EXTVLCOPT:http-(user-agent|referr?er)=/i.test(s)) { // (http-referer: the one-r spelling too)
|
|
304
|
+
if (cur) { var opt = /^#EXTVLCOPT:http-(user-agent|referr?er)=(.*)$/i.exec(s); headerInto(cur, opt[1], opt[2]); }
|
|
305
|
+
} else if (s.indexOf('#EXTHTTP:') === 0) { // #EXTHTTP:{"User-Agent": "…", "Referer": "…"}
|
|
306
|
+
var j = null;
|
|
307
|
+
try { j = JSON.parse(s.slice(9)); } catch (e) { j = null; }
|
|
308
|
+
if (cur && j && typeof j === 'object') { Object.keys(j).forEach(function (k) { if (typeof j[k] === 'string') { headerInto(cur, k, j[k]); } }); }
|
|
218
309
|
} else if (s && s.charAt(0) !== '#') {
|
|
219
310
|
if (cur) {
|
|
220
311
|
var u = s.trim();
|
|
312
|
+
// headers after a | (Kodi's): …m3u8|Referer=…&User-Agent=… (values may be %-encoded)
|
|
313
|
+
var bar = u.indexOf('|');
|
|
314
|
+
if (bar !== -1 && /^[A-Za-z][A-Za-z-]*=/.test(u.slice(bar + 1))) {
|
|
315
|
+
u.slice(bar + 1).split('&').forEach(function (kv) {
|
|
316
|
+
var eq = kv.indexOf('='), v = eq === -1 ? '' : kv.slice(eq + 1);
|
|
317
|
+
try { v = decodeURIComponent(v); } catch (e) {}
|
|
318
|
+
if (eq > 0) { headerInto(cur, kv.slice(0, eq), v); }
|
|
319
|
+
});
|
|
320
|
+
u = u.slice(0, bar);
|
|
321
|
+
}
|
|
221
322
|
if (/^https?:\/\//i.test(u)) {
|
|
222
323
|
if (max && r.channels.length >= max) { r.dropped++; cur = null; return; }
|
|
223
324
|
cur.url = u;
|
|
@@ -227,11 +328,7 @@ var IPTV = (function () {
|
|
|
227
328
|
cur._yt =
|
|
228
329
|
/^https?:\/\/(?:[^\/]+\.)?youtube\.com(?::\d+)?(?:\/|$)/i.test(u) ||
|
|
229
330
|
/^https?:\/\/youtu\.be(?::\d+)?(?:\/|$)/i.test(u);
|
|
230
|
-
|
|
231
|
-
var id = base, n = 2;
|
|
232
|
-
while (seen[id]) { id = base + '-' + n; n++; }
|
|
233
|
-
seen[id] = 1; cur.id = id;
|
|
234
|
-
r.channels.push(cur);
|
|
331
|
+
add(cur);
|
|
235
332
|
}
|
|
236
333
|
cur = null;
|
|
237
334
|
}
|
|
@@ -263,10 +360,33 @@ var IPTV = (function () {
|
|
|
263
360
|
});
|
|
264
361
|
}
|
|
265
362
|
|
|
363
|
+
// ---- ★ Favorites: channels you picked (hold OK on a channel card), a row first on the IPTV
|
|
364
|
+
// tab (FAV_CAT, asks to come first among Home feeds unless you've placed it) and first on the
|
|
365
|
+
// Guide tab; by channel id (its playlist and tvg-id / name), FAV_MAX at most.
|
|
366
|
+
var FAV_KEY = 'stremiok.iptv.favs', FAV_MAX = 300, FAV_CAT = 'iptv_favs';
|
|
367
|
+
function favs() { var f = U.store.get(FAV_KEY, []) || []; return Array.isArray(f) ? f.filter(function (x) { return typeof x === 'string'; }) : []; }
|
|
368
|
+
function isFavorite(id) { return favs().indexOf(id) !== -1; }
|
|
369
|
+
function setFavorite(id, on) {
|
|
370
|
+
var f = favs().filter(function (x) { return x !== id; });
|
|
371
|
+
if (on) { f.unshift(id); }
|
|
372
|
+
U.store.set(FAV_KEY, f.slice(0, FAV_MAX));
|
|
373
|
+
if (_cache) { favCatalog(_cache); }
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
// The Favorites catalog, first in the list, while there are favorites among the channels read
|
|
377
|
+
// (changed in place: the IPTV addon's manifest holds the same list).
|
|
378
|
+
function favCatalog(d) {
|
|
379
|
+
var at = -1;
|
|
380
|
+
for (var i = 0; i < d.catalogs.length; i++) { if (d.catalogs[i].id === FAV_CAT) { at = i; } }
|
|
381
|
+
var any = favs().some(function (id) { return !!d.byId[id]; });
|
|
382
|
+
if (any && at === -1) { d.catalogs.unshift({ type: 'tv', id: FAV_CAT, name: '★ Favorites', extra: [{ name: 'search' }], _favs: true, _first: true }); }
|
|
383
|
+
if (!any && at !== -1) { d.catalogs.splice(at, 1); }
|
|
384
|
+
}
|
|
266
385
|
function build(channels) {
|
|
267
386
|
var groups = [], byGroup = {}, byId = {};
|
|
268
387
|
channels.forEach(function (c) {
|
|
269
388
|
byId[c.id] = c;
|
|
389
|
+
if (c.guideOnly) { return; } // (its TV guide alone: the Guide tab, no row)
|
|
270
390
|
var key = c.type + '|' + c.group;
|
|
271
391
|
if (!byGroup[key]) { byGroup[key] = []; groups.push({ type: c.type, name: c.group }); }
|
|
272
392
|
byGroup[key].push(c);
|
|
@@ -276,7 +396,9 @@ var IPTV = (function () {
|
|
|
276
396
|
return { type: g.type, id: prefix + idOf(g.name, g.type + '|' + g.name), name: g.name,
|
|
277
397
|
extra: [{ name: 'search' }], _group: g.name };
|
|
278
398
|
});
|
|
279
|
-
|
|
399
|
+
var d = { ts: Date.now(), channels: channels, groups: groups, byId: byId, byGroup: byGroup, catalogs: catalogs };
|
|
400
|
+
favCatalog(d);
|
|
401
|
+
return d;
|
|
280
402
|
}
|
|
281
403
|
|
|
282
404
|
function tagChannels(channels, playlistId) {
|
|
@@ -313,6 +435,7 @@ var IPTV = (function () {
|
|
|
313
435
|
return parseM3UAsync(text, MAX_CHANNELS, function (n) { progress(playlist.id, n); }).then(function (r) {
|
|
314
436
|
if (r.dropped) { st.dropped = r.dropped; } // (kept to what the TV can hold; the rest dropped, and said so)
|
|
315
437
|
st.title = r.title || '';
|
|
438
|
+
st.guides = r.guides || [];
|
|
316
439
|
_sources = null;
|
|
317
440
|
return counted(tagChannels(r.channels, playlist.id));
|
|
318
441
|
});
|
|
@@ -368,12 +491,17 @@ var IPTV = (function () {
|
|
|
368
491
|
_progressFns.forEach(function (fn) { try { fn(total); } catch (e) {} });
|
|
369
492
|
}
|
|
370
493
|
function onProgress(fn) { _progressFns.push(fn); }
|
|
494
|
+
function isReady() { return !!_cache; } // (the playlists read: synthAddon has their channels)
|
|
371
495
|
|
|
372
496
|
// Each playlist is fetched once and saved (the big store). Passing a playlist id
|
|
373
497
|
// forces only that entry to refresh; true refreshes every configured playlist.
|
|
374
498
|
function load(forceId) {
|
|
375
499
|
var playlists = getPlaylists().filter(function (p) { return !p.off; }); // (switched off: not read)
|
|
376
|
-
if (!playlists.length) {
|
|
500
|
+
if (!playlists.length) {
|
|
501
|
+
_cache = build([]);
|
|
502
|
+
if (typeof EPG !== 'undefined' && EPG.start) { setTimeout(EPG.start, 0); } // (their guides dropped: no Guide tab)
|
|
503
|
+
return Promise.resolve(_cache);
|
|
504
|
+
}
|
|
377
505
|
if (!forceId && _cache) { return Promise.resolve(_cache); }
|
|
378
506
|
if (!forceId && _pending && _pending.rev === _rev) { return _pending.p; } // (one under way: shared)
|
|
379
507
|
var rev = _rev;
|
|
@@ -397,6 +525,7 @@ var IPTV = (function () {
|
|
|
397
525
|
});
|
|
398
526
|
});
|
|
399
527
|
_cache = build(channels);
|
|
528
|
+
if (typeof EPG !== 'undefined' && EPG.start) { setTimeout(EPG.start, 0); } // (their TV guides: see js/epg.js)
|
|
400
529
|
return _cache;
|
|
401
530
|
});
|
|
402
531
|
_pending = { rev: rev, p: pr };
|
|
@@ -408,7 +537,13 @@ var IPTV = (function () {
|
|
|
408
537
|
return [p ? p.href : c.url, c.name, c.group, c.type, c.ua || '', c.ref || ''].join('\u0001');
|
|
409
538
|
}
|
|
410
539
|
|
|
411
|
-
function reload(id) {
|
|
540
|
+
function reload(id) { // (its TV guide read again too: EPG.start)
|
|
541
|
+
reset();
|
|
542
|
+
return load(id || true).then(function (d) {
|
|
543
|
+
if (typeof EPG !== 'undefined' && EPG.start) { EPG.start(id || true); }
|
|
544
|
+
return d;
|
|
545
|
+
});
|
|
546
|
+
}
|
|
412
547
|
function status(id) {
|
|
413
548
|
if (id) { return _status[id] || { id: id, url: '', count: 0, groups: 0, error: '', title: '', fetchedAt: 0 }; }
|
|
414
549
|
return _status;
|
|
@@ -431,7 +566,8 @@ var IPTV = (function () {
|
|
|
431
566
|
if (!cat) { return []; }
|
|
432
567
|
var q = (extra && extra.search) ? ('' + extra.search).toLowerCase() : '';
|
|
433
568
|
var out = [];
|
|
434
|
-
(d.byGroup[cat.type + '|' + cat._group] || [])
|
|
569
|
+
var list = cat._favs ? favs().map(function (id) { return d.byId[id]; }).filter(Boolean) : (d.byGroup[cat.type + '|' + cat._group] || []);
|
|
570
|
+
list.forEach(function (c) {
|
|
435
571
|
if (q && c.name.toLowerCase().indexOf(q) === -1) { return; }
|
|
436
572
|
out.push(toMeta(c));
|
|
437
573
|
});
|
|
@@ -445,20 +581,54 @@ var IPTV = (function () {
|
|
|
445
581
|
return load().then(function (d) { var c = d.byId[id]; return c ? toMeta(c) : null; });
|
|
446
582
|
}
|
|
447
583
|
|
|
584
|
+
// An Xtream channel's link (…/live/user/pass/id.ext) can lead anywhere, YouTube among them
|
|
585
|
+
// (a provider's "Alt" channel): looked at (redirects followed, its headers only: the answer
|
|
586
|
+
// isn't read), within YT_PROBE_MS. Resolves the YouTube page it ends on, or ''. Where it led
|
|
587
|
+
// is kept YT_KEEP_MS, or until the playlists reload (reset): a server can renumber its
|
|
588
|
+
// channels, and the same link then leads somewhere else.
|
|
589
|
+
var XTREAM_LIVE = /^https?:\/\/[^\/]+(?:\/[^?#]*)?\/live\/[^\/?#]+\/[^\/?#]+\/\d+(?:\.[a-z0-9]{2,4})?(?:[?#]|$)/i, YT_PROBE_MS = 4000, YT_KEEP_MS = 5 * 60000;
|
|
590
|
+
function isYouTube(u) { return /^https?:\/\/(?:[^\/]+\.)?youtube\.com(?::\d+)?(?:\/|$)/i.test(u) || /^https?:\/\/youtu\.be(?::\d+)?(?:\/|$)/i.test(u); }
|
|
591
|
+
function youTubeEnd(url) {
|
|
592
|
+
if (!XTREAM_LIVE.test(url)) { return Promise.resolve(''); }
|
|
593
|
+
var kept = Object.prototype.hasOwnProperty.call(_ytEnds, url) ? _ytEnds[url] : null;
|
|
594
|
+
if (kept && Date.now() - kept.at < YT_KEEP_MS) { return Promise.resolve(kept.page); }
|
|
595
|
+
return new Promise(function (resolve) {
|
|
596
|
+
var x = null, done = false;
|
|
597
|
+
function end(page) { if (done) { return; } done = true; clearTimeout(t); try { x.abort(); } catch (e) {} _ytEnds[url] = { page: page, at: Date.now() }; resolve(page); }
|
|
598
|
+
var t = setTimeout(function () { done = true; try { x.abort(); } catch (e) {} resolve(''); }, YT_PROBE_MS); // (not remembered: asked again next time)
|
|
599
|
+
try {
|
|
600
|
+
x = new XMLHttpRequest();
|
|
601
|
+
x.open('GET', url, true);
|
|
602
|
+
x.onreadystatechange = function () {
|
|
603
|
+
if (x.readyState < 2) { return; }
|
|
604
|
+
var to = '' + (x.responseURL || '');
|
|
605
|
+
end(isYouTube(to) ? to : '');
|
|
606
|
+
};
|
|
607
|
+
x.onerror = function () { end(''); };
|
|
608
|
+
x.send();
|
|
609
|
+
} catch (e) { end(''); }
|
|
610
|
+
});
|
|
611
|
+
}
|
|
448
612
|
function streams(id) {
|
|
449
613
|
return load().then(function (d) {
|
|
450
614
|
var c = d.byId[id];
|
|
451
|
-
if (!c) { return []; }
|
|
615
|
+
if (!c || c.guideOnly) { return []; }
|
|
452
616
|
if (c._yt) {
|
|
453
617
|
return [{ url: c.url, _yt: true, ytPage: c.url, name: c.name, title: 'YouTube · ' + c.group }];
|
|
454
618
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
return [st];
|
|
619
|
+
return youTubeEnd(c.url).then(function (page) {
|
|
620
|
+
if (page) { return [{ url: page, _yt: true, ytPage: page, name: c.name, title: 'YouTube · ' + c.group }]; }
|
|
621
|
+
return plainStreams(c);
|
|
622
|
+
});
|
|
460
623
|
});
|
|
461
624
|
}
|
|
625
|
+
function plainStreams(c) {
|
|
626
|
+
var from = sourceOf(c);
|
|
627
|
+
var st = { url: c.url, name: c.name, title: 'IPTV · ' + c.group + (from ? ' · ' + from : '') };
|
|
628
|
+
var h = U.sanitizeHeaders({ ua: c.ua, ref: c.ref }); // (only what the player takes, checked)
|
|
629
|
+
if (h) { st._headers = h; }
|
|
630
|
+
return [st];
|
|
631
|
+
}
|
|
462
632
|
|
|
463
633
|
// A local addon object shaped like the HTTP addons in Stremio.loadAddons().
|
|
464
634
|
// `__local` tells Stremio to route resource calls here instead of over HTTP.
|
|
@@ -477,8 +647,11 @@ var IPTV = (function () {
|
|
|
477
647
|
return {
|
|
478
648
|
getPlaylists: getPlaylists, addPlaylist: addPlaylist, updatePlaylist: updatePlaylist,
|
|
479
649
|
removePlaylist: removePlaylist, renamePlaylist: renamePlaylist, setPlaylistOff: setPlaylistOff,
|
|
480
|
-
movePlaylist: movePlaylist,
|
|
481
|
-
|
|
650
|
+
movePlaylist: movePlaylist, setPlaylistGuide: setPlaylistGuide, label: labelOf,
|
|
651
|
+
xtreamParts: xtreamParts, xtreamUrl: xtreamUrl, xtreamCheck: xtreamCheck, addXtream: addXtream, xtreamFormat: xtreamFormat, setXtreamFormat: setXtreamFormat,
|
|
652
|
+
isFavorite: isFavorite, setFavorite: setFavorite,
|
|
653
|
+
channels: function () { return _cache ? _cache.channels : []; }, channel: function (id) { return _cache ? _cache.byId[id] || null : null; }, isEnabled: isEnabled, reset: reset, status: status,
|
|
654
|
+
load: load, reload: reload, catalog: catalog, meta: meta, streams: streams, synthAddon: synthAddon, isReady: isReady,
|
|
482
655
|
onProgress: onProgress
|
|
483
656
|
};
|
|
484
657
|
})();
|
package/js/keys.js
CHANGED
|
@@ -14,6 +14,7 @@ var Keys = (function () {
|
|
|
14
14
|
417: 'FF',
|
|
15
15
|
412: 'REW',
|
|
16
16
|
427: 'CHUP', 428: 'CHDOWN',
|
|
17
|
+
458: 'GUIDE', // the remote's GUIDE: StremioK's Guide tab, not the TV's own (App.guideKey)
|
|
17
18
|
10232: 'PREV', 10233: 'NEXT', // ⏮ / ⏭ (MediaTrackPrevious / MediaTrackNext)
|
|
18
19
|
403: 'RED', 404: 'GREEN', 405: 'YELLOW', 406: 'BLUE', // the colored keys (the player's audio, subtitles, sources, stream info)
|
|
19
20
|
48: 'NUM0', 49: 'NUM1', 50: 'NUM2', 51: 'NUM3', 52: 'NUM4', 53: 'NUM5', 54: 'NUM6', 55: 'NUM7', 56: 'NUM8', 57: 'NUM9' // the number keys
|
|
@@ -25,6 +26,7 @@ var Keys = (function () {
|
|
|
25
26
|
'MediaRewind', 'MediaFastForward',
|
|
26
27
|
'MediaTrackPrevious', 'MediaTrackNext',
|
|
27
28
|
'ChannelUp', 'ChannelDown', // CH+/CH− switch live channels in the player
|
|
29
|
+
'Guide',
|
|
28
30
|
'ColorF0Red', 'ColorF1Green', 'ColorF2Yellow', 'ColorF3Blue',
|
|
29
31
|
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9' // (Home's tabs, the player's subtitles, a channel's number)
|
|
30
32
|
];
|
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);
|
|
@@ -635,11 +638,42 @@
|
|
|
635
638
|
if (!S.isLive) {
|
|
636
639
|
items.push({ label: 'Playback', run: function () { pushMenu(playbackMenu()); } });
|
|
637
640
|
}
|
|
638
|
-
items.push({ label: 'Picture', detail: aspectDef(
|
|
641
|
+
items.push({ label: 'Picture', detail: aspectDef(P.aspectNow())[1].split(' (')[0], run: function () { pushMenu(pictureMenu()); } });
|
|
642
|
+
if (P.liveFormat()) { items.push({ label: 'Format', detail: P.liveFormat() === 'hls' ? 'HLS' : 'MPEG-TS', run: function () { pushMenu(formatMenu()); } }); }
|
|
639
643
|
items.push({ label: streamInfoOn() ? 'Hide stream info' : 'Stream info', run: function () { toggleStreamInfo(); closeMenu(); } });
|
|
644
|
+
items.push({ label: 'Reload', detail: S.isLive ? 'Open the channel again' : 'Open it again, where it is', run: function () { closeMenu(); P.reload(); } });
|
|
645
|
+
items.push({ label: 'Sleep timer', detail: sleepLeft(), run: function () { pushMenu(sleepMenu()); } });
|
|
640
646
|
return { title: 'Menu', items: items, idx: 0 };
|
|
641
647
|
}
|
|
642
648
|
|
|
649
|
+
// ---- Sleep timer: playback stops after 30, 60 or 90 minutes (said, then the player closes);
|
|
650
|
+
// for this playback session, cleared when the player closes ----
|
|
651
|
+
var SLEEP_MINUTES = [30, 60, 90], _sleep = null; // { at, timer }
|
|
652
|
+
function sleepLeft() {
|
|
653
|
+
if (!_sleep) { return 'Off'; }
|
|
654
|
+
return Math.max(1, Math.ceil((_sleep.at - Date.now()) / 60000)) + ' min left';
|
|
655
|
+
}
|
|
656
|
+
function sleepClear() { if (_sleep) { clearTimeout(_sleep.timer); _sleep = null; } }
|
|
657
|
+
function sleepSet(min) {
|
|
658
|
+
sleepClear();
|
|
659
|
+
if (!min) { return; }
|
|
660
|
+
var at = Date.now() + min * 60000;
|
|
661
|
+
_sleep = { at: at, timer: setTimeout(function () {
|
|
662
|
+
_sleep = null;
|
|
663
|
+
if (!S.active) { return; }
|
|
664
|
+
U.toast('Sleep timer: playback stopped', 5000);
|
|
665
|
+
exit();
|
|
666
|
+
}, min * 60000) };
|
|
667
|
+
}
|
|
668
|
+
function sleepMenu() {
|
|
669
|
+
var items = [{ label: 'Off', sel: !_sleep, run: function () { sleepSet(0); closeMenu(); U.toast('Sleep timer off', 2000); } }];
|
|
670
|
+
SLEEP_MINUTES.forEach(function (m) {
|
|
671
|
+
items.push({ label: m + ' minutes', run: function () { sleepSet(m); closeMenu(); U.toast('Sleep timer: playback stops in ' + m + ' minutes', 3000); } });
|
|
672
|
+
});
|
|
673
|
+
return { title: 'Sleep timer', items: items, idx: 0 };
|
|
674
|
+
}
|
|
675
|
+
P.sleepClear = sleepClear;
|
|
676
|
+
|
|
643
677
|
// Continue on phone: the link playing, or '' (a file on a USB drive: no link to give)…
|
|
644
678
|
function streamLink() {
|
|
645
679
|
var c = S.ctx;
|
|
@@ -683,6 +717,7 @@
|
|
|
683
717
|
function canDownload() {
|
|
684
718
|
var c = S.ctx, st = c && c.stream;
|
|
685
719
|
return !!(st && c.downloadAs && !S.isLive && !c.trailer && !c.yt && !c.localFile && !st._localFile && !st._yt && /^https?:\/\//i.test(st.url || '') &&
|
|
720
|
+
(typeof Downloads === 'undefined' || !Downloads.isFileLink || Downloads.isFileLink(st)) &&
|
|
686
721
|
typeof Downloads !== 'undefined' && Downloads.available());
|
|
687
722
|
}
|
|
688
723
|
function downloadPlaying() {
|
|
@@ -713,9 +748,10 @@
|
|
|
713
748
|
}
|
|
714
749
|
|
|
715
750
|
// ---------- picture mode ----------
|
|
716
|
-
// Apply the saved mode
|
|
751
|
+
// Apply the saved mode (a live channel's own, else the usual one: player-store's aspectNow); a
|
|
752
|
+
// mode this TV rejects falls back to Fit (quiet = at stream start).
|
|
717
753
|
function applyAspect(quiet) {
|
|
718
|
-
var a = aspectDef(
|
|
754
|
+
var a = aspectDef(P.aspectNow());
|
|
719
755
|
if (!useAV) { if (S.videoEl) { S.videoEl.style.objectFit = a[3]; } return true; }
|
|
720
756
|
try { avplay.setDisplayMethod(a[2]); return true; } catch (e) {
|
|
721
757
|
try { avplay.setDisplayMethod(aspectDef('fit')[2]); } catch (e2) {}
|
|
@@ -724,17 +760,26 @@
|
|
|
724
760
|
}
|
|
725
761
|
}
|
|
726
762
|
function pictureMenu() {
|
|
727
|
-
var cur =
|
|
763
|
+
var cur = P.aspectNow(), idx = 0;
|
|
728
764
|
var items = P.ASPECTS.map(function (a, i) {
|
|
729
765
|
if (a[0] === cur) { idx = i; }
|
|
730
766
|
return { label: a[1], sel: a[0] === cur, run: function () {
|
|
731
|
-
var prev =
|
|
732
|
-
|
|
733
|
-
if (!applyAspect(false)) {
|
|
767
|
+
var prev = P.aspectNow();
|
|
768
|
+
P.setAspectNow(a[0]);
|
|
769
|
+
if (!applyAspect(false)) { P.setAspectNow(prev === a[0] ? 'fit' : prev); applyAspect(true); }
|
|
734
770
|
replaceTop(pictureMenu());
|
|
735
771
|
} };
|
|
736
772
|
});
|
|
737
|
-
return { title: 'Picture', items: items, idx: idx };
|
|
773
|
+
return { title: S.isLive && S.ctx && S.ctx.id ? 'Picture (this channel)' : 'Picture', items: items, idx: idx };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// An Xtream channel's format (player-core's setLiveFormat): it opens again the other way.
|
|
777
|
+
function formatMenu() {
|
|
778
|
+
var now = P.liveFormat();
|
|
779
|
+
var items = [['ts', 'MPEG-TS'], ['hls', 'HLS']].map(function (f) {
|
|
780
|
+
return { label: f[1], sel: f[0] === now, run: function () { closeMenu(); if (f[0] !== now) { P.setLiveFormat(f[0]); } } };
|
|
781
|
+
});
|
|
782
|
+
return { title: 'Format (this channel)', items: items, idx: now === 'hls' ? 1 : 0 };
|
|
738
783
|
}
|
|
739
784
|
|
|
740
785
|
// ---------- stream info overlay ----------
|
|
@@ -807,6 +852,7 @@
|
|
|
807
852
|
}
|
|
808
853
|
lines.push(['Subtitles', subtitleSummary()]);
|
|
809
854
|
if (hasQualities()) { lines.push(['Quality', qualityLabel() + ' (' + qualityCount() + ' available)']); }
|
|
855
|
+
else if (qualityUnread()) { lines.push(['Quality', 'Auto (this stream’s qualities couldn’t be read)']); }
|
|
810
856
|
if (useAV) {
|
|
811
857
|
try {
|
|
812
858
|
var bw = +avplay.getStreamingProperty('CURRENT_BANDWIDTH');
|
|
@@ -957,6 +1003,7 @@
|
|
|
957
1003
|
// ----- for the other parts (and js/player.js) -----
|
|
958
1004
|
P.barShown = function () { return _barOpen; };
|
|
959
1005
|
P.menuOpen = menuOpen; P.scrubbing = scrubbing; P.skipShown = skipShown; P.resetControls = resetControls;
|
|
1006
|
+
P.rootMenu = rootMenu;
|
|
960
1007
|
P.closeControls = closeControls; P.showSkip = showSkip; P.skipTick = skipTick; P.renderScrub = renderScrub; P.topMenu = topMenu;
|
|
961
1008
|
P.closeMenu = closeMenu; P.pushMenu = pushMenu; P.popMenu = popMenu; P.replaceTop = replaceTop; P.applyAspect = applyAspect;
|
|
962
1009
|
P.handleKey = handleKey; P.ytLinksTick = ytLinksTick; P.phoneLink = phoneLink;
|