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/player-core.js CHANGED
@@ -58,7 +58,8 @@ var PlayerParts = {};
58
58
  dur: 0, // seconds
59
59
  cur: 0, // seconds
60
60
  isLive: false, // live stream (sports/IPTV): no fixed duration
61
- playStarted: false, // this AVPlay attempt reached playback (vs. failed early)
61
+ playStarted: false, // this AVPlay attempt was prepared (opened, ready to play)
62
+ playing: false, // ...and actually played: its position moved for a second (see oncurrentplaytime)
62
63
  // seek robustness (AVPlay can error if you seek mid-buffer)
63
64
  buffering: false,
64
65
  pendingSeek: null,
@@ -156,8 +157,14 @@ var PlayerParts = {};
156
157
 
157
158
  function isActive() { return S.active; }
158
159
 
159
- function saveProgress() {
160
+ // fromTicker: the 5-second save while it plays: skipped when nothing moved since the last one
161
+ // (paused); a stop, a leave or a jump always saves.
162
+ var _savedSig = '';
163
+ function saveProgress(fromTicker) {
160
164
  if (!S.ctx || !S.dur || S.isLive || S.ctx.trailer || S.ctx.private || isNotice()) { return; } // (a notice clip, private viewing: nothing saved, nothing removed)
165
+ var sig = progressKey(S.ctx) + '|' + Math.floor(S.cur) + '|' + Math.floor(S.dur);
166
+ if (fromTicker && sig === _savedSig) { return; }
167
+ _savedSig = sig;
161
168
  // Past the credits the episode is watched: its entry was removed and the next one
162
169
  // queued (handleCredits). Saving again would put it back next to the next episode.
163
170
  if (_creditsHandled) { return; }
@@ -292,15 +299,29 @@ var PlayerParts = {};
292
299
  } catch (er2) {}
293
300
  return out;
294
301
  }
302
+ // An IPTV channel's TV guide (js/epg.js): { now, next } or null.
303
+ function guideNow() { return (typeof EPG !== 'undefined' && S.ctx && /^iptv:/.test(S.ctx.id || '')) ? EPG.now(S.ctx.id) : null; }
304
+ function clock(sec) { var d = new Date(sec * 1000); return (d.getHours() < 10 ? '0' : '') + d.getHours() + ':' + (d.getMinutes() < 10 ? '0' : '') + d.getMinutes(); }
305
+ if (typeof EPG !== 'undefined' && EPG.onChange) { EPG.onChange(function () { if (S.active && S.isLive) { renderOSD(); } }); }
295
306
  function renderOSD() {
296
307
  U.byId('osd-title').textContent = S.ctx ? S.ctx.name : '';
308
+ var ck = U.byId('osd-clock'); // (the time of day, top right while the bar is on screen)
309
+ if (ck) { ck.textContent = clock(Date.now() / 1000); }
297
310
  renderTags();
298
311
  renderChapters();
299
312
  if (S.isLive) {
313
+ var cur = U.byId('osd-cur'), g = guideNow();
314
+ cur.className = 'live';
315
+ if (g && g.now) { // an IPTV channel with a TV guide: what's on, how far along, and next
316
+ var t = Date.now() / 1000;
317
+ U.byId('osd-bar').style.display = '';
318
+ U.byId('osd-progress').style.width = Math.max(0, Math.min(100, Math.round((t - g.now.start) / (g.now.stop - g.now.start) * 100))) + '%';
319
+ cur.textContent = '● LIVE ' + clock(g.now.start) + '–' + clock(g.now.stop) + ' ' + g.now.title;
320
+ U.byId('osd-dur').textContent = g.next ? 'Next ' + clock(g.next.start) + ' ' + g.next.title : '';
321
+ return;
322
+ }
300
323
  U.byId('osd-bar').style.display = 'none';
301
- var cur = U.byId('osd-cur');
302
324
  cur.textContent = '● LIVE';
303
- cur.className = 'live';
304
325
  U.byId('osd-dur').textContent = '';
305
326
  return;
306
327
  }
@@ -324,6 +345,41 @@ var PlayerParts = {};
324
345
  yt: page, ytLang: lang, type: 'YouTube', id: 'yt_id:' + link.id, parent: 'yt_id:' + link.id, name: link.title || 'YouTube',
325
346
  poster: 'https://i.ytimg.com/vi/' + link.id + '/hqdefault.jpg', streams: [], private: !!(S.ctx && S.ctx.private) || undefined }, _onExit); // (from a private one: private too)
326
347
  }
348
+ // ---------- a live Xtream channel's format ----------
349
+ // An Xtream server sends a channel as MPEG-TS (…/live/USER/PASS/123.ts) or as HLS (….m3u8).
350
+ // The menu's Format plays it the other way and keeps that for the channel (FORMAT_KEY, by its
351
+ // id, the last FORMAT_KEEP), so it starts that way next time; its playlist's own way isn't
352
+ // kept. ctx._ownUrl: the link as its playlist gives it.
353
+ var FORMAT_KEY = 'stremiok.liveformat', FORMAT_KEEP = 300;
354
+ var XT_LIVE = /^(https?:\/\/[^?#]+\/live\/[^\/?#]+\/[^\/?#]+\/[^\/?#.]+)\.(ts|m3u8)([?#].*)?$/i;
355
+ function liveFormat(url) { var m = XT_LIVE.exec(url || ''); return m ? (m[2].toLowerCase() === 'm3u8' ? 'hls' : 'ts') : ''; }
356
+ function withFormat(url, fmt) { var m = XT_LIVE.exec(url || ''); return m ? m[1] + '.' + (fmt === 'hls' ? 'm3u8' : 'ts') + (m[3] || '') : url; }
357
+ function formatStart(ctx) {
358
+ if (!S.isLive || !ctx.id || !liveFormat(ctx._ownUrl || ctx.url)) { return; }
359
+ if (!ctx._ownUrl) { ctx._ownUrl = ctx.url; }
360
+ var want = (U.store.get(FORMAT_KEY, {}) || {})[ctx.id];
361
+ ctx.url = (want === 'ts' || want === 'hls') ? withFormat(ctx._ownUrl, want) : ctx._ownUrl;
362
+ }
363
+ function setLiveFormat(fmt) {
364
+ var old = S.ctx;
365
+ if (!S.active || !old || !old._ownUrl || !liveFormat(old.url) || liveFormat(old.url) === fmt) { return; }
366
+ var all = U.store.get(FORMAT_KEY, {}) || {};
367
+ delete all[old.id];
368
+ if (fmt !== liveFormat(old._ownUrl)) {
369
+ all[old.id] = fmt;
370
+ var keys = Object.keys(all);
371
+ keys.slice(0, Math.max(0, keys.length - FORMAT_KEEP)).forEach(function (k) { delete all[k]; }); // (the oldest picked)
372
+ }
373
+ U.store.set(FORMAT_KEY, all);
374
+ var ctx = {}; // (the channel again, without what was learned from the other link: its relay, its look)
375
+ Object.keys(old).forEach(function (k) { if (!/^_(relay|relayFor|relayCc|ccOffer|liveChecked)$/.test(k)) { ctx[k] = old[k]; } });
376
+ ctx.switching = true;
377
+ play(ctx, _onExit);
378
+ U.spinner(true, 'Opening it as ' + (fmt === 'hls' ? 'HLS' : 'MPEG-TS') + '…');
379
+ }
380
+ P.liveFormat = function () { return S.active && S.ctx && S.isLive && S.ctx._ownUrl ? liveFormat(S.ctx.url) : ''; };
381
+ P.setLiveFormat = setLiveFormat;
382
+
327
383
  // Another source of what's playing (the player's source list): played in its place, at
328
384
  // the same point, as a playback of its own — its quality ladder, YouTube details,
329
385
  // captions, SponsorBlock sections, headers and file details are the new source's, and so
@@ -368,6 +424,7 @@ var PlayerParts = {};
368
424
  S.active = true; S.cur = 0; S.dur = 0; _bg = null; S.actual = null;
369
425
  // Stremio "tv" and "radio" types are strong live hints; refined after prepare.
370
426
  S.isLive = (ctx.type === 'tv' || ctx.type === 'radio') || /^iptv:/.test(ctx.id || '') || !!ctx.isLive; // (an IPTV channel: live, a sports one too)
427
+ formatStart(ctx);
371
428
  S.ctx.resumeSec = (ctx.trailer || ctx.fromStart) ? 0 : (ctx.resumeSec || getResume(ctx.type, ctx.id)); // (Play from start: 0:00)
372
429
 
373
430
  // reset subtitle + audio + quality + on-screen controls state
@@ -376,7 +433,8 @@ var PlayerParts = {};
376
433
  resetQuality(ctx);
377
434
  resetControls();
378
435
  _stall = { t: -1, since: 0, bufSince: 0, recoveries: [] };
379
- _prefetchedNext = false; S.playStarted = false;
436
+ recoReset();
437
+ _prefetchedNext = false; S.playStarted = false; S.playing = false;
380
438
  _segments = null; _creditsSec = null; _creditsHandled = false; _segmentsFor = null;
381
439
  S.buffering = false; S.pendingSeek = null; S.lastSeekAt = 0; S.settleFrom = 0; _seekRecovering = false; _jumpUndone = null; _pausedOnce = false;
382
440
  updateHint();
@@ -509,7 +567,7 @@ var PlayerParts = {};
509
567
  url = (S.ctx && S.ctx._relay && S.ctx._relayFor === url) ? S.ctx._relay : opened(url); // (a live channel's sound repacked: see liveCheck)
510
568
  var token = ++_playToken;
511
569
  objEl.className = 'active';
512
- S.playStarted = false;
570
+ S.playStarted = false; S.playing = false;
513
571
  _lastTickMs = null; _playedMs = 0;
514
572
  _tick = null; _pausedOnce = false; // (a new open: the jump watch starts over, see checkJump)
515
573
  subsBeforeOpen(); // a built-in subtitle track is selected again once this attempt plays
@@ -564,6 +622,7 @@ var PlayerParts = {};
564
622
  // Played, not just opened: a file can open and then fail (or show nothing)
565
623
  if (_lastTickMs !== null && ms > _lastTickMs && ms - _lastTickMs < 1500) { _playedMs += ms - _lastTickMs; }
566
624
  _lastTickMs = ms;
625
+ if (!S.playing && _playedMs >= 1000) { S.playing = true; _reco.tries = 0; } // (started: playing, not just opened; a reconnect's tries start over)
567
626
  if (_playedMs >= 2000 && !isNotice()) { reportStart(true); } // (a notice clip proves nothing about the format)
568
627
  audioOnTick();
569
628
  subsOnTick();
@@ -586,11 +645,15 @@ var PlayerParts = {};
586
645
  setTimeout(function () { _seekRecovering = false; }, 4000);
587
646
  return;
588
647
  }
589
- // Failed before playback ever started → retry / step down / Auto.
590
- if (!S.playStarted && (relayFailed() || tryFallback())) { return; }
648
+ // A live channel that played: its connection dropped → opened again (liveDropped).
649
+ if (S.playing && liveReconnects()) { U.log('player', 'Playback error: ' + err + ' (' + where(S.ctx.url) + ')'); liveDropped('Connection lost'); return; }
650
+ // A reconnect's try that didn't play → the next try.
651
+ if (!S.playing && _reco.tries) { if (!relayFailed()) { liveDropped('Couldn’t reconnect'); } return; }
652
+ // Failed before it ever played (opened, maybe, but nothing shown) → retry / step down / Auto.
653
+ if (!S.playing && (relayFailed() || tryFallback())) { return; }
591
654
  reportStart(false, err); // (unless it had already played)
592
655
  U.log('player', 'Playback error: ' + err + ' (' + where(S.ctx && S.ctx.url) + ')');
593
- if (!S.playStarted) { failedStart('Playback error: ' + err); return; }
656
+ if (!S.playing) { failedStart('Playback error: ' + err); return; }
594
657
  U.toast('Playback error: ' + err);
595
658
  exitLater();
596
659
  }
@@ -625,6 +688,7 @@ var PlayerParts = {};
625
688
  if (!S.active || token !== _playToken) { return; }
626
689
  U.spinner(false);
627
690
  if (relayFailed()) { return; }
691
+ if (_reco.tries) { liveDropped('Couldn’t reconnect'); return; } // (a reconnect's try: the next one)
628
692
  // Prepare failed → retry / step down / Auto.
629
693
  if (tryFallback()) { return; }
630
694
  U.log('player', 'Could not open: ' + (err && (err.name || err.message || err)) + ' (' + where(url) + ')');
@@ -710,7 +774,7 @@ var PlayerParts = {};
710
774
  _ticker = setInterval(function () {
711
775
  if (inBackground()) { return; } // (saved on the way there; nothing to watch until it's back)
712
776
  if (useAV) { try { S.cur = avplay.getCurrentTime() / 1000; } catch (e) {} onProgress(); checkStall(); }
713
- saveProgress();
777
+ saveProgress(true);
714
778
  }, 5000);
715
779
  }
716
780
 
@@ -723,8 +787,8 @@ var PlayerParts = {};
723
787
  return !!S.ctx && !S.ctx.yt && !S.ctx.trailer && !S.isLive && (S.ctx.type === 'movie' || S.ctx.type === 'series') && S.dur > 0 && S.dur < NOTICE_MAX_S;
724
788
  }
725
789
  function onEnded() {
726
- // a live channel whose stream ended (its server stopped it): opened again, as a stall is
727
- if (S.isLive && S.ctx && !S.ctx.trailer && !S.ctx.yt) { recoverStall('The stream ended — opening the channel again…'); return; }
790
+ // a live channel whose stream ended (its server stopped it): opened again (liveDropped)
791
+ if (liveReconnects()) { liveDropped('The stream ended'); return; }
728
792
  if (isNotice()) { // (back to its sources: another one, or this one later)
729
793
  U.toast('That source isn\'t ready yet (the debrid service is still getting it): try another, or this one later', 6000);
730
794
  exit();
@@ -834,6 +898,13 @@ var PlayerParts = {};
834
898
  if (_creditsHandled || !S.ctx || S.ctx.trailer || isNotice()) { return; }
835
899
  _creditsHandled = true;
836
900
  if (S.ctx.private) { return; } // (private viewing: not marked watched, nothing queued)
901
+ // With Trakt: sent as a finish (Trakt's own way, which also clears a paused place it
902
+ // kept for it), not as a separate "watched"
903
+ if (S.dur > 0 && typeof Trakt !== 'undefined' && Trakt.onStopped && Trakt.isConnected && Trakt.isConnected()) {
904
+ markFinished(true);
905
+ Trakt.onStopped(S.ctx.type, S.ctx.id, S.cur, S.dur, S.ctx.name);
906
+ return;
907
+ }
837
908
  markFinished(false);
838
909
  }
839
910
  // Watched: off Continue Watching, the next episode queued. quiet: Trakt's been told another
@@ -937,7 +1008,7 @@ var PlayerParts = {};
937
1008
  var NEXT_TRIES = 3, AV_START_MS = 45000, _openedAt = 0;
938
1009
  function nextSourceOnFail(notice) {
939
1010
  var c = S.ctx;
940
- if (!c || !S.active || S.playStarted || S.isLive || c.trailer || c.yt || c.localFile || !c.stream || typeof c.nextSource !== 'function') { return false; }
1011
+ if (!c || !S.active || S.playing || S.isLive || c.trailer || c.yt || c.localFile || !c.stream || typeof c.nextSource !== 'function') { return false; }
941
1012
  if (typeof Stremio === 'undefined' || !Stremio.getPrefs || !Stremio.getPrefs().nextOnFail) { return false; }
942
1013
  var tried = (c._tried || []).concat([Stremio.sourceId(c.stream)]);
943
1014
  if (tried.length > NEXT_TRIES) { return false; }
@@ -1000,11 +1071,17 @@ var PlayerParts = {};
1000
1071
  else { n.text = svc + ' couldn’t give this file (' + addon + ': “' + kind.replace(/_/g, ' ') + '”)'; }
1001
1072
  return n;
1002
1073
  }
1003
- // Not started after AV_START_MS (no answer from the server): as a refusal, when there's a
1004
- // next source to try (otherwise it's left to go on waiting).
1074
+ // Not playing after AV_START_MS (no answer from the server, or opened and nothing shown): the
1075
+ // next source when there's one; else it stops and says so (not while you've paused it).
1005
1076
  function startCheck() {
1006
- if (!S.active || S.playStarted || !useAV || !_openedAt || Date.now() - _openedAt < AV_START_MS) { return; }
1007
- nextSourceOnFail();
1077
+ if (!S.active || S.playing || !useAV || !_openedAt || Date.now() - _openedAt < AV_START_MS) { return; }
1078
+ if (_reco.tries) { liveDropped('Couldn’t reconnect'); return; } // (a reconnect's try: the next one)
1079
+ if (nextSourceOnFail()) { return; }
1080
+ if (S.playStarted && isPaused()) { return; }
1081
+ U.log('player', 'Didn’t start: nothing played in ' + (AV_START_MS / 1000) + ' s (' + where(S.ctx && S.ctx.url) + ')');
1082
+ U.spinner(false);
1083
+ U.toast('Didn’t start: nothing played in ' + (AV_START_MS / 1000) + ' seconds. Try another source.', 6000);
1084
+ exitLater();
1008
1085
  }
1009
1086
  P.startCheck = startCheck;
1010
1087
 
@@ -1014,6 +1091,11 @@ var PlayerParts = {};
1014
1091
  // cut that way, the channel restarts through the service's relay, which repacks the sound.
1015
1092
  // The answer is kept per link (LIVE_KEY, LIVE_DAYS): a channel known to need it starts
1016
1093
  // through the relay (startLive), one known not to isn't asked about again.
1094
+ // The same look says which closed-caption channels (CC1–CC4) are in its picture (cc, kept
1095
+ // with it once sure). They're offered in the subtitle menu (ctx._ccOffer); turned on (picked
1096
+ // there, remembered for the channel, or in your default subtitle language), the channel plays
1097
+ // through the relay with them as subtitle tracks (ctx._relayCc), the sound as it is unless it
1098
+ // needs repacking too.
1017
1099
  var LIVE_KEY = 'stremiok.livefix', LIVE_KEEP = 300, LIVE_DAYS = 14;
1018
1100
  function liveFixable(ctx) {
1019
1101
  return useAV && S.isLive && !!ctx && !ctx.yt && !ctx.trailer && /^https?:\/\//i.test(ctx.url || '') &&
@@ -1023,22 +1105,45 @@ var PlayerParts = {};
1023
1105
  var k = (U.store.get(LIVE_KEY, {}) || {})[url];
1024
1106
  return (k && Date.now() - k.at < LIVE_DAYS * 86400000) ? k : null;
1025
1107
  }
1026
- function liveKeep(url, fix) {
1108
+ // The caption channels kept for a link: [{ ch: 'CC1', lang: 'en' }], or null (not known yet).
1109
+ function ccOf(k) {
1110
+ if (!k || !Array.isArray(k.cc)) { return null; }
1111
+ return k.cc.filter(function (c) { return c && /^CC[1-4]$/.test(c.ch) && (!c.lang || /^[a-z]{2,3}$/.test(c.lang)); }).slice(0, 4);
1112
+ }
1113
+ function liveKeep(url, fix, cc) {
1027
1114
  var all = U.store.get(LIVE_KEY, {}) || {};
1028
1115
  all[url] = { fix: fix ? 1 : 0, at: Date.now() };
1116
+ if (cc) { all[url].cc = cc; }
1029
1117
  var keys = Object.keys(all);
1030
1118
  if (keys.length > LIVE_KEEP) {
1031
1119
  keys.sort(function (a, b) { return all[a].at - all[b].at; }).slice(0, keys.length - LIVE_KEEP).forEach(function (k) { delete all[k]; });
1032
1120
  }
1033
1121
  U.store.set(LIVE_KEY, all);
1034
1122
  }
1123
+ // Captions on from the start: your choice for this channel (a closed-caption track picked
1124
+ // there last), else one in your default subtitle language (or second choice).
1125
+ function ccWanted(ctx, cc) {
1126
+ if (!cc || !cc.length) { return false; }
1127
+ var mem = P.recallSub(ctx.id);
1128
+ if (mem) { return !!mem.embedded; }
1129
+ var cfg = P.getSubCfg(), langs = [cfg.lang, cfg.lang2].filter(function (l) { return l && l !== 'off' && l !== 'none'; });
1130
+ return cc.some(function (c) { return c.lang && langs.some(function (l) { return P.langMatches(l, c.lang); }); });
1131
+ }
1132
+ function ccIndex(cc, w) { for (var i = 0; i < cc.length; i++) { if (cc[i].ch === w.ch) { return i; } } return 0; }
1133
+ function relayed(ctx, r, cc) {
1134
+ if (!r || typeof r.url !== 'string' || !/^http:\/\/localhost:\d+\/live\//.test(r.url)) { return false; }
1135
+ ctx._relay = r.url; ctx._relayFor = ctx.url; ctx._relayCc = cc || null; ctx._ccOffer = cc ? null : ctx._ccOffer;
1136
+ return true;
1137
+ }
1035
1138
  function startLive(ctx, settle) {
1036
- var known = liveFixable(ctx) ? liveKnown(ctx.url) : null;
1037
- if (!known || !known.fix) { startAV(ctx.url, settle); return; }
1038
- ctx._liveChecked = true;
1039
- YTPlay.liveOpen(ctx.url, ctx._headers, true).then(function (r) {
1139
+ var known = liveFixable(ctx) ? liveKnown(ctx.url) : null, cc = ccOf(known);
1140
+ if (cc && cc.length) { ctx._ccOffer = cc; }
1141
+ var withCc = ccWanted(ctx, cc) ? cc : null;
1142
+ if (cc) { ctx._liveChecked = true; } // (both answers in: not asked again)
1143
+ if (!known || (!known.fix && !withCc)) { startAV(ctx.url, settle); return; }
1144
+ YTPlay.liveOpen(ctx.url, ctx._headers, true, { fix: !!known.fix, cc: withCc }).then(function (r) {
1040
1145
  if (S.ctx !== ctx || !S.active) { return; }
1041
- if (r && typeof r.url === 'string' && /^http:\/\/localhost:\d+\/live\//.test(r.url)) { ctx._relay = r.url; ctx._relayFor = ctx.url; }
1146
+ relayed(ctx, r, withCc);
1042
1147
  startAV(ctx.url, settle);
1043
1148
  }, function () { if (S.ctx === ctx && S.active) { startAV(ctx.url, settle); } });
1044
1149
  }
@@ -1046,17 +1151,37 @@ var PlayerParts = {};
1046
1151
  var ctx = S.ctx;
1047
1152
  if (!ctx || ctx._liveChecked || !liveFixable(ctx)) { return; }
1048
1153
  ctx._liveChecked = true;
1049
- if (liveKnown(ctx.url)) { return; }
1154
+ var known = liveKnown(ctx.url);
1155
+ if (known && ccOf(known)) { return; }
1050
1156
  var url = ctx.url;
1051
1157
  YTPlay.liveOpen(url, ctx._headers, false).then(function (r) {
1052
1158
  if (!r || !r.sure) { return; } // (couldn't tell: asked again next time)
1053
- liveKeep(url, !!r.fix);
1054
- if (!r.fix || typeof r.url !== 'string' || !/^http:\/\/localhost:\d+\/live\//.test(r.url) || S.ctx !== ctx || !S.active || ctx.url !== url) { return; }
1055
- ctx._relay = r.url; ctx._relayFor = url;
1159
+ var cc = (r.ccSure || r.cc === undefined) ? (ccOf(r) || []) : null, fix = known ? !!known.fix : !!r.fix; // (captions with no text yet: asked again next time; the sound's answer: as first found)
1160
+ liveKeep(url, fix, cc);
1161
+ if (S.ctx !== ctx || !S.active || ctx.url !== url) { return; }
1162
+ if (cc && cc.length && !ctx._relayCc) { ctx._ccOffer = cc; }
1163
+ if (cc && cc.length && ccWanted(ctx, cc)) { ccOn(ctx, 'Adding this channel’s captions…'); return; }
1164
+ if (known || !fix || ctx._relay || !relayed(ctx, r, null)) { return; } // (the sound: restarted for on its first look only)
1056
1165
  U.log('player', 'Live sound repacked by StremioK\'s service (' + (r.why || '') + ')');
1057
1166
  reopen('Fixing this channel’s sound…');
1058
1167
  }, function () {});
1059
1168
  }
1169
+ // The channel again through the relay with its closed captions as subtitle tracks (the sound
1170
+ // repacked too when it needs it); then: true once it's reopening. want: a caption track to
1171
+ // select once it plays ({ ch, lang }).
1172
+ function ccOn(ctx, message, want) {
1173
+ var known = liveKnown(ctx.url), cc = ccOf(known) || ctx._ccOffer;
1174
+ if (!cc || !cc.length || !liveFixable(ctx)) { return Promise.resolve(false); }
1175
+ return YTPlay.liveOpen(ctx.url, ctx._headers, true, { fix: !!(known && known.fix), cc: cc }).then(function (r) {
1176
+ if (S.ctx !== ctx || !S.active || !relayed(ctx, r, cc)) { return false; }
1177
+ var w = want || cc.filter(function (c) { return ccWanted(ctx, [c]); })[0] || null;
1178
+ U.log('player', 'Live captions through StremioK\'s service (' + cc.map(function (c) { return c.ch; }).join(', ') + ')');
1179
+ reopen(message);
1180
+ if (w && P.wantEmbedded) { P.wantEmbedded({ nth: ccIndex(cc, w), lang: w.lang }); } // (after the reopen: on its tracks)
1181
+ return true;
1182
+ }, function () { return false; });
1183
+ }
1184
+ P.ccOn = function (want) { return S.ctx ? ccOn(S.ctx, 'Adding this channel’s captions…', want) : Promise.resolve(false); };
1060
1185
  // The relay didn't start (StremioK's service gone): the channel as it is.
1061
1186
  function relayFailed() {
1062
1187
  if (S.playStarted || !S.ctx || !S.ctx._relay) { return false; }
@@ -1077,14 +1202,15 @@ var PlayerParts = {};
1077
1202
  var now = Date.now();
1078
1203
  if (S.buffering) {
1079
1204
  if (!_stall.bufSince) { _stall.bufSince = now; }
1080
- else if (now - _stall.bufSince > BUFFER_STALL_SECS * 1000) { recoverStall('Stream stopped loading — reloading…'); }
1205
+ else if (now - _stall.bufSince > BUFFER_STALL_SECS * 1000) { recoverStall('Stream stopped loading'); }
1081
1206
  return;
1082
1207
  }
1083
1208
  _stall.bufSince = 0;
1084
1209
  if (isPaused() || S.cur !== _stall.t) { _stall.t = S.cur; _stall.since = now; return; }
1085
- if (_stall.since && now - _stall.since > STALL_SECS * 1000) { recoverStall('Playback stalled — reloading…'); }
1210
+ if (_stall.since && now - _stall.since > STALL_SECS * 1000) { recoverStall('Playback stalled'); }
1086
1211
  }
1087
- function recoverStall(message) {
1212
+ function recoverStall(why) {
1213
+ if (liveReconnects()) { liveDropped(why); return; }
1088
1214
  var now = Date.now();
1089
1215
  _stall.recoveries = _stall.recoveries.filter(function (t) { return now - t < STALL_WINDOW_MS; });
1090
1216
  if (_stall.recoveries.length >= STALL_MAX) {
@@ -1094,7 +1220,51 @@ var PlayerParts = {};
1094
1220
  return;
1095
1221
  }
1096
1222
  _stall.recoveries.push(now);
1097
- reopen(message);
1223
+ reopen(why + ' — reloading…');
1224
+ }
1225
+
1226
+ // ---------- a live channel that drops ----------
1227
+ // A live channel that stops (an error once it played, its connection lost; frozen, stuck
1228
+ // loading, or ended by its server) is opened again at the live edge, as Settings › Playback ›
1229
+ // Seeking & buffering › Reconnect live channels says: Off (the player closes), A few times
1230
+ // (STALL_MAX within STALL_WINDOW_MS, as any stall) or Keep trying (no limit). A try that
1231
+ // doesn't play is followed by another, each a little later (RECO_WAIT_S); BACK stops it.
1232
+ // Not for a trailer or YouTube (a stall there reloads as a video's does).
1233
+ var RECO_WAIT_S = [0, 5, 10, 20, 40, 60], _reco = { tries: 0, timer: null };
1234
+ function liveReconnects() { return S.isLive && !!S.ctx && !S.ctx.trailer && !S.ctx.yt; }
1235
+ function recoReset() {
1236
+ if (_reco.timer) { clearTimeout(_reco.timer); }
1237
+ _reco = { tries: 0, timer: null };
1238
+ }
1239
+ function liveDropped(why) {
1240
+ if (_reco.timer) { return; } // (a try already waiting)
1241
+ var mode = P.playerCfg().reconnect, now = Date.now();
1242
+ if (mode === 'off') {
1243
+ U.log('player', 'Live channel: ' + why + '; reconnecting is off');
1244
+ U.spinner(false); U.toast(why + ': the channel stopped (reconnecting is off)', 5000);
1245
+ exitLater();
1246
+ return;
1247
+ }
1248
+ _stall.recoveries = _stall.recoveries.filter(function (t) { return now - t < STALL_WINDOW_MS; });
1249
+ if (mode !== 'always' && _stall.recoveries.length >= STALL_MAX) {
1250
+ U.log('player', 'Live channel: ' + why + '; dropped ' + STALL_MAX + ' times in ' + (STALL_WINDOW_MS / 60000) + ' minutes, given up');
1251
+ U.spinner(false); U.toast('This channel keeps dropping — try it again later', 5000);
1252
+ exit();
1253
+ return;
1254
+ }
1255
+ _stall.recoveries.push(now);
1256
+ var n = ++_reco.tries, wait = RECO_WAIT_S[Math.min(n - 1, RECO_WAIT_S.length - 1)] * 1000, ctx = S.ctx;
1257
+ var msg = why + ' — reconnecting' + (n > 1 ? ' (try ' + n + ')' : '') + '…';
1258
+ U.log('player', 'Live channel: ' + why + ', reconnecting (try ' + n + (wait ? ', in ' + (wait / 1000) + ' s' : '') + ')');
1259
+ if (!wait) { reopen(msg); return; }
1260
+ U.spinner(true, msg);
1261
+ function due() {
1262
+ _reco.timer = null;
1263
+ if (!S.active || S.ctx !== ctx) { return; }
1264
+ if (inBackground()) { _reco.timer = setTimeout(due, 5000); return; } // (back first)
1265
+ reopen(msg);
1266
+ }
1267
+ _reco.timer = setTimeout(due, wait);
1098
1268
  }
1099
1269
  // The stream opened again in place: VOD at the same position, live at the edge.
1100
1270
  function reopen(message) {
@@ -1109,6 +1279,7 @@ var PlayerParts = {};
1109
1279
  function exit() {
1110
1280
  if (!S.active) { return; }
1111
1281
  if (U.crumb) { U.crumb('player closed'); }
1282
+ if (P.sleepClear) { P.sleepClear(); } // (the sleep timer: this playback's)
1112
1283
  saveProgress();
1113
1284
  // Record partial progress to Trakt (unfinished VOD) so it shows in Continue Watching. Past
1114
1285
  // 85% (Trakt connected): finished, Trakt's way (it marks it watched), and here as at the credits.
@@ -1126,6 +1297,7 @@ var PlayerParts = {};
1126
1297
  closeControls();
1127
1298
  screenSaver(true);
1128
1299
  _playToken++; // invalidate AVPlay/HTML5 callbacks before stop/close emits any
1300
+ recoReset();
1129
1301
  if (_ticker) { clearInterval(_ticker); _ticker = null; }
1130
1302
  if (_osdTimer) { clearTimeout(_osdTimer); _osdTimer = null; }
1131
1303
  hideOSD();
@@ -1154,4 +1326,5 @@ var PlayerParts = {};
1154
1326
  P.sponsorHighlight = sponsorHighlight;
1155
1327
  P.chapters = chapters; P.chapterAt = chapterAt; P.chapterLabel = chapterLabel;
1156
1328
  P.seekTo = seekTo; P.doAVSeek = doAVSeek; P.seekBy = seekBy; P.exit = exit;
1329
+ P.reload = function () { if (S.active && S.ctx) { reopen(S.isLive ? 'Reloading the channel…' : 'Reloading…'); } }; // (the menu's Reload)
1157
1330
  })(PlayerParts);
package/js/player-lang.js CHANGED
@@ -10,7 +10,7 @@
10
10
  var info = tr && tr.extra_info;
11
11
  if (typeof info === 'string') {
12
12
  try { info = JSON.parse(info); } catch (e) {
13
- var out = {}, re = /["']?([A-Za-z_]+)["']?\s*[:=]\s*["']?([^"',}]*)/g, m;
13
+ var out = Object.create(null), re = /["']?([A-Za-z_]+)["']?\s*[:=]\s*["']?([^"',}]*)/g, m; // (no inherited names)
14
14
  while ((m = re.exec(info))) { out[m[1]] = m[2].trim(); }
15
15
  info = out;
16
16
  }
@@ -26,7 +26,7 @@
26
26
  function resetQuality(ctx) {
27
27
  _variants = []; _qualityBw = null; _ytRetried = false; _switchNote = null;
28
28
  _userPicked = false; _autoRetried = false; _startNote = '';
29
- loadVariants(ctx.variantUrl || ctx.url, ctx.initialQualityHeight || 0, !!ctx.variantUrl);
29
+ loadVariants(ctx.variantUrl || ctx.url, ctx.initialQualityHeight || 0, !!ctx.variantUrl, ctx._headers);
30
30
  }
31
31
  // Playback started: a switch is done; say where a fallback landed.
32
32
  function qualityOnStart() {
@@ -38,6 +38,8 @@
38
38
  function qualityHeight() { var v = variantByBw(_qualityBw); return v ? v.h : 0; }
39
39
  function qualityCount() { return _variants.length; }
40
40
  function hasQualities() { return _variants.length > 1; }
41
+ var _unread = false; // an HLS stream whose quality list couldn't be read
42
+ function qualityUnread() { return _unread && !_variants.length; }
41
43
 
42
44
  // Auto lets AVPlay adapt to bandwidth; an explicit pick caps the ladder via ADAPTIVE_INFO.
43
45
  // Two cap strategies, because YouTube-style HLS breaks the usual one:
@@ -187,8 +189,11 @@
187
189
  var u = '' + (url || ''), path = u.split('#')[0].split('?')[0], query = u.split('#')[0].split('?')[1] || '';
188
190
  return /\.m3u8$/i.test(path) || /(^|&)(type|format|ext|output)=m3u8(&|$)/i.test(query);
189
191
  }
190
- function getHlsMaster(url) {
191
- return U.getText(url, 8000, HLS_MASTER_MAX).then(function (text) {
192
+ // h: the stream's own User-Agent / Referer ({ ua, ref }): the list is then read through
193
+ // StremioK's service, which can send them (as the TV's player does); a web page can't.
194
+ function getHlsMaster(url, h) {
195
+ var viaService = !!(h && (h.ua || h.ref) && typeof YTPlay !== 'undefined' && YTPlay.hlsMaster && YTPlay.installMode && YTPlay.installMode() !== 'browser');
196
+ return (viaService ? YTPlay.hlsMaster(url, h) : U.getText(url, 8000, HLS_MASTER_MAX)).then(function (text) {
192
197
  var t = ('' + (text || '')).replace(/^\uFEFF/, '').replace(/^\s+/, '');
193
198
  if (t.indexOf('#EXTM3U') !== 0) { throw new Error('not an HLS playlist'); }
194
199
  return t;
@@ -205,12 +210,13 @@
205
210
  CLASSES.forEach(function (c) { if (Math.abs(c - t) <= c * 0.06) { best = c; } });
206
211
  return best;
207
212
  }
208
- function loadVariants(url, initialHeight, isMaster) {
213
+ function loadVariants(url, initialHeight, isMaster, headers) {
209
214
  var tok = ++_variantsToken; // a list arriving after you've moved on is for the old stream
210
215
  _variants = [];
211
216
  _sepAudio = false;
217
+ _unread = false;
212
218
  if (!url || !(isMaster || isHlsUrl(url))) { return; }
213
- getHlsMaster(url).then(function (text) {
219
+ getHlsMaster(url, headers).then(function (text) {
214
220
  if (tok !== _variantsToken) { return; }
215
221
  var t = '' + text;
216
222
  _sepAudio = t.indexOf('#EXT-X-MEDIA:TYPE=AUDIO') !== -1 || /#EXT-X-STREAM-INF[^\r\n]*[,:]AUDIO=/.test(t);
@@ -239,7 +245,9 @@
239
245
  }
240
246
  }
241
247
  }
242
- }, function () { if (tok === _variantsToken) { _variants = []; _sepAudio = false; } });
248
+ }, function (e) { // (couldn't be read: said so, not "one quality"; an answer that isn't a playlist at all: no list, as before)
249
+ if (tok === _variantsToken) { _variants = []; _sepAudio = false; _unread = !/not an HLS playlist/.test((e && e.message) || ''); }
250
+ });
243
251
  }
244
252
 
245
253
  // Switch to a different source mid-playback, keeping position (VOD): a playback of its
@@ -312,7 +320,7 @@
312
320
 
313
321
  // ----- for the other parts (and js/player.js) -----
314
322
  P.resetQuality = resetQuality; P.qualityOnStart = qualityOnStart; P.clearStartNote = clearStartNote;
315
- P.qualityHeight = qualityHeight; P.qualityCount = qualityCount; P.hasQualities = hasQualities;
323
+ P.qualityHeight = qualityHeight; P.qualityCount = qualityCount; P.hasQualities = hasQualities; P.qualityUnread = qualityUnread;
316
324
  P.adaptiveInfo = adaptiveInfo; P.tryFallback = tryFallback; P.switchSource = switchSource;
317
325
  P.qualityLabel = qualityLabel; P.qualityMenu = qualityMenu;
318
326
  P.hasYtLanguages = hasYtLanguages; P.ytLanguageLabel = ytLanguageLabel; P.ytLanguageMenu = ytLanguageMenu;
@@ -107,7 +107,9 @@
107
107
  for (var i = 0; i < SUB_FONTS.length; i++) { if (SUB_FONTS[i][0] === id) { return SUB_FONTS[i][2]; } }
108
108
  return '';
109
109
  }
110
- function setSubCfg(cfg) { U.store.set(SUBCFG_KEY, cfg); }
110
+ var _subCfgVer = 0; // bumped on every change: a styled line knows it's up to date
111
+ function setSubCfg(cfg) { U.store.set(SUBCFG_KEY, cfg); _subCfgVer++; }
112
+ function subCfgVersion() { return _subCfgVer; }
111
113
 
112
114
  // Preferred audio language ('default' = keep the stream's first audio track) and a
113
115
  // second choice used when the first isn't on the stream ('none' = no second choice).
@@ -236,13 +238,15 @@
236
238
  ['zoom', 'Zoom (fill the screen, trims edges)', 'PLAYER_DISPLAY_MODE_CROPPED_FULL', 'cover'],
237
239
  ['stretch', 'Stretch', 'PLAYER_DISPLAY_MODE_FULL_SCREEN', 'fill']
238
240
  ];
239
- // + seek: how far LEFT / RIGHT jump (s); buffer: 'normal' (the TV's own) | 'more' | 'most'
241
+ // + seek: how far LEFT / RIGHT jump (s); buffer: 'normal' (the TV's own) | 'more' | 'most';
242
+ // reconnect: a live channel that drops, 'off' | 'few' | 'always' (see player-core's liveDropped)
240
243
  // (Settings › Playback › Seeking & buffering)
241
244
  var SEEK_STEPS = [5, 10, 15, 30];
242
245
  function playerCfg() {
243
246
  var d = U.store.get(PLAYERCFG_KEY, {}) || {};
244
247
  return { aspect: d.aspect || 'fit', seek: SEEK_STEPS.indexOf(+d.seek) !== -1 ? +d.seek : 10,
245
248
  buffer: d.buffer === 'more' || d.buffer === 'most' ? d.buffer : 'normal',
249
+ reconnect: d.reconnect === 'off' || d.reconnect === 'always' ? d.reconnect : 'few',
246
250
  phone: d.phone === 'infuse' || d.phone === 'link' ? d.phone : 'vlc' }; // (Continue on phone: what the QR opens)
247
251
  }
248
252
  function setPlayerCfg(cfg) { // (what's given changes; the rest stays)
@@ -251,6 +255,26 @@
251
255
  U.store.set(PLAYERCFG_KEY, d);
252
256
  }
253
257
  function aspectDef(id) { for (var i = 0; i < ASPECTS.length; i++) { if (ASPECTS[i][0] === id) { return ASPECTS[i]; } } return ASPECTS[0]; }
258
+ // A live channel's own picture mode (picked while it played): { channel id: mode }, the last
259
+ // ASPECTS_KEEP; one set back to the usual mode isn't kept apart. Anything else: the usual one.
260
+ var ASPECTS_KEY = 'stremiok.aspects', ASPECTS_KEEP = 300;
261
+ function aspectChannel() { var S = P.S; return (S && S.isLive && S.ctx && S.ctx.id) ? S.ctx.id : ''; }
262
+ function aspectNow() {
263
+ var ch = aspectChannel(), own = ch ? (U.store.get(ASPECTS_KEY, {}) || {})[ch] : '';
264
+ return own && aspectDef(own)[0] === own ? own : playerCfg().aspect;
265
+ }
266
+ function setAspectNow(mode) {
267
+ var ch = aspectChannel();
268
+ if (!ch) { setPlayerCfg({ aspect: mode }); return; }
269
+ var all = U.store.get(ASPECTS_KEY, {}) || {};
270
+ delete all[ch];
271
+ if (mode !== playerCfg().aspect) {
272
+ all[ch] = mode;
273
+ var keys = Object.keys(all);
274
+ keys.slice(0, Math.max(0, keys.length - ASPECTS_KEEP)).forEach(function (k) { delete all[k]; }); // (the oldest picked)
275
+ }
276
+ U.store.set(ASPECTS_KEY, all);
277
+ }
254
278
 
255
279
  // ----- for the other parts (and js/player.js) -----
256
280
  P.watchedState = watchedState; P.isWatched = isWatched; P.watchedAt = watchedAt; P.watchedSince = watchedSince;
@@ -261,7 +285,7 @@
261
285
  P.getResume = getResume; P.removeProgress = removeProgress; P.continueList = continueList;
262
286
  P.rememberSub = rememberSub; P.recallSub = recallSub; P.rememberAudio = rememberAudio;
263
287
  P.recallAudio = recallAudio; P.playerCfg = playerCfg; P.setPlayerCfg = setPlayerCfg;
264
- P.aspectDef = aspectDef; P.SUB_FONTS = SUB_FONTS; P.SUB_COLORS = SUB_COLORS; P.SUB_BGS = SUB_BGS;
288
+ P.aspectDef = aspectDef; P.aspectNow = aspectNow; P.setAspectNow = setAspectNow; P.SUB_FONTS = SUB_FONTS; P.SUB_COLORS = SUB_COLORS; P.SUB_BGS = SUB_BGS;
265
289
  P.SUB_EDGES = SUB_EDGES; P.SUB_POSITIONS = SUB_POSITIONS; P.ASPECTS = ASPECTS;
266
- P.isPrivate = isPrivate; P.setPrivate = setPrivate;
290
+ P.isPrivate = isPrivate; P.setPrivate = setPrivate; P.privateNow = privateNow; P.subCfgVersion = subCfgVersion;
267
291
  })(PlayerParts);