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/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(), i = find(list, id), j = i + dir;
79
- if (i === -1 || j < 0 || j >= list.length) { return false; }
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-referrer="([^"]*)"/i),
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
- } else if (s.indexOf('#EXTVLCOPT:http-user-agent=') === 0) {
215
- if (cur) { cur.ua = s.slice('#EXTVLCOPT:http-user-agent='.length).trim(); }
216
- } else if (s.indexOf('#EXTVLCOPT:http-referrer=') === 0) {
217
- 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]); } }); }
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
- var base = 'iptv:' + idOf(cur.tvgId || cur.name);
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
- 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;
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) { _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,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, label: labelOf, isEnabled: isEnabled, reset: reset, status: status,
481
- load: load, reload: reload, catalog: catalog, meta: meta, streams: streams, synthAddon: synthAddon,
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
  ];
@@ -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(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()); } }); }
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; 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).
717
753
  function applyAspect(quiet) {
718
- var a = aspectDef(playerCfg().aspect);
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 = playerCfg().aspect, idx = 0;
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 = playerCfg().aspect;
732
- setPlayerCfg({ aspect: a[0] });
733
- 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); }
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;