nuvio-tizen 1.2.0 → 1.4.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
@@ -15,15 +15,28 @@ core behaviour, targeting 2017 Samsung TVs (Tizen 3.0, ~Chromium 47).
15
15
  **Cinemeta** (catalogs + metadata), **WatchHub** (streams), **OpenSubtitles v3** (subtitles).
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
- - Detail pages: movies (Play) and series (season/episode browser).
19
- - **Stream aggregation** across all installed stream addons.
18
+ - Detail pages: movies (Play) and series (season/episode browser). **Continue Watching**
19
+ resumes a series on the exact season + episode you left off.
20
+ - **Stream aggregation** across all installed stream addons, each source showing
21
+ **size + languages/description** (from `behaviorHints.videoSize` and the addon's own text,
22
+ e.g. MediaFusion / Torrentio).
23
+ - **In-player menu** (press **UP**): *Select source* (switch stream mid-playback, keeping
24
+ your position) and *Configure subtitles* → Language / Size / Style.
20
25
  - **Subtitles from addons**: fetched per title/episode, SRT + VTT parsed and rendered as a
21
26
  timed overlay; press **UP** during playback to pick a track (or turn them Off).
27
+ Configurable in Settings: **default language** (auto-loads a matching track),
28
+ **size** (S/M/L/XL) and **style** (white+shadow / black box / yellow).
29
+ - **Live streams** (sports / IPTV): live HLS and Stremio `tv`-type sources are detected
30
+ (unknown/infinite duration), shown with a **● LIVE** badge, and correctly skip
31
+ seek / resume / progress-tracking. Stream-only live addons with no `meta` resource still
32
+ open (the catalog entry is used as a fallback).
22
33
  - Playback via **AVPlay** (HLS/DASH/MP4/progressive) with an HTML5 `<video>` fallback
23
34
  when run in a desktop browser for development.
24
35
  - OSD with seek (±10s / ±30s), resume, and per-title progress saved to `localStorage`.
25
36
  - Full **D-pad + media-key** remote navigation (custom spatial focus engine).
26
- - Settings screen to add/remove addon manifest URLs (the add field is prefilled with `https://`).
37
+ - Settings: add addons on a **dedicated page** (field kept above the on-screen keyboard;
38
+ nothing is saved unless you press Confirm), remove with a confirmation prompt, and
39
+ **Reset to default addons**.
27
40
 
28
41
  > **Note on WatchHub:** it's a "where to watch" addon — its results are Rent/Buy/Subscription
29
42
  > *links to external services*, not direct video URLs, so they show as non-playable. Add your
package/css/style.css CHANGED
@@ -89,6 +89,16 @@ html, body {
89
89
  .addon-item .a-url { font-size: 15px; color: #8b93a5; }
90
90
  .hint { color: #8b93a5; font-size: 18px; margin-top: 20px; line-height: 1.5; }
91
91
 
92
+ /* --- Add-addon page (field kept high, above the on-screen keyboard) --- */
93
+ .add-wrap { position: absolute; top: 120px; left: 60px; right: 60px; }
94
+ .add-wrap h2 { font-size: 36px; margin-bottom: 14px; }
95
+ .add-hint { font-size: 22px; color: #8b93a5; margin-bottom: 28px; line-height: 1.4; max-width: 1200px; }
96
+ .add-input { width: 100%; padding: 20px 24px; font-size: 32px; background: #0e1117; color: #fff; border: 4px solid #2a3346; border-radius: 12px; }
97
+ .add-input.focused { border-color: #818cf8; box-shadow: 0 0 0 2px rgba(129,140,248,0.5); }
98
+ .add-btn-row { margin-top: 34px; }
99
+ .add-btn-row .pill { min-width: 220px; text-align: center; }
100
+ .cfg-row .a-url { color: #6f7788; }
101
+
92
102
  /* --- Player OSD --- */
93
103
  #player-osd { position: absolute; left: 0; right: 0; bottom: 0; height: 220px; z-index: 20; padding: 40px 60px; background: linear-gradient(transparent, rgba(0,0,0,0.9)); }
94
104
  #osd-title { font-size: 30px; font-weight: bold; margin-bottom: 20px; }
@@ -96,6 +106,7 @@ html, body {
96
106
  #osd-buffer { position: absolute; top: 0; left: 0; height: 8px; background: rgba(255,255,255,0.35); border-radius: 4px; width: 0; }
97
107
  #osd-progress { position: absolute; top: 0; left: 0; height: 8px; background: #818cf8; border-radius: 4px; width: 0; z-index: 2; }
98
108
  #osd-times { display: flex; justify-content: space-between; margin-top: 10px; font-size: 20px; color: #cfd3dc; }
109
+ #osd-cur.live { color: #ff4d4d; font-weight: bold; letter-spacing: 1px; }
99
110
  #osd-hint { margin-top: 14px; font-size: 18px; color: #8b93a5; }
100
111
 
101
112
  /* --- Subtitles --- */
package/index.html CHANGED
@@ -17,6 +17,7 @@
17
17
  <div id="screen-detail" class="screen hidden"></div>
18
18
  <div id="screen-search" class="screen hidden"></div>
19
19
  <div id="screen-settings" class="screen hidden"></div>
20
+ <div id="screen-addaddon" class="screen hidden"></div>
20
21
  </div>
21
22
 
22
23
  <!-- Subtitle overlay (over the video plane, under the OSD) -->
package/js/app.js CHANGED
@@ -8,7 +8,7 @@ var App = (function () {
8
8
  function screenEl(name) { return U.byId('screen-' + name); }
9
9
 
10
10
  function showScreen(name) {
11
- ['home', 'detail', 'search', 'settings'].forEach(function (n) {
11
+ ['home', 'detail', 'search', 'settings', 'addaddon'].forEach(function (n) {
12
12
  screenEl(n).className = 'screen' + (n === name ? '' : ' hidden');
13
13
  });
14
14
  _current = name;
@@ -16,11 +16,11 @@ var App = (function () {
16
16
 
17
17
  function pushFrame() { _stack.push({ screen: _current, focus: Focus.current() }); }
18
18
 
19
- function openDetail(type, id) {
19
+ function openDetail(type, id, preview, target) {
20
20
  if (!id) { return; }
21
21
  pushFrame();
22
22
  showScreen('detail');
23
- Views.renderDetail(type, id);
23
+ Views.renderDetail(type, id, preview, target);
24
24
  }
25
25
 
26
26
  function openSettings() {
@@ -35,6 +35,12 @@ var App = (function () {
35
35
  Views.renderSearch();
36
36
  }
37
37
 
38
+ function openAddAddon() {
39
+ pushFrame();
40
+ showScreen('addaddon');
41
+ Views.renderAddAddon();
42
+ }
43
+
38
44
  function back() {
39
45
  if (Player.isActive()) { Player.exit(); return; }
40
46
  if (Views.isModalOpen()) { Views.closeModal(); return; }
@@ -61,7 +67,12 @@ var App = (function () {
61
67
  case 'LEFT': Focus.move('LEFT'); return true;
62
68
  case 'RIGHT': Focus.move('RIGHT'); return true;
63
69
  case 'UP': Focus.move('UP'); return true;
64
- case 'DOWN': Focus.move('DOWN'); return true;
70
+ case 'DOWN': {
71
+ var c = Focus.current();
72
+ if (c && c.__navDOWN && c.__navDOWN() !== false) { return true; }
73
+ Focus.move('DOWN');
74
+ return true;
75
+ }
65
76
  case 'OK': Focus.clickCurrent(); return true;
66
77
  case 'BACK': back(); return true;
67
78
  default: return true;
@@ -78,7 +89,8 @@ var App = (function () {
78
89
 
79
90
  return {
80
91
  start: start, back: back,
81
- openDetail: openDetail, openSettings: openSettings, openSearch: openSearch
92
+ openDetail: openDetail, openSettings: openSettings, openSearch: openSearch,
93
+ openAddAddon: openAddAddon
82
94
  };
83
95
  })();
84
96
 
package/js/player.js CHANGED
@@ -4,6 +4,26 @@ var Player = (function () {
4
4
  'use strict';
5
5
 
6
6
  var PROGRESS_KEY = 'nuvio.progress';
7
+ var SUBCFG_KEY = 'nuvio.subcfg';
8
+ var SIZE_PX = { small: 28, medium: 36, large: 46, xlarge: 58 };
9
+
10
+ function getSubCfg() {
11
+ var d = U.store.get(SUBCFG_KEY, {}) || {};
12
+ return { lang: d.lang || 'off', size: d.size || 'medium', style: d.style || 'shadow' };
13
+ }
14
+ function setSubCfg(cfg) { U.store.set(SUBCFG_KEY, cfg); }
15
+
16
+ function styleSpan(span) {
17
+ var cfg = getSubCfg();
18
+ span.style.fontSize = (SIZE_PX[cfg.size] || 36) + 'px';
19
+ if (cfg.style === 'box') {
20
+ span.style.background = 'rgba(0,0,0,0.9)'; span.style.color = '#fff'; span.style.textShadow = 'none';
21
+ } else if (cfg.style === 'yellow') {
22
+ span.style.background = 'rgba(0,0,0,0.4)'; span.style.color = '#ffe14d'; span.style.textShadow = '0 2px 3px #000';
23
+ } else { // shadow (default)
24
+ span.style.background = 'rgba(0,0,0,0.55)'; span.style.color = '#fff'; span.style.textShadow = '0 2px 3px #000';
25
+ }
26
+ }
7
27
 
8
28
  var avplay = (window.webapis && webapis.avplay) ? webapis.avplay : null;
9
29
  var useAV = !!avplay;
@@ -18,6 +38,7 @@ var Player = (function () {
18
38
  var _ctx = null; // { id, type, name, poster, resumeSec }
19
39
  var _dur = 0; // seconds
20
40
  var _cur = 0; // seconds
41
+ var _isLive = false; // live stream (sports/IPTV): no fixed duration
21
42
  var _ticker = null;
22
43
  var _osdTimer = null;
23
44
  var _seekPending = 0;
@@ -44,7 +65,7 @@ var Player = (function () {
44
65
  function progressKey(ctx) { return ctx.type + ':' + ctx.id; }
45
66
 
46
67
  function saveProgress() {
47
- if (!_ctx || !_dur) { return; }
68
+ if (!_ctx || !_dur || _isLive) { return; }
48
69
  var all = progressAll();
49
70
  var k = progressKey(_ctx);
50
71
  if (_cur / _dur > 0.95 || _cur < 5) {
@@ -79,6 +100,16 @@ var Player = (function () {
79
100
 
80
101
  function renderOSD() {
81
102
  U.byId('osd-title').textContent = _ctx ? _ctx.name : '';
103
+ if (_isLive) {
104
+ U.byId('osd-bar').style.display = 'none';
105
+ var cur = U.byId('osd-cur');
106
+ cur.textContent = '● LIVE';
107
+ cur.className = 'live';
108
+ U.byId('osd-dur').textContent = '';
109
+ return;
110
+ }
111
+ U.byId('osd-bar').style.display = '';
112
+ U.byId('osd-cur').className = '';
82
113
  var pct = _dur > 0 ? (_cur / _dur * 100) : 0;
83
114
  U.byId('osd-progress').style.width = pct + '%';
84
115
  U.byId('osd-cur').textContent = U.fmtTime(_cur);
@@ -89,6 +120,8 @@ var Player = (function () {
89
120
  function play(ctx, onExit) {
90
121
  _ctx = ctx; _onExit = onExit;
91
122
  _active = true; _cur = 0; _dur = 0;
123
+ // Stremio "tv" type is a strong live hint; refined from duration after prepare.
124
+ _isLive = (ctx.type === 'tv') || !!ctx.isLive;
92
125
  _ctx.resumeSec = ctx.resumeSec || getResume(ctx.type, ctx.id);
93
126
 
94
127
  // reset subtitle state
@@ -110,17 +143,33 @@ var Player = (function () {
110
143
  if (!_active) { return; }
111
144
  _subTracks = tracks || [];
112
145
  updateHint();
113
- if (_subTracks.length) { U.toast(_subTracks.length + ' subtitle track(s) — press UP', 3000); }
146
+ if (!_subTracks.length) { return; }
147
+ var cfg = getSubCfg();
148
+ var match = null;
149
+ if (cfg.lang && cfg.lang !== 'off') {
150
+ match = _subTracks.find(function (t) {
151
+ var l = ('' + (t.lang || '')).toLowerCase();
152
+ return l && (l.indexOf(cfg.lang) === 0 || cfg.lang.indexOf(l) === 0);
153
+ });
154
+ }
155
+ if (match) { loadTrack(match); }
156
+ else { U.toast(_subTracks.length + ' subtitle track(s) — press UP', 3000); }
114
157
  }, function () {});
115
158
  }
116
159
 
117
160
  function updateHint() {
118
- var base = 'OK: Play/Pause ←/→: Seek RETURN: Back';
119
- if (_subTracks.length) { base += ' UP: Subtitles'; }
161
+ var base = _isLive ? 'OK: Play/Pause RETURN: Back' : 'OK: Play/Pause ←/→: Seek RETURN: Back';
162
+ base += ' UP: Menu';
120
163
  var h = U.byId('osd-hint');
121
164
  if (h) { h.textContent = base; }
122
165
  }
123
166
 
167
+ // Decides live vs VOD from the reported duration (0 / non-finite = live).
168
+ function markLiveFromDuration() {
169
+ if (_ctx && _ctx.type === 'tv') { _isLive = true; }
170
+ if (!_dur || _dur <= 0 || !isFinite(_dur)) { _isLive = true; }
171
+ }
172
+
124
173
  function startAV(url) {
125
174
  objEl.className = 'active';
126
175
  try {
@@ -141,7 +190,9 @@ var Player = (function () {
141
190
  try { avplay.setDisplayRect(0, 0, 1920, 1080); } catch (e1) {}
142
191
  try { avplay.setDisplayMethod('PLAYER_DISPLAY_MODE_LETTER_BOX'); } catch (e2) {}
143
192
  try { _dur = avplay.getDuration() / 1000; } catch (e3) { _dur = 0; }
144
- if (_ctx.resumeSec > 5) { try { avplay.seekTo(_ctx.resumeSec * 1000); } catch (e4) {} }
193
+ markLiveFromDuration();
194
+ updateHint();
195
+ if (!_isLive && _ctx.resumeSec > 5) { try { avplay.seekTo(_ctx.resumeSec * 1000); } catch (e4) {} }
145
196
  avplay.play();
146
197
  renderOSD();
147
198
  startTicker();
@@ -159,7 +210,9 @@ var Player = (function () {
159
210
  videoEl.src = url;
160
211
  videoEl.onloadedmetadata = function () {
161
212
  _dur = videoEl.duration || 0;
162
- if (_ctx.resumeSec > 5) { try { videoEl.currentTime = _ctx.resumeSec; } catch (e) {} }
213
+ markLiveFromDuration();
214
+ updateHint();
215
+ if (!_isLive && _ctx.resumeSec > 5) { try { videoEl.currentTime = _ctx.resumeSec; } catch (e) {} }
163
216
  U.spinner(false); showOSD(false);
164
217
  };
165
218
  videoEl.ontimeupdate = function () { _cur = videoEl.currentTime; renderOSD(); renderSubs(); };
@@ -197,6 +250,7 @@ var Player = (function () {
197
250
  }
198
251
 
199
252
  function seekBy(delta) {
253
+ if (_isLive) { showOSD(false); U.toast('Live — seeking unavailable', 1500); return; }
200
254
  var target = Math.max(0, Math.min((_dur || 1e9), _cur + delta));
201
255
  _cur = target;
202
256
  renderOSD();
@@ -243,7 +297,7 @@ var Player = (function () {
243
297
  if (show) {
244
298
  subsEl.className = '';
245
299
  var span = subsEl.firstChild;
246
- if (!span) { span = document.createElement('span'); subsEl.appendChild(span); }
300
+ if (!span) { span = document.createElement('span'); subsEl.appendChild(span); styleSpan(span); }
247
301
  if (span.textContent !== show) { span.textContent = show; }
248
302
  } else {
249
303
  subsEl.className = 'hidden';
@@ -266,55 +320,119 @@ var Player = (function () {
266
320
  subsEl.className = 'hidden'; U.clear(subsEl);
267
321
  }
268
322
 
269
- // ---------- subtitle menu ----------
270
- function openSubMenu() {
271
- if (!_subTracks.length) { return; }
323
+ function applyStyleNow() {
324
+ var span = subsEl.firstChild;
325
+ if (span) { styleSpan(span); }
326
+ }
327
+
328
+ // Switch to a different source mid-playback, keeping position (VOD).
329
+ function switchSource(s) {
330
+ if (!s || !s.url) { return; }
331
+ _ctx.url = s.url;
332
+ _ctx.resumeSec = _isLive ? 0 : _cur;
333
+ U.toast('Switching source…', 1500);
334
+ U.spinner(true);
335
+ if (useAV) { startAV(s.url); } else { startHTML5(s.url); }
336
+ }
337
+
338
+ // ---------- in-player menu (stack of levels) ----------
339
+ var _menuStack = [];
340
+
341
+ function topMenu() { return _menuStack[_menuStack.length - 1]; }
342
+
343
+ function openMenu() {
272
344
  _menuOpen = true;
273
- // index 0 = Off; tracks start at 1
274
- _menuIdx = 0;
275
- renderSubMenu();
345
+ _menuStack = [rootMenu()];
346
+ renderMenu();
276
347
  subMenuEl.className = '';
277
348
  showOSD(true);
278
349
  }
279
-
280
- function closeSubMenu() {
281
- _menuOpen = false;
350
+ function closeMenu() {
351
+ _menuOpen = false; _menuStack = [];
282
352
  subMenuEl.className = 'hidden';
283
353
  showOSD(false);
284
354
  }
355
+ function pushMenu(desc) { _menuStack.push(desc); renderMenu(); }
356
+ function popMenu() { _menuStack.pop(); if (!_menuStack.length) { closeMenu(); } else { renderMenu(); } }
357
+ function replaceTop(desc) { _menuStack[_menuStack.length - 1] = desc; renderMenu(); }
285
358
 
286
- function renderSubMenu() {
359
+ function renderMenu() {
360
+ var m = topMenu();
287
361
  U.clear(subMenuEl);
288
- subMenuEl.appendChild(U.el('div', 'sm-title', 'Subtitles'));
362
+ subMenuEl.appendChild(U.el('div', 'sm-title', m.title));
289
363
  var list = U.el('div', 'sm-list');
290
- var items = [{ label: 'Off' + (_subOn ? '' : ' ✓'), track: null }];
291
- _subTracks.forEach(function (tr) {
292
- var mark = (_subOn && _subLabel === (tr.lang || 'on')) ? ' ✓' : '';
293
- items.push({ label: (tr.lang || 'unknown') + ' · ' + (tr._addon || '') + mark, track: tr });
294
- });
295
- _menuItems = items;
296
- items.forEach(function (it, i) {
297
- var node = U.el('div', 'sm-item' + (i === _menuIdx ? ' sel' : ''), it.label);
298
- list.appendChild(node);
364
+ m.items.forEach(function (it, i) {
365
+ var label = it.label + (it.sel ? ' ✓' : '');
366
+ list.appendChild(U.el('div', 'sm-item' + (i === m.idx ? ' sel' : ''), label));
299
367
  });
300
368
  subMenuEl.appendChild(list);
301
- // keep selected item visible
302
- var sel = list.children[_menuIdx];
369
+ var sel = list.children[m.idx];
303
370
  if (sel && sel.scrollIntoView) { try { sel.scrollIntoView(false); } catch (e) {} }
304
371
  }
305
372
 
306
- var _menuItems = [];
373
+ function rootMenu() {
374
+ var items = [];
375
+ if (_ctx.streams && _ctx.streams.length) {
376
+ items.push({ label: 'Select source', run: function () { pushMenu(sourceMenu()); } });
377
+ }
378
+ items.push({ label: 'Configure subtitles', run: function () { pushMenu(subConfigMenu()); } });
379
+ return { title: 'Menu', items: items, idx: 0 };
380
+ }
381
+
382
+ function sourceMenu() {
383
+ var items = _ctx.streams.map(function (s) {
384
+ return { label: s.label || 'Source', sel: (s.url === _ctx.url),
385
+ run: function () { switchSource(s); closeMenu(); } };
386
+ });
387
+ return { title: 'Select source', items: items, idx: 0 };
388
+ }
389
+
390
+ function subConfigMenu() {
391
+ return { title: 'Configure subtitles', idx: 0, items: [
392
+ { label: 'Language', run: function () { pushMenu(langMenu()); } },
393
+ { label: 'Size', run: function () { pushMenu(sizeMenu()); } },
394
+ { label: 'Style', run: function () { pushMenu(styleMenu()); } }
395
+ ] };
396
+ }
397
+
398
+ function langMenu() {
399
+ var items = [{ label: 'Off', sel: !_subOn, run: function () { subsOff(); closeMenu(); } }];
400
+ _subTracks.forEach(function (tr) {
401
+ items.push({ label: (tr.lang || 'unknown') + ' · ' + (tr._addon || ''),
402
+ sel: (_subOn && _subLabel === (tr.lang || 'on')),
403
+ run: function () { loadTrack(tr); closeMenu(); } });
404
+ });
405
+ return { title: 'Subtitle language', items: items, idx: 0 };
406
+ }
407
+
408
+ function optionMenu(title, key, opts) {
409
+ var cfg = getSubCfg();
410
+ var idx = 0;
411
+ var items = opts.map(function (p, i) {
412
+ if (cfg[key] === p[0]) { idx = i; }
413
+ return { label: p[1], sel: (cfg[key] === p[0]), run: function () {
414
+ var c = getSubCfg(); c[key] = p[0]; setSubCfg(c); applyStyleNow();
415
+ replaceTop(optionMenu(title, key, opts)); // refresh checkmark
416
+ } };
417
+ });
418
+ return { title: title, items: items, idx: idx };
419
+ }
420
+ function sizeMenu() {
421
+ return optionMenu('Subtitle size', 'size',
422
+ [['small', 'Small'], ['medium', 'Medium'], ['large', 'Large'], ['xlarge', 'Extra Large']]);
423
+ }
424
+ function styleMenu() {
425
+ return optionMenu('Subtitle style', 'style',
426
+ [['shadow', 'White + shadow'], ['box', 'White on black box'], ['yellow', 'Yellow']]);
427
+ }
307
428
 
308
429
  function menuKey(action) {
309
- if (action === 'UP') { _menuIdx = Math.max(0, _menuIdx - 1); renderSubMenu(); return; }
310
- if (action === 'DOWN') { _menuIdx = Math.min(_menuItems.length - 1, _menuIdx + 1); renderSubMenu(); return; }
311
- if (action === 'OK') {
312
- var it = _menuItems[_menuIdx];
313
- if (!it || it.track === null) { subsOff(); } else { loadTrack(it.track); }
314
- closeSubMenu();
315
- return;
316
- }
317
- if (action === 'BACK' || action === 'STOP') { closeSubMenu(); return; }
430
+ var m = topMenu();
431
+ if (action === 'UP') { m.idx = Math.max(0, m.idx - 1); renderMenu(); return; }
432
+ if (action === 'DOWN') { m.idx = Math.min(m.items.length - 1, m.idx + 1); renderMenu(); return; }
433
+ if (action === 'OK') { var it = m.items[m.idx]; if (it && it.run) { it.run(); } return; }
434
+ if (action === 'LEFT' || action === 'BACK') { popMenu(); return; }
435
+ if (action === 'STOP') { closeMenu(); return; }
318
436
  }
319
437
 
320
438
  function handleKey(action) {
@@ -326,7 +444,7 @@ var Player = (function () {
326
444
  case 'PAUSE': if (useAV) { try { avplay.pause(); } catch (e) {} } else { videoEl.pause(); } return true;
327
445
  case 'RIGHT': case 'FF': seekBy(action === 'FF' ? 30 : 10); return true;
328
446
  case 'LEFT': case 'REW': seekBy(action === 'REW' ? -30 : -10); return true;
329
- case 'UP': if (_subTracks.length) { openSubMenu(); } return true;
447
+ case 'UP': openMenu(); return true;
330
448
  case 'DOWN': showOSD(false); return true;
331
449
  case 'STOP': case 'BACK': exit(); return true;
332
450
  }
@@ -341,7 +459,7 @@ var Player = (function () {
341
459
  if (_osdTimer) { clearTimeout(_osdTimer); _osdTimer = null; }
342
460
  hideOSD();
343
461
  subsEl.className = 'hidden'; U.clear(subsEl);
344
- subMenuEl.className = 'hidden'; _menuOpen = false;
462
+ subMenuEl.className = 'hidden'; _menuOpen = false; _menuStack = [];
345
463
  _subOn = false; _cues = null; _subTracks = [];
346
464
  if (useAV) {
347
465
  try { avplay.stop(); } catch (e) {}
@@ -361,6 +479,7 @@ var Player = (function () {
361
479
 
362
480
  return {
363
481
  init: init, play: play, handleKey: handleKey, isActive: isActive,
364
- exit: exit, continueList: continueList, getResume: getResume
482
+ exit: exit, continueList: continueList, getResume: getResume,
483
+ getSubCfg: getSubCfg, setSubCfg: setSubCfg
365
484
  };
366
485
  })();
package/js/stremio.js CHANGED
@@ -112,6 +112,12 @@ var Stremio = (function () {
112
112
 
113
113
  // ---- Catalogs ----
114
114
  // Returns list of {addon, type, id, name} across all addons.
115
+ var TYPE_LABELS = { movie: 'Movies', series: 'Series', tv: 'Live TV', channel: 'Channels' };
116
+ function prettyType(t) {
117
+ if (TYPE_LABELS[t]) { return TYPE_LABELS[t]; }
118
+ return t ? (t.charAt(0).toUpperCase() + t.slice(1)) : '';
119
+ }
120
+
115
121
  function listCatalogs() {
116
122
  return loadAddons().then(function (addons) {
117
123
  var cats = [];
@@ -120,8 +126,10 @@ var Stremio = (function () {
120
126
  cs.forEach(function (c) {
121
127
  if (!c.type || !c.id) { return; }
122
128
  var label = c.name || c.id;
129
+ // Include the type so per-type catalogs sharing a name (e.g. Cinemeta's
130
+ // "Popular" for both movie & series) don't look like duplicates.
123
131
  cats.push({ addon: addon, type: c.type, id: c.id,
124
- name: label + ' · ' + (addon.manifest.name || 'Addon') });
132
+ name: prettyType(c.type) + ' · ' + label + ' · ' + (addon.manifest.name || 'Addon') });
125
133
  });
126
134
  });
127
135
  return cats;
@@ -272,12 +280,46 @@ var Stremio = (function () {
272
280
  return ('' + t).replace(/\n/g, ' ');
273
281
  }
274
282
 
283
+ // Short first-line label (quality / addon tag), e.g. "MediaFusion 2160p".
284
+ function streamName(stream) {
285
+ var t = stream.name || stream.title || stream.description || 'Source';
286
+ return ('' + t).split('\n')[0].trim();
287
+ }
288
+
289
+ function humanSize(bytes) {
290
+ if (!bytes || bytes <= 0) { return ''; }
291
+ var u = ['B', 'KB', 'MB', 'GB', 'TB'], i = 0, n = bytes;
292
+ while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
293
+ return (n >= 10 ? Math.round(n) : n.toFixed(1)) + ' ' + u[i];
294
+ }
295
+
296
+ // File size from behaviorHints, or parsed from the title/description text.
297
+ function streamSize(stream) {
298
+ var b = stream.behaviorHints && stream.behaviorHints.videoSize;
299
+ if (b) { return humanSize(b); }
300
+ var txt = ('' + (stream.title || '') + ' ' + (stream.description || ''));
301
+ var m = txt.match(/(\d+(?:[.,]\d+)?)\s?(TB|GB|MB)\b/i);
302
+ return m ? (m[1].replace(',', '.') + ' ' + m[2].toUpperCase()) : '';
303
+ }
304
+
305
+ // The descriptive block (size, languages, seeds, etc. as the addon formats it).
306
+ // Kept multi-line so details like MediaFusion's language list survive.
307
+ function streamDescription(stream) {
308
+ var d = '' + (stream.description || stream.title || '');
309
+ if (!stream.name) {
310
+ var parts = d.split('\n');
311
+ if (parts.length > 1) { d = parts.slice(1).join('\n'); }
312
+ }
313
+ return d.trim();
314
+ }
315
+
275
316
  return {
276
317
  DEFAULT_ADDONS: DEFAULT_ADDONS,
277
318
  getAddonUrls: getAddonUrls, addAddon: addAddon, removeAddon: removeAddon, resetAddons: resetAddons,
278
319
  loadAddons: loadAddons, listCatalogs: listCatalogs, getCatalog: getCatalog,
279
320
  getMeta: getMeta, getStreams: getStreams, getSubtitles: getSubtitles,
280
321
  getSearchCatalogs: getSearchCatalogs, search: search,
281
- isPlayable: isPlayable, streamLabel: streamLabel
322
+ isPlayable: isPlayable, streamLabel: streamLabel, streamName: streamName,
323
+ streamSize: streamSize, streamDescription: streamDescription
282
324
  };
283
325
  })();
package/js/views.js CHANGED
@@ -62,16 +62,28 @@ var Views = (function () {
62
62
  var srch = U.el('div', 'gear focusable', '🔍');
63
63
  srch.style.marginRight = '24px';
64
64
  srch.__onselect = function () { App.openSearch(); };
65
+ srch.__navDOWN = focusFirstRowItem;
65
66
  h.appendChild(srch);
66
67
  }
67
68
  if (withGear) {
68
69
  var g = U.el('div', 'gear focusable', '⚙');
69
70
  g.__onselect = function () { App.openSettings(); };
71
+ g.__navDOWN = focusFirstRowItem;
70
72
  h.appendChild(g);
71
73
  }
72
74
  return h;
73
75
  }
74
76
 
77
+ // DOWN from the header lands on the first item of the first row
78
+ // (Continue Watching when present), not the geometrically-nearest card.
79
+ function focusFirstRowItem() {
80
+ var rows = scr('home').querySelector('.rows');
81
+ if (!rows) { return false; }
82
+ var first = rows.querySelector('.focusable');
83
+ if (first) { Focus.setFocus(first); return true; }
84
+ return false;
85
+ }
86
+
75
87
  function posterCard(meta, fallbackType) {
76
88
  var c = U.el('div', 'card focusable');
77
89
  var poster = meta.poster || '';
@@ -84,7 +96,7 @@ var Views = (function () {
84
96
  c.appendChild(U.el('div', 'fallback', meta.name || meta.title || ''));
85
97
  }
86
98
  c.appendChild(U.el('div', 'cap', meta.name || meta.title || ''));
87
- c.__onselect = function () { App.openDetail(meta.type || fallbackType, meta.id); };
99
+ c.__onselect = function () { App.openDetail(meta.type || fallbackType, meta.id, meta); };
88
100
  return c;
89
101
  }
90
102
 
@@ -101,6 +113,13 @@ var Views = (function () {
101
113
  if (cont.length) {
102
114
  rows.appendChild(buildRow('Continue Watching', cont.map(function (e) {
103
115
  var c = posterCard({ id: e.id.split(':')[0], type: e.type, name: e.name, poster: e.poster }, e.type);
116
+ // For a series, keep the full episode id so detail opens on the right
117
+ // season and focuses the exact episode being resumed.
118
+ c.__onselect = function () {
119
+ App.openDetail(e.type, e.id.split(':')[0],
120
+ { name: e.name, poster: e.poster, type: e.type },
121
+ e.type === 'series' ? e.id : null);
122
+ };
104
123
  if (e.duration) {
105
124
  var p = U.el('div', 'prog');
106
125
  p.style.width = Math.min(100, (e.position / e.duration * 100)) + '%';
@@ -162,8 +181,8 @@ var Views = (function () {
162
181
  // ================= DETAIL =================
163
182
  var _d = null; // { type, id, meta, mode, season }
164
183
 
165
- function renderDetail(type, id) {
166
- _d = { type: type, id: id, meta: null, mode: 'info', season: null };
184
+ function renderDetail(type, id, preview, target) {
185
+ _d = { type: type, id: id, meta: null, mode: 'info', season: null, preview: preview || null, target: target || null };
167
186
  var s = scr('detail');
168
187
  U.clear(s);
169
188
  s.appendChild(U.el('div', 'detail-hero'));
@@ -174,8 +193,15 @@ var Views = (function () {
174
193
  drawDetail();
175
194
  }, function (err) {
176
195
  U.spinner(false);
177
- U.toast('Could not load details');
178
- App.back();
196
+ // Fallback: stream-only addons (e.g. live TV/sports) may expose no meta
197
+ // resource. Use the catalog preview so the title still opens & plays.
198
+ if (_d.preview) {
199
+ _d.meta = Object.assign({ id: id, type: type }, _d.preview);
200
+ drawDetail();
201
+ } else {
202
+ U.toast('Could not load details');
203
+ App.back();
204
+ }
179
205
  });
180
206
  }
181
207
 
@@ -202,6 +228,13 @@ var Views = (function () {
202
228
  body.appendChild(U.el('div', 'detail-desc', meta.description || ''));
203
229
  s.appendChild(body);
204
230
 
231
+ // 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); }
235
+ }
236
+
237
+ _d._epFocus = null;
205
238
  if (_d.mode === 'streams') {
206
239
  body.appendChild(streamActionsBar());
207
240
  s.appendChild(streamsPanel());
@@ -210,7 +243,8 @@ var Views = (function () {
210
243
  } else {
211
244
  body.appendChild(movieActions());
212
245
  }
213
- Focus.setScope(s);
246
+ Focus.setScope(s, _d._epFocus);
247
+ _d.target = null; // consume: don't re-jump after the user navigates
214
248
  }
215
249
 
216
250
  function movieActions() {
@@ -258,6 +292,7 @@ var Views = (function () {
258
292
  e.__onselect = function () {
259
293
  openStreams(v.id, _d.meta.name + ' S' + _d.season + 'E' + (v.episode || ''));
260
294
  };
295
+ if (_d.target && v.id === _d.target) { _d._epFocus = e; }
261
296
  listScroll.appendChild(e);
262
297
  });
263
298
  panel.appendChild(listScroll);
@@ -303,21 +338,34 @@ var Views = (function () {
303
338
  _d.streams.forEach(function (stream) {
304
339
  var playable = Stremio.isPlayable(stream);
305
340
  var node = U.el('div', 'stream focusable');
306
- node.appendChild(document.createTextNode(Stremio.streamLabel(stream)));
307
- var why = '';
341
+ node.appendChild(document.createTextNode(Stremio.streamName(stream)));
342
+
343
+ // Detail block: size + the addon's own description (languages, seeds, etc.)
344
+ var lines = [];
345
+ var size = Stremio.streamSize(stream);
346
+ var desc = Stremio.streamDescription(stream);
347
+ if (size && !/(TB|GB|MB)\b/i.test(desc)) { lines.push('💾 ' + size); }
348
+ if (desc) { lines.push(desc); }
349
+ if (stream._addon) { lines.push('⚙️ ' + stream._addon); }
308
350
  if (!playable) {
309
- if (stream.infoHash) { why = ' torrent, needs a debrid/torrent client'; }
310
- else if (stream.externalUrl) { why = ' opens in an external app (not playable here)'; }
311
- else { why = ' not directly playable on the TV'; }
351
+ if (stream.infoHash) { lines.push('— torrent, needs a debrid/torrent client'); }
352
+ else if (stream.externalUrl) { lines.push('— opens in an external app (not playable here)'); }
353
+ else { lines.push('— not directly playable on the TV'); }
312
354
  }
313
- var sub = (stream._addon || '') + why;
314
- node.appendChild(U.el('span', 's-sub', sub));
355
+ var sub = U.el('span', 's-sub', lines.join('\n'));
356
+ sub.style.whiteSpace = 'pre-line';
357
+ node.appendChild(sub);
315
358
  if (!playable) { node.style.opacity = '0.55'; }
316
359
  node.__onselect = function () {
317
360
  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
+ return { url: st.url, label: (st._addon ? st._addon + ' · ' : '') + Stremio.streamName(st) + (sz ? ' · ' + sz : '') };
364
+ });
318
365
  Player.play({
319
366
  url: stream.url, type: _d.type, id: _d.streamId,
320
- name: _d.streamLabel || _d.meta.name, poster: _d.meta.poster
367
+ name: _d.streamLabel || _d.meta.name, poster: _d.meta.poster,
368
+ streams: sources
321
369
  }, function () { drawDetail(); });
322
370
  };
323
371
  listScroll.appendChild(node);
@@ -391,12 +439,104 @@ var Views = (function () {
391
439
  });
392
440
  }
393
441
 
442
+ // ================= ADD ADDON (dedicated page) =================
443
+ function renderAddAddon() {
444
+ var s = scr('addaddon');
445
+ U.clear(s);
446
+ s.appendChild(header(false, false));
447
+
448
+ var wrap = U.el('div', 'add-wrap');
449
+ wrap.appendChild(U.el('h2', null, 'Add addon'));
450
+ wrap.appendChild(U.el('div', 'add-hint', 'Type a Stremio addon manifest URL, then choose Confirm. RETURN cancels — nothing is saved unless you confirm.'));
451
+
452
+ var input = document.createElement('input');
453
+ input.id = 'addaddon-input';
454
+ input.type = 'text';
455
+ input.className = 'focusable add-input';
456
+ input.value = 'https://';
457
+ input.placeholder = 'https://addon.example/manifest.json';
458
+ input.__onselect = function () {
459
+ input.focus();
460
+ var n = input.value.length;
461
+ try { input.setSelectionRange(n, n); } catch (e) {}
462
+ };
463
+ input.onkeydown = function (ev) {
464
+ if (ev.keyCode === 13) { if (ev.stopPropagation) { ev.stopPropagation(); } doConfirm(); }
465
+ };
466
+ wrap.appendChild(input);
467
+
468
+ var row = U.el('div', 'add-btn-row');
469
+ var confirmBtn = U.el('div', 'pill focusable', 'Confirm');
470
+ var cancelBtn = U.el('div', 'pill focusable', 'Cancel');
471
+ confirmBtn.__onselect = doConfirm;
472
+ cancelBtn.__onselect = function () { App.back(); };
473
+ row.appendChild(confirmBtn);
474
+ row.appendChild(cancelBtn);
475
+ wrap.appendChild(row);
476
+
477
+ s.appendChild(wrap);
478
+ Focus.setScope(s, input);
479
+
480
+ function doConfirm() {
481
+ var v = ('' + input.value).trim();
482
+ if (!v || v === 'https://' || v.indexOf('://') === -1 || v.length < 12) {
483
+ U.toast('Enter a full manifest URL');
484
+ return;
485
+ }
486
+ Stremio.addAddon(v);
487
+ U.toast('Addon added');
488
+ App.back();
489
+ renderSettings();
490
+ }
491
+ }
492
+
493
+ // ================= SUBTITLE CONFIG =================
494
+ var SUB_LANGS = [['off', 'Off'], ['eng', 'English'], ['spa', 'Spanish'], ['fre', 'French'],
495
+ ['ger', 'German'], ['ita', 'Italian'], ['por', 'Portuguese'], ['dut', 'Dutch'],
496
+ ['rus', 'Russian'], ['ara', 'Arabic'], ['tur', 'Turkish'], ['pol', 'Polish']];
497
+ var SUB_SIZES = [['small', 'Small'], ['medium', 'Medium'], ['large', 'Large'], ['xlarge', 'Extra Large']];
498
+ var SUB_STYLES = [['shadow', 'White + shadow'], ['box', 'White on black box'], ['yellow', 'Yellow']];
499
+
500
+ function labelFor(list, v) {
501
+ for (var i = 0; i < list.length; i++) { if (list[i][0] === v) { return list[i][1]; } }
502
+ return v;
503
+ }
504
+ function nextVal(list, v) {
505
+ var i = 0;
506
+ for (; i < list.length; i++) { if (list[i][0] === v) { break; } }
507
+ return list[(i + 1) % list.length][0];
508
+ }
509
+
510
+ function subCycleRow(caption, list, key) {
511
+ var row = U.el('div', 'addon-item focusable cfg-row');
512
+ var name = U.el('div', 'a-name');
513
+ function render() { var cfg = Player.getSubCfg(); name.textContent = caption + ': ' + labelFor(list, cfg[key]); }
514
+ render();
515
+ row.appendChild(name);
516
+ row.appendChild(U.el('div', 'a-url', 'Press OK to change'));
517
+ row.__onselect = function () {
518
+ var cfg = Player.getSubCfg();
519
+ cfg[key] = nextVal(list, cfg[key]);
520
+ Player.setSubCfg(cfg);
521
+ render();
522
+ };
523
+ return row;
524
+ }
525
+
526
+ function buildSubtitleSection(wrap) {
527
+ wrap.appendChild(U.el('h2', null, 'Subtitles'));
528
+ wrap.appendChild(subCycleRow('Default language', SUB_LANGS, 'lang'));
529
+ wrap.appendChild(subCycleRow('Size', SUB_SIZES, 'size'));
530
+ wrap.appendChild(subCycleRow('Style', SUB_STYLES, 'style'));
531
+ }
532
+
394
533
  // ================= SETTINGS =================
395
534
  function renderSettings() {
396
535
  var s = scr('settings');
397
536
  U.clear(s);
398
537
  s.appendChild(header(false));
399
538
  var wrap = U.el('div', 'settings-wrap');
539
+ buildSubtitleSection(wrap);
400
540
  wrap.appendChild(U.el('h2', null, 'Addons'));
401
541
 
402
542
  Stremio.loadAddons().then(function (addons) {
@@ -430,25 +570,11 @@ var Views = (function () {
430
570
  };
431
571
  wrap.appendChild(reset);
432
572
 
433
- // Add-addon input
434
- var addRow = U.el('div', 'addon-item focusable');
435
- addRow.appendChild(U.el('div', 'a-name', '+ Add addon (focus, then type a manifest URL)'));
436
- var input = document.createElement('input');
437
- input.type = 'text';
438
- input.value = 'https://';
439
- input.placeholder = 'https://addon.example/manifest.json';
440
- input.style.cssText = 'width:100%;margin-top:10px;padding:12px;font-size:20px;background:#0e1117;color:#fff;border:2px solid #2a3346;border-radius:6px;';
441
- input.onchange = function () {
442
- var v = ('' + input.value).trim();
443
- if (v && v !== 'https://') { Stremio.addAddon(v); U.toast('Addon added'); setTimeout(renderSettings, 500); }
444
- };
445
- addRow.appendChild(input);
446
- addRow.__onselect = function () {
447
- input.focus();
448
- // place cursor at the end so the user types after https://
449
- var n = input.value.length;
450
- try { input.setSelectionRange(n, n); } catch (e) {}
451
- };
573
+ // Add-addon (opens a dedicated page — keeps the field clear of the keyboard)
574
+ var addRow = U.el('div', 'addon-item focusable add-row');
575
+ addRow.appendChild(U.el('div', 'a-name', '+ Add addon'));
576
+ addRow.appendChild(U.el('div', 'a-url', 'Enter a Stremio addon manifest URL on its own page'));
577
+ addRow.__onselect = function () { App.openAddAddon(); };
452
578
  wrap.appendChild(addRow);
453
579
 
454
580
  wrap.appendChild(U.el('div', 'hint',
@@ -460,7 +586,7 @@ var Views = (function () {
460
586
 
461
587
  return {
462
588
  renderHome: renderHome, renderDetail: renderDetail, renderSettings: renderSettings,
463
- renderSearch: renderSearch, backInDetail: backInDetail,
589
+ renderSearch: renderSearch, renderAddAddon: renderAddAddon, backInDetail: backInDetail,
464
590
  isModalOpen: function () { return _modalOpen; }, closeModal: closeModal
465
591
  };
466
592
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuvio-tizen",
3
- "version": "1.2.0",
3
+ "version": "1.4.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",
@@ -25,4 +25,4 @@
25
25
  ],
26
26
  "evaluateScriptOnDocumentStart": false,
27
27
  "license": "MIT"
28
- }
28
+ }