nuvio-tizen 1.6.1 → 1.8.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/README.md +2 -1
- package/css/style.css +19 -4
- package/index.html +17 -10
- package/js/app.js +12 -5
- package/js/keys.js +26 -5
- package/js/player.js +232 -31
- package/js/stremio.js +50 -0
- package/js/views.js +217 -60
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,8 @@ core behaviour, targeting 2017 Samsung TVs (Tizen 3.0, ~Chromium 47).
|
|
|
16
16
|
- Home screen with catalog rows + **Continue Watching**.
|
|
17
17
|
- **Search** (🔍 in the header) across every addon catalog that supports the `search` extra.
|
|
18
18
|
- Detail pages: movies (Play) and series (season/episode browser). **Continue Watching**
|
|
19
|
-
resumes a series on the exact season + episode you left off
|
|
19
|
+
resumes a series on the exact season + episode you left off; **hold OK** on an item to
|
|
20
|
+
remove it from Continue Watching.
|
|
20
21
|
- **Stream aggregation** across all installed stream addons, each source showing
|
|
21
22
|
**size + languages/description** (from `behaviorHints.videoSize` and the addon's own text,
|
|
22
23
|
e.g. MediaFusion / Torrentio).
|
package/css/style.css
CHANGED
|
@@ -36,9 +36,10 @@ html, body {
|
|
|
36
36
|
|
|
37
37
|
/* --- Rows --- */
|
|
38
38
|
.rows { position: absolute; top: 110px; left: 0; right: 0; bottom: 0; overflow: hidden; }
|
|
39
|
-
.row { margin: 0 0
|
|
40
|
-
.row-title { font-size: 26px; font-weight: bold; margin: 0 0
|
|
41
|
-
|
|
39
|
+
.row { margin: 0 0 16px 0; }
|
|
40
|
+
.row-title { font-size: 26px; font-weight: bold; margin: 0 0 8px 60px; color: #c7cbd4; }
|
|
41
|
+
/* vertical padding leaves room for the scaled focus ring (overflow clips the rest) */
|
|
42
|
+
.row-scroller { white-space: nowrap; padding: 26px 60px; overflow: hidden; }
|
|
42
43
|
|
|
43
44
|
/* --- Cards --- */
|
|
44
45
|
.card {
|
|
@@ -140,10 +141,24 @@ html, body {
|
|
|
140
141
|
#search-input { flex: 1; padding: 16px 20px; font-size: 26px; background: #0e1117; color: #fff; border: 4px solid #2a3346; border-radius: 8px; }
|
|
141
142
|
#search-input.focused { border-color: #818cf8; }
|
|
142
143
|
.search-go { margin-left: 16px; }
|
|
143
|
-
.search-results { position: absolute; top: 96px; left: 0; right: 0; bottom: 0; overflow: hidden; white-space: normal; }
|
|
144
|
+
.search-results { position: absolute; top: 96px; left: 0; right: 0; bottom: 0; overflow: hidden; white-space: normal; padding: 22px 24px; }
|
|
144
145
|
.search-results .card { margin: 0 22px 24px 0; }
|
|
145
146
|
.search-empty { font-size: 24px; color: #8b93a5; margin-top: 30px; }
|
|
146
147
|
|
|
148
|
+
/* --- Contextual quick-action bar (UP) --- */
|
|
149
|
+
#player-bar { position: absolute; left: 0; right: 0; bottom: 60px; z-index: 22; text-align: center; }
|
|
150
|
+
#player-bar .pb-btn { display: inline-block; margin: 0 12px; padding: 16px 34px; font-size: 26px; border-radius: 40px; background: rgba(22,27,38,0.95); border: 4px solid transparent; color: #e6e9ef; }
|
|
151
|
+
#player-bar .pb-btn.sel { background: #818cf8; color: #0e1117; border-color: #fff; }
|
|
152
|
+
|
|
153
|
+
/* --- Skip intro/recap/credits prompt --- */
|
|
154
|
+
#skip-prompt { position: absolute; right: 60px; bottom: 200px; z-index: 24; text-align: right; }
|
|
155
|
+
#skip-prompt .skip-btn { display: inline-block; margin-left: 12px; padding: 16px 30px; font-size: 26px; font-weight: bold; border-radius: 10px; background: rgba(22,27,38,0.96); border: 4px solid #818cf8; color: #e6e9ef; }
|
|
156
|
+
#skip-prompt .skip-btn.sel { background: #818cf8; color: #0e1117; border-color: #fff; }
|
|
157
|
+
|
|
158
|
+
/* --- Landscape poster mode --- */
|
|
159
|
+
.card .poster { object-fit: cover; }
|
|
160
|
+
.card.landscape { width: 340px; height: 200px; }
|
|
161
|
+
|
|
147
162
|
/* --- Confirm modal --- */
|
|
148
163
|
.modal-overlay { position: absolute; top: 0; left: 0; width: 1920px; height: 1080px; z-index: 45; background: rgba(0,0,0,0.72); display: flex; align-items: center; justify-content: center; }
|
|
149
164
|
.modal-box { width: 820px; background: #161b26; border: 2px solid #2a3346; border-radius: 16px; padding: 44px 48px; text-align: center; }
|
package/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=1920, height=1080, user-scalable=no">
|
|
6
6
|
<title>Nuvio</title>
|
|
7
|
-
<link rel="stylesheet" href="css/style.css">
|
|
7
|
+
<link rel="stylesheet" href="css/style.css?v=1.8.0">
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
10
10
|
<!-- AVPlay video plane (Tizen). Sits behind the UI; shown only during playback. -->
|
|
@@ -31,20 +31,27 @@
|
|
|
31
31
|
<div id="osd-hint">OK: Play/Pause ←/→: Seek RETURN: Back</div>
|
|
32
32
|
</div>
|
|
33
33
|
|
|
34
|
-
<!--
|
|
34
|
+
<!-- In-player menu (opened with DOWN during playback) -->
|
|
35
35
|
<div id="sub-menu" class="hidden"></div>
|
|
36
36
|
|
|
37
|
+
<!-- Contextual quick-action bar (opened with UP during playback) -->
|
|
38
|
+
<div id="player-bar" class="hidden"></div>
|
|
39
|
+
|
|
40
|
+
<!-- Skip intro/recap/credits prompt (TheIntroDB) -->
|
|
41
|
+
<div id="skip-prompt" class="hidden"></div>
|
|
42
|
+
|
|
37
43
|
<div id="toast" class="hidden"></div>
|
|
38
44
|
<div id="spinner" class="hidden"><div class="spin"></div></div>
|
|
39
45
|
|
|
40
46
|
<!-- Load order matters: polyfills -> util -> data -> input -> player -> views -> app -->
|
|
41
|
-
|
|
42
|
-
<script src="js/
|
|
43
|
-
<script src="js/
|
|
44
|
-
<script src="js/
|
|
45
|
-
<script src="js/
|
|
46
|
-
<script src="js/
|
|
47
|
-
<script src="js/
|
|
48
|
-
<script src="js/
|
|
47
|
+
<!-- ?v= busts the TV webview cache on every release (keep in sync with package.json) -->
|
|
48
|
+
<script src="js/polyfills.js?v=1.8.0"></script>
|
|
49
|
+
<script src="js/util.js?v=1.8.0"></script>
|
|
50
|
+
<script src="js/stremio.js?v=1.8.0"></script>
|
|
51
|
+
<script src="js/keys.js?v=1.8.0"></script>
|
|
52
|
+
<script src="js/focus.js?v=1.8.0"></script>
|
|
53
|
+
<script src="js/player.js?v=1.8.0"></script>
|
|
54
|
+
<script src="js/views.js?v=1.8.0"></script>
|
|
55
|
+
<script src="js/app.js?v=1.8.0"></script>
|
|
49
56
|
</body>
|
|
50
57
|
</html>
|
package/js/app.js
CHANGED
|
@@ -26,7 +26,7 @@ var App = (function () {
|
|
|
26
26
|
function openSettings() {
|
|
27
27
|
pushFrame();
|
|
28
28
|
showScreen('settings');
|
|
29
|
-
Views.
|
|
29
|
+
Views.settingsRoot();
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function openSearch() {
|
|
@@ -35,16 +35,17 @@ var App = (function () {
|
|
|
35
35
|
Views.renderSearch();
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
function
|
|
38
|
+
function openEntry(cfg) {
|
|
39
39
|
pushFrame();
|
|
40
40
|
showScreen('addaddon');
|
|
41
|
-
Views.
|
|
41
|
+
Views.renderEntry(cfg);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function back() {
|
|
45
45
|
if (Player.isActive()) { Player.exit(); return; }
|
|
46
46
|
if (Views.isModalOpen()) { Views.closeModal(); return; }
|
|
47
47
|
if (_current === 'detail' && Views.backInDetail()) { return; }
|
|
48
|
+
if (_current === 'settings' && Views.backInSettings()) { return; }
|
|
48
49
|
var f = _stack.pop();
|
|
49
50
|
if (!f) { tryExitApp(); return; }
|
|
50
51
|
showScreen(f.screen);
|
|
@@ -62,7 +63,7 @@ var App = (function () {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
function onKey(action) {
|
|
65
|
-
if (Player.isActive()) { return Player.handleKey(action); }
|
|
66
|
+
if (Player.isActive()) { return Player.handleKey(action === 'OK_LONG' ? 'OK' : action); }
|
|
66
67
|
switch (action) {
|
|
67
68
|
case 'LEFT': Focus.move('LEFT'); return true;
|
|
68
69
|
case 'RIGHT': Focus.move('RIGHT'); return true;
|
|
@@ -74,6 +75,12 @@ var App = (function () {
|
|
|
74
75
|
return true;
|
|
75
76
|
}
|
|
76
77
|
case 'OK': Focus.clickCurrent(); return true;
|
|
78
|
+
case 'OK_LONG': {
|
|
79
|
+
var cur = Focus.current();
|
|
80
|
+
if (cur && cur.__onlongpress) { cur.__onlongpress(cur); }
|
|
81
|
+
else { Focus.clickCurrent(); }
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
77
84
|
case 'BACK': back(); return true;
|
|
78
85
|
default: return true;
|
|
79
86
|
}
|
|
@@ -90,7 +97,7 @@ var App = (function () {
|
|
|
90
97
|
return {
|
|
91
98
|
start: start, back: back,
|
|
92
99
|
openDetail: openDetail, openSettings: openSettings, openSearch: openSearch,
|
|
93
|
-
|
|
100
|
+
openEntry: openEntry
|
|
94
101
|
};
|
|
95
102
|
})();
|
|
96
103
|
|
package/js/keys.js
CHANGED
|
@@ -36,20 +36,41 @@ var Keys = (function () {
|
|
|
36
36
|
var _handler = null;
|
|
37
37
|
function setHandler(fn) { _handler = fn; }
|
|
38
38
|
|
|
39
|
+
var LONG_MS = 550; // hold threshold for a long-press
|
|
40
|
+
var _okDownAt = 0;
|
|
41
|
+
|
|
42
|
+
function dispatch(action, ev) {
|
|
43
|
+
if (!_handler) { return; }
|
|
44
|
+
var handled = _handler(action, ev);
|
|
45
|
+
if (handled !== false && ev.preventDefault) { ev.preventDefault(); }
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
function onKeyDown(ev) {
|
|
40
49
|
var action = CODE[ev.keyCode];
|
|
41
50
|
if (!action) { return; }
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
if (action === 'OK') {
|
|
52
|
+
// Defer OK to keyup so we can tell a tap from a long-press.
|
|
53
|
+
if (_okDownAt === 0) { _okDownAt = Date.now(); }
|
|
54
|
+
if (ev.preventDefault) { ev.preventDefault(); }
|
|
55
|
+
return;
|
|
47
56
|
}
|
|
57
|
+
dispatch(action, ev);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function onKeyUp(ev) {
|
|
61
|
+
if (CODE[ev.keyCode] !== 'OK') { return; }
|
|
62
|
+
// If _okDownAt is 0 the keydown was consumed elsewhere (e.g. a text input
|
|
63
|
+
// that stopped propagation) — don't fire a stray action.
|
|
64
|
+
if (_okDownAt === 0) { return; }
|
|
65
|
+
var held = Date.now() - _okDownAt;
|
|
66
|
+
_okDownAt = 0;
|
|
67
|
+
dispatch(held >= LONG_MS ? 'OK_LONG' : 'OK', ev);
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
function init() {
|
|
51
71
|
register();
|
|
52
72
|
document.addEventListener('keydown', onKeyDown, false);
|
|
73
|
+
document.addEventListener('keyup', onKeyUp, false);
|
|
53
74
|
}
|
|
54
75
|
|
|
55
76
|
return { init: init, setHandler: setHandler, CODE: CODE };
|
package/js/player.js
CHANGED
|
@@ -5,8 +5,18 @@ var Player = (function () {
|
|
|
5
5
|
|
|
6
6
|
var PROGRESS_KEY = 'nuvio.progress';
|
|
7
7
|
var SUBCFG_KEY = 'nuvio.subcfg';
|
|
8
|
+
var WATCHED_KEY = 'nuvio.watched';
|
|
8
9
|
var SIZE_PX = { small: 28, medium: 36, large: 46, xlarge: 58 };
|
|
9
10
|
|
|
11
|
+
// ---------- watched model ----------
|
|
12
|
+
function watchedAll() { return U.store.get(WATCHED_KEY, {}) || {}; }
|
|
13
|
+
function isWatched(type, id) { return !!watchedAll()[type + ':' + id]; }
|
|
14
|
+
function setWatched(type, id, on) {
|
|
15
|
+
var a = watchedAll();
|
|
16
|
+
if (on) { a[type + ':' + id] = Date.now(); } else { delete a[type + ':' + id]; }
|
|
17
|
+
U.store.set(WATCHED_KEY, a);
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
function getSubCfg() {
|
|
11
21
|
var d = U.store.get(SUBCFG_KEY, {}) || {};
|
|
12
22
|
return { lang: d.lang || 'off', size: d.size || 'medium', style: d.style || 'shadow' };
|
|
@@ -32,6 +42,17 @@ var Player = (function () {
|
|
|
32
42
|
var objEl = null;
|
|
33
43
|
var subsEl = null;
|
|
34
44
|
var subMenuEl = null;
|
|
45
|
+
var barEl = null; // contextual quick-action bar (UP)
|
|
46
|
+
var skipEl = null; // skip intro/recap/credits prompt
|
|
47
|
+
|
|
48
|
+
// skip segments (TheIntroDB) + auto-advance
|
|
49
|
+
var _segments = null; // { intro, recap, credits, preview } arrays of {start,end} sec
|
|
50
|
+
var _creditsSec = null; // credits start (sec) if known
|
|
51
|
+
var _creditsHandled = false;
|
|
52
|
+
var _segShown = {}; // which segment prompts already shown this playback
|
|
53
|
+
var _skip = null; // active skip prompt {type, buttons, idx, timer}
|
|
54
|
+
var _barOpen = false;
|
|
55
|
+
var _barIdx = 0;
|
|
35
56
|
|
|
36
57
|
var _active = false;
|
|
37
58
|
var _onExit = null;
|
|
@@ -59,6 +80,8 @@ var Player = (function () {
|
|
|
59
80
|
videoEl = U.byId('html5-player');
|
|
60
81
|
subsEl = U.byId('subs');
|
|
61
82
|
subMenuEl = U.byId('sub-menu');
|
|
83
|
+
barEl = U.byId('player-bar');
|
|
84
|
+
skipEl = U.byId('skip-prompt');
|
|
62
85
|
}
|
|
63
86
|
|
|
64
87
|
function isActive() { return _active; }
|
|
@@ -85,6 +108,12 @@ var Player = (function () {
|
|
|
85
108
|
return e ? e.position : 0;
|
|
86
109
|
}
|
|
87
110
|
|
|
111
|
+
function removeProgress(type, id) {
|
|
112
|
+
var all = progressAll();
|
|
113
|
+
delete all[type + ':' + id];
|
|
114
|
+
U.store.set(PROGRESS_KEY, all);
|
|
115
|
+
}
|
|
116
|
+
|
|
88
117
|
function continueList() {
|
|
89
118
|
var all = progressAll();
|
|
90
119
|
var arr = [];
|
|
@@ -130,9 +159,12 @@ var Player = (function () {
|
|
|
130
159
|
// reset subtitle + audio state
|
|
131
160
|
_subTracks = []; _cues = null; _subOn = false; _subLabel = ''; _subTrackUrl = '';
|
|
132
161
|
_audioIdx = null;
|
|
133
|
-
_menuOpen = false;
|
|
162
|
+
_menuOpen = false; _barOpen = false;
|
|
163
|
+
_segments = null; _creditsSec = null; _creditsHandled = false; _segShown = {};
|
|
164
|
+
hideSkip();
|
|
134
165
|
subsEl.className = 'hidden'; U.clear(subsEl);
|
|
135
166
|
subMenuEl.className = 'hidden';
|
|
167
|
+
barEl.className = 'hidden';
|
|
136
168
|
updateHint();
|
|
137
169
|
|
|
138
170
|
U.byId('app').className = 'hidden';
|
|
@@ -157,13 +189,21 @@ var Player = (function () {
|
|
|
157
189
|
});
|
|
158
190
|
}
|
|
159
191
|
if (match) { loadTrack(match); }
|
|
160
|
-
|
|
192
|
+
}, function () {});
|
|
193
|
+
|
|
194
|
+
// Fetch skip segments (intro/recap/credits) from TheIntroDB.
|
|
195
|
+
Stremio.getIntroSegments(ctx.type, ctx.id).then(function (seg) {
|
|
196
|
+
if (!_active) { return; }
|
|
197
|
+
_segments = seg;
|
|
198
|
+
if (seg && seg.credits && seg.credits[0] && seg.credits[0].start != null) {
|
|
199
|
+
_creditsSec = seg.credits[0].start;
|
|
200
|
+
}
|
|
161
201
|
}, function () {});
|
|
162
202
|
}
|
|
163
203
|
|
|
164
204
|
function updateHint() {
|
|
165
|
-
var base = _isLive ? 'OK: Play/Pause
|
|
166
|
-
base += ' UP: Menu';
|
|
205
|
+
var base = _isLive ? 'OK: Play/Pause' : 'OK: Play/Pause ←/→: Seek';
|
|
206
|
+
base += ' UP: Controls DOWN: Menu RETURN: Back';
|
|
167
207
|
var h = U.byId('osd-hint');
|
|
168
208
|
if (h) { h.textContent = base; }
|
|
169
209
|
}
|
|
@@ -185,7 +225,7 @@ var Player = (function () {
|
|
|
185
225
|
onbufferingstart: function () { U.spinner(true); },
|
|
186
226
|
onbufferingcomplete: function () { U.spinner(false); },
|
|
187
227
|
onstreamcompleted: function () { _cur = _dur; onEnded(); },
|
|
188
|
-
oncurrentplaytime: function (ms) { _cur = ms / 1000;
|
|
228
|
+
oncurrentplaytime: function (ms) { _cur = ms / 1000; onProgress(); },
|
|
189
229
|
onerror: function (err) { U.spinner(false); U.toast('Playback error: ' + err); setTimeout(exit, 1500); }
|
|
190
230
|
});
|
|
191
231
|
try { avplay.setStreamingProperty('ADAPTIVE_INFO', 'BITRATES=1'); } catch (e0) {}
|
|
@@ -220,7 +260,7 @@ var Player = (function () {
|
|
|
220
260
|
if (!_isLive && _ctx.resumeSec > 5) { try { videoEl.currentTime = _ctx.resumeSec; } catch (e) {} }
|
|
221
261
|
U.spinner(false); showOSD(false);
|
|
222
262
|
};
|
|
223
|
-
videoEl.ontimeupdate = function () { _cur = videoEl.currentTime;
|
|
263
|
+
videoEl.ontimeupdate = function () { _cur = videoEl.currentTime; onProgress(); };
|
|
224
264
|
videoEl.onended = onEnded;
|
|
225
265
|
videoEl.onerror = function () { U.toast('Playback error'); setTimeout(exit, 1500); };
|
|
226
266
|
var p = videoEl.play();
|
|
@@ -231,12 +271,108 @@ var Player = (function () {
|
|
|
231
271
|
function startTicker() {
|
|
232
272
|
if (_ticker) { clearInterval(_ticker); }
|
|
233
273
|
_ticker = setInterval(function () {
|
|
234
|
-
if (useAV) { try { _cur = avplay.getCurrentTime() / 1000; } catch (e) {}
|
|
274
|
+
if (useAV) { try { _cur = avplay.getCurrentTime() / 1000; } catch (e) {} onProgress(); }
|
|
235
275
|
saveProgress();
|
|
236
276
|
}, 5000);
|
|
237
277
|
}
|
|
238
278
|
|
|
239
|
-
function onEnded() {
|
|
279
|
+
function onEnded() {
|
|
280
|
+
// reaching the end counts as watched + advances the series queue
|
|
281
|
+
if (_ctx) { handleCredits(true); }
|
|
282
|
+
saveProgress();
|
|
283
|
+
exit();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function onProgress() {
|
|
287
|
+
renderOSD();
|
|
288
|
+
renderSubs();
|
|
289
|
+
checkSegments();
|
|
290
|
+
checkCredits();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Show a transient Skip button when entering an intro/recap/credits segment.
|
|
294
|
+
function checkSegments() {
|
|
295
|
+
if (!_segments || _isLive) { return; }
|
|
296
|
+
var t = _cur;
|
|
297
|
+
function inSeg(list, endRequired) {
|
|
298
|
+
if (!list || !list.length) { return null; }
|
|
299
|
+
var s = list[0];
|
|
300
|
+
var start = (s.start != null ? s.start : 0);
|
|
301
|
+
var end = (s.end != null ? s.end : (endRequired ? 0 : 1e9));
|
|
302
|
+
if (endRequired) { return (t >= start && t <= end) ? s : null; }
|
|
303
|
+
return (t >= start) ? s : null; // credits/preview: open-ended
|
|
304
|
+
}
|
|
305
|
+
// priority: credits (near end) > recap > intro
|
|
306
|
+
if (inSeg(_segments.credits, false) && !_segShown.credits) { showSkip('credits', _segments.credits[0]); return; }
|
|
307
|
+
if (inSeg(_segments.recap, true) && !_segShown.recap) { showSkip('recap', _segments.recap[0]); return; }
|
|
308
|
+
if (inSeg(_segments.intro, true) && !_segShown.intro) { showSkip('intro', _segments.intro[0]); return; }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Passing the credits threshold marks the item watched and queues the next episode.
|
|
312
|
+
function checkCredits() {
|
|
313
|
+
if (_isLive || _creditsHandled || !_ctx) { return; }
|
|
314
|
+
var threshold = (_creditsSec != null) ? _creditsSec : (_dur ? _dur * 0.92 : Infinity);
|
|
315
|
+
if (_cur >= threshold && isFinite(threshold)) { handleCredits(false); }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function handleCredits() {
|
|
319
|
+
if (_creditsHandled || !_ctx) { return; }
|
|
320
|
+
_creditsHandled = true;
|
|
321
|
+
setWatched(_ctx.type, _ctx.id, true);
|
|
322
|
+
removeProgress(_ctx.type, _ctx.id);
|
|
323
|
+
if (_ctx.nextEpisode) {
|
|
324
|
+
var ne = _ctx.nextEpisode;
|
|
325
|
+
var all = progressAll();
|
|
326
|
+
all[ne.type + ':' + ne.id] = { id: ne.id, type: ne.type, name: ne.name, poster: ne.poster,
|
|
327
|
+
position: 0, duration: 0, updated: Date.now() };
|
|
328
|
+
U.store.set(PROGRESS_KEY, all);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------- skip prompt ----------
|
|
333
|
+
function showSkip(type, seg) {
|
|
334
|
+
_segShown[type] = true;
|
|
335
|
+
var buttons = [];
|
|
336
|
+
if (type === 'intro' || type === 'recap') {
|
|
337
|
+
buttons.push({ label: (type === 'intro' ? 'Skip Intro' : 'Skip Recap'),
|
|
338
|
+
run: function () { if (seg.end != null) { seekTo(seg.end); } hideSkip(); } });
|
|
339
|
+
} else { // credits
|
|
340
|
+
buttons.push({ label: _ctx.nextEpisode ? 'Skip Credits → Next' : 'Skip Credits',
|
|
341
|
+
run: function () {
|
|
342
|
+
handleCredits();
|
|
343
|
+
hideSkip();
|
|
344
|
+
if (_ctx.nextEpisode && _ctx.playNext) { _ctx.playNext(); } else { exit(); }
|
|
345
|
+
} });
|
|
346
|
+
buttons.push({ label: 'Watch Credits', run: function () { hideSkip(); } });
|
|
347
|
+
}
|
|
348
|
+
_skip = { type: type, buttons: buttons, idx: 0, timer: null };
|
|
349
|
+
renderSkip();
|
|
350
|
+
// auto-hide after a few seconds
|
|
351
|
+
_skip.timer = setTimeout(hideSkip, 9000);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function renderSkip() {
|
|
355
|
+
if (!_skip) { return; }
|
|
356
|
+
U.clear(skipEl);
|
|
357
|
+
_skip.buttons.forEach(function (b, i) {
|
|
358
|
+
skipEl.appendChild(U.el('div', 'skip-btn' + (i === _skip.idx ? ' sel' : ''), b.label));
|
|
359
|
+
});
|
|
360
|
+
skipEl.className = '';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function hideSkip() {
|
|
364
|
+
if (_skip && _skip.timer) { clearTimeout(_skip.timer); }
|
|
365
|
+
_skip = null;
|
|
366
|
+
if (skipEl) { skipEl.className = 'hidden'; U.clear(skipEl); }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function skipKey(action) {
|
|
370
|
+
if (!_skip) { return; }
|
|
371
|
+
if (action === 'OK') { var b = _skip.buttons[_skip.idx]; if (b && b.run) { b.run(); } return; }
|
|
372
|
+
if (action === 'LEFT') { _skip.idx = Math.max(0, _skip.idx - 1); renderSkip(); return; }
|
|
373
|
+
if (action === 'RIGHT') { _skip.idx = Math.min(_skip.buttons.length - 1, _skip.idx + 1); renderSkip(); return; }
|
|
374
|
+
if (action === 'BACK') { hideSkip(); return; }
|
|
375
|
+
}
|
|
240
376
|
|
|
241
377
|
// ---------- controls ----------
|
|
242
378
|
function state() {
|
|
@@ -244,14 +380,17 @@ var Player = (function () {
|
|
|
244
380
|
return videoEl.paused ? 'PAUSED' : 'PLAYING';
|
|
245
381
|
}
|
|
246
382
|
|
|
383
|
+
function isPaused() {
|
|
384
|
+
if (useAV) { return state() !== 'PLAYING'; }
|
|
385
|
+
return videoEl.paused;
|
|
386
|
+
}
|
|
387
|
+
function play_() { if (useAV) { try { avplay.play(); } catch (e) {} } else { videoEl.play(); } }
|
|
388
|
+
function pause_() { if (useAV) { try { avplay.pause(); } catch (e) {} } else { videoEl.pause(); } }
|
|
389
|
+
|
|
247
390
|
function togglePlay() {
|
|
248
|
-
if (
|
|
249
|
-
var s = state();
|
|
250
|
-
if (s === 'PLAYING') { avplay.pause(); } else { avplay.play(); }
|
|
251
|
-
} else {
|
|
252
|
-
if (videoEl.paused) { videoEl.play(); } else { videoEl.pause(); }
|
|
253
|
-
}
|
|
391
|
+
if (isPaused()) { play_(); } else { pause_(); }
|
|
254
392
|
showOSD(false);
|
|
393
|
+
if (_barOpen) { renderBar(); }
|
|
255
394
|
}
|
|
256
395
|
|
|
257
396
|
function seekTo(sec) {
|
|
@@ -424,23 +563,68 @@ var Player = (function () {
|
|
|
424
563
|
if (sel && sel.scrollIntoView) { try { sel.scrollIntoView(false); } catch (e) {} }
|
|
425
564
|
}
|
|
426
565
|
|
|
566
|
+
// ---------- contextual quick-action bar (UP) ----------
|
|
567
|
+
function barButtons() {
|
|
568
|
+
var b = [{ label: isPaused() ? '▶ Play' : '❙❙ Pause', run: togglePlay }];
|
|
569
|
+
if (!_isLive) { b.push({ label: '↺ Restart', run: function () { seekTo(0); closeBar(); } }); }
|
|
570
|
+
if (_ctx.hasPrev) { b.push({ label: '⏮ Previous', run: function () { closeBar(); _ctx.playPrev(); } }); }
|
|
571
|
+
if (_ctx.hasNext) { b.push({ label: '⏭ Next', run: function () { closeBar(); _ctx.playNext(); } }); }
|
|
572
|
+
return b;
|
|
573
|
+
}
|
|
574
|
+
function openBar() {
|
|
575
|
+
_barOpen = true; _barIdx = 0;
|
|
576
|
+
renderBar();
|
|
577
|
+
showOSD(true);
|
|
578
|
+
}
|
|
579
|
+
function closeBar() {
|
|
580
|
+
_barOpen = false;
|
|
581
|
+
barEl.className = 'hidden';
|
|
582
|
+
showOSD(false);
|
|
583
|
+
}
|
|
584
|
+
function renderBar() {
|
|
585
|
+
var b = barButtons();
|
|
586
|
+
if (_barIdx >= b.length) { _barIdx = b.length - 1; }
|
|
587
|
+
U.clear(barEl);
|
|
588
|
+
b.forEach(function (it, i) {
|
|
589
|
+
barEl.appendChild(U.el('div', 'pb-btn' + (i === _barIdx ? ' sel' : ''), it.label));
|
|
590
|
+
});
|
|
591
|
+
barEl.className = '';
|
|
592
|
+
}
|
|
593
|
+
function barKey(action) {
|
|
594
|
+
var b = barButtons();
|
|
595
|
+
if (action === 'LEFT') { _barIdx = Math.max(0, _barIdx - 1); renderBar(); return; }
|
|
596
|
+
if (action === 'RIGHT') { _barIdx = Math.min(b.length - 1, _barIdx + 1); renderBar(); return; }
|
|
597
|
+
if (action === 'OK') { var it = b[_barIdx]; if (it && it.run) { it.run(); } return; }
|
|
598
|
+
if (action === 'UP' || action === 'BACK') { closeBar(); return; }
|
|
599
|
+
if (action === 'DOWN') { closeBar(); openMenu(); return; }
|
|
600
|
+
}
|
|
601
|
+
|
|
427
602
|
function rootMenu() {
|
|
603
|
+
// Order per spec: Configure Subtitles, Audio Track, Select Source, Playback
|
|
428
604
|
var items = [];
|
|
429
|
-
|
|
430
|
-
items.push({ label: 'Select source', run: function () { pushMenu(sourceMenu()); } });
|
|
431
|
-
}
|
|
605
|
+
items.push({ label: 'Configure subtitles', run: function () { pushMenu(subConfigMenu()); } });
|
|
432
606
|
if (getAudioTracks().length > 1) {
|
|
433
607
|
items.push({ label: 'Audio track', run: function () { pushMenu(audioMenu()); } });
|
|
434
608
|
}
|
|
609
|
+
if (_ctx.streams && _ctx.streams.length) {
|
|
610
|
+
items.push({ label: 'Select source', run: function () { pushMenu(sourceMenu()); } });
|
|
611
|
+
}
|
|
435
612
|
if (!_isLive) {
|
|
436
|
-
items.push({ label: '
|
|
437
|
-
items.push({ label: 'Skip back 10 minutes', run: function () { seekBy(-600); closeMenu(); } });
|
|
438
|
-
items.push({ label: 'Skip forward 10 minutes', run: function () { seekBy(600); closeMenu(); } });
|
|
613
|
+
items.push({ label: 'Playback', run: function () { pushMenu(playbackMenu()); } });
|
|
439
614
|
}
|
|
440
|
-
items.push({ label: 'Configure subtitles', run: function () { pushMenu(subConfigMenu()); } });
|
|
441
615
|
return { title: 'Menu', items: items, idx: 0 };
|
|
442
616
|
}
|
|
443
617
|
|
|
618
|
+
function playbackMenu() {
|
|
619
|
+
return { title: 'Playback', idx: 0, items: [
|
|
620
|
+
{ label: 'Restart', run: function () { seekTo(0); closeMenu(); } },
|
|
621
|
+
{ label: 'Rewind 10′', run: function () { seekBy(-600); closeMenu(); } },
|
|
622
|
+
{ label: 'Rewind 5′', run: function () { seekBy(-300); closeMenu(); } },
|
|
623
|
+
{ label: 'Forward 5′', run: function () { seekBy(300); closeMenu(); } },
|
|
624
|
+
{ label: 'Forward 10′', run: function () { seekBy(600); closeMenu(); } }
|
|
625
|
+
] };
|
|
626
|
+
}
|
|
627
|
+
|
|
444
628
|
function sourceMenu() {
|
|
445
629
|
var idx = 0;
|
|
446
630
|
var items = _ctx.streams.map(function (s, i) {
|
|
@@ -531,17 +715,31 @@ var Player = (function () {
|
|
|
531
715
|
}
|
|
532
716
|
|
|
533
717
|
function handleKey(action) {
|
|
718
|
+
// Dedicated transport keys ALWAYS control playback, even with an overlay open (#9).
|
|
719
|
+
switch (action) {
|
|
720
|
+
case 'PLAY': play_(); if (_barOpen) { renderBar(); } showOSD(false); return true;
|
|
721
|
+
case 'PAUSE': pause_(); if (_barOpen) { renderBar(); } showOSD(false); return true;
|
|
722
|
+
case 'PLAYPAUSE': togglePlay(); return true;
|
|
723
|
+
case 'STOP': exit(); return true;
|
|
724
|
+
case 'FF': seekBy(30); return true;
|
|
725
|
+
case 'REW': seekBy(-30); return true;
|
|
726
|
+
}
|
|
727
|
+
// Route directional/OK/BACK to the active overlay (menu > bar > skip prompt).
|
|
534
728
|
if (_menuOpen) { menuKey(action); return true; }
|
|
729
|
+
if (_barOpen) { barKey(action); return true; }
|
|
730
|
+
if (_skip) {
|
|
731
|
+
if (action === 'OK' || action === 'BACK') { skipKey(action); return true; }
|
|
732
|
+
if ((action === 'LEFT' || action === 'RIGHT') && _skip.buttons.length > 1) { skipKey(action); return true; }
|
|
733
|
+
}
|
|
734
|
+
// No overlay: default transport on the D-pad.
|
|
535
735
|
showOSD(false);
|
|
536
736
|
switch (action) {
|
|
537
|
-
case 'OK':
|
|
538
|
-
case '
|
|
539
|
-
case '
|
|
540
|
-
case '
|
|
541
|
-
case '
|
|
542
|
-
case '
|
|
543
|
-
case 'DOWN': showOSD(false); return true;
|
|
544
|
-
case 'STOP': case 'BACK': exit(); return true;
|
|
737
|
+
case 'OK': togglePlay(); return true;
|
|
738
|
+
case 'RIGHT': seekBy(10); return true;
|
|
739
|
+
case 'LEFT': seekBy(-10); return true;
|
|
740
|
+
case 'UP': openBar(); return true;
|
|
741
|
+
case 'DOWN': openMenu(); return true;
|
|
742
|
+
case 'BACK': exit(); return true;
|
|
545
743
|
}
|
|
546
744
|
return true;
|
|
547
745
|
}
|
|
@@ -555,6 +753,8 @@ var Player = (function () {
|
|
|
555
753
|
hideOSD();
|
|
556
754
|
subsEl.className = 'hidden'; U.clear(subsEl);
|
|
557
755
|
subMenuEl.className = 'hidden'; _menuOpen = false; _menuStack = [];
|
|
756
|
+
barEl.className = 'hidden'; _barOpen = false;
|
|
757
|
+
hideSkip();
|
|
558
758
|
_subOn = false; _cues = null; _subTracks = [];
|
|
559
759
|
if (useAV) {
|
|
560
760
|
try { avplay.stop(); } catch (e) {}
|
|
@@ -574,7 +774,8 @@ var Player = (function () {
|
|
|
574
774
|
|
|
575
775
|
return {
|
|
576
776
|
init: init, play: play, handleKey: handleKey, isActive: isActive,
|
|
577
|
-
exit: exit, continueList: continueList, getResume: getResume,
|
|
578
|
-
getSubCfg: getSubCfg, setSubCfg: setSubCfg
|
|
777
|
+
exit: exit, continueList: continueList, getResume: getResume, removeProgress: removeProgress,
|
|
778
|
+
getSubCfg: getSubCfg, setSubCfg: setSubCfg,
|
|
779
|
+
isWatched: isWatched, setWatched: setWatched
|
|
579
780
|
};
|
|
580
781
|
})();
|
package/js/stremio.js
CHANGED
|
@@ -57,6 +57,30 @@ var Stremio = (function () {
|
|
|
57
57
|
_loaded = null;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// ---- Integrations (API keys) + UI prefs ----
|
|
61
|
+
var INTEG_KEY = 'nuvio.integrations';
|
|
62
|
+
var PREFS_KEY = 'nuvio.prefs';
|
|
63
|
+
|
|
64
|
+
function getIntegrations() {
|
|
65
|
+
var o = U.store.get(INTEG_KEY, {}) || {};
|
|
66
|
+
return { tmdb: o.tmdb || '', mdblist: o.mdblist || '', theintrodb: o.theintrodb || '' };
|
|
67
|
+
}
|
|
68
|
+
function setIntegration(key, val) {
|
|
69
|
+
var o = getIntegrations();
|
|
70
|
+
o[key] = ('' + val).trim();
|
|
71
|
+
U.store.set(INTEG_KEY, o);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getPrefs() {
|
|
75
|
+
var p = U.store.get(PREFS_KEY, {}) || {};
|
|
76
|
+
return { posterShape: p.posterShape || 'portrait' };
|
|
77
|
+
}
|
|
78
|
+
function setPref(key, val) {
|
|
79
|
+
var p = getPrefs();
|
|
80
|
+
p[key] = val;
|
|
81
|
+
U.store.set(PREFS_KEY, p);
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
// ---- Manifest loading (cached in-memory per session) ----
|
|
61
85
|
var _loaded = null;
|
|
62
86
|
|
|
@@ -227,6 +251,30 @@ var Stremio = (function () {
|
|
|
227
251
|
});
|
|
228
252
|
}
|
|
229
253
|
|
|
254
|
+
// ---- Skip segments (TheIntroDB) ----
|
|
255
|
+
// GET https://api.theintrodb.org/v3/media?imdb_id=tt..[&season=&episode=][&duration_ms=]
|
|
256
|
+
// Returns { intro, recap, credits, preview } arrays of {start,end} in seconds (null = open).
|
|
257
|
+
function getIntroSegments(type, id, durationMs) {
|
|
258
|
+
var parts = ('' + id).split(':');
|
|
259
|
+
var imdb = parts[0];
|
|
260
|
+
if (!/^tt\d+/.test(imdb)) { return Promise.resolve(null); }
|
|
261
|
+
var url = 'https://api.theintrodb.org/v3/media?imdb_id=' + encodeURIComponent(imdb);
|
|
262
|
+
if (type === 'series' && parts.length >= 3) {
|
|
263
|
+
url += '&season=' + encodeURIComponent(parts[1]) + '&episode=' + encodeURIComponent(parts[2]);
|
|
264
|
+
}
|
|
265
|
+
if (durationMs && durationMs > 0) { url += '&duration_ms=' + Math.floor(durationMs); }
|
|
266
|
+
return U.getJSON(url, 10000).then(function (r) {
|
|
267
|
+
if (!r) { return null; }
|
|
268
|
+
function segs(arr) {
|
|
269
|
+
return (arr || []).map(function (s) {
|
|
270
|
+
return { start: (s.start_ms != null ? s.start_ms / 1000 : null),
|
|
271
|
+
end: (s.end_ms != null ? s.end_ms / 1000 : null) };
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return { intro: segs(r.intro), recap: segs(r.recap), credits: segs(r.credits), preview: segs(r.preview) };
|
|
275
|
+
}, function () { return null; });
|
|
276
|
+
}
|
|
277
|
+
|
|
230
278
|
// ---- Search ----
|
|
231
279
|
// Catalogs (across addons) that declare support for the "search" extra.
|
|
232
280
|
function getSearchCatalogs() {
|
|
@@ -316,6 +364,8 @@ var Stremio = (function () {
|
|
|
316
364
|
return {
|
|
317
365
|
DEFAULT_ADDONS: DEFAULT_ADDONS,
|
|
318
366
|
getAddonUrls: getAddonUrls, addAddon: addAddon, removeAddon: removeAddon, resetAddons: resetAddons,
|
|
367
|
+
getIntegrations: getIntegrations, setIntegration: setIntegration, getPrefs: getPrefs, setPref: setPref,
|
|
368
|
+
getIntroSegments: getIntroSegments,
|
|
319
369
|
loadAddons: loadAddons, listCatalogs: listCatalogs, getCatalog: getCatalog,
|
|
320
370
|
getMeta: getMeta, getStreams: getStreams, getSubtitles: getSubtitles,
|
|
321
371
|
getSearchCatalogs: getSearchCatalogs, search: search,
|
package/js/views.js
CHANGED
|
@@ -43,7 +43,7 @@ var Views = (function () {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function _current() {
|
|
46
|
-
var names = ['home', 'detail', 'search', 'settings'];
|
|
46
|
+
var names = ['home', 'detail', 'search', 'settings', 'addaddon'];
|
|
47
47
|
for (var i = 0; i < names.length; i++) {
|
|
48
48
|
var e = scr(names[i]);
|
|
49
49
|
if (e && e.className.indexOf('hidden') === -1) { return e; }
|
|
@@ -85,12 +85,13 @@ var Views = (function () {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
function posterCard(meta, fallbackType) {
|
|
88
|
-
var
|
|
89
|
-
var
|
|
90
|
-
|
|
88
|
+
var landscape = Stremio.getPrefs().posterShape === 'landscape';
|
|
89
|
+
var c = U.el('div', 'card focusable' + (landscape ? ' landscape' : ''));
|
|
90
|
+
var src = landscape ? (meta.background || meta.thumbnail || meta.poster) : (meta.poster || meta.background);
|
|
91
|
+
if (src) {
|
|
91
92
|
var img = U.el('img', 'poster');
|
|
92
93
|
img.onerror = function () { img.style.display = 'none'; c.appendChild(U.el('div', 'fallback', meta.name || meta.title || '')); };
|
|
93
|
-
img.src =
|
|
94
|
+
img.src = src;
|
|
94
95
|
c.appendChild(img);
|
|
95
96
|
} else {
|
|
96
97
|
c.appendChild(U.el('div', 'fallback', meta.name || meta.title || ''));
|
|
@@ -111,7 +112,7 @@ var Views = (function () {
|
|
|
111
112
|
// Continue watching
|
|
112
113
|
var cont = Player.continueList();
|
|
113
114
|
if (cont.length) {
|
|
114
|
-
rows.appendChild(buildRow('Continue Watching', cont.map(function (e) {
|
|
115
|
+
rows.appendChild(buildRow('Continue Watching · hold OK to remove', cont.map(function (e) {
|
|
115
116
|
var c = posterCard({ id: e.id.split(':')[0], type: e.type, name: e.name, poster: e.poster }, e.type);
|
|
116
117
|
// For a series, keep the full episode id so detail opens on the right
|
|
117
118
|
// season and focuses the exact episode being resumed.
|
|
@@ -120,6 +121,14 @@ var Views = (function () {
|
|
|
120
121
|
{ name: e.name, poster: e.poster, type: e.type },
|
|
121
122
|
e.type === 'series' ? e.id : null);
|
|
122
123
|
};
|
|
124
|
+
// Long-press (hold OK) removes the item from Continue Watching.
|
|
125
|
+
c.__onlongpress = function () {
|
|
126
|
+
confirm('Remove “' + (e.name || 'this') + '” from Continue Watching?', 'Remove', function () {
|
|
127
|
+
Player.removeProgress(e.type, e.id);
|
|
128
|
+
U.toast('Removed from Continue Watching');
|
|
129
|
+
renderHome();
|
|
130
|
+
});
|
|
131
|
+
};
|
|
123
132
|
if (e.duration) {
|
|
124
133
|
var p = U.el('div', 'prog');
|
|
125
134
|
p.style.width = Math.min(100, (e.position / e.duration * 100)) + '%';
|
|
@@ -229,9 +238,15 @@ var Views = (function () {
|
|
|
229
238
|
s.appendChild(body);
|
|
230
239
|
|
|
231
240
|
// If resuming a series episode, pre-select its season.
|
|
232
|
-
if (_d.type === 'series' && _d.target && _d.season === null
|
|
233
|
-
var tv = meta.videos.find(function (v) { return v.id === _d.target; });
|
|
234
|
-
if (tv) {
|
|
241
|
+
if (_d.type === 'series' && _d.target && _d.season === null) {
|
|
242
|
+
var tv = meta.videos && meta.videos.find(function (v) { return v.id === _d.target; });
|
|
243
|
+
if (tv) {
|
|
244
|
+
_d.season = (tv.season === undefined ? 1 : tv.season);
|
|
245
|
+
} else {
|
|
246
|
+
// fallback: parse "<id>:<season>:<episode>"
|
|
247
|
+
var pp = ('' + _d.target).split(':');
|
|
248
|
+
if (pp.length >= 2) { var sn = parseInt(pp[1], 10); if (!isNaN(sn)) { _d.season = sn; } }
|
|
249
|
+
}
|
|
235
250
|
}
|
|
236
251
|
|
|
237
252
|
_d._epFocus = null;
|
|
@@ -286,12 +301,21 @@ var Views = (function () {
|
|
|
286
301
|
var eps = meta.videos.filter(function (v) { return (v.season === undefined ? 1 : v.season) === _d.season; });
|
|
287
302
|
eps.sort(function (a, b) { return (a.episode || 0) - (b.episode || 0); });
|
|
288
303
|
eps.forEach(function (v) {
|
|
289
|
-
var
|
|
290
|
-
e.
|
|
304
|
+
var watched = Player.isWatched('series', v.id);
|
|
305
|
+
var e = U.el('div', 'ep focusable' + (watched ? ' watched' : ''));
|
|
306
|
+
e.appendChild(document.createTextNode((watched ? '✓ ' : '') + 'E' + (v.episode || '?') + ' ' + (v.title || v.name || '')));
|
|
291
307
|
if (v.overview) { e.appendChild(U.el('span', 'ep-sub', v.overview)); }
|
|
292
308
|
e.__onselect = function () {
|
|
293
309
|
openStreams(v.id, _d.meta.name + ' S' + _d.season + 'E' + (v.episode || ''));
|
|
294
310
|
};
|
|
311
|
+
// Long-press toggles watched/unwatched.
|
|
312
|
+
e.__onlongpress = function () {
|
|
313
|
+
var now = !Player.isWatched('series', v.id);
|
|
314
|
+
Player.setWatched('series', v.id, now);
|
|
315
|
+
U.toast(now ? 'Marked watched' : 'Marked unwatched');
|
|
316
|
+
_d.target = v.id; // keep focus on this episode after re-render
|
|
317
|
+
drawDetail();
|
|
318
|
+
};
|
|
295
319
|
if (_d.target && v.id === _d.target) { _d._epFocus = e; }
|
|
296
320
|
listScroll.appendChild(e);
|
|
297
321
|
});
|
|
@@ -358,23 +382,7 @@ var Views = (function () {
|
|
|
358
382
|
if (!playable) { node.style.opacity = '0.55'; }
|
|
359
383
|
node.__onselect = function () {
|
|
360
384
|
if (!playable) { U.toast('This source can’t play directly on the TV.'); return; }
|
|
361
|
-
|
|
362
|
-
var sz = Stremio.streamSize(st);
|
|
363
|
-
var desc = Stremio.streamDescription(st).replace(/\n/g, ' ');
|
|
364
|
-
var dparts = [];
|
|
365
|
-
if (sz && !/(TB|GB|MB)\b/i.test(desc)) { dparts.push('💾 ' + sz); }
|
|
366
|
-
if (desc) { dparts.push(desc); }
|
|
367
|
-
return {
|
|
368
|
-
url: st.url,
|
|
369
|
-
label: (st._addon ? st._addon + ' · ' : '') + Stremio.streamName(st),
|
|
370
|
-
detail: dparts.join(' ')
|
|
371
|
-
};
|
|
372
|
-
});
|
|
373
|
-
Player.play({
|
|
374
|
-
url: stream.url, type: _d.type, id: _d.streamId,
|
|
375
|
-
name: _d.streamLabel || _d.meta.name, poster: _d.meta.poster,
|
|
376
|
-
streams: sources
|
|
377
|
-
}, function () { drawDetail(); });
|
|
385
|
+
startPlayback(stream, _d.streamId, _d.streamLabel || _d.meta.name);
|
|
378
386
|
};
|
|
379
387
|
listScroll.appendChild(node);
|
|
380
388
|
});
|
|
@@ -382,6 +390,71 @@ var Views = (function () {
|
|
|
382
390
|
return panel;
|
|
383
391
|
}
|
|
384
392
|
|
|
393
|
+
// ----- playback launch (with prev/next episode wiring) -----
|
|
394
|
+
function orderedEpisodes(meta) {
|
|
395
|
+
var vids = (meta.videos || []).slice();
|
|
396
|
+
vids.sort(function (a, b) {
|
|
397
|
+
var sa = (a.season === undefined ? 1 : a.season), sb = (b.season === undefined ? 1 : b.season);
|
|
398
|
+
if (sa !== sb) { return sa - sb; }
|
|
399
|
+
return (a.episode || 0) - (b.episode || 0);
|
|
400
|
+
});
|
|
401
|
+
return vids;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function epMeta(v) {
|
|
405
|
+
return { type: 'series', id: v.id,
|
|
406
|
+
name: _d.meta.name + ' S' + (v.season === undefined ? 1 : v.season) + 'E' + (v.episode || ''),
|
|
407
|
+
poster: _d.meta.poster };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function playableSources() {
|
|
411
|
+
return _d.streams.filter(Stremio.isPlayable).map(function (st) {
|
|
412
|
+
var sz = Stremio.streamSize(st);
|
|
413
|
+
var desc = Stremio.streamDescription(st).replace(/\n/g, ' ');
|
|
414
|
+
var dparts = [];
|
|
415
|
+
if (sz && !/(TB|GB|MB)\b/i.test(desc)) { dparts.push('💾 ' + sz); }
|
|
416
|
+
if (desc) { dparts.push(desc); }
|
|
417
|
+
return { url: st.url, label: (st._addon ? st._addon + ' · ' : '') + Stremio.streamName(st),
|
|
418
|
+
detail: dparts.join(' ') };
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function startPlayback(stream, videoId, label) {
|
|
423
|
+
var ctx = { url: stream.url, type: _d.type, id: videoId, name: label,
|
|
424
|
+
poster: _d.meta.poster, streams: playableSources() };
|
|
425
|
+
if (_d.type === 'series' && _d.meta.videos) {
|
|
426
|
+
var ord = orderedEpisodes(_d.meta);
|
|
427
|
+
var idx = ord.findIndex(function (v) { return v.id === videoId; });
|
|
428
|
+
if (idx > 0) { ctx.hasPrev = true; ctx.playPrev = function () { playEpisode(ord[idx - 1]); }; }
|
|
429
|
+
if (idx >= 0 && idx < ord.length - 1) {
|
|
430
|
+
ctx.hasNext = true;
|
|
431
|
+
ctx.nextEpisode = epMeta(ord[idx + 1]);
|
|
432
|
+
ctx.playNext = function () { playEpisode(ord[idx + 1]); };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
Player.play(ctx, function () { drawDetail(); });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Load a specific episode's sources and auto-play the first playable one.
|
|
439
|
+
function playEpisode(v) {
|
|
440
|
+
_d.mode = 'streams';
|
|
441
|
+
_d.streamId = v.id;
|
|
442
|
+
_d.season = (v.season === undefined ? 1 : v.season);
|
|
443
|
+
_d.streamLabel = _d.meta.name + ' S' + _d.season + 'E' + (v.episode || '');
|
|
444
|
+
U.spinner(true);
|
|
445
|
+
Stremio.getStreams(_d.type, v.id).then(function (list) {
|
|
446
|
+
_d.streams = list;
|
|
447
|
+
U.spinner(false);
|
|
448
|
+
var playable = list.filter(Stremio.isPlayable);
|
|
449
|
+
if (!playable.length) {
|
|
450
|
+
U.toast('No playable source for that episode');
|
|
451
|
+
if (!Player.isActive()) { drawDetail(); }
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
startPlayback(playable[0], v.id, _d.streamLabel);
|
|
455
|
+
}, function () { U.spinner(false); U.toast('Failed to load that episode'); });
|
|
456
|
+
}
|
|
457
|
+
|
|
385
458
|
// Return true if BACK was handled inside detail (streams -> info).
|
|
386
459
|
function backInDetail() {
|
|
387
460
|
if (_d && _d.mode === 'streams') {
|
|
@@ -407,7 +480,7 @@ var Views = (function () {
|
|
|
407
480
|
input.className = 'focusable';
|
|
408
481
|
input.placeholder = 'Search movies & series…';
|
|
409
482
|
input.__onselect = function () { input.focus(); };
|
|
410
|
-
input.onkeydown = function (ev) { if (ev.keyCode === 13) { runSearch(input.value); } };
|
|
483
|
+
input.onkeydown = function (ev) { if (ev.keyCode === 13) { if (ev.stopPropagation) { ev.stopPropagation(); } runSearch(input.value); } };
|
|
411
484
|
input.onchange = function () { runSearch(input.value); };
|
|
412
485
|
bar.appendChild(input);
|
|
413
486
|
|
|
@@ -447,22 +520,24 @@ var Views = (function () {
|
|
|
447
520
|
});
|
|
448
521
|
}
|
|
449
522
|
|
|
450
|
-
// =================
|
|
451
|
-
|
|
523
|
+
// ================= GENERIC TEXT-ENTRY PAGE =================
|
|
524
|
+
// cfg = { title, hint, value, placeholder, validate(v)->err|null, onConfirm(v) }
|
|
525
|
+
// Field kept high on its own page so the on-screen keyboard never covers it.
|
|
526
|
+
function renderEntry(cfg) {
|
|
452
527
|
var s = scr('addaddon');
|
|
453
528
|
U.clear(s);
|
|
454
529
|
s.appendChild(header(false, false));
|
|
455
530
|
|
|
456
531
|
var wrap = U.el('div', 'add-wrap');
|
|
457
|
-
wrap.appendChild(U.el('h2', null, '
|
|
458
|
-
wrap.appendChild(U.el('div', 'add-hint',
|
|
532
|
+
wrap.appendChild(U.el('h2', null, cfg.title || 'Enter value'));
|
|
533
|
+
if (cfg.hint) { wrap.appendChild(U.el('div', 'add-hint', cfg.hint)); }
|
|
459
534
|
|
|
460
535
|
var input = document.createElement('input');
|
|
461
|
-
input.id = '
|
|
536
|
+
input.id = 'entry-input';
|
|
462
537
|
input.type = 'text';
|
|
463
538
|
input.className = 'focusable add-input';
|
|
464
|
-
input.value = '
|
|
465
|
-
input.placeholder =
|
|
539
|
+
input.value = cfg.value || '';
|
|
540
|
+
if (cfg.placeholder) { input.placeholder = cfg.placeholder; }
|
|
466
541
|
input.__onselect = function () {
|
|
467
542
|
input.focus();
|
|
468
543
|
var n = input.value.length;
|
|
@@ -487,14 +562,9 @@ var Views = (function () {
|
|
|
487
562
|
|
|
488
563
|
function doConfirm() {
|
|
489
564
|
var v = ('' + input.value).trim();
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
494
|
-
Stremio.addAddon(v);
|
|
495
|
-
U.toast('Addon added');
|
|
496
|
-
App.back();
|
|
497
|
-
renderSettings();
|
|
565
|
+
var err = cfg.validate ? cfg.validate(v) : null;
|
|
566
|
+
if (err) { U.toast(err); return; }
|
|
567
|
+
cfg.onConfirm(v);
|
|
498
568
|
}
|
|
499
569
|
}
|
|
500
570
|
|
|
@@ -531,22 +601,50 @@ var Views = (function () {
|
|
|
531
601
|
return row;
|
|
532
602
|
}
|
|
533
603
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
604
|
+
// ================= SETTINGS (sectioned) =================
|
|
605
|
+
var _settingsSection = 'menu';
|
|
606
|
+
|
|
607
|
+
function settingsRoot() { _settingsSection = 'menu'; renderSettings(); }
|
|
608
|
+
|
|
609
|
+
// Return true if BACK stepped a settings section back to the menu.
|
|
610
|
+
function backInSettings() {
|
|
611
|
+
if (_settingsSection !== 'menu') { _settingsSection = 'menu'; renderSettings(); return true; }
|
|
612
|
+
return false;
|
|
539
613
|
}
|
|
540
614
|
|
|
541
|
-
// ================= SETTINGS =================
|
|
542
615
|
function renderSettings() {
|
|
543
616
|
var s = scr('settings');
|
|
544
617
|
U.clear(s);
|
|
545
|
-
s.appendChild(header(false));
|
|
618
|
+
s.appendChild(header(false, false));
|
|
546
619
|
var wrap = U.el('div', 'settings-wrap');
|
|
547
|
-
|
|
548
|
-
|
|
620
|
+
s.appendChild(wrap);
|
|
621
|
+
if (_settingsSection === 'addons') { settingsAddons(wrap); }
|
|
622
|
+
else if (_settingsSection === 'integrations') { settingsIntegrations(wrap); }
|
|
623
|
+
else if (_settingsSection === 'subtitles') { settingsSubtitles(wrap); }
|
|
624
|
+
else if (_settingsSection === 'display') { settingsDisplay(wrap); }
|
|
625
|
+
else { settingsMenu(wrap); }
|
|
626
|
+
}
|
|
549
627
|
|
|
628
|
+
function sectionRow(title, sub, section) {
|
|
629
|
+
var r = U.el('div', 'addon-item focusable');
|
|
630
|
+
r.appendChild(U.el('div', 'a-name', title));
|
|
631
|
+
if (sub) { r.appendChild(U.el('div', 'a-url', sub)); }
|
|
632
|
+
r.__onselect = function () { _settingsSection = section; renderSettings(); };
|
|
633
|
+
return r;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function settingsMenu(wrap) {
|
|
637
|
+
wrap.appendChild(U.el('h2', null, 'Settings'));
|
|
638
|
+
wrap.appendChild(sectionRow('Addons', 'Catalog, stream & subtitle addons', 'addons'));
|
|
639
|
+
wrap.appendChild(sectionRow('Integrations', 'API keys (TMDB, MDBList)', 'integrations'));
|
|
640
|
+
wrap.appendChild(sectionRow('Subtitle Options', 'Default language, size, style', 'subtitles'));
|
|
641
|
+
wrap.appendChild(sectionRow('Display', 'Poster orientation', 'display'));
|
|
642
|
+
wrap.appendChild(U.el('div', 'hint', 'Press RETURN to go back.'));
|
|
643
|
+
Focus.setScope(scr('settings'));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function settingsAddons(wrap) {
|
|
647
|
+
wrap.appendChild(U.el('h2', null, 'Addons'));
|
|
550
648
|
Stremio.loadAddons().then(function (addons) {
|
|
551
649
|
var urls = Stremio.getAddonUrls();
|
|
552
650
|
urls.forEach(function (url) {
|
|
@@ -565,7 +663,6 @@ var Views = (function () {
|
|
|
565
663
|
wrap.appendChild(item);
|
|
566
664
|
});
|
|
567
665
|
|
|
568
|
-
// Reset to defaults
|
|
569
666
|
var reset = U.el('div', 'addon-item focusable reset-item');
|
|
570
667
|
reset.appendChild(U.el('div', 'a-name', '↺ Reset to default addons'));
|
|
571
668
|
reset.appendChild(U.el('div', 'a-url', 'Removes all addons and restores Cinemeta, WatchHub, OpenSubtitles v3'));
|
|
@@ -578,23 +675,83 @@ var Views = (function () {
|
|
|
578
675
|
};
|
|
579
676
|
wrap.appendChild(reset);
|
|
580
677
|
|
|
581
|
-
// Add-addon (opens a dedicated page — keeps the field clear of the keyboard)
|
|
582
678
|
var addRow = U.el('div', 'addon-item focusable add-row');
|
|
583
679
|
addRow.appendChild(U.el('div', 'a-name', '+ Add addon'));
|
|
584
680
|
addRow.appendChild(U.el('div', 'a-url', 'Enter a Stremio addon manifest URL on its own page'));
|
|
585
|
-
addRow.__onselect = function () {
|
|
681
|
+
addRow.__onselect = function () {
|
|
682
|
+
App.openEntry({
|
|
683
|
+
title: 'Add addon',
|
|
684
|
+
hint: 'Type a Stremio addon manifest URL, then Confirm. RETURN cancels — nothing is saved unless you confirm.',
|
|
685
|
+
value: 'https://', placeholder: 'https://addon.example/manifest.json',
|
|
686
|
+
validate: function (v) { return (!v || v === 'https://' || v.indexOf('://') === -1 || v.length < 12) ? 'Enter a full manifest URL' : null; },
|
|
687
|
+
onConfirm: function (v) { Stremio.addAddon(v); U.toast('Addon added'); App.back(); renderSettings(); }
|
|
688
|
+
});
|
|
689
|
+
};
|
|
586
690
|
wrap.appendChild(addRow);
|
|
587
691
|
|
|
588
692
|
wrap.appendChild(U.el('div', 'hint',
|
|
589
|
-
'
|
|
590
|
-
|
|
591
|
-
Focus.setScope(s);
|
|
693
|
+
'Nuvio hosts no content — add Stremio addon manifest URLs for catalogs, streams and subtitles. RETURN goes back.'));
|
|
694
|
+
Focus.setScope(scr('settings'));
|
|
592
695
|
});
|
|
593
696
|
}
|
|
594
697
|
|
|
698
|
+
function maskKey(v) { v = '' + v; return v.length <= 4 ? '••••' : ('••••' + v.slice(-4)); }
|
|
699
|
+
|
|
700
|
+
function keyRow(title, key, placeholder) {
|
|
701
|
+
var val = Stremio.getIntegrations()[key] || '';
|
|
702
|
+
var r = U.el('div', 'addon-item focusable');
|
|
703
|
+
r.appendChild(U.el('div', 'a-name', title));
|
|
704
|
+
r.appendChild(U.el('div', 'a-url', val ? ('Set — ' + maskKey(val)) : 'Not set — press OK to enter'));
|
|
705
|
+
r.__onselect = function () {
|
|
706
|
+
App.openEntry({
|
|
707
|
+
title: title,
|
|
708
|
+
hint: 'Paste your key, then Confirm. Leave blank and Confirm to clear it.',
|
|
709
|
+
value: val, placeholder: placeholder,
|
|
710
|
+
onConfirm: function (v) { Stremio.setIntegration(key, v); U.toast(v ? 'Saved' : 'Cleared'); App.back(); renderSettings(); }
|
|
711
|
+
});
|
|
712
|
+
};
|
|
713
|
+
return r;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function settingsIntegrations(wrap) {
|
|
717
|
+
wrap.appendChild(U.el('h2', null, 'Integrations'));
|
|
718
|
+
wrap.appendChild(keyRow('TMDB API key (v3)', 'tmdb', 'TMDB v3 API key'));
|
|
719
|
+
wrap.appendChild(keyRow('MDBList API key', 'mdblist', 'MDBList API key'));
|
|
720
|
+
wrap.appendChild(U.el('div', 'hint', 'Keys are stored on this TV only. RETURN goes back.'));
|
|
721
|
+
Focus.setScope(scr('settings'));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function settingsSubtitles(wrap) {
|
|
725
|
+
wrap.appendChild(U.el('h2', null, 'Subtitle Options'));
|
|
726
|
+
wrap.appendChild(subCycleRow('Default language', SUB_LANGS, 'lang'));
|
|
727
|
+
wrap.appendChild(subCycleRow('Size', SUB_SIZES, 'size'));
|
|
728
|
+
wrap.appendChild(subCycleRow('Style', SUB_STYLES, 'style'));
|
|
729
|
+
wrap.appendChild(U.el('div', 'hint', 'RETURN goes back.'));
|
|
730
|
+
Focus.setScope(scr('settings'));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
var POSTER_SHAPES = [['portrait', 'Portrait'], ['landscape', 'Landscape']];
|
|
734
|
+
function settingsDisplay(wrap) {
|
|
735
|
+
wrap.appendChild(U.el('h2', null, 'Display'));
|
|
736
|
+
var row = U.el('div', 'addon-item focusable cfg-row');
|
|
737
|
+
var name = U.el('div', 'a-name');
|
|
738
|
+
function render() { name.textContent = 'Poster orientation: ' + labelFor(POSTER_SHAPES, Stremio.getPrefs().posterShape); }
|
|
739
|
+
render();
|
|
740
|
+
row.appendChild(name);
|
|
741
|
+
row.appendChild(U.el('div', 'a-url', 'Press OK to change'));
|
|
742
|
+
row.__onselect = function () {
|
|
743
|
+
Stremio.setPref('posterShape', nextVal(POSTER_SHAPES, Stremio.getPrefs().posterShape));
|
|
744
|
+
render();
|
|
745
|
+
};
|
|
746
|
+
wrap.appendChild(row);
|
|
747
|
+
wrap.appendChild(U.el('div', 'hint', 'Landscape uses wide artwork when available (falls back to the poster). RETURN goes back.'));
|
|
748
|
+
Focus.setScope(scr('settings'));
|
|
749
|
+
}
|
|
750
|
+
|
|
595
751
|
return {
|
|
596
752
|
renderHome: renderHome, renderDetail: renderDetail, renderSettings: renderSettings,
|
|
597
|
-
|
|
753
|
+
settingsRoot: settingsRoot, backInSettings: backInSettings,
|
|
754
|
+
renderSearch: renderSearch, renderEntry: renderEntry, backInDetail: backInDetail,
|
|
598
755
|
isModalOpen: function () { return _modalOpen; }, closeModal: closeModal
|
|
599
756
|
};
|
|
600
757
|
})();
|