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 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 34px 0; }
40
- .row-title { font-size: 26px; font-weight: bold; margin: 0 0 12px 60px; color: #c7cbd4; }
41
- .row-scroller { white-space: nowrap; padding: 0 60px; overflow: hidden; }
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 &nbsp; &larr;/&rarr;: Seek &nbsp; RETURN: Back</div>
32
32
  </div>
33
33
 
34
- <!-- Subtitle track picker (opened with UP during playback) -->
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
- <script src="js/polyfills.js"></script>
42
- <script src="js/util.js"></script>
43
- <script src="js/stremio.js"></script>
44
- <script src="js/keys.js"></script>
45
- <script src="js/focus.js"></script>
46
- <script src="js/player.js"></script>
47
- <script src="js/views.js"></script>
48
- <script src="js/app.js"></script>
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.renderSettings();
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 openAddAddon() {
38
+ function openEntry(cfg) {
39
39
  pushFrame();
40
40
  showScreen('addaddon');
41
- Views.renderAddAddon();
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
- openAddAddon: openAddAddon
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 (_handler) {
43
- var handled = _handler(action, ev);
44
- if (handled !== false) {
45
- if (ev.preventDefault) { ev.preventDefault(); }
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
- else { U.toast(_subTracks.length + ' subtitle track(s) — press UP', 3000); }
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 RETURN: Back' : 'OK: Play/Pause ←/→: Seek RETURN: Back';
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; renderOSD(); renderSubs(); },
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; renderOSD(); renderSubs(); };
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) {} renderOSD(); renderSubs(); }
274
+ if (useAV) { try { _cur = avplay.getCurrentTime() / 1000; } catch (e) {} onProgress(); }
235
275
  saveProgress();
236
276
  }, 5000);
237
277
  }
238
278
 
239
- function onEnded() { saveProgress(); exit(); }
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 (useAV) {
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
- if (_ctx.streams && _ctx.streams.length) {
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: 'Restart from beginning', run: function () { seekTo(0); closeMenu(); } });
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': case 'PLAYPAUSE': togglePlay(); return true;
538
- case 'PLAY': if (useAV) { try { avplay.play(); } catch (e) {} } else { videoEl.play(); } return true;
539
- case 'PAUSE': if (useAV) { try { avplay.pause(); } catch (e) {} } else { videoEl.pause(); } return true;
540
- case 'RIGHT': case 'FF': seekBy(action === 'FF' ? 30 : 10); return true;
541
- case 'LEFT': case 'REW': seekBy(action === 'REW' ? -30 : -10); return true;
542
- case 'UP': openMenu(); return true;
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 c = U.el('div', 'card focusable');
89
- var poster = meta.poster || '';
90
- if (poster) {
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 = poster;
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 && meta.videos) {
233
- var tv = meta.videos.find(function (v) { return v.id === _d.target; });
234
- if (tv) { _d.season = (tv.season === undefined ? 1 : tv.season); }
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 e = U.el('div', 'ep focusable');
290
- e.appendChild(document.createTextNode('E' + (v.episode || '?') + ' ' + (v.title || v.name || '')));
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
- var sources = _d.streams.filter(Stremio.isPlayable).map(function (st) {
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
- // ================= ADD ADDON (dedicated page) =================
451
- function renderAddAddon() {
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, 'Add addon'));
458
- wrap.appendChild(U.el('div', 'add-hint', 'Type a Stremio addon manifest URL, then choose Confirm. RETURN cancels — nothing is saved unless you confirm.'));
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 = 'addaddon-input';
536
+ input.id = 'entry-input';
462
537
  input.type = 'text';
463
538
  input.className = 'focusable add-input';
464
- input.value = 'https://';
465
- input.placeholder = 'https://addon.example/manifest.json';
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
- if (!v || v === 'https://' || v.indexOf('://') === -1 || v.length < 12) {
491
- U.toast('Enter a full manifest URL');
492
- return;
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
- function buildSubtitleSection(wrap) {
535
- wrap.appendChild(U.el('h2', null, 'Subtitles'));
536
- wrap.appendChild(subCycleRow('Default language', SUB_LANGS, 'lang'));
537
- wrap.appendChild(subCycleRow('Size', SUB_SIZES, 'size'));
538
- wrap.appendChild(subCycleRow('Style', SUB_STYLES, 'style'));
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
- buildSubtitleSection(wrap);
548
- wrap.appendChild(U.el('h2', null, 'Addons'));
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 () { App.openAddAddon(); };
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
- 'Select an addon to remove it. Nuvio hosts no content — catalogs & metadata come from Cinemeta; add your own Stremio stream addon URLs above to get playable sources. Press RETURN to go back.'));
590
- s.appendChild(wrap);
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
- renderSearch: renderSearch, renderAddAddon: renderAddAddon, backInDetail: backInDetail,
753
+ settingsRoot: settingsRoot, backInSettings: backInSettings,
754
+ renderSearch: renderSearch, renderEntry: renderEntry, backInDetail: backInDetail,
598
755
  isModalOpen: function () { return _modalOpen; }, closeModal: closeModal
599
756
  };
600
757
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuvio-tizen",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "description": "Nuvio-style Stremio-addon streaming client for Tizen 3.0+ Samsung TVs (TizenBrew app module).",
5
5
  "packageType": "app",
6
6
  "appName": "Nuvio",