stremiok 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/js/player-core.js CHANGED
@@ -299,15 +299,29 @@ var PlayerParts = {};
299
299
  } catch (er2) {}
300
300
  return out;
301
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(); } }); }
302
306
  function renderOSD() {
303
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); }
304
310
  renderTags();
305
311
  renderChapters();
306
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
+ }
307
323
  U.byId('osd-bar').style.display = 'none';
308
- var cur = U.byId('osd-cur');
309
324
  cur.textContent = '● LIVE';
310
- cur.className = 'live';
311
325
  U.byId('osd-dur').textContent = '';
312
326
  return;
313
327
  }
@@ -331,6 +345,41 @@ var PlayerParts = {};
331
345
  yt: page, ytLang: lang, type: 'YouTube', id: 'yt_id:' + link.id, parent: 'yt_id:' + link.id, name: link.title || 'YouTube',
332
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)
333
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
+
334
383
  // Another source of what's playing (the player's source list): played in its place, at
335
384
  // the same point, as a playback of its own — its quality ladder, YouTube details,
336
385
  // captions, SponsorBlock sections, headers and file details are the new source's, and so
@@ -375,6 +424,7 @@ var PlayerParts = {};
375
424
  S.active = true; S.cur = 0; S.dur = 0; _bg = null; S.actual = null;
376
425
  // Stremio "tv" and "radio" types are strong live hints; refined after prepare.
377
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);
378
428
  S.ctx.resumeSec = (ctx.trailer || ctx.fromStart) ? 0 : (ctx.resumeSec || getResume(ctx.type, ctx.id)); // (Play from start: 0:00)
379
429
 
380
430
  // reset subtitle + audio + quality + on-screen controls state
@@ -1041,6 +1091,11 @@ var PlayerParts = {};
1041
1091
  // cut that way, the channel restarts through the service's relay, which repacks the sound.
1042
1092
  // The answer is kept per link (LIVE_KEY, LIVE_DAYS): a channel known to need it starts
1043
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.
1044
1099
  var LIVE_KEY = 'stremiok.livefix', LIVE_KEEP = 300, LIVE_DAYS = 14;
1045
1100
  function liveFixable(ctx) {
1046
1101
  return useAV && S.isLive && !!ctx && !ctx.yt && !ctx.trailer && /^https?:\/\//i.test(ctx.url || '') &&
@@ -1050,22 +1105,45 @@ var PlayerParts = {};
1050
1105
  var k = (U.store.get(LIVE_KEY, {}) || {})[url];
1051
1106
  return (k && Date.now() - k.at < LIVE_DAYS * 86400000) ? k : null;
1052
1107
  }
1053
- 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) {
1054
1114
  var all = U.store.get(LIVE_KEY, {}) || {};
1055
1115
  all[url] = { fix: fix ? 1 : 0, at: Date.now() };
1116
+ if (cc) { all[url].cc = cc; }
1056
1117
  var keys = Object.keys(all);
1057
1118
  if (keys.length > LIVE_KEEP) {
1058
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]; });
1059
1120
  }
1060
1121
  U.store.set(LIVE_KEY, all);
1061
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
+ }
1062
1138
  function startLive(ctx, settle) {
1063
- var known = liveFixable(ctx) ? liveKnown(ctx.url) : null;
1064
- if (!known || !known.fix) { startAV(ctx.url, settle); return; }
1065
- ctx._liveChecked = true;
1066
- 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) {
1067
1145
  if (S.ctx !== ctx || !S.active) { return; }
1068
- 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);
1069
1147
  startAV(ctx.url, settle);
1070
1148
  }, function () { if (S.ctx === ctx && S.active) { startAV(ctx.url, settle); } });
1071
1149
  }
@@ -1073,17 +1151,37 @@ var PlayerParts = {};
1073
1151
  var ctx = S.ctx;
1074
1152
  if (!ctx || ctx._liveChecked || !liveFixable(ctx)) { return; }
1075
1153
  ctx._liveChecked = true;
1076
- if (liveKnown(ctx.url)) { return; }
1154
+ var known = liveKnown(ctx.url);
1155
+ if (known && ccOf(known)) { return; }
1077
1156
  var url = ctx.url;
1078
1157
  YTPlay.liveOpen(url, ctx._headers, false).then(function (r) {
1079
1158
  if (!r || !r.sure) { return; } // (couldn't tell: asked again next time)
1080
- liveKeep(url, !!r.fix);
1081
- if (!r.fix || typeof r.url !== 'string' || !/^http:\/\/localhost:\d+\/live\//.test(r.url) || S.ctx !== ctx || !S.active || ctx.url !== url) { return; }
1082
- 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)
1083
1165
  U.log('player', 'Live sound repacked by StremioK\'s service (' + (r.why || '') + ')');
1084
1166
  reopen('Fixing this channel’s sound…');
1085
1167
  }, function () {});
1086
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); };
1087
1185
  // The relay didn't start (StremioK's service gone): the channel as it is.
1088
1186
  function relayFailed() {
1089
1187
  if (S.playStarted || !S.ctx || !S.ctx._relay) { return false; }
@@ -1181,6 +1279,7 @@ var PlayerParts = {};
1181
1279
  function exit() {
1182
1280
  if (!S.active) { return; }
1183
1281
  if (U.crumb) { U.crumb('player closed'); }
1282
+ if (P.sleepClear) { P.sleepClear(); } // (the sleep timer: this playback's)
1184
1283
  saveProgress();
1185
1284
  // Record partial progress to Trakt (unfinished VOD) so it shows in Continue Watching. Past
1186
1285
  // 85% (Trakt connected): finished, Trakt's way (it marks it watched), and here as at the credits.
@@ -1227,4 +1326,5 @@ var PlayerParts = {};
1227
1326
  P.sponsorHighlight = sponsorHighlight;
1228
1327
  P.chapters = chapters; P.chapterAt = chapterAt; P.chapterLabel = chapterLabel;
1229
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)
1230
1330
  })(PlayerParts);
@@ -255,6 +255,26 @@
255
255
  U.store.set(PLAYERCFG_KEY, d);
256
256
  }
257
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
+ }
258
278
 
259
279
  // ----- for the other parts (and js/player.js) -----
260
280
  P.watchedState = watchedState; P.isWatched = isWatched; P.watchedAt = watchedAt; P.watchedSince = watchedSince;
@@ -265,7 +285,7 @@
265
285
  P.getResume = getResume; P.removeProgress = removeProgress; P.continueList = continueList;
266
286
  P.rememberSub = rememberSub; P.recallSub = recallSub; P.rememberAudio = rememberAudio;
267
287
  P.recallAudio = recallAudio; P.playerCfg = playerCfg; P.setPlayerCfg = setPlayerCfg;
268
- 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;
269
289
  P.SUB_EDGES = SUB_EDGES; P.SUB_POSITIONS = SUB_POSITIONS; P.ASPECTS = ASPECTS;
270
290
  P.isPrivate = isPrivate; P.setPrivate = setPrivate; P.privateNow = privateNow; P.subCfgVersion = subCfgVersion;
271
291
  })(PlayerParts);
package/js/player-subs.js CHANGED
@@ -113,7 +113,7 @@
113
113
  if (mem.off) { return true; }
114
114
  if (mem.embedded) {
115
115
  if (partial) { return false; }
116
- wantEmbedded({ index: mem.index, lang: mem.lang }); _embDelay = Math.max(0, +mem.delay || 0); return true;
116
+ wantEmbedded({ index: mem.index, lang: mem.lang, nth: mem.nth }); _embDelay = Math.max(0, +mem.delay || 0); return true;
117
117
  }
118
118
  var exact =
119
119
  (mem.trackId != null ? _subTracks.find(function (t) { return t.id === mem.trackId; }) : null) ||
@@ -202,6 +202,21 @@
202
202
  var WEBVTT_ASIDE = /^(NOTE|STYLE|REGION)([ \t]|$)/;
203
203
  // Character codes (&amp; &gt; &#8212; …) as the characters they stand for.
204
204
  var CHAR_CODES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0', lrm: '\u200e', rlm: '\u200f' };
205
+ // A subtitle's tags taken out, a stray < with them: one pass, in time with the text's length
206
+ // however it's made (a run of < with no > after it used to hold the remote for seconds).
207
+ // Its text is only ever shown as text (textContent): this is for how it reads.
208
+ function noTags(t) {
209
+ var out = '', i = 0, n = t.length;
210
+ while (i < n) {
211
+ var lt = t.indexOf('<', i);
212
+ if (lt === -1) { out += t.slice(i); break; }
213
+ out += t.slice(i, lt);
214
+ var gt = t.indexOf('>', lt + 1);
215
+ if (gt === -1) { out += t.slice(lt + 1).split('<').join(''); break; } // (no tag ends after it: the rest, its < gone)
216
+ i = gt + 1;
217
+ }
218
+ return out;
219
+ }
205
220
  function charCodes(t) {
206
221
  if (t.indexOf('&') === -1) { return t; }
207
222
  return t.replace(/&(#[xX][0-9a-fA-F]{1,6}|#[0-9]{1,7}|[a-zA-Z]+);/g, function (all, c) {
@@ -213,13 +228,16 @@
213
228
  return CHAR_CODES.hasOwnProperty(c) ? CHAR_CODES[c] : all;
214
229
  });
215
230
  }
231
+ // A file's cues: CUES_MAX at most, CUE_TEXT_MAX characters of text each (a broken or hostile
232
+ // file can't hold the remote: the whole parse stays in time with the file's length).
233
+ var CUES_MAX = 20000, CUE_TEXT_MAX = 1000;
216
234
  function parseCues(text) {
217
235
  text = ('' + text).replace(/\r/g, '');
218
236
  var blocks = text.split(/\n\n+/);
219
237
  var cues = [], skip = skipper(); // (Skip lines with: your words)
220
238
  var re = /(?:(\d{1,3}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,3}):)?(\d{2}):(\d{2})[,.](\d{1,3})/;
221
239
  function ms(x) { return +((x + '00').slice(0, 3)) / 1000; } // (".5" is half a second)
222
- for (var i = 0; i < blocks.length; i++) {
240
+ for (var i = 0; i < blocks.length && cues.length < CUES_MAX; i++) {
223
241
  var lines = blocks[i].replace(/^\n+/, '').split('\n');
224
242
  if (WEBVTT_ASIDE.test(lines[0])) { continue; } // (a WebVTT comment, style or region: not shown)
225
243
  var ti = -1;
@@ -229,7 +247,9 @@
229
247
  if (!m) { continue; }
230
248
  var start = (+(m[1] || 0)) * 3600 + (+m[2]) * 60 + (+m[3]) + ms(m[4]);
231
249
  var end = (+(m[5] || 0)) * 3600 + (+m[6]) * 60 + (+m[7]) + ms(m[8]);
232
- var txt = skip(charCodes(lines.slice(ti + 1).join('\n').replace(/<[^>]+>/g, '').replace(/\{[^}]+\}/g, '')).replace(/\s+$/, ''));
250
+ // (tags out, a stray < with them, in one pass; then entities read: one written as &lt; is the file's own text,
251
+ // "<b>" shown as written. Never a tag: subtitles reach the screen as text only, textContent)
252
+ var txt = skip(charCodes(noTags(lines.slice(ti + 1).join('\n').slice(0, CUE_TEXT_MAX)).replace(/\{[^}]+\}/g, '')).replace(/\s+$/, ''));
233
253
  if (txt) { cues.push({ start: start, end: end, text: txt }); }
234
254
  }
235
255
  cues.sort(function (a, b) { return a.start - b.start; });
@@ -384,6 +404,7 @@
384
404
  pick = tracks.find(function (t) { return t.index === w.index && (!key || langKey(textLang(t)) === key); }) || null;
385
405
  }
386
406
  if (!pick && key) { pick = tracks.find(function (t) { return langMatches(key, textLang(t)); }) || null; }
407
+ if (!pick && w.nth != null && S.ctx && S.ctx._relayCc) { pick = tracks[w.nth] || null; } // (a live channel's closed captions: in the order asked for)
387
408
  if (!pick && w.lang2) { // nothing in the default language anywhere: the second choice, a track to download first
388
409
  var key2 = langKey(w.lang2);
389
410
  var dl = _subTracks.find(function (t) { return langMatches(key2, t.lang); });
@@ -398,7 +419,7 @@
398
419
  try { avplay.setSelectTrack('TEXT', tr.index); }
399
420
  catch (e2) { try { avplay.setSelectTrack('TEXT', '' + tr.index); } catch (e3) {} }
400
421
  _embSub = tr.index; _embLang = textLang(tr);
401
- U.toast('Subtitles: ' + (_embLang || 'on') + ' (built-in)', 1800);
422
+ U.toast('Subtitles: ' + (_embLang || 'on') + (S.ctx && S.ctx._relayCc ? ' (closed captions)' : ' (built-in)'), 1800);
402
423
  }
403
424
  function embOff() {
404
425
  if (_embSub != null) { try { avplay.setSilentSubtitle(true); } catch (e) {} }
@@ -446,7 +467,7 @@
446
467
  function drawEmbedded(text, duration) {
447
468
  if (_embSub == null || _subOn) { return; }
448
469
  if (_embTimer) { clearTimeout(_embTimer); _embTimer = null; }
449
- var t = ('' + (text || '')).replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '').replace(/\{[^}]+\}/g, '').trim();
470
+ var t = noTags(('' + (text || '')).replace(/<br\s*\/?>/gi, '\n')).replace(/\{[^}]+\}/g, '').trim();
450
471
  if (!t) { S.subsEl.className = 'hidden'; return; }
451
472
  S.subsEl.className = '';
452
473
  var span = S.subsEl.firstChild;
@@ -515,7 +536,7 @@
515
536
  replaceTop(timingMenu(topMenu().idx));
516
537
  U.toast('Subtitles: ' + fmtDelay(_subDelay) + (_subDelay > 0 ? ' (later)' : _subDelay < 0 ? ' (earlier)' : ' (as in the file)'), 1800);
517
538
  }
518
- function lineText(c) { return '“' + ('' + c.text).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().slice(0, 70) + '”'; }
539
+ function lineText(c) { return '“' + noTags('' + c.text).replace(/\s+/g, ' ').trim().slice(0, 70) + '”'; }
519
540
  // The next line coming up (as timed now): OK the moment it's said.
520
541
  function earMenu() {
521
542
  var t = S.cur - _subDelay, next = null;
@@ -569,6 +590,8 @@
569
590
  groups[k].push(item);
570
591
  }
571
592
  getTextTracks().forEach(function (tr, i) { add(langKey(textLang(tr)), { emb: tr, n: i }); });
593
+ var offer = S.ctx && !S.ctx._relayCc ? S.ctx._ccOffer : null; // (a live channel's closed captions, not on yet)
594
+ (offer || []).forEach(function (c, i) { add(langKey(c.lang || ''), { cc: c, n: i, emb: true }); });
572
595
  _subTracks.forEach(function (tr) { add(langKey(tr.lang), { track: tr }); });
573
596
  var curKey = _subOn ? langKey(_subLabel) : (_embSub != null ? langKey(_embLang) : null);
574
597
  var pref = getSubCfg().lang;
@@ -583,7 +606,7 @@
583
606
  order.forEach(function (k) {
584
607
  var list = groups[k], n = list.length, emb = list.filter(function (x) { return x.emb; }).length;
585
608
  items.push({ label: langName(k),
586
- detail: n + ' option' + (n > 1 ? 's' : '') + (emb ? (emb === n ? ', in the file' : ', ' + emb + ' in the file') : ''),
609
+ detail: n + ' option' + (n > 1 ? 's' : '') + (emb ? (emb === n ? ', ' + inFile() : ', ' + emb + ' ' + inFile()) : ''),
587
610
  sel: (k === curKey),
588
611
  run: function () { pushMenu(langTracksMenu(k, list)); } });
589
612
  });
@@ -594,6 +617,7 @@
594
617
  return { title: 'Subtitle language', items: items, idx: idx, rebuild: langMenu };
595
618
  }
596
619
 
620
+ function inFile() { return S.ctx && (S.ctx._relayCc || S.ctx._ccOffer) ? 'closed captions' : 'in the file'; }
597
621
  // Level 2: the tracks in one language: the file's own first, then each addon's.
598
622
  // Picking a track keeps the menu open, the checkmark on it (an addon's once it loads), so
599
623
  // several can be tried in a row: the file's own the same as an addon's.
@@ -601,10 +625,15 @@
601
625
  var idx = 0, nAddon = 0;
602
626
  list = list.filter(function (x) { return x.emb; }).concat(list.filter(function (x) { return !x.emb; }));
603
627
  var items = list.map(function (x, i) {
628
+ if (x.cc) { // (turning a live channel's closed captions on: it restarts with them)
629
+ var c = x.cc;
630
+ return { label: 'Closed captions (' + c.ch + ')', detail: 'The channel restarts with them',
631
+ run: function () { closeMenu(); rememberSub(S.ctx.id, { embedded: true, nth: x.n, lang: c.lang }); P.ccOn(c); } };
632
+ }
604
633
  if (x.emb) {
605
634
  var tr = x.emb, l = textLang(tr);
606
635
  if (_embSub === tr.index) { idx = i; }
607
- return { label: 'Built into the file' + (l ? ' (' + l + ')' : ' · track ' + (x.n + 1)), sel: _embSub === tr.index,
636
+ return { label: (S.ctx && S.ctx._relayCc ? 'Closed captions' : 'Built into the file') + (l ? ' (' + l + ')' : ' · track ' + (x.n + 1)), sel: _embSub === tr.index,
608
637
  run: function () {
609
638
  _embDelay = 0; _embQueue = []; _embNudge = null; selectEmbeddedSub(tr); rememberSub(S.ctx.id, { embedded: true, index: tr.index, lang: l });
610
639
  if (menuOpen()) { replaceTop(langTracksMenu(key, list)); }
@@ -690,5 +719,6 @@
690
719
  P.fileHints = fileHints; P.parseCues = parseCues; P.loadTrack = loadTrack; P.subsOff = subsOff;
691
720
  P.resetSubs = resetSubs; P.closeSubs = closeSubs; P.startSubtitles = startSubtitles;
692
721
  P.subsBeforeOpen = subsBeforeOpen; P.subsOnTick = subsOnTick; P.subtitleSummary = subtitleSummary;
722
+ P.wantEmbedded = wantEmbedded;
693
723
  P.renderSubs = renderSubs; P.showEmbeddedText = showEmbeddedText; P.subConfigMenu = subConfigMenu; P.langMenu = langMenu;
694
724
  })(PlayerParts);
package/js/stremio.js CHANGED
@@ -175,7 +175,8 @@ var Stremio = (function () {
175
175
  posterRatings: p.posterRatings === true, // Settings > Appearance: IMDb rating on cards
176
176
  homePreview: p.homePreview !== false, // Settings > Appearance: Continue Watching on the TV's home bar (js/hubpreview.js)
177
177
  homePreviewOrder: p.homePreviewOrder === 'watching' ? 'watching' : 'recent', // ...its tiles: the latest first, or as in Watching
178
- hiddenTabs: wordList(p.hiddenTabs, 20, 20), // Settings > Appearance > Home tabs
178
+ hiddenTabs: wordList(p.hiddenTabs, 20, 20), // Settings > Appearance > Menu: tabs hidden
179
+ menuOrder: wordList(p.menuOrder, 20, 20), // ...and the menu's order (see ViewParts.menuOrder)
179
180
  hideWords: wordList(p.hideWords, 30, 40), // Settings > Source ranking: sources with these words at the bottom
180
181
  watchRegion: /^[A-Z]{2}$/.test(p.watchRegion || '') ? p.watchRegion : '' }; // Settings > Integrations ('' = automatic)
181
182
  }
@@ -481,7 +482,18 @@ var Stremio = (function () {
481
482
  if (_hasIptv) { IPTV.removePlaylist(id); }
482
483
  configChanged();
483
484
  }
485
+ function iptvAddXtream(base, user, pass) { // (as any playlist added: the addons' list made again)
486
+ var id = _hasIptv ? IPTV.addXtream(base, user, pass) : false;
487
+ configChanged();
488
+ return id;
489
+ }
490
+ function iptvSetXtreamFormat(id, fmt) {
491
+ var changed = _hasIptv ? IPTV.setXtreamFormat(id, fmt) : false;
492
+ configChanged();
493
+ return changed;
494
+ }
484
495
  function iptvRenamePlaylist(id, name) { return _hasIptv ? IPTV.renamePlaylist(id, name) : false; }
496
+ function iptvSetPlaylistGuide(id, url) { return _hasIptv && IPTV.setPlaylistGuide ? IPTV.setPlaylistGuide(id, url) : false; }
485
497
  function iptvSetPlaylistOff(id, off) { var ok = _hasIptv ? IPTV.setPlaylistOff(id, off) : false; configChanged(); return ok; }
486
498
  function iptvMovePlaylist(id, dir) { var ok = _hasIptv ? IPTV.movePlaylist(id, dir) : false; configChanged(); return ok; }
487
499
  function iptvLabel(id) { return _hasIptv ? IPTV.label(id) : ''; }
@@ -685,7 +697,8 @@ var Stremio = (function () {
685
697
  // ---- Feed lists: order + show / hide, as the user sets them ----
686
698
  // Home feeds (the Home rows: every catalog) and search sources (the Search rows:
687
699
  // every catalog that can search) each keep their own { order: [keys], hidden: [keys] }.
688
- // Anything new (an addon added later) goes after the ordered ones, in addon order.
700
+ // Anything new (an addon added later) goes after the ordered ones, in addon order, but for a
701
+ // catalog that asks to come first (def._first: IPTV's Favorites) until you place it.
689
702
  var FEEDS_KEY = 'stremiok.feeds', SEARCH_FEEDS_KEY = 'stremiok.searchfeeds';
690
703
  function catalogKey(cat) {
691
704
  var aid = (cat.addon.manifest && cat.addon.manifest.id) || cat.addon.url;
@@ -698,8 +711,8 @@ var Stremio = (function () {
698
711
  var pos = {};
699
712
  cfg().order.forEach(function (k, i) { pos[k] = i; });
700
713
  return items.map(function (c, i) { return { c: c, i: i, k: keyOf(c) }; }).sort(function (a, b) {
701
- var pa = (pos[a.k] !== undefined) ? pos[a.k] : (100000 + a.i);
702
- var pb = (pos[b.k] !== undefined) ? pos[b.k] : (100000 + b.i);
714
+ var pa = (pos[a.k] !== undefined) ? pos[a.k] : (a.c && a.c.def && a.c.def._first) ? -1 : (100000 + a.i);
715
+ var pb = (pos[b.k] !== undefined) ? pos[b.k] : (b.c && b.c.def && b.c.def._first) ? -1 : (100000 + b.i);
703
716
  return pa - pb;
704
717
  }).map(function (x) { return x.c; });
705
718
  }
@@ -1455,6 +1468,57 @@ var Stremio = (function () {
1455
1468
  return tmdbGet('/' + kind + '/' + tmdbId + '/external_ids', '', cancel).then(function (r) { return (r && r.imdb_id) || null; });
1456
1469
  }
1457
1470
 
1471
+ // A film known only by a TV guide's program (the Guide's guide-only channels): { title (as
1472
+ // shown, Spanish say), orig (its original title), year } -> Promise of { type, id (IMDb), name,
1473
+ // poster } when exactly one film has that name (and that year, when known), else null.
1474
+ // TMDB with your key (the original title, else the shown one in Spanish), else Cinemeta
1475
+ // (original titles only).
1476
+ function plainTitle(s) { // ("El Señor de los Anillos:" -> "elsenordelosanillos")
1477
+ var t = ('' + (s || '')).toLowerCase();
1478
+ if (t.normalize) { try { t = t.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } catch (e) {} }
1479
+ return t.replace(/[^a-z0-9]+/g, '');
1480
+ }
1481
+ function findFilm(p, cancel) {
1482
+ var year = +p.year || 0;
1483
+ function near(y) { return !year || (y && Math.abs(y - year) <= 1); }
1484
+ function one(list, names) {
1485
+ var want = names.map(plainTitle).filter(Boolean), hits = (list || []).filter(function (r) {
1486
+ return near(r.year) && want.some(function (w) { return w === plainTitle(r.name) || w === plainTitle(r.orig); });
1487
+ });
1488
+ return hits.length === 1 ? hits[0] : null;
1489
+ }
1490
+ function tmdb(query, spanish) {
1491
+ var extra = '&query=' + encodeURIComponent(query) + (year ? '&year=' + year : '') + (spanish ? '&language=es-AR' : '');
1492
+ return tmdbGet('/search/movie', extra, cancel).then(function (r) {
1493
+ return ((r && r.results) || []).map(function (x) {
1494
+ return { tmdbId: x.id, name: x.title || '', orig: x.original_title || '', year: +('' + (x.release_date || '')).slice(0, 4) || 0, poster: tmdbImg(x.poster_path, 'w342') };
1495
+ });
1496
+ }, function () { return []; });
1497
+ }
1498
+ function viaTmdb() {
1499
+ if (!tmdbKey()) { return Promise.resolve(null); }
1500
+ var first = p.orig ? tmdb(p.orig, false).then(function (l) { return one(l, [p.orig]); }) : Promise.resolve(null);
1501
+ return first.then(function (hit) {
1502
+ return hit || (p.title ? tmdb(p.title, true).then(function (l) { return one(l, [p.title]); }) : null);
1503
+ }).then(function (hit) {
1504
+ if (!hit) { return null; }
1505
+ return tmdbImdbId('movie', hit.tmdbId, cancel).then(function (imdb) {
1506
+ return imdb ? { type: 'movie', id: imdb, name: hit.name || p.title, poster: hit.poster } : null;
1507
+ }, function () { return null; });
1508
+ });
1509
+ }
1510
+ function viaCinemeta() {
1511
+ if (!p.orig) { return Promise.resolve(null); }
1512
+ return U.getJSON('https://v3-cinemeta.strem.io/catalog/movie/top/search=' + encodeURIComponent(p.orig) + '.json', 10000, null, cancel).then(function (r) {
1513
+ var hit = one(((r && r.metas) || []).map(function (m) {
1514
+ return { id: m.id, name: m.name || '', year: parseInt(m.releaseInfo || m.year || '', 10) || 0, poster: m.poster || '' };
1515
+ }), [p.orig]);
1516
+ return hit && /^tt\d+$/.test(hit.id) ? { type: 'movie', id: hit.id, name: p.title || hit.name, poster: hit.poster } : null;
1517
+ }, function () { return null; });
1518
+ }
1519
+ return viaTmdb().then(function (hit) { return hit || viaCinemeta(); });
1520
+ }
1521
+
1458
1522
  // ---- MDBList ratings (Settings > Integrations > MDBList) ----
1459
1523
  // -> [{ key, label, text }] in a fixed order, only the ones the title has.
1460
1524
  var MDB_SOURCES = [['imdb', 'IMDb'], ['tomatoes', 'Rotten Tomatoes'], ['popcorn', 'Audience'],
@@ -2012,7 +2076,7 @@ var Stremio = (function () {
2012
2076
  getIntroSegments: getIntroSegments,
2013
2077
  tmdbValidate: tmdbValidate, mdblistValidate: mdblistValidate, omdbValidate: omdbValidate,
2014
2078
  tmdbFindTvId: tmdbFindTvId, tmdbSeasonEpisodes: tmdbSeasonEpisodes, tmdbImg: tmdbImg,
2015
- tmdbTitleExtras: tmdbTitleExtras, tmdbExtrasById: tmdbExtrasById, tmdbEpisodeCast: tmdbEpisodeCast, tmdbEpisodeTrailer: tmdbEpisodeTrailer, setHideWords: setHideWords, hideWordHit: hideWordHit, normalizeAddonUrl: normalizeManifestUrl, debridOf: debridOf, addonOrigin: addonOrigin, setAddonOrigin: setAddonOrigin, parseSetupList: parseSetupList, applySetupEntries: applySetupEntries, sizeLimitHit: sizeLimitHit, setSizeLimit: setSizeLimit, gbText: gbText, runtimeMinutes: runtimeMinutes, tmdbPeople: tmdbPeople, tmdbPerson: tmdbPerson, tmdbBrowse: tmdbBrowse, tmdbTitleTrailers: tmdbTitleTrailers, tmdbProviders: tmdbProviders, tmdbProvidersKept: tmdbProvidersKept, tmdbGenres: tmdbGenres, tmdbWhereToWatch: tmdbWhereToWatch, watchRegion: watchRegion, autoWatchRegion: autoWatchRegion, tmdbImdbId: tmdbImdbId, hasTmdb: function () { return !!tmdbKey(); },
2079
+ tmdbTitleExtras: tmdbTitleExtras, tmdbExtrasById: tmdbExtrasById, tmdbEpisodeCast: tmdbEpisodeCast, tmdbEpisodeTrailer: tmdbEpisodeTrailer, setHideWords: setHideWords, hideWordHit: hideWordHit, normalizeAddonUrl: normalizeManifestUrl, debridOf: debridOf, addonOrigin: addonOrigin, setAddonOrigin: setAddonOrigin, parseSetupList: parseSetupList, applySetupEntries: applySetupEntries, sizeLimitHit: sizeLimitHit, setSizeLimit: setSizeLimit, gbText: gbText, runtimeMinutes: runtimeMinutes, tmdbPeople: tmdbPeople, tmdbPerson: tmdbPerson, tmdbBrowse: tmdbBrowse, tmdbTitleTrailers: tmdbTitleTrailers, tmdbProviders: tmdbProviders, tmdbProvidersKept: tmdbProvidersKept, tmdbGenres: tmdbGenres, tmdbWhereToWatch: tmdbWhereToWatch, watchRegion: watchRegion, autoWatchRegion: autoWatchRegion, tmdbImdbId: tmdbImdbId, findFilm: findFilm, hasTmdb: function () { return !!tmdbKey(); },
2016
2080
  mdblistRatings: mdblistRatings,
2017
2081
  omdbSeasonRatings: omdbSeasonRatings,
2018
2082
  loadAddons: loadAddons, listCatalogs: listCatalogs, getCatalog: getCatalog,
@@ -2042,7 +2106,7 @@ var Stremio = (function () {
2042
2106
  importSetupList: importSetupList, savedManifest: savedManifest, isAddonOff: isAddonOff, setAddonOff: setAddonOff, onIptvReady: onIptvReady, streamListenerCount: function (type, id) { var e = _streamCache[type + ':' + id]; return e ? e.listeners.length : 0; },
2043
2107
  iptvGetPlaylists: iptvGetPlaylists, iptvAddPlaylist: iptvAddPlaylist,
2044
2108
  iptvUpdatePlaylist: iptvUpdatePlaylist, iptvRemovePlaylist: iptvRemovePlaylist,
2045
- iptvRenamePlaylist: iptvRenamePlaylist, iptvSetPlaylistOff: iptvSetPlaylistOff, iptvMovePlaylist: iptvMovePlaylist, iptvLabel: iptvLabel,
2109
+ iptvRenamePlaylist: iptvRenamePlaylist, iptvSetPlaylistGuide: iptvSetPlaylistGuide, iptvAddXtream: iptvAddXtream, iptvSetXtreamFormat: iptvSetXtreamFormat, iptvSetPlaylistOff: iptvSetPlaylistOff, iptvMovePlaylist: iptvMovePlaylist, iptvLabel: iptvLabel,
2046
2110
  iptvLoad: iptvLoad, iptvStatus: iptvStatus, iptvReload: iptvReload
2047
2111
  };
2048
2112
  })();
package/js/trakt.js CHANGED
@@ -375,6 +375,7 @@ var Trakt = (function () {
375
375
  function next() {
376
376
  return authed('GET', path + (path.indexOf('?') === -1 ? '?' : '&') + 'page=' + page + '&limit=' + perPage, null).then(function (r) {
377
377
  live(g);
378
+ if (!Array.isArray(r.data)) { var bad = new Error('Trakt sent a page that couldn\'t be read (' + path.split('?')[0] + ', page ' + page + ')'); bad.status = r.status || 0; throw bad; } // (not the last page: the refresh fails, the copy kept)
378
379
  var items = list(r.data, perPage), first = items.length ? JSON.stringify(items[0]) : '';
379
380
  if (page > 1 && first && first === before) { return out.slice(0, max); }
380
381
  before = first;
package/js/util.js CHANGED
@@ -430,15 +430,20 @@ var U = (function () {
430
430
  /* GET via XHR with a timeout. Returns a Promise of the response text.
431
431
  maxBytes: give up as soon as more than that has arrived ("Too large (over …)");
432
432
  TEXT_MAX when not given. */
433
- // A failed download, in Diagnostics. During a private playback, only its server: its link (a
434
- // title's name can be in it) isn't kept, in the log or the copy saved for the next launch.
433
+ // A failed download, in Diagnostics. One asked during a private playback (when it started:
434
+ // it may fail after the playback ended) says only that, what kind of failure, and its
435
+ // server: not its link nor what the server answered (a title's name can be in either), in
436
+ // the log or the copy saved for the next launch.
435
437
  function privateNow() { try { return typeof Player !== 'undefined' && !!Player.privateNow && Player.privateNow(); } catch (e) { return false; } }
436
- function logged(err) {
438
+ function logged(err, priv) {
437
439
  var msg = '' + err.message;
438
- if (privateNow()) { msg = msg.replace(/(https?:\/\/[^\/\s?#]+)[^\s]*/g, '$1/…'); }
440
+ if (priv || privateNow()) {
441
+ var kind = /^(HTTP \d+|Timeout|Network error|Too large|Bad JSON|Redirected into the home network)/.exec(msg), host = /(https?:\/\/[^\/\s?#]+)/.exec(msg);
442
+ msg = 'Private viewing: a download' + (host ? ' from ' + host[1] : '') + ' failed' + (kind ? ' (' + kind[1] + ')' : '');
443
+ }
439
444
  log('network', msg); err.logged = true; return err;
440
445
  }
441
- function noteNetwork(err) { return logged(err); }
446
+ function noteNetwork(err, priv) { return logged(err, priv); }
442
447
  // What a server said when it refused (an error answer's own message, e.g. AIOStreams'
443
448
  // { error: { message: "Invalid stream expression … Expected :" } }, or short plain text),
444
449
  // on one line, links in it masked, a long one kept to its start and end (where the what
@@ -464,8 +469,10 @@ var U = (function () {
464
469
  // quiet: a failure isn't noted in Diagnostics (a check that's expected to fail at times, like
465
470
  // the YouTube service's while it starts; the caller says so if it matters).
466
471
  // headers (optional): { name: value } sent with it.
467
- function getText(url, timeoutMs, maxBytes, cancel, quiet, headers) {
468
- function logged(err) { return quiet ? err : noteNetwork(err); }
472
+ // body (optional): sent as a POST (text), else it's a GET.
473
+ function getText(url, timeoutMs, maxBytes, cancel, quiet, headers, body) {
474
+ var priv = privateNow(); // (asked during a private playback: logged as one, however late it fails)
475
+ function logged(err) { return quiet ? err : noteNetwork(err, priv); }
469
476
  maxBytes = maxBytes || TEXT_MAX;
470
477
  return new Promise(function (resolveP, rejectP) {
471
478
  if (cancel && cancel.cancelled) { rejectP(cancelledError(url)); return; } // (nothing started)
@@ -482,7 +489,7 @@ var U = (function () {
482
489
  });
483
490
  }
484
491
  try {
485
- xhr.open('GET', url, true);
492
+ xhr.open(body == null ? 'GET' : 'POST', url, true);
486
493
  if (headers) { Object.keys(headers).forEach(function (h) { xhr.setRequestHeader(h, headers[h]); }); }
487
494
  } catch (e) { reject(e); return; }
488
495
  xhr.timeout = timeoutMs || 15000;
@@ -516,7 +523,7 @@ var U = (function () {
516
523
  };
517
524
  xhr.ontimeout = function () { if (!done) { done = true; reject(logged(new Error('Timeout: ' + redactUrl(url)))); } };
518
525
  xhr.onerror = function () { if (!done) { done = true; reject(logged(new Error('Network error: ' + redactUrl(url)))); } };
519
- try { xhr.send(); } catch (e3) { reject(e3); }
526
+ try { if (body == null) { xhr.send(); } else { xhr.send('' + body); } } catch (e3) { reject(e3); }
520
527
  });
521
528
  }
522
529
 
@@ -557,10 +564,13 @@ var U = (function () {
557
564
 
558
565
  /* GET JSON via XHR with a timeout. Returns a Promise of the parsed body (JSON_MAX
559
566
  unless maxBytes says otherwise). cancel, headers: see getText. */
560
- function getJSON(url, timeoutMs, maxBytes, cancel, headers) {
561
- return getText(url, timeoutMs, maxBytes || JSON_MAX, cancel, false, headers).then(function (text) {
567
+ // A POST (body: text), its answer JSON.
568
+ function postJSON(url, body, timeoutMs, maxBytes, headers) { return getJSON(url, timeoutMs, maxBytes, null, headers, body); }
569
+ function getJSON(url, timeoutMs, maxBytes, cancel, headers, body) {
570
+ var priv = privateNow(); // (asked during a private playback: its answer unreadable later is logged as one too)
571
+ return getText(url, timeoutMs, maxBytes || JSON_MAX, cancel, false, headers, body).then(function (text) {
562
572
  try { return JSON.parse(text); }
563
- catch (e) { throw logged(new Error('Bad JSON from ' + redactUrl(url))); }
573
+ catch (e) { throw logged(new Error('Bad JSON from ' + redactUrl(url)), priv); }
564
574
  });
565
575
  }
566
576
 
@@ -1491,7 +1501,8 @@ var U = (function () {
1491
1501
  }
1492
1502
 
1493
1503
  return {
1494
- byId: byId, el: el, clear: clear, moveIn: moveIn, getJSON: getJSON, getText: getText, redactUrl: redactUrl, problem: problem, problemDetail: problemDetail, listOf: listOf, lru: lru, cachedJSON: cachedJSON,
1504
+ byId: byId, el: el, clear: clear, moveIn: moveIn, getJSON: getJSON, getText: getText,
1505
+ postJSON: postJSON, redactUrl: redactUrl, problem: problem, problemDetail: problemDetail, listOf: listOf, lru: lru, cachedJSON: cachedJSON,
1495
1506
  parseUrl: parseUrl, urlProblem: urlProblem, isPlaylistUrl: isPlaylistUrl, isManifestUrl: isManifestUrl,
1496
1507
  isPrivateHost: isPrivateHost, urlIsPrivate: urlIsPrivate, setLocalPolicy: setLocalPolicy, sanitizeHeaders: sanitizeHeaders, drawable: drawable,
1497
1508
  log: log, logEntries: logEntries, clearLog: clearLog, bigStore: bigStore, diskInfo: diskInfo,