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/CHANGELOG.md +82 -0
- package/README.md +10 -8
- package/config.xml +1 -1
- package/css/style.css +37 -2
- package/index.html +34 -31
- package/js/app.js +19 -7
- package/js/downloads.js +17 -5
- package/js/epg.js +320 -0
- package/js/hubpreview.js +15 -5
- package/js/iptv.js +202 -29
- package/js/keys.js +2 -0
- package/js/player-controls.js +55 -8
- package/js/player-core.js +204 -31
- package/js/player-lang.js +1 -1
- package/js/player-quality.js +15 -7
- package/js/player-store.js +28 -4
- package/js/player-subs.js +45 -11
- package/js/player.js +1 -1
- package/js/stremio-account.js +3 -0
- package/js/stremio.js +157 -28
- package/js/trakt.js +61 -9
- package/js/util.js +36 -10
- package/js/view-core.js +139 -55
- package/js/view-detail.js +5 -4
- package/js/view-guide.js +332 -0
- package/js/view-home.js +168 -36
- package/js/view-settings.js +170 -80
- package/js/views.js +3 -2
- package/js/youtube.js +1 -3
- package/js/ytplay.js +62 -5
- package/package.json +2 -1
- package/service/index.js +1131 -108
- package/service/preview.js +14 -4
package/js/trakt.js
CHANGED
|
@@ -92,7 +92,9 @@ var Trakt = (function () {
|
|
|
92
92
|
try { data = text ? JSON.parse(text) : null; } catch (e2) { bad = true; }
|
|
93
93
|
var retry = 0;
|
|
94
94
|
try { retry = parseInt(xhr.getResponseHeader('Retry-After'), 10) || 0; } catch (e5) {}
|
|
95
|
-
|
|
95
|
+
var pages = 0, limit = 0; // (a list sent a page at a time: how many pages, how many a page)
|
|
96
|
+
try { pages = parseInt(xhr.getResponseHeader('X-Pagination-Page-Count'), 10) || 0; limit = parseInt(xhr.getResponseHeader('X-Pagination-Limit'), 10) || 0; } catch (e6) {}
|
|
97
|
+
if (xhr.status >= 200 && xhr.status < 300) { resolve({ status: xhr.status, data: data, bad: bad, pages: pages, limit: limit }); }
|
|
96
98
|
else { reject({ status: xhr.status, data: data, retryAfter: retry }); }
|
|
97
99
|
};
|
|
98
100
|
xhr.ontimeout = function () { if (!done) { done = true; reject({ status: 0, timeout: true }); } };
|
|
@@ -338,19 +340,67 @@ var Trakt = (function () {
|
|
|
338
340
|
return out;
|
|
339
341
|
}
|
|
340
342
|
function isWatched(type, id) { return watchedAt(type, id) > 0; }
|
|
343
|
+
// Watched past a place kept (Trakt's paused one, or this TV's): a movie watched since; an
|
|
344
|
+
// episode watched since, or a later one of the show (on any device). A place kept after
|
|
345
|
+
// those were watched (watching again) still stands. e: { type, id, updated }.
|
|
346
|
+
var _byShow = null, _byShowFor = null; // { imdb: [[season, episode, when]] }, built from _watched when it changes
|
|
347
|
+
function byShow() {
|
|
348
|
+
if (_byShowFor === _watched) { return _byShow; }
|
|
349
|
+
var m = {};
|
|
350
|
+
Object.keys(_watched).forEach(function (k) {
|
|
351
|
+
var p = /^series:(tt\d+):(\d+):(\d+)$/.exec(k), v = _watched[k];
|
|
352
|
+
if (p && typeof v === 'number' && v > 0) { (m[p[1]] = m[p[1]] || []).push([+p[2], +p[3], v]); }
|
|
353
|
+
});
|
|
354
|
+
_byShow = m; _byShowFor = _watched;
|
|
355
|
+
return m;
|
|
356
|
+
}
|
|
357
|
+
function watchedPast(e) {
|
|
358
|
+
var since = (e && e.updated) || 0;
|
|
359
|
+
if (!e || !since) { return false; }
|
|
360
|
+
if (e.type === 'movie') { return watchedAt('movie', e.id) > since; }
|
|
361
|
+
var p = /^(tt\d+):(\d+):(\d+)$/.exec('' + e.id);
|
|
362
|
+
if (e.type !== 'series' || !p) { return false; }
|
|
363
|
+
var sn = +p[2], en = +p[3];
|
|
364
|
+
return (byShow()[p[1]] || []).some(function (w) { return w[2] > since && (w[0] > sn || (w[0] === sn && w[1] >= en)); });
|
|
365
|
+
}
|
|
341
366
|
|
|
367
|
+
// Trakt's watched lists come a page at a time (since July 2026), and shows carry their
|
|
368
|
+
// episodes only when asked (extended=progress). Every page is read: up to the count Trakt
|
|
369
|
+
// gives, or, without one, until a page is shorter than its page size (Trakt's, or the one
|
|
370
|
+
// asked); PAGES_MAX at most, and no more items than max. A page that repeats the one
|
|
371
|
+
// before (a server that ignores the page asked) ends it too, and isn't kept twice.
|
|
372
|
+
var PAGES_MAX = 100;
|
|
373
|
+
function allPages(path, perPage, max, g) {
|
|
374
|
+
var out = [], page = 1, before = '';
|
|
375
|
+
function next() {
|
|
376
|
+
return authed('GET', path + (path.indexOf('?') === -1 ? '?' : '&') + 'page=' + page + '&limit=' + perPage, null).then(function (r) {
|
|
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)
|
|
379
|
+
var items = list(r.data, perPage), first = items.length ? JSON.stringify(items[0]) : '';
|
|
380
|
+
if (page > 1 && first && first === before) { return out.slice(0, max); }
|
|
381
|
+
before = first;
|
|
382
|
+
out = out.concat(items);
|
|
383
|
+
var last = !items.length || out.length >= max || page >= PAGES_MAX ||
|
|
384
|
+
(r.pages ? page >= r.pages : items.length < (r.limit || perPage));
|
|
385
|
+
if (last) { return out.slice(0, max); }
|
|
386
|
+
page++;
|
|
387
|
+
return next();
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
return next();
|
|
391
|
+
}
|
|
342
392
|
var SHOWS_MAX = 5000, SEASONS_MAX = 200, EPISODES_MAX = 1000;
|
|
343
393
|
function fetchWatched(g) {
|
|
344
394
|
var map = {};
|
|
345
|
-
return
|
|
346
|
-
|
|
395
|
+
return allPages('/sync/watched/movies', 250, 20000, g).then(function (movies) {
|
|
396
|
+
movies.forEach(function (m) {
|
|
347
397
|
var imdb = m && imdbOf(m.movie && m.movie.ids);
|
|
348
398
|
if (imdb) { map['movie:' + imdb] = when(m.last_watched_at) || 1; }
|
|
349
399
|
});
|
|
350
|
-
return
|
|
351
|
-
}).then(function (
|
|
400
|
+
return allPages('/sync/watched/shows?extended=progress', 100, SHOWS_MAX, g);
|
|
401
|
+
}).then(function (all) {
|
|
352
402
|
var shows = [];
|
|
353
|
-
|
|
403
|
+
all.forEach(function (sh) {
|
|
354
404
|
var show = sh && sh.show, imdb = show && imdbOf(show.ids);
|
|
355
405
|
if (!imdb) { return; }
|
|
356
406
|
var trakt = num(show.ids.trakt, 1e10), at = when(sh.last_watched_at);
|
|
@@ -472,13 +522,15 @@ var Trakt = (function () {
|
|
|
472
522
|
|
|
473
523
|
// ---- playback (Continue Watching) cache ----
|
|
474
524
|
// Trakt's paused titles, but not one you removed from Continue Watching here, unless Trakt
|
|
475
|
-
// has newer progress for it since (you watched more of it, here or elsewhere)
|
|
525
|
+
// has newer progress for it since (you watched more of it, here or elsewhere), nor one
|
|
526
|
+
// you've watched past since (watchedPast: Trakt keeps a paused place it's been told of in
|
|
527
|
+
// other ways, a history entry, and the show's next episode would wait behind it).
|
|
476
528
|
// And the shows you're watching (Trakt's Up next): their next episode, placed by when you
|
|
477
529
|
// last watched the show, unless Trakt has a paused episode of it already.
|
|
478
530
|
function continueList() {
|
|
479
531
|
var gone = U.store.get(DISMISSED_KEY, {}) || {};
|
|
480
532
|
function kept(e) { var at = gone[e.type + ':' + e.id]; return !at || (e.updated || 0) > at; }
|
|
481
|
-
var list = (U.store.get(PLAYBACK_KEY, []) || []).filter(kept), paused = {};
|
|
533
|
+
var list = (U.store.get(PLAYBACK_KEY, []) || []).filter(function (e) { return kept(e) && !watchedPast(e); }), paused = {};
|
|
482
534
|
list.forEach(function (e) { if (e.type === 'series') { paused[('' + e.id).split(':')[0]] = true; } });
|
|
483
535
|
(U.store.get(UPNEXT_KEY, []) || []).forEach(function (u) {
|
|
484
536
|
if (!u || !u.imdb || paused[u.imdb]) { return; }
|
|
@@ -837,7 +889,7 @@ var Trakt = (function () {
|
|
|
837
889
|
return {
|
|
838
890
|
hasCreds: hasCreds, isConnected: isConnected, connectStatus: connectStatus,
|
|
839
891
|
startDeviceFlow: startDeviceFlow, disconnect: disconnect, refreshCaches: refreshCaches,
|
|
840
|
-
isWatched: isWatched, watchedAt: watchedAt, watchedSince: watchedSince, continueList: continueList, dismiss: dismiss, synthAddon: synthAddon,
|
|
892
|
+
isWatched: isWatched, watchedAt: watchedAt, watchedSince: watchedSince, watchedPast: watchedPast, continueList: continueList, dismiss: dismiss, synthAddon: synthAddon,
|
|
841
893
|
onWatched: onWatched, onMarked: onMarked, onProgress: onProgress, onStopped: onStopped, refreshSoon: refreshSoon, rate: rate, myRating: myRating,
|
|
842
894
|
retryFailed: retryFailed, discardFailed: discardFailed, sendNow: sendNow, flush: flushOutbox, noteEpisodes: noteEpisodes
|
|
843
895
|
};
|
package/js/util.js
CHANGED
|
@@ -410,6 +410,14 @@ var U = (function () {
|
|
|
410
410
|
are dropped), and at most `max` of them. Addons and APIs are outside our control:
|
|
411
411
|
a list that's a string, or has nulls in it, or runs to a million items, must not
|
|
412
412
|
stop the app. */
|
|
413
|
+
// Moves list[i] in place: dir -1 / 1 one step, -Infinity / Infinity to the top / bottom
|
|
414
|
+
// (the others keep their order). False when it can't move (not found, already there).
|
|
415
|
+
function moveIn(list, i, dir) {
|
|
416
|
+
var j = dir === -Infinity ? 0 : dir === Infinity ? list.length - 1 : i + dir;
|
|
417
|
+
if (i < 0 || i >= list.length || j < 0 || j >= list.length || j === i) { return false; }
|
|
418
|
+
list.splice(j, 0, list.splice(i, 1)[0]);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
413
421
|
function listOf(x, max, keep) { // keep(item): an extra test, applied before counting
|
|
414
422
|
if (!Array.isArray(x)) { return []; }
|
|
415
423
|
var out = [];
|
|
@@ -422,8 +430,20 @@ var U = (function () {
|
|
|
422
430
|
/* GET via XHR with a timeout. Returns a Promise of the response text.
|
|
423
431
|
maxBytes: give up as soon as more than that has arrived ("Too large (over β¦)");
|
|
424
432
|
TEXT_MAX when not given. */
|
|
425
|
-
|
|
426
|
-
|
|
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.
|
|
437
|
+
function privateNow() { try { return typeof Player !== 'undefined' && !!Player.privateNow && Player.privateNow(); } catch (e) { return false; } }
|
|
438
|
+
function logged(err, priv) {
|
|
439
|
+
var msg = '' + err.message;
|
|
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
|
+
}
|
|
444
|
+
log('network', msg); err.logged = true; return err;
|
|
445
|
+
}
|
|
446
|
+
function noteNetwork(err, priv) { return logged(err, priv); }
|
|
427
447
|
// What a server said when it refused (an error answer's own message, e.g. AIOStreams'
|
|
428
448
|
// { error: { message: "Invalid stream expression β¦ Expected :" } }, or short plain text),
|
|
429
449
|
// on one line, links in it masked, a long one kept to its start and end (where the what
|
|
@@ -449,8 +469,10 @@ var U = (function () {
|
|
|
449
469
|
// quiet: a failure isn't noted in Diagnostics (a check that's expected to fail at times, like
|
|
450
470
|
// the YouTube service's while it starts; the caller says so if it matters).
|
|
451
471
|
// headers (optional): { name: value } sent with it.
|
|
452
|
-
|
|
453
|
-
|
|
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); }
|
|
454
476
|
maxBytes = maxBytes || TEXT_MAX;
|
|
455
477
|
return new Promise(function (resolveP, rejectP) {
|
|
456
478
|
if (cancel && cancel.cancelled) { rejectP(cancelledError(url)); return; } // (nothing started)
|
|
@@ -467,7 +489,7 @@ var U = (function () {
|
|
|
467
489
|
});
|
|
468
490
|
}
|
|
469
491
|
try {
|
|
470
|
-
xhr.open('GET', url, true);
|
|
492
|
+
xhr.open(body == null ? 'GET' : 'POST', url, true);
|
|
471
493
|
if (headers) { Object.keys(headers).forEach(function (h) { xhr.setRequestHeader(h, headers[h]); }); }
|
|
472
494
|
} catch (e) { reject(e); return; }
|
|
473
495
|
xhr.timeout = timeoutMs || 15000;
|
|
@@ -501,7 +523,7 @@ var U = (function () {
|
|
|
501
523
|
};
|
|
502
524
|
xhr.ontimeout = function () { if (!done) { done = true; reject(logged(new Error('Timeout: ' + redactUrl(url)))); } };
|
|
503
525
|
xhr.onerror = function () { if (!done) { done = true; reject(logged(new Error('Network error: ' + redactUrl(url)))); } };
|
|
504
|
-
try { xhr.send(); } catch (e3) { reject(e3); }
|
|
526
|
+
try { if (body == null) { xhr.send(); } else { xhr.send('' + body); } } catch (e3) { reject(e3); }
|
|
505
527
|
});
|
|
506
528
|
}
|
|
507
529
|
|
|
@@ -542,10 +564,13 @@ var U = (function () {
|
|
|
542
564
|
|
|
543
565
|
/* GET JSON via XHR with a timeout. Returns a Promise of the parsed body (JSON_MAX
|
|
544
566
|
unless maxBytes says otherwise). cancel, headers: see getText. */
|
|
545
|
-
|
|
546
|
-
|
|
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) {
|
|
547
572
|
try { return JSON.parse(text); }
|
|
548
|
-
catch (e) { throw logged(new Error('Bad JSON from ' + redactUrl(url))); }
|
|
573
|
+
catch (e) { throw logged(new Error('Bad JSON from ' + redactUrl(url)), priv); }
|
|
549
574
|
});
|
|
550
575
|
}
|
|
551
576
|
|
|
@@ -1476,7 +1501,8 @@ var U = (function () {
|
|
|
1476
1501
|
}
|
|
1477
1502
|
|
|
1478
1503
|
return {
|
|
1479
|
-
byId: byId, el: el, clear: clear, getJSON: getJSON, getText: getText,
|
|
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,
|
|
1480
1506
|
parseUrl: parseUrl, urlProblem: urlProblem, isPlaylistUrl: isPlaylistUrl, isManifestUrl: isManifestUrl,
|
|
1481
1507
|
isPrivateHost: isPrivateHost, urlIsPrivate: urlIsPrivate, setLocalPolicy: setLocalPolicy, sanitizeHeaders: sanitizeHeaders, drawable: drawable,
|
|
1482
1508
|
log: log, logEntries: logEntries, clearLog: clearLog, bigStore: bigStore, diskInfo: diskInfo,
|
package/js/view-core.js
CHANGED
|
@@ -8,6 +8,7 @@ var ViewParts = {};
|
|
|
8
8
|
|
|
9
9
|
// ----- from the other parts -----
|
|
10
10
|
function availableTabs() { return V.availableTabs.apply(null, arguments); } // home
|
|
11
|
+
function menuItems() { return V.menuItems.apply(null, arguments); } // home
|
|
11
12
|
function clearCatalogCache() { return V.clearCatalogCache.apply(null, arguments); } // home
|
|
12
13
|
function refreshAddonRows() { return V.refreshAddonRows.apply(null, arguments); } // home
|
|
13
14
|
function epCode() { return V.epCode.apply(null, arguments); } // detail
|
|
@@ -191,6 +192,16 @@ var ViewParts = {};
|
|
|
191
192
|
var cancel = U.el('div', 'pill focusable modal-btn', 'Cancel');
|
|
192
193
|
var refresh = U.el('div', 'pill focusable modal-btn', 'Refresh');
|
|
193
194
|
var remove = U.el('div', 'pill focusable modal-btn danger', 'Remove');
|
|
195
|
+
// Turn off / on: kept in your list, not asked for anything while it's off (a backup)
|
|
196
|
+
var isOff = !!(Stremio.isAddonOff && Stremio.isAddonOff(url));
|
|
197
|
+
var onoff = U.el('div', 'pill focusable modal-btn', isOff ? 'Turn on' : 'Turn off');
|
|
198
|
+
onoff.__onselect = function () {
|
|
199
|
+
closeModal();
|
|
200
|
+
Stremio.setAddonOff(url, !isOff);
|
|
201
|
+
refreshAddonRows(); // (its rows come or go; nothing else is fetched)
|
|
202
|
+
U.toast(isOff ? 'β' + name + 'β is on' : 'β' + name + 'β is off: not asked for anything until you turn it on', 4000);
|
|
203
|
+
renderSettings();
|
|
204
|
+
};
|
|
194
205
|
|
|
195
206
|
cancel.__onselect = function () {
|
|
196
207
|
closeModal();
|
|
@@ -237,6 +248,7 @@ var ViewParts = {};
|
|
|
237
248
|
}
|
|
238
249
|
row.appendChild(cancel);
|
|
239
250
|
row.appendChild(refresh);
|
|
251
|
+
row.appendChild(onoff);
|
|
240
252
|
if (configure) { row.appendChild(configure); }
|
|
241
253
|
row.appendChild(share);
|
|
242
254
|
row.appendChild(remove);
|
|
@@ -260,6 +272,16 @@ var ViewParts = {};
|
|
|
260
272
|
var rename = U.el('div', 'pill focusable modal-btn', 'Rename');
|
|
261
273
|
var onoff = U.el('div', 'pill focusable modal-btn', playlist.off ? 'Turn on' : 'Turn off');
|
|
262
274
|
var edit = U.el('div', 'pill focusable modal-btn', 'Edit link');
|
|
275
|
+
var guide = U.el('div', 'pill focusable modal-btn', 'Guide link');
|
|
276
|
+
var fmt = IPTV.xtreamFormat ? IPTV.xtreamFormat(playlist.id) : ''; // (an Xtream account: MPEG-TS or HLS)
|
|
277
|
+
var format = U.el('div', 'pill focusable modal-btn', 'Format: ' + (fmt === 'hls' ? 'HLS' : 'MPEG-TS'));
|
|
278
|
+
format.__onselect = function () {
|
|
279
|
+
closeModal();
|
|
280
|
+
Stremio.iptvSetXtreamFormat(playlist.id, fmt === 'hls' ? 'ts' : 'hls');
|
|
281
|
+
refreshAddonRows('local:iptv');
|
|
282
|
+
U.toast('Its channels now play as ' + (fmt === 'hls' ? 'MPEG-TS' : 'HLS') + ' (reading the account againβ¦)', 4000);
|
|
283
|
+
renderSettings();
|
|
284
|
+
};
|
|
263
285
|
var reload = U.el('div', 'pill focusable modal-btn', 'Reload');
|
|
264
286
|
var remove = U.el('div', 'pill focusable modal-btn danger', 'Remove');
|
|
265
287
|
|
|
@@ -301,6 +323,20 @@ var ViewParts = {};
|
|
|
301
323
|
}
|
|
302
324
|
});
|
|
303
325
|
};
|
|
326
|
+
guide.__onselect = function () { // its TV guide: yours, or the playlist's own (empty)
|
|
327
|
+
closeModal();
|
|
328
|
+
App.openEntry({
|
|
329
|
+
title: 'TV guide link', phone: 'playlist',
|
|
330
|
+
hint: 'An XMLTV guide (.xml or .xml.gz), then Confirm. Empty uses the playlistβs own (the one it names, or an Xtream playlistβs). RETURN cancels.',
|
|
331
|
+
value: playlist.guide || '', placeholder: 'https://example.com/guide.xml.gz',
|
|
332
|
+
validate: function (v) { return ('' + v).trim() ? (U.urlProblem(v) || null) : null; },
|
|
333
|
+
onConfirm: function (v) {
|
|
334
|
+
Stremio.iptvSetPlaylistGuide(playlist.id, v);
|
|
335
|
+
U.toast(('' + v).trim() ? 'Guide link set: reading itβ¦' : 'Back to the playlistβs own guide', 3000);
|
|
336
|
+
App.back(); renderSettings();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
};
|
|
304
340
|
reload.__onselect = function () {
|
|
305
341
|
closeModal();
|
|
306
342
|
U.toast('Reloading playlistβ¦', 4000);
|
|
@@ -323,6 +359,8 @@ var ViewParts = {};
|
|
|
323
359
|
row.appendChild(rename);
|
|
324
360
|
row.appendChild(onoff);
|
|
325
361
|
row.appendChild(edit);
|
|
362
|
+
if (!playlist.off && typeof EPG !== 'undefined' && EPG.available && EPG.available()) { row.appendChild(guide); }
|
|
363
|
+
if (!playlist.off && fmt) { row.appendChild(format); }
|
|
326
364
|
if (!playlist.off) { row.appendChild(reload); }
|
|
327
365
|
row.appendChild(remove);
|
|
328
366
|
box.appendChild(row);
|
|
@@ -362,33 +400,6 @@ var ViewParts = {};
|
|
|
362
400
|
showModal(overlay, pref);
|
|
363
401
|
}
|
|
364
402
|
|
|
365
|
-
// Switches in a popup (Settings βΊ Appearance βΊ Home tabs): OK on one turns it on or off and
|
|
366
|
-
// the list stays open (BACK closes it). items: [[key, label, on]]; words: { on, off } (the
|
|
367
|
-
// state shown after each label); onToggle(key, on).
|
|
368
|
-
function toggleList(title, items, words, onToggle) {
|
|
369
|
-
var existing = document.querySelector('.modal-overlay');
|
|
370
|
-
if (existing) { existing.parentNode.removeChild(existing); }
|
|
371
|
-
var overlay = U.el('div', 'modal-overlay');
|
|
372
|
-
var box = U.el('div', 'modal-box pick-box');
|
|
373
|
-
box.appendChild(U.el('div', 'modal-msg', title));
|
|
374
|
-
var listEl = U.el('div', 'pick-list'), first = null;
|
|
375
|
-
items.forEach(function (opt) {
|
|
376
|
-
var on = !!opt[2], it = U.el('div', 'pick-item focusable');
|
|
377
|
-
function draw() {
|
|
378
|
-
it.textContent = opt[1] + ': ' + (on ? words.on : words.off);
|
|
379
|
-
if (on) { it.classList.add('sel'); } else { it.classList.remove('sel'); }
|
|
380
|
-
}
|
|
381
|
-
draw();
|
|
382
|
-
it.__onselect = function () { on = !on; draw(); onToggle(opt[0], on); };
|
|
383
|
-
if (!first) { first = it; }
|
|
384
|
-
listEl.appendChild(it);
|
|
385
|
-
});
|
|
386
|
-
box.appendChild(listEl);
|
|
387
|
-
overlay.appendChild(box);
|
|
388
|
-
document.body.appendChild(overlay);
|
|
389
|
-
showModal(overlay, first);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
403
|
// Ticks in a popup (what to import): nothing ticked at first; OK on one ticks or unticks it,
|
|
393
404
|
// Select all ticks every one that can be (again: none); the button counts what's ticked.
|
|
394
405
|
// items: [{ key, label, have (already yours: shown as such, can't be ticked), note }];
|
|
@@ -471,7 +482,8 @@ var ViewParts = {};
|
|
|
471
482
|
}
|
|
472
483
|
|
|
473
484
|
// The shared top menu, used on Home / Search / Settings so they look and navigate
|
|
474
|
-
// the same:
|
|
485
|
+
// the same: Watching, then the tabs, Discover and Search in your order (menuItems), then
|
|
486
|
+
// Settings, on the left; StremioK on the right.
|
|
475
487
|
// `active` is the key of the current view: a tab key, 'search' or 'settings'.
|
|
476
488
|
function buildTopMenu(active) {
|
|
477
489
|
var head = U.el('div', 'header home-header');
|
|
@@ -484,34 +496,23 @@ var ViewParts = {};
|
|
|
484
496
|
};
|
|
485
497
|
function down() { return focusBelowTop(head); }
|
|
486
498
|
var tabbar = U.el('div', 'tabbar-inline');
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
499
|
+
// a pill: a Home tab, Discover (browse any catalog in full, its own screen), Search;
|
|
500
|
+
// Settings a chip like them (name, then icon), after them
|
|
501
|
+
function item(cls, key, label, icon, open, into) {
|
|
502
|
+
var p = U.el('div', cls + ' focusable' + (key === active ? ' active' : ''));
|
|
503
|
+
p.appendChild(document.createTextNode(label));
|
|
504
|
+
p.appendChild(U.el('span', 'tab-ic', icon));
|
|
505
|
+
p.__onselect = open;
|
|
506
|
+
p.__navDOWN = down;
|
|
507
|
+
into.appendChild(p);
|
|
508
|
+
}
|
|
509
|
+
menuItems().forEach(function (m) {
|
|
510
|
+
if (m.tab) { item('tab-pill', m.key, m.tab.label, m.tab.icon, function () { App.goHome(m.key); }, tabbar); }
|
|
511
|
+
else if (m.key === 'discover') { item('tab-pill', 'discover', 'Discover', 'π', function () { App.openDiscover(); }, tabbar); }
|
|
512
|
+
else if (m.key === 'search') { item('hchip', 'search', 'Search', 'π', function () { App.openSearch(); }, tabbar); }
|
|
494
513
|
});
|
|
495
|
-
// Discover: browse any catalog in full, by genre / year (its own screen)
|
|
496
|
-
var disc = U.el('div', 'tab-pill focusable' + (active === 'discover' ? ' active' : ''));
|
|
497
|
-
disc.appendChild(document.createTextNode('Discover'));
|
|
498
|
-
disc.appendChild(U.el('span', 'tab-ic', 'π'));
|
|
499
|
-
disc.__onselect = function () { App.openDiscover(); };
|
|
500
|
-
disc.__navDOWN = down;
|
|
501
|
-
tabbar.appendChild(disc);
|
|
502
514
|
head.appendChild(tabbar);
|
|
503
|
-
|
|
504
|
-
// Search and Settings: pills like the tabs (name, then icon), after them
|
|
505
|
-
function chip(key, label, icon, open) {
|
|
506
|
-
var c = U.el('div', 'hchip focusable' + (active === key ? ' active' : ''));
|
|
507
|
-
c.appendChild(document.createTextNode(label));
|
|
508
|
-
c.appendChild(U.el('span', 'tab-ic', icon));
|
|
509
|
-
c.__onselect = open;
|
|
510
|
-
c.__navDOWN = down;
|
|
511
|
-
head.appendChild(c);
|
|
512
|
-
}
|
|
513
|
-
chip('search', 'Search', 'π', function () { App.openSearch(); });
|
|
514
|
-
chip('settings', 'Settings', 'β', function () { App.openSettings(); });
|
|
515
|
+
item('hchip', 'settings', 'Settings', 'β', function () { App.openSettings(); }, head);
|
|
515
516
|
|
|
516
517
|
head.appendChild(U.el('div', 'spacer'));
|
|
517
518
|
var brand = U.el('div', 'brand');
|
|
@@ -568,6 +569,10 @@ var ViewParts = {};
|
|
|
568
569
|
if (rating > 0 && rating <= 10 && Stremio.getPrefs().posterRatings) { c.appendChild(U.el('div', 'card-rating', 'β
' + rating.toFixed(1))); } // (a series: marked watched whole, see setTitleWatched / the title page)
|
|
569
570
|
if (kind === 'movie' || kind === 'series') { c.__onlongpress = function () { posterActions(meta, kind, c); }; }
|
|
570
571
|
else if (youTubeList(meta.id)) { c.__onlongpress = function () { addYouTubeCatalog(meta); }; } // (a channel / playlist: to your YouTube)
|
|
572
|
+
else if (/^iptv:/.test(meta.id || '') && typeof EPG !== 'undefined') { // an IPTV channel: what's on, and its schedule
|
|
573
|
+
guideOnCard(c, meta.id);
|
|
574
|
+
c.__onlongpress = function () { liveSchedule(meta, c); };
|
|
575
|
+
}
|
|
571
576
|
c.__onselect = function () {
|
|
572
577
|
var t = titleType(meta.type || fallbackType);
|
|
573
578
|
// Live TV catalogs (anything that isn't movies/series) skip the info page and
|
|
@@ -586,6 +591,86 @@ var ViewParts = {};
|
|
|
586
591
|
return c;
|
|
587
592
|
}
|
|
588
593
|
|
|
594
|
+
// ---- TV guide (js/epg.js) on IPTV channel cards ----
|
|
595
|
+
// What's on now, under the channel's name, with how far along it is; drawn again when the
|
|
596
|
+
// guide brings more (the cards still on a page; the most recent GUIDE_CARDS).
|
|
597
|
+
var _guideCards = [], GUIDE_CARDS = 3000;
|
|
598
|
+
function guideOnCard(card, id) {
|
|
599
|
+
drawGuide(card, id);
|
|
600
|
+
_guideCards.push(card);
|
|
601
|
+
if (_guideCards.length > GUIDE_CARDS) { _guideCards = _guideCards.slice(-GUIDE_CARDS); }
|
|
602
|
+
}
|
|
603
|
+
function drawGuide(card, id) {
|
|
604
|
+
guideLogo(card, id);
|
|
605
|
+
var g = EPG.now(id), old = card.querySelector('.card-epg');
|
|
606
|
+
if (!g || !g.now) { if (old) { card.removeChild(old); } return; }
|
|
607
|
+
var t = Date.now() / 1000, pct = Math.max(0, Math.min(100, Math.round((t - g.now.start) / (g.now.stop - g.now.start) * 100)));
|
|
608
|
+
var box = old || U.el('div', 'card-epg');
|
|
609
|
+
U.clear(box);
|
|
610
|
+
box.appendChild(U.el('div', 'card-epg-t', g.now.title));
|
|
611
|
+
var bar = U.el('div', 'card-epg-bar'), fill = U.el('i', 'card-epg-fill');
|
|
612
|
+
fill.setAttribute('style', 'width: ' + pct + '%');
|
|
613
|
+
bar.appendChild(fill);
|
|
614
|
+
box.appendChild(bar);
|
|
615
|
+
if (!old) { card.appendChild(box); }
|
|
616
|
+
}
|
|
617
|
+
// A channel card without a picture (no tvg-logo): its guide's logo, once it's known; the
|
|
618
|
+
// name stays until it has loaded.
|
|
619
|
+
function guideLogo(card, id) {
|
|
620
|
+
var src = !card.__guideLogo && !card.querySelector('img.poster') && EPG.logo(id);
|
|
621
|
+
if (!src) { return; }
|
|
622
|
+
card.__guideLogo = true;
|
|
623
|
+
var img = U.el('img', 'poster'), fb = card.querySelector('.fallback');
|
|
624
|
+
img.style.visibility = 'hidden';
|
|
625
|
+
img.onload = function () { img.style.visibility = ''; if (fb && fb.parentNode) { fb.parentNode.removeChild(fb); } };
|
|
626
|
+
img.onerror = function () { if (img.parentNode) { img.parentNode.removeChild(img); } };
|
|
627
|
+
card.insertBefore(img, card.firstChild);
|
|
628
|
+
U.deferImage(img, U.cardImage(src, 'landscape'));
|
|
629
|
+
}
|
|
630
|
+
function redrawGuideCards() {
|
|
631
|
+
_guideCards = _guideCards.filter(function (c) { return document.documentElement.contains(c); });
|
|
632
|
+
_guideCards.forEach(function (c) { drawGuide(c, c.getAttribute('data-id')); });
|
|
633
|
+
}
|
|
634
|
+
if (typeof EPG !== 'undefined') {
|
|
635
|
+
EPG.onChange(redrawGuideCards);
|
|
636
|
+
// every minute: the cards' progress bars (and what's on, once it changed) along with the time
|
|
637
|
+
setInterval(function () {
|
|
638
|
+
if (!_guideCards.length || document.hidden || (typeof Player !== 'undefined' && Player.isActive())) { return; }
|
|
639
|
+
redrawGuideCards();
|
|
640
|
+
}, 60000);
|
|
641
|
+
}
|
|
642
|
+
// Hold OK on a channel card: βΆ Watch now, β
Add to / Remove from favorites, and its schedule
|
|
643
|
+
// from 2 hours ago to tomorrow (what's on now marked, and picked); OK on a program says
|
|
644
|
+
// what it is. A channel without a guide: the first two.
|
|
645
|
+
var _schedGen = 0;
|
|
646
|
+
function liveSchedule(meta, card) {
|
|
647
|
+
var gen = ++_schedGen;
|
|
648
|
+
U.spinner(true);
|
|
649
|
+
EPG.schedule(meta.id).then(null, function () { return []; }).then(function (list) {
|
|
650
|
+
if (gen !== _schedGen) { return; } // (held on another card since: its own)
|
|
651
|
+
U.spinner(false);
|
|
652
|
+
if (!document.documentElement.contains(card) || (typeof Player !== 'undefined' && Player.isActive())) { return; } // (left meanwhile)
|
|
653
|
+
var fav = IPTV.isFavorite(meta.id), t = Date.now() / 1000, at = 'play';
|
|
654
|
+
var items = [['play', 'βΆ Watch now'], ['fav', fav ? 'β
Remove from favorites' : 'β
Add to favorites']];
|
|
655
|
+
list.forEach(function (p, i) {
|
|
656
|
+
var on = p.start <= t && t < p.stop;
|
|
657
|
+
if (on) { at = 'p' + i; }
|
|
658
|
+
items.push(['p' + i, (on ? 'β ' : '') + EPG.dayTime(p.start) + ' ' + p.title]);
|
|
659
|
+
});
|
|
660
|
+
pickList((meta.name || '') + (list.length ? ' Β· TV guide' : ''), items, at, function (v) {
|
|
661
|
+
if (v === 'play') { card.__onselect(); return; }
|
|
662
|
+
if (v === 'fav') {
|
|
663
|
+
IPTV.setFavorite(meta.id, !fav);
|
|
664
|
+
U.toast(fav ? 'Removed from favorites' : 'Added to favorites: β
Favorites, first on the IPTV tab', 3000);
|
|
665
|
+
refreshAddonRows('local:iptv');
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
var p = list[+v.slice(1)];
|
|
669
|
+
if (p) { U.toast(EPG.dayTime(p.start) + 'β' + EPG.hhmm(p.stop) + ' ' + p.title + (p.desc ? ': ' + p.desc : ''), 8000); }
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
589
674
|
// The channels of the row a live card sits in, for CH+/CHβ while it plays.
|
|
590
675
|
function rowChannels(card) {
|
|
591
676
|
var sib = card.parentNode ? card.parentNode.children : [card], list = [], idx = 0;
|
|
@@ -739,7 +824,6 @@ var ViewParts = {};
|
|
|
739
824
|
V.inNowPlaying = inNowPlaying;
|
|
740
825
|
V.onModalClose = onModalClose;
|
|
741
826
|
V.pickList = pickList;
|
|
742
|
-
V.toggleList = toggleList;
|
|
743
827
|
V.pickMany = pickMany;
|
|
744
828
|
V.playlistActions = playlistActions;
|
|
745
829
|
V.posterCard = posterCard;
|
package/js/view-detail.js
CHANGED
|
@@ -62,10 +62,11 @@
|
|
|
62
62
|
U.spinner(false);
|
|
63
63
|
_d.streams = list;
|
|
64
64
|
var playable = list.filter(Stremio.isPlayable);
|
|
65
|
-
if (!playable.length) { // (and why, in Settings βΊ Diagnostics)
|
|
65
|
+
if (!playable.length) { // (and why: on screen, each addon's reason; in Settings βΊ Diagnostics)
|
|
66
|
+
var why = list.length ? '' : (Stremio.streamNotes ? Stremio.streamNotes(type, id) : []).slice(0, 3).map(function (n) { return n.addon + ' ' + n.title; }).join('; ');
|
|
66
67
|
U.log('sources', 'No playable source for β' + ((_d.meta && _d.meta.name) || id) + 'β (' + type + ' ' + id + '): ' +
|
|
67
|
-
(list.length ? list.length + ' source(s), none playable here' : 'no addon sent any'));
|
|
68
|
-
U.toast('No playable source for this channel'); App.back(); return;
|
|
68
|
+
(list.length ? list.length + ' source(s), none playable here' : 'no addon sent any') + (why ? ' (' + why + ')' : ''));
|
|
69
|
+
U.toast('No playable source for this channel' + (why ? ': ' + why : ''), why ? 6000 : 3000); App.back(); return;
|
|
69
70
|
}
|
|
70
71
|
if (playable.length === 1) {
|
|
71
72
|
startPlayback(playable[0], id, _d.streamLabel || _d.meta.name || 'Live');
|
|
@@ -1566,7 +1567,7 @@
|
|
|
1566
1567
|
// offer, it's asked at once.
|
|
1567
1568
|
function sourceMenu(stream) {
|
|
1568
1569
|
var canStart = !howHere() && savedPlace(_d.streamId);
|
|
1569
|
-
var canDownload = typeof Downloads !== 'undefined' && Downloads.available() && /^https?:\/\//i.test(stream.url || '') && !stream._localFile && !stream._yt;
|
|
1570
|
+
var canDownload = typeof Downloads !== 'undefined' && Downloads.available() && (Downloads.isFileLink ? Downloads.isFileLink(stream) : /^https?:\/\//i.test(stream.url || '')) && !stream._localFile && !stream._yt;
|
|
1570
1571
|
if (!canStart && !canDownload) { toggleUnsuitable(stream); return; }
|
|
1571
1572
|
var unsuit = isUnsuitable(_d.id, stream), opts = [];
|
|
1572
1573
|
if (canStart) { opts.push(['start', 'Play from the start']); }
|