nuvio-tizen 1.3.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,8 +15,13 @@ 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).
22
27
  Configurable in Settings: **default language** (auto-loads a matching track),
package/js/app.js CHANGED
@@ -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, preview) {
19
+ function openDetail(type, id, preview, target) {
20
20
  if (!id) { return; }
21
21
  pushFrame();
22
22
  showScreen('detail');
23
- Views.renderDetail(type, id, preview);
23
+ Views.renderDetail(type, id, preview, target);
24
24
  }
25
25
 
26
26
  function openSettings() {
@@ -67,7 +67,12 @@ var App = (function () {
67
67
  case 'LEFT': Focus.move('LEFT'); return true;
68
68
  case 'RIGHT': Focus.move('RIGHT'); return true;
69
69
  case 'UP': Focus.move('UP'); return true;
70
- 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
+ }
71
76
  case 'OK': Focus.clickCurrent(); return true;
72
77
  case 'BACK': back(); return true;
73
78
  default: return true;
package/js/player.js CHANGED
@@ -159,7 +159,7 @@ var Player = (function () {
159
159
 
160
160
  function updateHint() {
161
161
  var base = _isLive ? 'OK: Play/Pause RETURN: Back' : 'OK: Play/Pause ←/β†’: Seek RETURN: Back';
162
- if (_subTracks.length) { base += ' UP: Subtitles'; }
162
+ base += ' UP: Menu';
163
163
  var h = U.byId('osd-hint');
164
164
  if (h) { h.textContent = base; }
165
165
  }
@@ -320,55 +320,119 @@ var Player = (function () {
320
320
  subsEl.className = 'hidden'; U.clear(subsEl);
321
321
  }
322
322
 
323
- // ---------- subtitle menu ----------
324
- function openSubMenu() {
325
- 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() {
326
344
  _menuOpen = true;
327
- // index 0 = Off; tracks start at 1
328
- _menuIdx = 0;
329
- renderSubMenu();
345
+ _menuStack = [rootMenu()];
346
+ renderMenu();
330
347
  subMenuEl.className = '';
331
348
  showOSD(true);
332
349
  }
333
-
334
- function closeSubMenu() {
335
- _menuOpen = false;
350
+ function closeMenu() {
351
+ _menuOpen = false; _menuStack = [];
336
352
  subMenuEl.className = 'hidden';
337
353
  showOSD(false);
338
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(); }
339
358
 
340
- function renderSubMenu() {
359
+ function renderMenu() {
360
+ var m = topMenu();
341
361
  U.clear(subMenuEl);
342
- subMenuEl.appendChild(U.el('div', 'sm-title', 'Subtitles'));
362
+ subMenuEl.appendChild(U.el('div', 'sm-title', m.title));
343
363
  var list = U.el('div', 'sm-list');
344
- var items = [{ label: 'Off' + (_subOn ? '' : ' βœ“'), track: null }];
345
- _subTracks.forEach(function (tr) {
346
- var mark = (_subOn && _subLabel === (tr.lang || 'on')) ? ' βœ“' : '';
347
- items.push({ label: (tr.lang || 'unknown') + ' Β· ' + (tr._addon || '') + mark, track: tr });
348
- });
349
- _menuItems = items;
350
- items.forEach(function (it, i) {
351
- var node = U.el('div', 'sm-item' + (i === _menuIdx ? ' sel' : ''), it.label);
352
- 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));
353
367
  });
354
368
  subMenuEl.appendChild(list);
355
- // keep selected item visible
356
- var sel = list.children[_menuIdx];
369
+ var sel = list.children[m.idx];
357
370
  if (sel && sel.scrollIntoView) { try { sel.scrollIntoView(false); } catch (e) {} }
358
371
  }
359
372
 
360
- 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
+ }
361
428
 
362
429
  function menuKey(action) {
363
- if (action === 'UP') { _menuIdx = Math.max(0, _menuIdx - 1); renderSubMenu(); return; }
364
- if (action === 'DOWN') { _menuIdx = Math.min(_menuItems.length - 1, _menuIdx + 1); renderSubMenu(); return; }
365
- if (action === 'OK') {
366
- var it = _menuItems[_menuIdx];
367
- if (!it || it.track === null) { subsOff(); } else { loadTrack(it.track); }
368
- closeSubMenu();
369
- return;
370
- }
371
- 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; }
372
436
  }
373
437
 
374
438
  function handleKey(action) {
@@ -380,7 +444,7 @@ var Player = (function () {
380
444
  case 'PAUSE': if (useAV) { try { avplay.pause(); } catch (e) {} } else { videoEl.pause(); } return true;
381
445
  case 'RIGHT': case 'FF': seekBy(action === 'FF' ? 30 : 10); return true;
382
446
  case 'LEFT': case 'REW': seekBy(action === 'REW' ? -30 : -10); return true;
383
- case 'UP': if (_subTracks.length) { openSubMenu(); } return true;
447
+ case 'UP': openMenu(); return true;
384
448
  case 'DOWN': showOSD(false); return true;
385
449
  case 'STOP': case 'BACK': exit(); return true;
386
450
  }
@@ -395,7 +459,7 @@ var Player = (function () {
395
459
  if (_osdTimer) { clearTimeout(_osdTimer); _osdTimer = null; }
396
460
  hideOSD();
397
461
  subsEl.className = 'hidden'; U.clear(subsEl);
398
- subMenuEl.className = 'hidden'; _menuOpen = false;
462
+ subMenuEl.className = 'hidden'; _menuOpen = false; _menuStack = [];
399
463
  _subOn = false; _cues = null; _subTracks = [];
400
464
  if (useAV) {
401
465
  try { avplay.stop(); } catch (e) {}
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 || '';
@@ -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, preview) {
166
- _d = { type: type, id: id, meta: null, mode: 'info', season: null, preview: preview || 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'));
@@ -209,6 +228,13 @@ var Views = (function () {
209
228
  body.appendChild(U.el('div', 'detail-desc', meta.description || ''));
210
229
  s.appendChild(body);
211
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;
212
238
  if (_d.mode === 'streams') {
213
239
  body.appendChild(streamActionsBar());
214
240
  s.appendChild(streamsPanel());
@@ -217,7 +243,8 @@ var Views = (function () {
217
243
  } else {
218
244
  body.appendChild(movieActions());
219
245
  }
220
- Focus.setScope(s);
246
+ Focus.setScope(s, _d._epFocus);
247
+ _d.target = null; // consume: don't re-jump after the user navigates
221
248
  }
222
249
 
223
250
  function movieActions() {
@@ -265,6 +292,7 @@ var Views = (function () {
265
292
  e.__onselect = function () {
266
293
  openStreams(v.id, _d.meta.name + ' S' + _d.season + 'E' + (v.episode || ''));
267
294
  };
295
+ if (_d.target && v.id === _d.target) { _d._epFocus = e; }
268
296
  listScroll.appendChild(e);
269
297
  });
270
298
  panel.appendChild(listScroll);
@@ -310,21 +338,34 @@ var Views = (function () {
310
338
  _d.streams.forEach(function (stream) {
311
339
  var playable = Stremio.isPlayable(stream);
312
340
  var node = U.el('div', 'stream focusable');
313
- node.appendChild(document.createTextNode(Stremio.streamLabel(stream)));
314
- 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); }
315
350
  if (!playable) {
316
- if (stream.infoHash) { why = ' β€” torrent, needs a debrid/torrent client'; }
317
- else if (stream.externalUrl) { why = ' β€” opens in an external app (not playable here)'; }
318
- 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'); }
319
354
  }
320
- var sub = (stream._addon || '') + why;
321
- 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);
322
358
  if (!playable) { node.style.opacity = '0.55'; }
323
359
  node.__onselect = function () {
324
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
+ });
325
365
  Player.play({
326
366
  url: stream.url, type: _d.type, id: _d.streamId,
327
- name: _d.streamLabel || _d.meta.name, poster: _d.meta.poster
367
+ name: _d.streamLabel || _d.meta.name, poster: _d.meta.poster,
368
+ streams: sources
328
369
  }, function () { drawDetail(); });
329
370
  };
330
371
  listScroll.appendChild(node);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuvio-tizen",
3
- "version": "1.3.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",