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.
@@ -17,7 +17,6 @@
17
17
  function markHomeDirty() { return V.markHomeDirty.apply(null, arguments); } // home
18
18
  function onModalClose() { return V.onModalClose.apply(null, arguments); } // core
19
19
  function pickList() { return V.pickList.apply(null, arguments); } // core
20
- function toggleList() { return V.toggleList.apply(null, arguments); } // core
21
20
  function playlistActions() { return V.playlistActions.apply(null, arguments); } // core
22
21
  function scr() { return V.scr.apply(null, arguments); } // core
23
22
  function showModal() { return V.showModal.apply(null, arguments); } // core
@@ -207,14 +206,16 @@
207
206
  get: function () { return { spoilers: Stremio.getPrefs().spoilers }; },
208
207
  set: function (cfg) { Stremio.setPref('spoilers', cfg.spoilers); }
209
208
  }));
210
- wrap.appendChild(homeTabsRow());
209
+ var menu = sectionRow('Menu ›', 'Order the top menu, and show or hide its tabs', 'menuorder');
210
+ menu.className += ' cfg-row';
211
+ wrap.appendChild(menu);
211
212
  var feeds = sectionRow('Home feeds ›', 'Order the Home rows, and show or hide each one', 'feeds');
212
213
  feeds.className += ' cfg-row';
213
214
  wrap.appendChild(feeds);
214
215
  var sfeeds = sectionRow('Search results ›', 'Order the Search rows (one per catalog that can search), and show or hide each one', 'searchfeeds');
215
216
  sfeeds.className += ' cfg-row';
216
217
  wrap.appendChild(sfeeds);
217
- var back = _focusChild === 'feeds' ? feeds : _focusChild === 'searchfeeds' ? sfeeds : null;
218
+ var back = _focusChild === 'menuorder' ? menu : _focusChild === 'feeds' ? feeds : _focusChild === 'searchfeeds' ? sfeeds : null;
218
219
  _focusChild = null;
219
220
  if (back) { Focus.setScope(scr('settings'), back); } else { focusSettings(); }
220
221
  }
@@ -323,7 +324,7 @@
323
324
  ['Audio', 'Preferred and second-choice audio language', 'audio'],
324
325
  ['Subtitles', 'Default language, size, color, background, position, font', 'subtitles'],
325
326
  ['Skipping', 'Intros, recaps and previews, and the next episode at the credits: ask, or on their own after a countdown', 'skipping'],
326
- ['Seeking & buffering', 'How far ◀ ▶ jump; a bigger buffer for slow connections', 'seeking'],
327
+ ['Seeking & buffering', 'How far ◀ ▶ jump; a bigger buffer for slow connections; reconnecting live channels', 'seeking'],
327
328
  ['Source ranking', 'How sources are ordered (played before, resolution, release type…), and your addons: their order, on or off', 'ranking'],
328
329
  ['Formats', 'Allowed resolutions, and what plays on this TV, learned as you watch', 'formats'],
329
330
  ['Private viewing', 'Play without anything recorded: no progress, Continue Watching, watched marks or Trakt', 'private']] },
@@ -337,7 +338,7 @@
337
338
  // BACK goes up one level, onto the row it came from: from a section to its group (on
338
339
  // that section, not the first one), from Home feeds / Search results to Appearance,
339
340
  // from a group to the Settings list (on that group).
340
- var SECTION_PARENT = { feeds: 'appearance', searchfeeds: 'appearance', aiomanager: 'addons' };
341
+ var SECTION_PARENT = { menuorder: 'appearance', feeds: 'appearance', searchfeeds: 'appearance', aiomanager: 'addons' };
341
342
  Object.keys(SETTINGS_GROUPS).forEach(function (g) {
342
343
  SETTINGS_GROUPS[g].rows.forEach(function (row) { SECTION_PARENT[row[2]] = g; });
343
344
  });
@@ -366,6 +367,7 @@
366
367
  else if (_settingsSection === 'subtitles') { settingsSubtitles(wrap); }
367
368
  else if (_settingsSection === 'skipping') { settingsSkipping(wrap); }
368
369
  else if (_settingsSection === 'seeking') { settingsSeeking(wrap); }
370
+ else if (_settingsSection === 'menuorder') { settingsFeeds(wrap, 'menu'); }
369
371
  else if (_settingsSection === 'feeds') { settingsFeeds(wrap, 'home'); }
370
372
  else if (_settingsSection === 'searchfeeds') { settingsFeeds(wrap, 'search'); }
371
373
  else if (_settingsSection === 'ranking') { settingsFeeds(wrap, 'ranking'); }
@@ -470,6 +472,24 @@
470
472
  });
471
473
  return 'Gives: ' + (bits.length ? bits.join(' · ') : 'nothing StremioK uses') + (types.length ? ' · ' + types.slice(0, 8).join(', ') : '');
472
474
  }
475
+ // An addon's logo tile: its initial (the first character, an emoji whole; "?" for one that
476
+ // didn't load; drawn by the CSS from data-initial, so the row's text is still its name),
477
+ // and over it the logo from its manifest (Stremio.addonLogo), loaded with the other
478
+ // artwork; one that can't load is taken away, leaving the initial.
479
+ function addonTile(m, url) {
480
+ var name = ('' + ((m && m.name) || '')).replace(/^\s+/, ''), c = name.charCodeAt(0);
481
+ var tile = U.el('div', 'addon-logo');
482
+ tile.setAttribute('data-initial', !m ? '?' : name ? name.slice(0, c >= 0xD800 && c <= 0xDBFF ? 2 : 1).toUpperCase() : 'A');
483
+ var src = Stremio.addonLogo ? Stremio.addonLogo(m, url) : null;
484
+ if (src) {
485
+ var img = document.createElement('img');
486
+ img.onload = function () { tile.className = 'addon-logo loaded'; };
487
+ img.onerror = function () { if (img.parentNode) { img.parentNode.removeChild(img); } tile.className = 'addon-logo'; };
488
+ tile.appendChild(img);
489
+ U.deferImage(img, src);
490
+ }
491
+ return tile;
492
+ }
473
493
  function settingsAddons(wrap) {
474
494
  wrap.appendChild(U.el('h2', null, 'Addons'));
475
495
  // Home-network links from addons on the internet (see Stremio.blocksLocal)
@@ -489,16 +509,14 @@
489
509
  var urls = Stremio.getAddonUrls(), want = null;
490
510
  if (urls.length > 1) { wrap.appendChild(U.el('div', 'hint', '▲ / ▼ set the order: a title\'s details come from the highest addon that has them (its page says which), and the higher addon comes first where several answer.')); }
491
511
  urls.forEach(function (url) {
492
- var loaded = addons.find(function (a) { return a.url === url; });
493
- var frow = U.el('div', 'feed-row playlist-row');
494
- function move(dir, ctl) { return function () { if (Stremio.moveAddon(url, dir)) { _addonFocus = { url: url, ctl: ctl }; refreshAddonRows(); renderSettings(); } }; }
495
- var up = U.el('div', 'feed-btn focusable', '▲');
496
- up.__onselect = move(-1, 'up');
497
- var down = U.el('div', 'feed-btn focusable', '▼');
498
- down.__onselect = move(1, 'down');
499
- var item = U.el('div', 'addon-item focusable playlist-item');
512
+ var off = !!(Stremio.isAddonOff && Stremio.isAddonOff(url)), kept = off && Stremio.savedManifest ? Stremio.savedManifest(url) : null;
513
+ var loaded = addons.find(function (a) { return a.url === url; }) || (kept ? { url: url, manifest: kept } : null); // (one that's off: its saved manifest)
514
+ var frow = U.el('div', 'feed-row playlist-row' + (off ? ' feed-off' : ''));
515
+ var btns = orderButtons(function (dir, ctl) { if (Stremio.moveAddon(url, dir)) { _addonFocus = { url: url, ctl: ctl }; refreshAddonRows(); renderSettings(); } });
516
+ var item = U.el('div', 'addon-item focusable playlist-item has-logo');
517
+ item.appendChild(addonTile(loaded && loaded.manifest, url));
500
518
  var why = !loaded && Stremio.manifestProblem ? Stremio.manifestProblem(url) : ''; // "too large (over 2 MB)", "not an addon manifest"…
501
- var nm = U.el('div', 'a-name', loaded ? (loaded.manifest.name || 'Addon') : '(failed to load' + (why ? ': ' + why : '') + ')');
519
+ var nm = U.el('div', 'a-name', (loaded ? (loaded.manifest.name || 'Addon') : off ? 'Addon' : '(failed to load' + (why ? ': ' + why : '') + ')') + (off ? ' (off)' : ''));
502
520
  var origin = Stremio.addonOrigin ? Stremio.addonOrigin(url) : '', ob = originBadge(origin);
503
521
  if (ob) { nm.appendChild(ob); }
504
522
  item.appendChild(nm);
@@ -512,10 +530,10 @@
512
530
  item.__onselect = function () {
513
531
  addonActions(name, url);
514
532
  };
515
- if (urls.length > 1) { frow.appendChild(up); frow.appendChild(down); }
533
+ if (urls.length > 1) { btns.forEach(function (b) { frow.appendChild(b); }); }
516
534
  frow.appendChild(item);
517
535
  wrap.appendChild(frow);
518
- if (_addonFocus && _addonFocus.url === url) { want = _addonFocus.ctl === 'up' ? up : down; }
536
+ if (_addonFocus && _addonFocus.url === url) { want = btns[ctlIndex(_addonFocus.ctl)]; }
519
537
  });
520
538
 
521
539
  var addRow = U.el('div', 'addon-item focusable add-row');
@@ -731,9 +749,12 @@
731
749
  var byNorm = {};
732
750
  there.forEach(function (x) { byNorm[norm(x.url)] = x; });
733
751
  var mine = Stremio.getAddonUrls(), mineNorm = mine.map(norm);
734
- var shared = mine.filter(function (u) { var x = byNorm[norm(u)]; return x && x.enabled !== false; });
752
+ // Only what came from AIOManager is its to refresh or remove (am.synced: the ones a sync
753
+ // added); an addon you added yourself stays yours, even when AIOManager has it too.
754
+ function fromIt(u) { return am.synced.indexOf(norm(u)) !== -1; }
755
+ var shared = mine.filter(function (u) { var x = byNorm[norm(u)]; return fromIt(u) && x && x.enabled !== false; });
735
756
  var add = there.filter(function (x) { return x.enabled !== false && mineNorm.indexOf(norm(x.url)) === -1; });
736
- var gone = mine.filter(function (u) { var x = byNorm[norm(u)]; return am.synced.indexOf(norm(u)) !== -1 && (!x || x.enabled === false); });
757
+ var gone = mine.filter(function (u) { var x = byNorm[norm(u)]; return fromIt(u) && (!x || x.enabled === false); });
737
758
  var items = [];
738
759
  if (shared.length) { items.push({ key: 'refresh', label: '↻ Refresh the ' + shared.length + ' addon' + (shared.length === 1 ? '' : 's') + ' you have from AIOManager' }); }
739
760
  add.forEach(function (x, i) { items.push({ key: 'add:' + i, label: '+ Add · ' + U.drawable(x.name || shortHost(x.url)).slice(0, 50) + ' · ' + shortHost(x.url) }); });
@@ -741,18 +762,18 @@
741
762
  var x = byNorm[norm(u)];
742
763
  items.push({ key: 'rm:' + i, label: '− Remove · ' + (x && x.name ? U.drawable(x.name).slice(0, 50) + ' · ' : '') + shortHost(u), note: x ? 'off in AIOManager' : 'gone from AIOManager' });
743
764
  });
744
- function done(n) {
745
- var keptUrls = Stremio.getAddonUrls().filter(function (u) { var x = byNorm[norm(u)]; return x && x.enabled !== false; });
746
- keptUrls.forEach(function (u) { if (!Stremio.addonOrigin(u)) { Stremio.setAddonOrigin(u, 'aiomanager'); } }); // (yours that AIOManager has too: from it from now on)
765
+ function done(n, addedUrls) { // what came from it: as before, and what this sync added (still in your list)
766
+ var now = (addedUrls || []).map(norm);
767
+ var keptUrls = Stremio.getAddonUrls().filter(function (u) { return fromIt(u) || now.indexOf(norm(u)) !== -1; });
747
768
  aiomSave({ synced: keptUrls.map(norm), at: Date.now(), count: there.length });
748
769
  if (n) { clearCatalogCache(); refreshAddonRows(null); }
749
770
  renderSettings();
750
771
  }
751
772
  if (!items.length) { U.toast('Nothing to sync: AIOManager has no addons StremioK can use', 4000); done(0); return; }
752
773
  pickMany('Sync with AIOManager: tick what to do', items, { button: function (n) { return 'Apply ' + n; } }, function (keys) {
753
- var added = 0, removed = 0, jobs = [];
774
+ var added = 0, removed = 0, addedUrls = [];
754
775
  keys.forEach(function (k) {
755
- if (k.indexOf('add:') === 0) { var got = Stremio.addAddon(add[+k.slice(4)].url); if (got) { added++; Stremio.setAddonOrigin(got, 'aiomanager'); } }
776
+ if (k.indexOf('add:') === 0) { var got = Stremio.addAddon(add[+k.slice(4)].url); if (got) { added++; addedUrls.push(got); Stremio.setAddonOrigin(got, 'aiomanager'); } }
756
777
  else if (k.indexOf('rm:') === 0) { Stremio.removeAddon(gone[+k.slice(3)]); removed++; }
757
778
  });
758
779
  var refreshing = keys.indexOf('refresh') !== -1 ? shared.filter(function (u) { return Stremio.getAddonUrls().indexOf(u) !== -1; }) : [];
@@ -762,7 +783,7 @@
762
783
  if (removed) { parts.push(removed + ' removed'); }
763
784
  if (refreshing.length) { parts.push(refreshed + ' refreshed' + (refreshed < refreshing.length ? ' (' + (refreshing.length - refreshed) + ' didn’t answer)' : '')); }
764
785
  U.toast('Synced: ' + (parts.join(', ') || 'nothing changed'), 4000);
765
- done(added + removed + refreshed);
786
+ done(added + removed + refreshed, addedUrls);
766
787
  });
767
788
  });
768
789
  }, function (e) {
@@ -848,6 +869,15 @@
848
869
  var _iptvFocus = null; // { id, ctl } to focus once the page is drawn again
849
870
  function two(n) { return (n < 10 ? '0' : '') + n; }
850
871
  function thousands(n) { return ('' + n).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
872
+ // The playlist rows IPTV Settings drew last: their guide lines kept current by one listener
873
+ // (one added each time it was drawn used to pile up, each holding its rows).
874
+ var _guideRows = [];
875
+ if (typeof EPG !== 'undefined' && EPG.onChange) {
876
+ EPG.onChange(function () {
877
+ _guideRows = _guideRows.filter(function (r) { return document.documentElement.contains(r.info); });
878
+ _guideRows.forEach(function (r) { r.info.textContent = playlistInfo(r.p); });
879
+ });
880
+ }
851
881
  function playlistInfo(p) {
852
882
  if (p.off) { return 'Off — its channels aren’t shown'; }
853
883
  var st = Stremio.iptvStatus(p.id), parts = [];
@@ -856,13 +886,65 @@
856
886
  if (st.fetchedAt) { var d = new Date(st.fetchedAt); parts.push('downloaded ' + U.dayLabel(st.fetchedAt) + ' ' + two(d.getHours()) + ':' + two(d.getMinutes())); }
857
887
  if (st.dropped) { parts.push(thousands(st.dropped) + ' more left out (a playlist keeps its first 30,000)'); }
858
888
  if (st.uncached) { parts.push('too large to save on the TV: downloaded each launch'); }
889
+ var g = guideInfo(p);
890
+ if (g) { parts.push(g); }
859
891
  return parts.join(' · ');
860
892
  }
893
+ // Its TV guide (js/epg.js): being read, read (for how many of its channels), or why not.
894
+ function guideInfo(p) {
895
+ var g = typeof EPG !== 'undefined' && EPG.status ? EPG.status(p.id) : null;
896
+ if (!g) { return typeof EPG !== 'undefined' && EPG.available && EPG.available() ? 'no TV guide' : ''; }
897
+ if (g.state === 'ready') {
898
+ var d = new Date(g.at);
899
+ return 'TV guide: ' + thousands(g.channels) + ' channel' + (g.channels === 1 ? '' : 's') + ', ' + thousands(g.programmes) + ' programs' + (g.partial ? ' (part of it: too large)' : '') +
900
+ (g.at ? ', read ' + U.dayLabel(g.at) + ' ' + two(d.getHours()) + ':' + two(d.getMinutes()) : '') + (g.error ? ' (the last read failed: ' + g.error + ')' : '');
901
+ }
902
+ if (g.state === 'failed') { return '⚠ TV guide: ' + (g.error || 'couldn’t be read'); }
903
+ return 'TV guide: reading…';
904
+ }
861
905
  function playlistProblem(p) {
862
906
  var st = Stremio.iptvStatus(p.id);
863
907
  if (p.off || !st.error) { return ''; }
864
908
  return '⚠ ' + (st.stale ? 'Couldn’t refresh it (showing the saved copy): ' : '') + st.error;
865
909
  }
910
+ // + Add Xtream account: its server, username and password, a page each (a whole Xtream link
911
+ // in the server's fills the rest); checked with the server, then added (Stremio.iptvAddXtream).
912
+ function addXtream() {
913
+ function done(base, user, pass) {
914
+ U.toast('Checking the account…', 15000);
915
+ IPTV.xtreamCheck(base, user, pass).then(function (r) {
916
+ if (r.unreachable) { U.toast('Couldn’t reach the server: check its address (nothing was saved)', 5000); return; }
917
+ if (r.refused) { U.toast('The server refused that username and password' + (r.status ? ' (' + r.status + ')' : '') + ': nothing was saved', 5000); return; }
918
+ Stremio.iptvAddXtream(base, user, pass);
919
+ refreshAddonRows('local:iptv');
920
+ U.toast('Xtream account added: reading its channels and TV guide…', 4000);
921
+ renderSettings();
922
+ });
923
+ }
924
+ App.openEntry({
925
+ title: 'Xtream account: server', phone: 'playlist',
926
+ hint: 'The server address your provider gave you (http://line.example.com:8080), or a whole Xtream link. RETURN cancels.',
927
+ value: 'http://', placeholder: 'http://line.example.com:8080',
928
+ validate: function (v) { return IPTV.xtreamParts(v) ? null : 'That isn’t a server address'; },
929
+ onConfirm: function (v) {
930
+ var x = IPTV.xtreamParts(v);
931
+ App.back();
932
+ if (x.user && x.pass) { done(x.base, x.user, x.pass); return; }
933
+ App.openEntry({
934
+ title: 'Xtream account: username', hint: 'Your account’s username. RETURN cancels.', value: x.user, placeholder: 'username',
935
+ validate: function (u) { return ('' + u).trim() ? null : 'Type the username'; },
936
+ onConfirm: function (u) {
937
+ App.back();
938
+ App.openEntry({
939
+ title: 'Xtream account: password', hint: 'Your account’s password (kept on the TV, in the playlist’s link). RETURN cancels.', value: '', placeholder: 'password', secret: true,
940
+ validate: function (p) { return ('' + p) ? null : 'Type the password'; },
941
+ onConfirm: function (p) { App.back(); done(x.base, ('' + u).trim(), '' + p); }
942
+ });
943
+ }
944
+ });
945
+ }
946
+ });
947
+ }
866
948
  function settingsIptv(wrap) {
867
949
  wrap.appendChild(U.el('h2', null, 'IPTV'));
868
950
  var playlists = Stremio.iptvGetPlaylists ? Stremio.iptvGetPlaylists() : [];
@@ -888,10 +970,7 @@
888
970
  renderSettings();
889
971
  };
890
972
  }
891
- var up = ctl(U.el('div', 'feed-btn focusable', '▲'), 'up');
892
- up.__onselect = move(-1, 'up');
893
- var down = ctl(U.el('div', 'feed-btn focusable', '▼'), 'down');
894
- down.__onselect = move(1, 'down');
973
+ var btns = orderButtons(function (dir, name) { move(dir, name)(); }, ctl);
895
974
  var item = ctl(U.el('div', 'addon-item focusable playlist-item'), 'item');
896
975
  item.appendChild(U.el('div', 'a-name', label + (playlist.off ? ' (off)' : '')));
897
976
  item.appendChild(U.el('div', 'a-url', U.redactUrl(playlist.url))); // (credentials masked)
@@ -903,13 +982,13 @@
903
982
  _iptvFocus = { id: playlist.id, ctl: 'item' };
904
983
  playlistActions(playlist, label);
905
984
  };
906
- frow.appendChild(up);
907
- frow.appendChild(down);
985
+ btns.forEach(function (b) { frow.appendChild(b); });
908
986
  frow.appendChild(item);
909
987
  wrap.appendChild(frow);
910
988
  rows.push({ p: playlist, info: info, problem: problem, idx: idx });
911
989
  });
912
990
 
991
+ _guideRows = rows; // (their guide lines kept current as guides are read: _guideRows)
913
992
  if (statusEl) {
914
993
  Stremio.iptvLoad().then(function () {
915
994
  var channels = 0, groups = 0, failed = 0, on = 0;
@@ -947,6 +1026,11 @@
947
1026
  });
948
1027
  };
949
1028
  wrap.appendChild(add);
1029
+ var xt = U.el('div', 'addon-item focusable add-row');
1030
+ xt.appendChild(U.el('div', 'a-name', '+ Add Xtream account'));
1031
+ xt.appendChild(U.el('div', 'a-url', 'Its server, username and password: its channels and TV guide added'));
1032
+ xt.__onselect = addXtream;
1033
+ wrap.appendChild(xt);
950
1034
 
951
1035
  wrap.appendChild(U.el('div', 'hint',
952
1036
  'Channels are grouped into IPTV and Radio rows on Home. ▲ / ▼ set the playlists\' order: a channel in two playlists comes from the first.'));
@@ -1090,15 +1174,12 @@
1090
1174
  list.forEach(function (x) {
1091
1175
  var frow = U.el('div', 'feed-row playlist-row' + (x.off ? ' feed-off' : ''));
1092
1176
  function ctl(node, name) { node.setAttribute('data-pl', x.id); node.setAttribute('data-ctl', name); return node; }
1093
- var up = ctl(U.el('div', 'feed-btn focusable', '▲'), 'up');
1094
- up.__onselect = function () { if (YouTube.move(x.id, -1)) { ytChanged({ id: x.id, ctl: 'up' }); } };
1095
- var down = ctl(U.el('div', 'feed-btn focusable', '▼'), 'down');
1096
- down.__onselect = function () { if (YouTube.move(x.id, 1)) { ytChanged({ id: x.id, ctl: 'down' }); } };
1177
+ var btns = orderButtons(function (dir, name) { if (YouTube.move(x.id, dir)) { ytChanged({ id: x.id, ctl: name }); } }, ctl);
1097
1178
  var item = ctl(U.el('div', 'addon-item focusable playlist-item yt-catalog'), 'item');
1098
1179
  item.appendChild(U.el('div', 'a-name', x.name + (x.off ? ' (off)' : '')));
1099
1180
  item.appendChild(U.el('div', 'a-url', ytDescribe(x)));
1100
1181
  item.__onselect = function () { ytActions(x); };
1101
- frow.appendChild(up); frow.appendChild(down); frow.appendChild(item);
1182
+ btns.forEach(function (b) { frow.appendChild(b); }); frow.appendChild(item);
1102
1183
  wrap.appendChild(frow);
1103
1184
  });
1104
1185
 
@@ -1347,14 +1428,17 @@
1347
1428
  // unused fails the first time while it wakes up).
1348
1429
  var ADDON_WAITS = [['0', 'Normal (12 s; sources 15, search 25)'], ['20', '20 seconds'], ['30', '30 seconds'], ['45', '45 seconds'], ['60', '60 seconds']];
1349
1430
  var NEXT_CHOICES = [['0', 'Ask'], ['5', 'After 5 seconds'], ['10', 'After 10 seconds'], ['15', 'After 15 seconds'], ['30', 'After 30 seconds']];
1350
- // Seeking & buffering: how far LEFT / RIGHT jump; a bigger buffer for slow connections.
1431
+ // Seeking & buffering: how far LEFT / RIGHT jump; a bigger buffer for slow connections; how
1432
+ // often a live channel that drops is opened again.
1433
+ var RECONNECTS = [['off', 'Off'], ['few', 'A few times (3 in 5 minutes)'], ['always', 'Keep trying']];
1351
1434
  function settingsSeeking(wrap) {
1352
1435
  wrap.appendChild(U.el('h2', null, 'Seeking & buffering'));
1353
- var store = { get: function () { var c = Player.playerCfg(); return { seek: '' + c.seek, buffer: c.buffer }; },
1354
- set: function (cfg) { Player.setPlayerCfg({ seek: +cfg.seek || 10, buffer: cfg.buffer }); } };
1436
+ var store = { get: function () { var c = Player.playerCfg(); return { seek: '' + c.seek, buffer: c.buffer, reconnect: c.reconnect }; },
1437
+ set: function (cfg) { Player.setPlayerCfg({ seek: +cfg.seek || 10, buffer: cfg.buffer, reconnect: cfg.reconnect }); } };
1355
1438
  wrap.appendChild(subPickRow('Seek step', [['5', '5 s'], ['10', '10 s'], ['15', '15 s'], ['30', '30 s']], 'seek', store));
1356
1439
  wrap.appendChild(subPickRow('Buffer', [['normal', 'Normal (the TV’s own)'], ['more', 'More'], ['most', 'Most']], 'buffer', store));
1357
- wrap.appendChild(U.el('div', 'hint', 'Seek step: how far LEFT / RIGHT jump (FF / REW jump three steps). Buffer: More or Most starts a little slower but stops less on a slow connection, from the next video you play.'));
1440
+ wrap.appendChild(subPickRow('Reconnect live channels', RECONNECTS, 'reconnect', store));
1441
+ wrap.appendChild(U.el('div', 'hint', 'Seek step: how far LEFT / RIGHT jump (FF / REW jump three steps). Buffer: More or Most starts a little slower but stops less on a slow connection, from the next video you play. Reconnect: a live channel that drops opens again; Keep trying waits a little longer each time (BACK stops it).'));
1358
1442
  focusSettings();
1359
1443
  }
1360
1444
  function settingsSkipping(wrap) {
@@ -1371,34 +1455,6 @@
1371
1455
  focusSettings();
1372
1456
  }
1373
1457
 
1374
- // Home tabs: hide the ones you never use (Watching always stays: it's where Home opens). A
1375
- // row like the ones above it; OK opens the tabs, OK on one shows or hides it (the list
1376
- // stays open). A hidden tab's rows are back when it's shown again.
1377
- function homeTabsRow() {
1378
- function hiddenNow() { return Stremio.getPrefs().hiddenTabs || []; }
1379
- var r = U.el('div', 'addon-item focusable cfg-row'), name = U.el('div', 'a-name');
1380
- var tabs = (V.HOME_TABS || []).filter(function (t) { return t.key !== 'continue'; });
1381
- function draw() {
1382
- var hidden = hiddenNow().filter(function (k) { return tabs.some(function (t) { return t.key === k; }); });
1383
- name.textContent = 'Home tabs: ' + (hidden.length ? hidden.length + ' hidden' : 'All shown');
1384
- }
1385
- draw();
1386
- r.appendChild(name);
1387
- r.appendChild(U.el('div', 'a-url', 'Press OK to choose'));
1388
- r.__onselect = function () {
1389
- var hidden = hiddenNow();
1390
- toggleList('Home tabs', tabs.map(function (t) { return [t.key, t.label + (t.key === 'local' ? ' (with a USB drive in)' : ''), hidden.indexOf(t.key) === -1]; }),
1391
- { on: 'Shown', off: 'Hidden' }, function (key, on) {
1392
- var now = hiddenNow().filter(function (k) { return k !== key; });
1393
- if (!on) { now.push(key); }
1394
- Stremio.setPref('hiddenTabs', now);
1395
- markHomeDirty();
1396
- draw();
1397
- });
1398
- };
1399
- return r;
1400
- }
1401
-
1402
1458
  var POSTER_SHAPES = [['portrait', 'Portrait'], ['landscape', 'Landscape']];
1403
1459
  // ----- Diagnostics › Logs: the recent problems U.log kept (in memory, URLs masked), and
1404
1460
  // how long launches took -----
@@ -1615,14 +1671,17 @@
1615
1671
  if (!inf.entries.length) { line(dbOn ? 'Nothing in the TV’s database.' : 'The TV’s database isn’t used (turned off above).'); }
1616
1672
  line('Settings storage: ' + fmtBytes(inf.ls) + ' (progress, watched marks, settings…)' + (inf.lsTop.length ? '; the largest:' : ''));
1617
1673
  inf.lsTop.forEach(function (e) { line(' ' + fmtBytes(e.bytes) + ' ' + e.key); });
1674
+ if (inf.guides && inf.guides.count) { line('Saved TV guides (in StremioK’s folder): ' + fmtBytes(inf.guides.bytes) + ' in ' + inf.guides.count + (inf.guides.count === 1 ? ' file' : ' files')); }
1618
1675
  if (inf.disk) { line('The TV’s own disk: ' + fmtBytes(inf.disk.free) + ' free of ' + fmtBytes(inf.disk.total) + ' (what the TV allows StremioK follows it: freeing space, unused apps for one, raises it)'); }
1619
1676
  }
1620
1677
  r.__onselect = function () {
1621
1678
  if (!U.bigStore || !U.bigStore.info || _storageInfo === 'busy') { return; }
1622
1679
  _storageInfo = 'busy'; renderSettings();
1623
- Promise.all([U.bigStore.info(), U.diskInfo ? U.diskInfo() : null]).then(function (both) {
1680
+ var guides = (typeof YTPlay !== 'undefined' && YTPlay.epgFiles) ? YTPlay.epgFiles().then(null, function () { return null; }) : null;
1681
+ Promise.all([U.bigStore.info(), U.diskInfo ? U.diskInfo() : null, guides]).then(function (both) {
1624
1682
  var inf = both[0], ls = 0, lsAll = [];
1625
1683
  inf.disk = both[1];
1684
+ inf.guides = both[2]; // ({ count, bytes }: the TV guides StremioK's service saved)
1626
1685
  inf.lsKeys = {};
1627
1686
  try {
1628
1687
  for (var i = 0; i < window.localStorage.length; i++) {
@@ -1654,12 +1713,15 @@
1654
1713
  }
1655
1714
  var clr = U.el('div', 'addon-item focusable');
1656
1715
  clr.appendChild(U.el('div', 'a-name', 'Clear saved copies'));
1657
- clr.appendChild(U.el('div', 'a-url', 'Home’s saved rows, IPTV playlist copies, the USB index: made again when needed (playlists download again)'));
1716
+ clr.appendChild(U.el('div', 'a-url', 'Home’s saved rows, IPTV playlist copies, saved TV guides, the USB index: made again when needed (playlists and guides download again)'));
1658
1717
  clr.__onselect = function () {
1659
- confirm('Clear the copies StremioK can make again (Home rows, playlist copies, USB index)?', 'Clear', function () {
1718
+ confirm('Clear the copies StremioK can make again (Home rows, playlist copies, TV guides, USB index)?', 'Clear', function () {
1660
1719
  U.bigStore.keys().then(function (ks) {
1661
1720
  return Promise.all(ks.filter(function (k) { return /^stremiok\.(homecache|local\.index|iptv\.cache\.)/.test(k); }).map(function (k) { return U.bigStore.remove(k); }));
1662
1721
  }).then(function () {
1722
+ return (typeof YTPlay !== 'undefined' && YTPlay.epgClear) ? YTPlay.epgClear().then(null, function () {}) : null; // (the saved guides: read again as they're needed)
1723
+ }).then(function () {
1724
+ if (typeof EPG !== 'undefined' && EPG.reset) { EPG.reset(); }
1663
1725
  if (typeof LocalMedia !== 'undefined') { LocalMedia.clearIndex(); }
1664
1726
  _storageInfo = null;
1665
1727
  U.toast('Saved copies cleared');
@@ -1831,7 +1893,28 @@
1831
1893
  // ----- Home feeds and Search results (Settings > Appearance): order, show / hide -----
1832
1894
  // kind 'home': the Home rows (every catalog); 'search': the Search rows (every catalog
1833
1895
  // that can search, IPTV as one per type). ▲/▼ moves one; OK on its name shows / hides it.
1896
+ // 'menu': the top menu between Watching and Settings (the tabs, Discover, Search: ViewParts'
1897
+ // menuOrder); OK on a tab shows / hides it, Discover and Search always show.
1898
+ var MENU_FIXED = { discover: 'Discover', search: 'Search' };
1899
+ function menuHidden(k) { return !MENU_FIXED[k] && (Stremio.getPrefs().hiddenTabs || []).indexOf(k) !== -1; }
1834
1900
  var FEED_KINDS = {
1901
+ menu: { section: 'menuorder', title: 'Appearance › Menu', hint: 'Watching comes first and Settings last: ⇈ ▲ ▼ ⇊ put the rest in your order · OK on a tab shows/hides it.',
1902
+ list: function () {
1903
+ return Promise.resolve(V.menuOrder().map(function (k) {
1904
+ var t = (V.HOME_TABS || []).filter(function (x) { return x.key === k; })[0];
1905
+ return { key: k, name: MENU_FIXED[k] || (t ? t.label : k) + (k === 'local' ? ' (with a USB drive in)' : k === 'guide' ? ' (once a TV guide is read)' : '') };
1906
+ }));
1907
+ },
1908
+ keyOf: function (x) { return x.key; },
1909
+ move: function (k, d) { V.moveMenuItem(k, d); markHomeDirty(); return Promise.resolve(); },
1910
+ toggle: function (k) {
1911
+ if (MENU_FIXED[k]) { U.toast(MENU_FIXED[k] + ' is always in the menu'); return; }
1912
+ var now = (Stremio.getPrefs().hiddenTabs || []).filter(function (x) { return x !== k; });
1913
+ if (!menuHidden(k)) { now.push(k); }
1914
+ Stremio.setPref('hiddenTabs', now);
1915
+ markHomeDirty();
1916
+ },
1917
+ hidden: menuHidden },
1835
1918
  home: { section: 'feeds', title: 'Appearance › Home feeds', hint: '▲/▼ reorder a feed · OK on the name shows/hides it on Home.',
1836
1919
  list: function () { return Stremio.listCatalogsOrdered(); }, keyOf: function (c) { return Stremio.catalogKey(c); },
1837
1920
  move: function (k, d) { return Stremio.moveFeed(k, d); }, toggle: function (k) { Stremio.toggleFeed(k); },
@@ -1962,7 +2045,20 @@
1962
2045
  var SOURCE_ROWS = { prefix: 'src:', off: 'off', keyOf: function (x) { return x.key; },
1963
2046
  move: function (k, d) { return Stremio.moveFeedSource(k, d); }, toggle: function (k) { Stremio.toggleFeedSource(k); },
1964
2047
  hidden: function (k) { return Stremio.isFeedSourceOff(k); } };
1965
- // ▲ / ▼ / name rows for an ordered list (L: keyOf, hidden, move, toggle, off, prefix).
2048
+ // ⇈ ▲ ▼ ⇊ for a row of a list you put in order: to the top, one place up, one down, to the
2049
+ // bottom. move(dir, ctl) moves it (dir -Infinity / -1 / 1 / Infinity: see U.moveIn); mark(node,
2050
+ // ctl) tags each button, so the page can focus the one used once it's drawn again.
2051
+ var ORDER_BTNS = [['top', '⇈', -Infinity], ['up', '▲', -1], ['down', '▼', 1], ['bottom', '⇊', Infinity]];
2052
+ function orderButtons(move, mark) {
2053
+ return ORDER_BTNS.map(function (b) {
2054
+ var n = U.el('div', 'feed-btn focusable', b[1]);
2055
+ if (mark) { mark(n, b[0]); }
2056
+ n.__onselect = function () { move(b[2], b[0]); };
2057
+ return n;
2058
+ });
2059
+ }
2060
+ function ctlIndex(ctl) { for (var i = 0; i < ORDER_BTNS.length; i++) { if (ORDER_BTNS[i][0] === ctl) { return i; } } return 1; }
2061
+ // ⇈ ▲ ▼ ⇊ / name rows for an ordered list (L: keyOf, hidden, move, toggle, off, prefix).
1966
2062
  // After a change the page is drawn again, focused on the control used (refeed).
1967
2063
  function feedRows(wrap, kind, items, L) {
1968
2064
  items.forEach(function (item) {
@@ -1970,20 +2066,14 @@
1970
2066
  var hidden = L.hidden(key);
1971
2067
  var frow = U.el('div', 'feed-row' + (hidden ? ' feed-off' : ''));
1972
2068
 
1973
- var up = U.el('div', 'feed-btn focusable', '▲');
1974
- up.setAttribute('data-fk', fk); up.setAttribute('data-ctl', 'up');
1975
- up.__onselect = function () { L.move(key, -1).then(function () { refeed(kind, fk, 'up'); }); };
1976
-
1977
- var down = U.el('div', 'feed-btn focusable', '▼');
1978
- down.setAttribute('data-fk', fk); down.setAttribute('data-ctl', 'down');
1979
- down.__onselect = function () { L.move(key, 1).then(function () { refeed(kind, fk, 'down'); }); };
2069
+ var btns = orderButtons(function (dir, ctl) { L.move(key, dir).then(function () { refeed(kind, fk, ctl); }); },
2070
+ function (n, ctl) { n.setAttribute('data-fk', fk); n.setAttribute('data-ctl', ctl); });
1980
2071
 
1981
2072
  var name = U.el('div', 'feed-name focusable', item.name + (hidden ? ' (' + (L.off || 'hidden') + ')' : ''));
1982
2073
  name.setAttribute('data-fk', fk); name.setAttribute('data-ctl', 'name');
1983
2074
  name.__onselect = function () { L.toggle(key); refeed(kind, fk, 'name'); };
1984
2075
 
1985
- frow.appendChild(up);
1986
- frow.appendChild(down);
2076
+ btns.forEach(function (b) { frow.appendChild(b); });
1987
2077
  frow.appendChild(name);
1988
2078
  wrap.appendChild(frow);
1989
2079
  });
package/js/views.js CHANGED
@@ -12,8 +12,9 @@ var Views = (function (V) {
12
12
  // pages, a search's rows) stopped
13
13
  leaveScreen: function (name) { var f = { detail: V.leaveDetail, discover: V.leaveDiscover, search: V.leaveSearch }[name]; if (f) { f(); } }, backInLocal: V.backInLocal, setHomeTab: V.setHomeTab,
14
14
  renderLive: V.renderLive,
15
- renderSearch: V.renderSearch, renderDiscover: V.renderDiscover, renderEntry: V.renderEntry, backInDetail: V.backInDetail,
15
+ renderSearch: V.renderSearch, searchFor: function (q) { V.searchFor(q); }, renderDiscover: V.renderDiscover, renderEntry: V.renderEntry, backInDetail: V.backInDetail,
16
16
  homeDirty: V.homeDirty, refreshHomeIfStale: V.refreshHomeIfStale, alertNewEpisodes: V.alertNewEpisodes, previewTiles: V.previewTiles, withShowInfo: V.withShowInfo,
17
- isModalOpen: V.isModalOpen, closeModal: V.closeModal, moveKey: V.moveKey, availableTabs: V.availableTabs
17
+ isModalOpen: V.isModalOpen, closeModal: V.closeModal, moveKey: V.moveKey, availableTabs: V.availableTabs,
18
+ menuItems: V.menuItems, menuOrder: V.menuOrder, moveMenuItem: V.moveMenuItem
18
19
  };
19
20
  })(ViewParts);
package/js/youtube.js CHANGED
@@ -130,9 +130,7 @@ var YouTube = (function () {
130
130
  function move(id, dir) {
131
131
  var c = cfg(), i = -1;
132
132
  c.catalogs.forEach(function (x, n) { if (x.id === id) { i = n; } });
133
- var j = i + dir;
134
- if (i < 0 || j < 0 || j >= c.catalogs.length) { return false; }
135
- var t = c.catalogs[i]; c.catalogs[i] = c.catalogs[j]; c.catalogs[j] = t;
133
+ if (!U.moveIn(c.catalogs, i, dir)) { return false; } // (one place, or to the top / bottom)
136
134
  save(c);
137
135
  return true;
138
136
  }
package/js/ytplay.js CHANGED
@@ -62,15 +62,28 @@ var YTPlay = (function () {
62
62
  }
63
63
  // A live HLS channel whose sound the TV's player stutters on (its server cuts the AAC sound
64
64
  // mid-frame): the service looks at it and, if so, gives a relay that repacks the sound:
65
- // { fix, sure, why, url }. force: the relay at once (the channel's already known to need it).
66
- // h: the channel's own User-Agent / Referer ({ ua, ref }).
67
- function liveOpen(url, h, force) {
65
+ // { fix, sure, why, url, cc: [{ ch, lang }] (its closed captions), ccSure }. force: the relay
66
+ // at once (the channel's already known to need it). h: the channel's own User-Agent /
67
+ // Referer ({ ua, ref }). opts (with force): { fix: false (the sound as it is), cc: the caption
68
+ // channels to add as subtitle tracks }.
69
+ function liveOpen(url, h, force, opts) {
68
70
  if (MODE === 'browser') { return Promise.reject(new Error('no service')); }
71
+ var cc = opts && opts.cc ? opts.cc.map(function (c) { return c.ch + (c.lang ? '-' + c.lang : ''); }).join(',') : '';
69
72
  return ensureService().then(entryAuth).then(function (a) {
70
- var q = '/live/open?u=' + encodeURIComponent(url) + (h && h.ua ? '&ua=' + encodeURIComponent(h.ua) : '') + (h && h.ref ? '&ref=' + encodeURIComponent(h.ref) : '') + (force ? '&force=1' : '');
73
+ var q = '/live/open?u=' + encodeURIComponent(url) + (h && h.ua ? '&ua=' + encodeURIComponent(h.ua) : '') + (h && h.ref ? '&ref=' + encodeURIComponent(h.ref) : '') + (force ? '&force=1' : '') +
74
+ (force && opts && opts.fix === false ? '&fix=0' : '') + (force && cc ? '&cc=' + encodeURIComponent(cc) : '');
71
75
  return U.getJSON(PROXY + q + a.q, force ? 5000 : 30000, 65536, null, a.h);
72
76
  });
73
77
  }
78
+ // An HLS stream's quality list, read by the service with the stream's own User-Agent /
79
+ // Referer (h: { ua, ref }), which a web page can't send: its text.
80
+ function hlsMaster(url, h) {
81
+ if (MODE === 'browser') { return Promise.reject(new Error('no service')); }
82
+ return ensureService().then(entryAuth).then(function (a) {
83
+ var q = '/live/master?u=' + encodeURIComponent(url) + (h && h.ua ? '&ua=' + encodeURIComponent(h.ua) : '') + (h && h.ref ? '&ref=' + encodeURIComponent(h.ref) : '');
84
+ return U.getText(PROXY + q + a.q, 12000, 524288, null, false, a.h);
85
+ });
86
+ }
74
87
  // An AIOManager account's addons (its Hydra API, through the service: an instance only
75
88
  // answers the web origins it's set to): { addons: [{ url, name, enabled }] }. base: its
76
89
  // address; key: the account's API key (a header, never in the link).
@@ -82,6 +95,50 @@ var YTPlay = (function () {
82
95
  return U.getJSON(PROXY + '/aiom/addons?base=' + encodeURIComponent(base) + a.q, 40000, 1048576, null, h);
83
96
  });
84
97
  }
98
+ // A playlist's TV guide, read and kept by the service (see service/index.js, epgControl):
99
+ // epgLoad asks it to read it for these channels (k: their keys as a bitset, EPG.bitsFor) ->
100
+ // { state, at, channels, programmes, partial, error }; epgGet: their programs -> { <key>:
101
+ // [[start, stop, title, desc]] }. Both POSTs, what's asked in the body: a newer TV's service
102
+ // reads 8 KB of a request's address and headers, less than thousands of channels' keys.
103
+ // (a POST refused, a service from before it took them or one without the token: asked
104
+ // again as a GET, when it fits in the address)
105
+ // An app that can't read the service's token (its header instead): the guide ticket, asked
106
+ // for once, given with the POSTs (the TV adds an Origin to them, which the header alone doesn't pass).
107
+ var _ticket = null;
108
+ function epgTicket(a) {
109
+ if (a.q || MODE === 'tizenbrew') { return Promise.resolve(a.q); } // (the token; TizenBrew's origin passes as it is)
110
+ if (!_ticket) {
111
+ _ticket = U.getJSON(PROXY + '/epg/ticket', 8000, 4096, null, a.h).then(function (r) {
112
+ return r && /^[0-9a-f]{48}$/.test(r.ticket || '') ? '&ticket=' + r.ticket : '';
113
+ }, function () { _ticket = null; return ''; });
114
+ }
115
+ return _ticket;
116
+ }
117
+ function epgAsk(path, body, maxBytes) {
118
+ if (MODE === 'browser') { return Promise.reject(new Error('no service')); }
119
+ return ensureService().then(entryAuth).then(function (a) {
120
+ return epgTicket(a).then(function (cred) { return { a: a, cred: cred }; });
121
+ }).then(function (x) {
122
+ var a = x.a, q = x.cred ? '?' + x.cred.replace(/^&/, '') : '';
123
+ return U.postJSON(PROXY + path + q, body, 15000, maxBytes, a.h).then(null, function (err) {
124
+ if (err && /^HTTP 403\b/.test(err.message || '') && /ticket=/.test(q)) { _ticket = null; } // (the service started again since: a new ticket next time)
125
+ if (!(err && /^HTTP (403|405)\b/.test(err.message || '') && body.length < 6000)) { throw err; }
126
+ return U.getJSON(PROXY + path + (q ? q + '&' : '?') + body, 15000, maxBytes, null, a.h);
127
+ });
128
+ });
129
+ }
130
+ // Settings › Storage: the saved guides ({ count, bytes }), and deleting them all.
131
+ function epgFiles() {
132
+ if (MODE === 'browser') { return Promise.resolve(null); }
133
+ return ensureService().then(entryAuth).then(function (a) { return U.getJSON(PROXY + '/epg/files' + (a.q ? '?' + a.q.replace(/^&/, '') : ''), 8000, 4096, null, a.h); });
134
+ }
135
+ function epgClear() { return epgAsk('/epg/clear', '', 4096); }
136
+ function epgLoad(u, k, force) {
137
+ return epgAsk('/epg/load', 'u=' + encodeURIComponent(u) + '&k=' + k + (force ? '&force=1' : ''), 65536);
138
+ }
139
+ function epgGet(u, keys, from, to, max) {
140
+ return epgAsk('/epg/get', 'u=' + encodeURIComponent(u) + '&ids=' + encodeURIComponent(keys.join('|')) + '&from=' + from + '&to=' + to + (max ? '&max=' + max : ''), 4194304);
141
+ }
85
142
  // Whether YouTube gives the video out to the service (it refuses some uploads to the player
86
143
  // the service asks as): false when it says no; true when it does, or no answer came (the
87
144
  // player tries). The service keeps what it found, so playing it next asks YouTube nothing new.
@@ -172,6 +229,6 @@ var YTPlay = (function () {
172
229
  return MODE === 'tizenbrew' ? 'TizenBrew module' : MODE === 'wgt' ? 'Sideloaded app (.wgt)' : 'Browser (development)';
173
230
  }
174
231
 
175
- return { ensureService: ensureService, resolverUrl: resolverUrl, infoUrl: infoUrl, canPlay: canPlay, liveOpen: liveOpen, aiomAddons: aiomAddons, entryOpen: entryOpen, entryPoll: entryPoll, entryClose: entryClose, subsUrl: subsUrl, audioLang: audioLang, setAudioLang: setAudioLang, installMode: function () { return MODE; },
232
+ return { ensureService: ensureService, resolverUrl: resolverUrl, infoUrl: infoUrl, canPlay: canPlay, liveOpen: liveOpen, hlsMaster: hlsMaster, aiomAddons: aiomAddons, epgLoad: epgLoad, epgGet: epgGet, epgFiles: epgFiles, epgClear: epgClear, entryOpen: entryOpen, entryPoll: entryPoll, entryClose: entryClose, subsUrl: subsUrl, audioLang: audioLang, setAudioLang: setAudioLang, installMode: function () { return MODE; },
176
233
  installLabel: installLabel, unavailableMessage: unavailableMessage, isRunning: isRunning, port: PORT };
177
234
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stremiok",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "StremioK streaming client for Tizen 3.0+ Samsung TVs (TizenBrew app module).",
5
5
  "packageType": "app",
6
6
  "appName": "StremioK",
@@ -14,6 +14,7 @@
14
14
  "icon.png",
15
15
  "config.xml",
16
16
  "README.md",
17
+ "CHANGELOG.md",
17
18
  "LICENSE"
18
19
  ],
19
20
  "keys": [