stremiok 2.1.0 → 2.2.1
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 +97 -0
- package/README.md +10 -9
- package/config.xml +1 -1
- package/css/style.css +27 -0
- package/index.html +34 -31
- package/js/app.js +25 -10
- package/js/epg.js +320 -0
- package/js/iptv.js +198 -25
- package/js/keys.js +2 -0
- package/js/player-controls.js +50 -8
- package/js/player-core.js +112 -12
- package/js/player-lang.js +66 -5
- package/js/player-store.js +23 -2
- package/js/player-subs.js +57 -13
- package/js/stremio.js +70 -6
- package/js/trakt.js +1 -0
- package/js/util.js +24 -13
- package/js/view-core.js +128 -55
- package/js/view-detail.js +1 -1
- package/js/view-guide.js +371 -0
- package/js/view-home.js +148 -33
- package/js/view-settings.js +241 -90
- package/js/views.js +4 -3
- package/js/ytplay.js +53 -5
- package/package.json +2 -1
- package/service/index.js +863 -46
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);
|
|
@@ -121,6 +133,58 @@ var IPTV = (function () {
|
|
|
121
133
|
return id;
|
|
122
134
|
}
|
|
123
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
|
+
|
|
124
188
|
function updatePlaylist(id, url) {
|
|
125
189
|
url = checkedUrl(url);
|
|
126
190
|
if (!url) { return false; }
|
|
@@ -144,8 +208,8 @@ var IPTV = (function () {
|
|
|
144
208
|
function isEnabled() { return getPlaylists().some(function (p) { return !p.off; }); }
|
|
145
209
|
// The playlists' revision: changed (added, edited, removed, switched off, moved, reloaded),
|
|
146
210
|
// a load still under way for the old ones keeps nothing, and they're read as they are now.
|
|
147
|
-
var _rev = 0, _pending = null;
|
|
148
|
-
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 = {}; }
|
|
149
213
|
|
|
150
214
|
// Playlist text is cached per playlist (one key each), in the big store (IndexedDB:
|
|
151
215
|
// far more room than localStorage, which keeps settings and progress). A playlist that
|
|
@@ -177,6 +241,13 @@ var IPTV = (function () {
|
|
|
177
241
|
function idOf(s, exact) { return slug(s) + '-' + hash(exact === undefined ? s : exact); }
|
|
178
242
|
|
|
179
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
|
+
}
|
|
180
251
|
|
|
181
252
|
function truthy(v) { return /^(?:1|true|yes)$/i.test(('' + (v || '')).trim()); }
|
|
182
253
|
|
|
@@ -186,11 +257,23 @@ var IPTV = (function () {
|
|
|
186
257
|
|
|
187
258
|
// Returns [{ id, type, name, logo, group, tvgId, ua, ref, url, _yt }]
|
|
188
259
|
// The M3U reader, a line at a time (so a big playlist can be read in chunks, see
|
|
189
|
-
// 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).
|
|
190
262
|
function m3uReader(max) {
|
|
191
|
-
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
|
+
}
|
|
192
271
|
r.line = function (raw) {
|
|
193
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
|
+
}
|
|
194
277
|
if (!r.title && s.indexOf('#EXTM3U') === 0) { // the playlist's own name, if it has one
|
|
195
278
|
r.title = attr(s, /(?:^|\s)(?:x-playlist-name|playlist-name|x-tvg-name)="([^"]{1,200})"/i).trim().slice(0, NAME_MAX);
|
|
196
279
|
} else if (!r.title && s.indexOf('#PLAYLIST:') === 0) {
|
|
@@ -205,18 +288,37 @@ var IPTV = (function () {
|
|
|
205
288
|
group: group,
|
|
206
289
|
tvgId: attr(s, /tvg-id="([^"]*)"/i),
|
|
207
290
|
ua: attr(s, /http-user-agent="([^"]*)"/i),
|
|
208
|
-
ref: attr(s, /http-
|
|
291
|
+
ref: attr(s, /http-referr?er="([^"]*)"/i), // (both spellings)
|
|
209
292
|
radio: truthy(attr(s, /(?:^|\s)radio="([^"]*)"/i)) || /^radio$/i.test(kind),
|
|
210
293
|
sport: /^sports?$/i.test(kind), // (StremioK's own mark: the Sports tab)
|
|
211
294
|
url: ''
|
|
212
295
|
};
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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]); } }); }
|
|
217
309
|
} else if (s && s.charAt(0) !== '#') {
|
|
218
310
|
if (cur) {
|
|
219
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
|
+
}
|
|
220
322
|
if (/^https?:\/\//i.test(u)) {
|
|
221
323
|
if (max && r.channels.length >= max) { r.dropped++; cur = null; return; }
|
|
222
324
|
cur.url = u;
|
|
@@ -226,11 +328,7 @@ var IPTV = (function () {
|
|
|
226
328
|
cur._yt =
|
|
227
329
|
/^https?:\/\/(?:[^\/]+\.)?youtube\.com(?::\d+)?(?:\/|$)/i.test(u) ||
|
|
228
330
|
/^https?:\/\/youtu\.be(?::\d+)?(?:\/|$)/i.test(u);
|
|
229
|
-
|
|
230
|
-
var id = base, n = 2;
|
|
231
|
-
while (seen[id]) { id = base + '-' + n; n++; }
|
|
232
|
-
seen[id] = 1; cur.id = id;
|
|
233
|
-
r.channels.push(cur);
|
|
331
|
+
add(cur);
|
|
234
332
|
}
|
|
235
333
|
cur = null;
|
|
236
334
|
}
|
|
@@ -262,10 +360,33 @@ var IPTV = (function () {
|
|
|
262
360
|
});
|
|
263
361
|
}
|
|
264
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
|
+
}
|
|
265
385
|
function build(channels) {
|
|
266
386
|
var groups = [], byGroup = {}, byId = {};
|
|
267
387
|
channels.forEach(function (c) {
|
|
268
388
|
byId[c.id] = c;
|
|
389
|
+
if (c.guideOnly) { return; } // (its TV guide alone: the Guide tab, no row)
|
|
269
390
|
var key = c.type + '|' + c.group;
|
|
270
391
|
if (!byGroup[key]) { byGroup[key] = []; groups.push({ type: c.type, name: c.group }); }
|
|
271
392
|
byGroup[key].push(c);
|
|
@@ -275,7 +396,9 @@ var IPTV = (function () {
|
|
|
275
396
|
return { type: g.type, id: prefix + idOf(g.name, g.type + '|' + g.name), name: g.name,
|
|
276
397
|
extra: [{ name: 'search' }], _group: g.name };
|
|
277
398
|
});
|
|
278
|
-
|
|
399
|
+
var d = { ts: Date.now(), channels: channels, groups: groups, byId: byId, byGroup: byGroup, catalogs: catalogs };
|
|
400
|
+
favCatalog(d);
|
|
401
|
+
return d;
|
|
279
402
|
}
|
|
280
403
|
|
|
281
404
|
function tagChannels(channels, playlistId) {
|
|
@@ -312,6 +435,7 @@ var IPTV = (function () {
|
|
|
312
435
|
return parseM3UAsync(text, MAX_CHANNELS, function (n) { progress(playlist.id, n); }).then(function (r) {
|
|
313
436
|
if (r.dropped) { st.dropped = r.dropped; } // (kept to what the TV can hold; the rest dropped, and said so)
|
|
314
437
|
st.title = r.title || '';
|
|
438
|
+
st.guides = r.guides || [];
|
|
315
439
|
_sources = null;
|
|
316
440
|
return counted(tagChannels(r.channels, playlist.id));
|
|
317
441
|
});
|
|
@@ -373,7 +497,11 @@ var IPTV = (function () {
|
|
|
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,7 +647,10 @@ 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,
|
|
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,
|
|
481
654
|
load: load, reload: reload, catalog: catalog, meta: meta, streams: streams, synthAddon: synthAddon, isReady: isReady,
|
|
482
655
|
onProgress: onProgress
|
|
483
656
|
};
|
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
|
@@ -638,11 +638,42 @@
|
|
|
638
638
|
if (!S.isLive) {
|
|
639
639
|
items.push({ label: 'Playback', run: function () { pushMenu(playbackMenu()); } });
|
|
640
640
|
}
|
|
641
|
-
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()); } }); }
|
|
642
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()); } });
|
|
643
646
|
return { title: 'Menu', items: items, idx: 0 };
|
|
644
647
|
}
|
|
645
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
|
+
|
|
646
677
|
// Continue on phone: the link playing, or '' (a file on a USB drive: no link to give)…
|
|
647
678
|
function streamLink() {
|
|
648
679
|
var c = S.ctx;
|
|
@@ -717,9 +748,10 @@
|
|
|
717
748
|
}
|
|
718
749
|
|
|
719
750
|
// ---------- picture mode ----------
|
|
720
|
-
// 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).
|
|
721
753
|
function applyAspect(quiet) {
|
|
722
|
-
var a = aspectDef(
|
|
754
|
+
var a = aspectDef(P.aspectNow());
|
|
723
755
|
if (!useAV) { if (S.videoEl) { S.videoEl.style.objectFit = a[3]; } return true; }
|
|
724
756
|
try { avplay.setDisplayMethod(a[2]); return true; } catch (e) {
|
|
725
757
|
try { avplay.setDisplayMethod(aspectDef('fit')[2]); } catch (e2) {}
|
|
@@ -728,17 +760,26 @@
|
|
|
728
760
|
}
|
|
729
761
|
}
|
|
730
762
|
function pictureMenu() {
|
|
731
|
-
var cur =
|
|
763
|
+
var cur = P.aspectNow(), idx = 0;
|
|
732
764
|
var items = P.ASPECTS.map(function (a, i) {
|
|
733
765
|
if (a[0] === cur) { idx = i; }
|
|
734
766
|
return { label: a[1], sel: a[0] === cur, run: function () {
|
|
735
|
-
var prev =
|
|
736
|
-
|
|
737
|
-
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); }
|
|
738
770
|
replaceTop(pictureMenu());
|
|
739
771
|
} };
|
|
740
772
|
});
|
|
741
|
-
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 };
|
|
742
783
|
}
|
|
743
784
|
|
|
744
785
|
// ---------- stream info overlay ----------
|
|
@@ -962,6 +1003,7 @@
|
|
|
962
1003
|
// ----- for the other parts (and js/player.js) -----
|
|
963
1004
|
P.barShown = function () { return _barOpen; };
|
|
964
1005
|
P.menuOpen = menuOpen; P.scrubbing = scrubbing; P.skipShown = skipShown; P.resetControls = resetControls;
|
|
1006
|
+
P.rootMenu = rootMenu;
|
|
965
1007
|
P.closeControls = closeControls; P.showSkip = showSkip; P.skipTick = skipTick; P.renderScrub = renderScrub; P.topMenu = topMenu;
|
|
966
1008
|
P.closeMenu = closeMenu; P.pushMenu = pushMenu; P.popMenu = popMenu; P.replaceTop = replaceTop; P.applyAspect = applyAspect;
|
|
967
1009
|
P.handleKey = handleKey; P.ytLinksTick = ytLinksTick; P.phoneLink = phoneLink;
|