stremiok 2.1.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/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-referrer="([^"]*)"/i),
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
- } else if (s.indexOf('#EXTVLCOPT:http-user-agent=') === 0) {
214
- if (cur) { cur.ua = s.slice('#EXTVLCOPT:http-user-agent='.length).trim(); }
215
- } else if (s.indexOf('#EXTVLCOPT:http-referrer=') === 0) {
216
- if (cur) { cur.ref = s.slice('#EXTVLCOPT:http-referrer='.length).trim(); }
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
- var base = 'iptv:' + idOf(cur.tvgId || cur.name);
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
- return { ts: Date.now(), channels: channels, groups: groups, byId: byId, byGroup: byGroup, catalogs: catalogs };
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) { _cache = build([]); return Promise.resolve(_cache); }
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) { reset(); return load(id || true); }
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] || []).forEach(function (c) {
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
- var from = sourceOf(c);
456
- var st = { url: c.url, name: c.name, title: 'IPTV · ' + c.group + (from ? ' · ' + from : '') };
457
- var h = U.sanitizeHeaders({ ua: c.ua, ref: c.ref }); // (only what the player takes, checked)
458
- if (h) { st._headers = h; }
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, label: labelOf, isEnabled: isEnabled, reset: reset, status: status,
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
  ];
@@ -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(playerCfg().aspect)[1].split(' (')[0], run: function () { pushMenu(pictureMenu()); } });
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; a mode this TV rejects falls back to Fit (quiet = at stream start).
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(playerCfg().aspect);
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 = playerCfg().aspect, idx = 0;
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 = playerCfg().aspect;
736
- setPlayerCfg({ aspect: a[0] });
737
- if (!applyAspect(false)) { setPlayerCfg({ aspect: prev === a[0] ? 'fit' : prev }); applyAspect(true); }
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;