nuvio-tizen 1.9.4 → 1.9.6

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/config.xml CHANGED
@@ -1,6 +1,6 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
2
  <widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
3
- id="http://nuvio.tizen/NuvioTizen" version="1.9.4" viewmodes="maximized">
3
+ id="http://nuvio.tizen/NuvioTizen" version="1.9.6" viewmodes="maximized">
4
4
  <tizen:application id="Nuvio00001.Nuvio" package="Nuvio00001" required_version="3.0"/>
5
5
  <content src="index.html"/>
6
6
  <feature name="http://tizen.org/feature/screen.size.all"/>
package/css/style.css CHANGED
@@ -220,4 +220,5 @@ html, body {
220
220
  .tab-ic { margin-left: 8px; font-size: 22px; }
221
221
  .tab-pill.active { background: #2a3346; color: #fff; }
222
222
  .tab-pill.focusable.focused { background: #818cf8; color: #0e1117; border-color: #fff; }
223
+ .home-header .hchip.active { background: #2a3346; color: #fff; opacity: 1; }
223
224
  .row-empty { margin: 50px 60px; font-size: 26px; color: #8b93a3; }
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?v=1.9.4">
7
+ <link rel="stylesheet" href="css/style.css?v=1.9.6">
8
8
  </head>
9
9
  <body>
10
10
  <!-- CSS-applied sentinel (kept off-screen); app.js waits for its width before rendering -->
@@ -50,15 +50,15 @@
50
50
 
51
51
  <!-- Load order matters: polyfills -> util -> iptv -> data -> input -> player -> ytplay -> views -> app -->
52
52
  <!-- ?v= busts the TV webview cache on every release (keep in sync with package.json) -->
53
- <script src="js/polyfills.js?v=1.9.4"></script>
54
- <script src="js/util.js?v=1.9.4"></script>
55
- <script src="js/iptv.js?v=1.9.4"></script>
56
- <script src="js/stremio.js?v=1.9.4"></script>
57
- <script src="js/keys.js?v=1.9.4"></script>
58
- <script src="js/focus.js?v=1.9.4"></script>
59
- <script src="js/player.js?v=1.9.4"></script>
60
- <script src="js/ytplay.js?v=1.9.4"></script>
61
- <script src="js/views.js?v=1.9.4"></script>
62
- <script src="js/app.js?v=1.9.4"></script>
53
+ <script src="js/polyfills.js?v=1.9.6"></script>
54
+ <script src="js/util.js?v=1.9.6"></script>
55
+ <script src="js/iptv.js?v=1.9.6"></script>
56
+ <script src="js/stremio.js?v=1.9.6"></script>
57
+ <script src="js/keys.js?v=1.9.6"></script>
58
+ <script src="js/focus.js?v=1.9.6"></script>
59
+ <script src="js/player.js?v=1.9.6"></script>
60
+ <script src="js/ytplay.js?v=1.9.6"></script>
61
+ <script src="js/views.js?v=1.9.6"></script>
62
+ <script src="js/app.js?v=1.9.6"></script>
63
63
  </body>
64
64
  </html>
package/js/app.js CHANGED
@@ -4,6 +4,7 @@ var App = (function () {
4
4
 
5
5
  var _current = 'home';
6
6
  var _stack = []; // frames: { screen, focus }
7
+ var _lastBackAt = 0; // debounce BACK across keydown + tizenhwkey
7
8
 
8
9
  function screenEl(name) { return U.byId('screen-' + name); }
9
10
 
@@ -23,6 +24,14 @@ var App = (function () {
23
24
  Views.renderDetail(type, id, preview, target);
24
25
  }
25
26
 
27
+ // Go to a Home tab from the shared top menu (works from any screen).
28
+ function goHome(tab) {
29
+ Views.setHomeTab(tab);
30
+ _stack = []; // Home is the root
31
+ showScreen('home');
32
+ Views.renderHome();
33
+ }
34
+
26
35
  // Live TV: skip the info page — load sources and (if a single one) play immediately.
27
36
  function openLive(type, id, preview) {
28
37
  if (!id) { return; }
@@ -50,6 +59,7 @@ var App = (function () {
50
59
  }
51
60
 
52
61
  function back() {
62
+ if (typeof YTPlay !== 'undefined' && YTPlay.isOpen && YTPlay.isOpen()) { YTPlay.close(); return; }
53
63
  if (Player.isActive()) { Player.exit(); return; }
54
64
  if (Views.isModalOpen()) { Views.closeModal(); return; }
55
65
  if (_current === 'detail' && Views.backInDetail()) { return; }
@@ -94,7 +104,14 @@ var App = (function () {
94
104
  else { Focus.clickCurrent(); }
95
105
  return true;
96
106
  }
97
- case 'BACK': back(); return true;
107
+ case 'BACK': {
108
+ // keydown(10009) and tizenhwkey can both fire for one press — debounce.
109
+ var now = Date.now();
110
+ if (now - _lastBackAt < 400) { return true; }
111
+ _lastBackAt = now;
112
+ back();
113
+ return true;
114
+ }
98
115
  default: return true;
99
116
  }
100
117
  }
@@ -133,8 +150,8 @@ var App = (function () {
133
150
 
134
151
  return {
135
152
  start: start, back: back,
136
- openDetail: openDetail, openLive: openLive, openSettings: openSettings, openSearch: openSearch,
137
- openEntry: openEntry
153
+ openDetail: openDetail, openLive: openLive, goHome: goHome,
154
+ openSettings: openSettings, openSearch: openSearch, openEntry: openEntry
138
155
  };
139
156
  })();
140
157
 
package/js/keys.js CHANGED
@@ -67,10 +67,19 @@ var Keys = (function () {
67
67
  dispatch(held >= LONG_MS ? 'OK_LONG' : 'OK', ev);
68
68
  }
69
69
 
70
+ // Tizen delivers the hardware RETURN/BACK as a 'tizenhwkey' event to the app
71
+ // document even when a cross-origin iframe (e.g. the YouTube leanback overlay)
72
+ // holds focus — where a normal keydown never reaches us. Route it through the same
73
+ // handler and preventDefault so the platform doesn't exit the app behind our back.
74
+ function onHwKey(ev) {
75
+ if (ev && ev.keyName === 'back') { dispatch('BACK', ev); }
76
+ }
77
+
70
78
  function init() {
71
79
  register();
72
80
  document.addEventListener('keydown', onKeyDown, false);
73
81
  document.addEventListener('keyup', onKeyUp, false);
82
+ document.addEventListener('tizenhwkey', onHwKey, false);
74
83
  }
75
84
 
76
85
  return { init: init, setHandler: setHandler, CODE: CODE };
package/js/player.js CHANGED
@@ -610,11 +610,13 @@ var Player = (function () {
610
610
 
611
611
  // ---------- contextual quick-action bar (UP) ----------
612
612
  function barButtons() {
613
- var b = [{ label: isPaused() ? '▶ Play' : '❙❙ Pause', run: togglePlay }];
613
+ // Glyphs kept to the old Geometric-Shapes/Arrows blocks the media-control
614
+ // symbols (⏵ ⏸ ⏮ ⏭ ⤶) don't render on Tizen 3.0's fonts.
615
+ var b = [{ label: isPaused() ? '▶ Play' : '‖ Pause', run: togglePlay }];
614
616
  if (!_isLive) { b.push({ label: '↺ Restart', run: function () { seekTo(0); closeBar(); } }); }
615
- if (_ctx.hasPrev) { b.push({ label: ' Previous', run: function () { closeBar(); _ctx.playPrev(); } }); }
616
- if (_ctx.hasNext) { b.push({ label: ' Next', run: function () { closeBar(); _ctx.playNext(); } }); }
617
- b.push({ label: ' Back', run: function () { exit(); } }); // stop playback, return to the previous page
617
+ if (_ctx.hasPrev) { b.push({ label: '◀◀ Previous', run: function () { closeBar(); _ctx.playPrev(); } }); }
618
+ if (_ctx.hasNext) { b.push({ label: '▶▶ Next', run: function () { closeBar(); _ctx.playNext(); } }); }
619
+ b.push({ label: ' Back', run: function () { exit(); } }); // stop playback, return to the previous page
618
620
  return b;
619
621
  }
620
622
  function openBar() {
package/js/views.js CHANGED
@@ -77,42 +77,70 @@ var Views = (function () {
77
77
  }
78
78
 
79
79
  // ---------- shared bits ----------
80
- function header(withGear, withSearch) {
80
+ // Plain brand-only header, used by the text-entry page. Home/Search/Settings use
81
+ // buildTopMenu() instead (shared top menu with tab pills + Search + Settings).
82
+ function header() {
81
83
  var h = U.el('div', 'header');
82
84
  var brand = U.el('div', 'brand');
83
85
  brand.innerHTML = 'NUVIO<span class="dot">.</span>';
84
86
  h.appendChild(brand);
85
87
  h.appendChild(U.el('div', 'spacer'));
86
- if (withSearch) {
87
- var srch = U.el('div', 'gear hchip focusable', 'Search 🔍');
88
- srch.style.marginRight = '20px';
89
- srch.__onselect = function () { App.openSearch(); };
90
- srch.__navDOWN = focusActiveTabOrRow;
91
- h.appendChild(srch);
92
- }
93
- if (withGear) {
94
- var g = U.el('div', 'gear hchip focusable', 'Settings ⚙');
95
- g.__onselect = function () { App.openSettings(); };
96
- g.__navDOWN = focusActiveTabOrRow;
97
- h.appendChild(g);
98
- }
99
88
  return h;
100
89
  }
101
90
 
102
- // DOWN from the header lands on the active tab pill (then DOWN again the rows).
103
- function focusActiveTabOrRow() {
104
- var home = scr('home');
105
- var pill = home.querySelector('.tab-pill.active');
106
- if (pill) { Focus.setFocus(pill); return true; }
107
- return focusFirstRowItem();
91
+ // The shared top menu, used on Home / Search / Settings so they look and navigate
92
+ // the same: tab pills + Search + Settings on the left, NUVIO on the right.
93
+ // `active` is the key of the current view: a tab key, 'search' or 'settings'.
94
+ function buildTopMenu(active) {
95
+ var head = U.el('div', 'header home-header');
96
+ var tabbar = U.el('div', 'tabbar-inline');
97
+ availableTabs().forEach(function (t) {
98
+ var pill = U.el('div', 'tab-pill focusable' + (t.key === active ? ' active' : ''));
99
+ pill.appendChild(document.createTextNode(t.label));
100
+ pill.appendChild(U.el('span', 'tab-ic', t.icon));
101
+ pill.__onselect = function () { App.goHome(t.key); };
102
+ pill.__navDOWN = focusBelowTop;
103
+ tabbar.appendChild(pill);
104
+ });
105
+ head.appendChild(tabbar);
106
+
107
+ var srch = U.el('div', 'gear hchip focusable' + (active === 'search' ? ' active' : ''), 'Search 🔍');
108
+ srch.__onselect = function () { App.openSearch(); };
109
+ srch.__navDOWN = focusBelowTop;
110
+ head.appendChild(srch);
111
+
112
+ var gear = U.el('div', 'gear hchip focusable' + (active === 'settings' ? ' active' : ''), 'Settings ⚙');
113
+ gear.__onselect = function () { App.openSettings(); };
114
+ gear.__navDOWN = focusBelowTop;
115
+ head.appendChild(gear);
116
+
117
+ head.appendChild(U.el('div', 'spacer'));
118
+ var brand = U.el('div', 'brand');
119
+ brand.innerHTML = 'NUVIO<span class="dot">.</span>';
120
+ head.appendChild(brand);
121
+ return head;
122
+ }
123
+
124
+ // Which tabs to show in the top menu (needs the home catalogs; falls back to the
125
+ // standard set before they've loaded so the menu is consistent everywhere).
126
+ function availableTabs() {
127
+ var cont = Player.continueList();
128
+ if (_homeCatalogs === null) {
129
+ return HOME_TABS.filter(function (t) { return t.key !== 'continue'; });
130
+ }
131
+ return HOME_TABS.filter(function (t) {
132
+ return t.key === 'continue' ? cont.length > 0 : catsForTab(t.key).length > 0;
133
+ });
108
134
  }
109
135
 
110
- // DOWN from a tab pill lands on the first item of the first row.
111
- function focusFirstRowItem() {
112
- var rows = scr('home').querySelector('.rows');
113
- if (!rows) { return false; }
114
- var first = rows.querySelector('.focusable');
115
- if (first) { Focus.setFocus(first); return true; }
136
+ // DOWN from the top menu lands on the first focusable in the content area below it.
137
+ function focusBelowTop() {
138
+ var s = _current();
139
+ var all = s.querySelectorAll('.focusable');
140
+ for (var i = 0; i < all.length; i++) {
141
+ var r = all[i].getBoundingClientRect();
142
+ if (r.height > 0 && r.top > 80) { Focus.setFocus(all[i]); return true; }
143
+ }
116
144
  return false;
117
145
  }
118
146
 
@@ -147,7 +175,7 @@ var Views = (function () {
147
175
  // other three are the catalog rows of that Stremio type, in the user's
148
176
  // Home-Feeds order/visibility. A tab only appears when it has content.
149
177
  var HOME_TABS = [
150
- { key: 'continue', icon: '', label: 'Continue Watching' },
178
+ { key: 'continue', icon: '', label: 'Continue Watching' },
151
179
  { key: 'movie', icon: '🎬', label: 'Movies' },
152
180
  { key: 'series', icon: '📺', label: 'Series' },
153
181
  { key: 'tv', icon: '📡', label: 'Live TV' },
@@ -169,6 +197,7 @@ var Views = (function () {
169
197
  var _homeCatalogs = null; // [{name,type,metas}] — fetched once per session, filtered per tab
170
198
  var _homeTab = null; // active tab key
171
199
  var _homeDirty = false; // set when Continue Watching / poster / addons changed
200
+ var _focusTopPill = false; // next home render should focus the active tab pill
172
201
 
173
202
  function markHomeDirty() { _homeDirty = true; }
174
203
  function homeDirty() { return _homeDirty; }
@@ -206,92 +235,62 @@ var Views = (function () {
206
235
  return c;
207
236
  }
208
237
 
238
+ // Switch the active home tab (from the shared top menu) and show it.
239
+ function setHomeTab(key) { _homeTab = key || _homeTab; _focusTopPill = true; }
240
+
209
241
  function renderHome() {
210
242
  _homeDirty = false;
211
243
  var s = scr('home');
212
244
  U.clear(s);
213
245
 
214
- // Single top row: tab pills + Search + Settings on the left, NUVIO logo top-right.
215
- var head = U.el('div', 'header home-header');
216
- var tabbar = U.el('div', 'tabbar-inline');
217
- head.appendChild(tabbar);
218
- var srch = U.el('div', 'gear hchip focusable', 'Search 🔍');
219
- srch.__onselect = function () { App.openSearch(); };
220
- srch.__navDOWN = focusFirstRowItem;
221
- head.appendChild(srch);
222
- var gear = U.el('div', 'gear hchip focusable', 'Settings ⚙');
223
- gear.__onselect = function () { App.openSettings(); };
224
- gear.__navDOWN = focusFirstRowItem;
225
- head.appendChild(gear);
226
- head.appendChild(U.el('div', 'spacer'));
227
- var brand = U.el('div', 'brand');
228
- brand.innerHTML = 'NUVIO<span class="dot">.</span>';
229
- head.appendChild(brand);
230
- s.appendChild(head);
246
+ // Keep the active tab valid for what's actually available.
247
+ var avail = availableTabs();
248
+ if (!_homeTab || !avail.some(function (t) { return t.key === _homeTab; })) {
249
+ _homeTab = avail.length ? avail[0].key : 'movie';
250
+ }
231
251
 
252
+ s.appendChild(buildTopMenu(_homeTab));
232
253
  var rows = U.el('div', 'rows');
233
254
  s.appendChild(rows);
234
255
 
235
- // Re-render the pills + the active tab's rows from cached data (no refetch).
236
- function paint(preferPill) {
237
- var cont = Player.continueList();
238
- var avail = HOME_TABS.filter(function (t) {
239
- return t.key === 'continue' ? cont.length > 0 : catsForTab(t.key).length > 0;
240
- });
241
- if (!_homeTab || !avail.some(function (t) { return t.key === _homeTab; })) {
242
- _homeTab = avail.length ? avail[0].key : 'movie';
243
- }
244
-
245
- U.clear(tabbar);
246
- avail.forEach(function (t) {
247
- var pill = U.el('div', 'tab-pill focusable' + (t.key === _homeTab ? ' active' : ''));
248
- pill.appendChild(document.createTextNode(t.label));
249
- pill.appendChild(U.el('span', 'tab-ic', t.icon));
250
- pill.__onselect = function () { if (_homeTab !== t.key) { _homeTab = t.key; paint(true); } };
251
- pill.__navDOWN = focusFirstRowItem;
252
- tabbar.appendChild(pill);
253
- });
254
-
255
- U.clear(rows);
256
- if (_homeTab === 'continue') {
257
- rows.appendChild(buildRow('Continue Watching · hold OK to remove', cont.map(continueCard)));
258
- } else {
259
- var cats = catsForTab(_homeTab);
260
- if (!cats.length) {
261
- rows.appendChild(U.el('div', 'row-empty', 'Nothing here yet.'));
262
- } else {
263
- cats.forEach(function (c) {
264
- rows.appendChild(buildRow(c.name, c.metas.map(function (m) { return posterCard(m, c.type); })));
265
- });
266
- }
256
+ // Rows for the active tab, from the once-per-session catalog cache.
257
+ if (_homeTab === 'continue') {
258
+ rows.appendChild(buildRow('Continue Watching · hold OK to remove',
259
+ Player.continueList().map(continueCard)));
260
+ } else {
261
+ var cats = catsForTab(_homeTab);
262
+ if (!cats.length) { rows.appendChild(U.el('div', 'row-empty', 'Nothing here yet.')); }
263
+ else {
264
+ cats.forEach(function (c) {
265
+ rows.appendChild(buildRow(c.name, c.metas.map(function (m) { return posterCard(m, c.type); })));
266
+ });
267
267
  }
268
- focusHome(s, preferPill);
269
268
  }
270
-
271
- // Catalog metas are fetched once per session and cached; tab switches are instant.
272
- if (_homeCatalogs) { paint(false); return; }
273
-
274
- // Paint immediately so Continue Watching / pills show while catalogs load.
275
- paint(false);
276
- U.spinner(true);
277
- Stremio.catalogsForHome().then(function (cats) {
278
- var acc = cats.map(function (cat) { return { name: cat.name, type: cat.type, metas: [] }; });
279
- var tasks = cats.map(function (cat, i) {
280
- return function () {
281
- return Stremio.getCatalog(cat).then(function (metas) { acc[i].metas = metas.slice(0, 24); });
282
- };
283
- });
284
- U.settleAll(tasks, 3).then(function () {
285
- _homeCatalogs = acc.filter(function (c) { return c.metas && c.metas.length; });
269
+ focusHome(s, _focusTopPill);
270
+ _focusTopPill = false;
271
+
272
+ // Cold start: fetch every catalog's metas once, cache, then re-render.
273
+ if (_homeCatalogs === null) {
274
+ _homeCatalogs = []; // guard against re-entry while loading
275
+ U.spinner(true);
276
+ Stremio.catalogsForHome().then(function (cats) {
277
+ var acc = cats.map(function (cat) { return { name: cat.name, type: cat.type, metas: [] }; });
278
+ var tasks = cats.map(function (cat, i) {
279
+ return function () {
280
+ return Stremio.getCatalog(cat).then(function (metas) { acc[i].metas = metas.slice(0, 24); });
281
+ };
282
+ });
283
+ U.settleAll(tasks, 3).then(function () {
284
+ _homeCatalogs = acc.filter(function (c) { return c.metas && c.metas.length; });
285
+ U.spinner(false);
286
+ if (_current() === s) { renderHome(); }
287
+ });
288
+ }, function () {
286
289
  U.spinner(false);
287
- paint(false);
290
+ U.toast('Failed to load addons. Check network / Settings.');
291
+ _homeCatalogs = [];
288
292
  });
289
- }, function () {
290
- U.spinner(false);
291
- U.toast('Failed to load addons. Check network / Settings.');
292
- _homeCatalogs = [];
293
- paint(false);
294
- });
293
+ }
295
294
  }
296
295
 
297
296
  function buildRow(title, cards) {
@@ -329,6 +328,7 @@ var Views = (function () {
329
328
  var playable = list.filter(Stremio.isPlayable);
330
329
  if (!playable.length) { U.toast('No playable source for this channel'); App.back(); return; }
331
330
  if (playable.length === 1) {
331
+ _d.exitToBack = true; // exiting playback returns straight to the grid
332
332
  startPlayback(playable[0], id, _d.streamLabel || _d.meta.name || 'Live');
333
333
  } else {
334
334
  drawDetail(); // multiple sources → straight to the source list
@@ -345,19 +345,26 @@ var Views = (function () {
345
345
  Stremio.getMeta(type, id).then(function (meta) {
346
346
  _d.meta = meta;
347
347
  U.spinner(false);
348
- drawDetail();
348
+ afterMeta();
349
349
  }, function (err) {
350
350
  U.spinner(false);
351
351
  // Fallback: stream-only addons (e.g. live TV/sports) may expose no meta
352
352
  // resource. Use the catalog preview so the title still opens & plays.
353
353
  if (_d.preview) {
354
354
  _d.meta = Object.assign({ id: id, type: type }, _d.preview);
355
- drawDetail();
355
+ afterMeta();
356
356
  } else {
357
357
  U.toast('Could not load details');
358
358
  App.back();
359
359
  }
360
360
  });
361
+
362
+ // On a movie landing page, skip the "Play" button and load sources automatically
363
+ // (series still shows the episode browser; each episode loads its own sources).
364
+ function afterMeta() {
365
+ if (_d.type === 'movie') { openStreams(_d.id, _d.meta.name || _d.meta.title); }
366
+ else { drawDetail(); }
367
+ }
361
368
  }
362
369
 
363
370
  function drawDetail() {
@@ -519,6 +526,29 @@ var Views = (function () {
519
526
  }
520
527
 
521
528
  // ----- streams -----
529
+ // "Where to watch" / subscription links (Paramount+, Netflix, rent/buy, etc.) are
530
+ // pushed to the bottom, below directly-playable sources.
531
+ function isSubscriptionStream(stream) {
532
+ if (Stremio.isPlayable(stream)) {
533
+ var t = ('' + (stream.name || '') + ' ' + (stream.title || '') + ' ' + (stream.description || ''));
534
+ return /\b(subscription|subscribe|rent|buy|purchase)\b|paramount|netflix|disney\+|disney plus|hbo|max\b|hulu|peacock|prime video|apple tv|starz|showtime/i.test(t);
535
+ }
536
+ return true; // non-playable (external "where to watch") always sinks to the bottom
537
+ }
538
+ function streamRank(stream) {
539
+ if (Stremio.isPlayable(stream) && !isSubscriptionStream(stream)) { return 0; }
540
+ if (Stremio.isPlayable(stream)) { return 1; }
541
+ return 2;
542
+ }
543
+ function sortStreams(list) {
544
+ return list.map(function (s, i) { return { s: s, i: i }; })
545
+ .sort(function (a, b) {
546
+ var ra = streamRank(a.s), rb = streamRank(b.s);
547
+ return ra !== rb ? ra - rb : a.i - b.i; // stable within a rank
548
+ })
549
+ .map(function (x) { return x.s; });
550
+ }
551
+
522
552
  function openStreams(videoId, label) {
523
553
  _d.mode = 'streams';
524
554
  _d.streamId = videoId;
@@ -527,7 +557,7 @@ var Views = (function () {
527
557
  drawDetail();
528
558
  U.spinner(true);
529
559
  Stremio.getStreams(_d.type, videoId).then(function (list) {
530
- _d.streams = list;
560
+ _d.streams = sortStreams(list);
531
561
  U.spinner(false);
532
562
  if (_d.mode === 'streams') { drawDetail(); }
533
563
  }, function () {
@@ -537,6 +567,11 @@ var Views = (function () {
537
567
  });
538
568
  }
539
569
 
570
+ // Re-fetch the current title's sources and land focus on the first result.
571
+ function rescanStreams() {
572
+ if (_d && _d.streamId) { openStreams(_d.streamId, _d.streamLabel); }
573
+ }
574
+
540
575
  function streamActionsBar() {
541
576
  var row = U.el('div', 'btn-row');
542
577
  row.appendChild(U.el('div', 'detail-meta', 'Sources for ' + (_d.streamLabel || '')));
@@ -581,6 +616,14 @@ var Views = (function () {
581
616
  };
582
617
  listScroll.appendChild(node);
583
618
  });
619
+
620
+ // Re-scan: refresh the source list (and jump back to the first result).
621
+ var rescan = U.el('div', 'stream focusable rescan-row');
622
+ rescan.appendChild(document.createTextNode('↻ Re-scan for sources'));
623
+ rescan.appendChild(U.el('span', 's-sub', 'Refresh the list from all stream addons'));
624
+ rescan.__onselect = function () { rescanStreams(); };
625
+ listScroll.appendChild(rescan);
626
+
584
627
  panel.appendChild(listScroll);
585
628
  return panel;
586
629
  }
@@ -616,8 +659,14 @@ var Views = (function () {
616
659
 
617
660
  function startPlayback(stream, videoId, label) {
618
661
  markHomeDirty(); // progress/watched will change → refresh Continue Watching on return
662
+ // Exit handler: single-source live channels return straight to the grid (skipping
663
+ // the source/info pages we skipped on the way in); everything else redraws detail.
664
+ var exitToBack = !!(_d && _d.exitToBack);
665
+ // Single-source live: skip the (already-skipped) source/info pages and go
666
+ // straight back to the home grid, on the tab we came from.
667
+ var onExit = function () { if (exitToBack) { App.goHome(); } else { drawDetail(); } };
619
668
  // YouTube-live IPTV channels play through the leanback proxy overlay, not AVPlay.
620
- if (stream._yt) { YTPlay.open(stream, function () { drawDetail(); }); return; }
669
+ if (stream._yt) { YTPlay.open(stream, onExit); return; }
621
670
  var ctx = { url: stream.url, type: _d.type, id: videoId, name: label,
622
671
  poster: _d.meta.poster, streams: playableSources(), _headers: stream._headers };
623
672
  if (_d.type === 'series' && _d.meta.videos) {
@@ -630,7 +679,7 @@ var Views = (function () {
630
679
  ctx.playNext = function () { playEpisode(ord[idx + 1]); };
631
680
  }
632
681
  }
633
- Player.play(ctx, function () { drawDetail(); });
682
+ Player.play(ctx, onExit);
634
683
  }
635
684
 
636
685
  // Load a specific episode's sources and auto-play the first playable one.
@@ -667,7 +716,7 @@ var Views = (function () {
667
716
  function renderSearch() {
668
717
  var s = scr('search');
669
718
  U.clear(s);
670
- s.appendChild(header(false, false));
719
+ s.appendChild(buildTopMenu('search')); // shared top menu (UP reaches it; no Back button)
671
720
 
672
721
  var wrap = U.el('div', 'search-wrap');
673
722
  var bar = U.el('div', 'search-bar');
@@ -686,10 +735,6 @@ var Views = (function () {
686
735
  var go = U.el('div', 'pill focusable search-go', 'Search');
687
736
  go.__onselect = function () { runSearch(input.value); };
688
737
  bar.appendChild(go);
689
-
690
- var back = U.el('div', 'pill focusable search-go', '← Back');
691
- back.__onselect = function () { App.back(); };
692
- bar.appendChild(back);
693
738
  wrap.appendChild(bar);
694
739
 
695
740
  var results = U.el('div', 'search-results');
@@ -844,6 +889,12 @@ var Views = (function () {
844
889
 
845
890
  function settingsRoot() { _settingsSection = 'menu'; renderSettings(); }
846
891
 
892
+ // Focus the first item in the settings content (not the top menu) so UP reaches the menu.
893
+ function focusSettings() {
894
+ var s = scr('settings');
895
+ Focus.setScope(s, s.querySelector('.settings-wrap .focusable') || undefined);
896
+ }
897
+
847
898
  // Return true if BACK stepped a settings section back to the menu.
848
899
  function backInSettings() {
849
900
  if (_settingsSection !== 'menu') { _settingsSection = 'menu'; renderSettings(); return true; }
@@ -853,7 +904,7 @@ var Views = (function () {
853
904
  function renderSettings() {
854
905
  var s = scr('settings');
855
906
  U.clear(s);
856
- s.appendChild(header(false, false));
907
+ s.appendChild(buildTopMenu('settings')); // shared top menu (UP reaches it; no Back button)
857
908
  var wrap = U.el('div', 'settings-wrap');
858
909
  s.appendChild(wrap);
859
910
  if (_settingsSection === 'addons') { settingsAddons(wrap); }
@@ -882,13 +933,12 @@ var Views = (function () {
882
933
  function settingsMenu(wrap) {
883
934
  wrap.appendChild(U.el('h2', null, 'Settings'));
884
935
  wrap.appendChild(sectionRow('Addons', 'Catalog, stream & subtitle addons', 'addons'));
885
- wrap.appendChild(sectionRow('Live TV (IPTV)', 'Load channels from an M3U playlist URL', 'iptv'));
886
936
  wrap.appendChild(sectionRow('Integrations', 'API keys (OMDb, TMDB, MDBList)', 'integrations'));
937
+ wrap.appendChild(sectionRow('Live TV (IPTV)', 'Load channels from an M3U playlist URL', 'iptv'));
887
938
  wrap.appendChild(sectionRow('Subtitle Options', 'Default language, size, style', 'subtitles'));
888
939
  wrap.appendChild(sectionRow('Home Feeds', 'Order & show/hide the home rows', 'feeds'));
889
- wrap.appendChild(sectionRow('Display', 'Poster orientation', 'display'));
890
- wrap.appendChild(backRow());
891
- Focus.setScope(scr('settings'));
940
+ wrap.appendChild(sectionRow('Posters', 'Poster orientation', 'display'));
941
+ focusSettings();
892
942
  }
893
943
 
894
944
  function settingsAddons(wrap) {
@@ -939,10 +989,38 @@ var Views = (function () {
939
989
  };
940
990
  wrap.appendChild(addRow);
941
991
 
992
+ // Add several addons at once from a plain-text list URL — one addon/manifest
993
+ // URL per line. Handy for long URLs that are painful to type on the TV.
994
+ var listRow = U.el('div', 'addon-item focusable add-row');
995
+ listRow.appendChild(U.el('div', 'a-name', '+ Add from list URL'));
996
+ listRow.appendChild(U.el('div', 'a-url', 'Fetch a .txt of addon URLs (one per line) and add them all'));
997
+ listRow.__onselect = function () {
998
+ App.openEntry({
999
+ title: 'Add addons from a list URL',
1000
+ hint: 'Enter the URL of a text file with one addon/manifest URL per line (lines starting with # are ignored), then Confirm.',
1001
+ value: 'https://', placeholder: 'https://example.com/addons.txt',
1002
+ validate: function (v) { return (!v || v === 'https://' || v.indexOf('://') === -1 || v.length < 12) ? 'Enter a full list URL' : null; },
1003
+ onConfirm: function (v) {
1004
+ U.toast('Fetching addon list…', 5000);
1005
+ U.getText(v, 15000).then(function (text) {
1006
+ var added = 0;
1007
+ text.split(/\r?\n/).forEach(function (line) {
1008
+ var u = line.trim();
1009
+ if (!u || u.charAt(0) === '#' || u.indexOf('://') === -1) { return; }
1010
+ if (Stremio.addAddon(u)) { added++; }
1011
+ });
1012
+ clearCatalogCache();
1013
+ U.toast(added ? ('Added ' + added + ' addon(s)') : 'No addon URLs found in that file', 3500);
1014
+ App.back(); renderSettings();
1015
+ }, function () { U.toast('Could not fetch that list URL (check the URL/CORS).', 4000); });
1016
+ }
1017
+ });
1018
+ };
1019
+ wrap.appendChild(listRow);
1020
+
942
1021
  wrap.appendChild(U.el('div', 'hint',
943
1022
  'Nuvio hosts no content — add Stremio addon manifest URLs for catalogs, streams and subtitles.'));
944
- wrap.appendChild(backRow());
945
- Focus.setScope(scr('settings'));
1023
+ focusSettings();
946
1024
  });
947
1025
  }
948
1026
 
@@ -1005,8 +1083,7 @@ var Views = (function () {
1005
1083
  wrap.appendChild(U.el('div', 'hint',
1006
1084
  'Channels are grouped by the playlist\'s group-title into Live TV rows on Home. ' +
1007
1085
  'Direct HLS streams play via AVPlay; YouTube channels play via the built-in leanback proxy.'));
1008
- wrap.appendChild(backRow());
1009
- Focus.setScope(scr('settings'));
1086
+ focusSettings();
1010
1087
  }
1011
1088
 
1012
1089
  function maskKey(v) { v = '' + v; return v.length <= 4 ? '••••' : ('••••' + v.slice(-4)); }
@@ -1033,8 +1110,7 @@ var Views = (function () {
1033
1110
  wrap.appendChild(keyRow('TMDB API key (v3)', 'tmdb', 'TMDB v3 API key', Stremio.tmdbValidate));
1034
1111
  wrap.appendChild(keyRow('MDBList API key', 'mdblist', 'MDBList API key', Stremio.mdblistValidate));
1035
1112
  wrap.appendChild(U.el('div', 'hint', 'Keys are stored on this TV only. OMDb gives real IMDb per-episode ratings (used first when set); TMDB adds episode images & ratings.'));
1036
- wrap.appendChild(backRow());
1037
- Focus.setScope(scr('settings'));
1113
+ focusSettings();
1038
1114
  }
1039
1115
 
1040
1116
  function settingsSubtitles(wrap) {
@@ -1042,13 +1118,12 @@ var Views = (function () {
1042
1118
  wrap.appendChild(subPickRow('Default language', SUB_LANGS, 'lang'));
1043
1119
  wrap.appendChild(subCycleRow('Size', SUB_SIZES, 'size'));
1044
1120
  wrap.appendChild(subCycleRow('Style', SUB_STYLES, 'style'));
1045
- wrap.appendChild(backRow());
1046
- Focus.setScope(scr('settings'));
1121
+ focusSettings();
1047
1122
  }
1048
1123
 
1049
1124
  var POSTER_SHAPES = [['portrait', 'Portrait'], ['landscape', 'Landscape']];
1050
1125
  function settingsDisplay(wrap) {
1051
- wrap.appendChild(U.el('h2', null, 'Display'));
1126
+ wrap.appendChild(U.el('h2', null, 'Posters'));
1052
1127
  var row = U.el('div', 'addon-item focusable cfg-row');
1053
1128
  var name = U.el('div', 'a-name');
1054
1129
  function render() { name.textContent = 'Poster orientation: ' + labelFor(POSTER_SHAPES, Stremio.getPrefs().posterShape); }
@@ -1062,8 +1137,7 @@ var Views = (function () {
1062
1137
  };
1063
1138
  wrap.appendChild(row);
1064
1139
  wrap.appendChild(U.el('div', 'hint', 'Landscape uses wide artwork when available (falls back to the poster).'));
1065
- wrap.appendChild(backRow());
1066
- Focus.setScope(scr('settings'));
1140
+ focusSettings();
1067
1141
  }
1068
1142
 
1069
1143
  // ----- Home Feeds ordering -----
@@ -1096,7 +1170,6 @@ var Views = (function () {
1096
1170
  frow.appendChild(name);
1097
1171
  wrap.appendChild(frow);
1098
1172
  });
1099
- wrap.appendChild(backRow());
1100
1173
 
1101
1174
  // restore focus to the control that triggered the last change
1102
1175
  var pref = null;
@@ -1113,7 +1186,7 @@ var Views = (function () {
1113
1186
 
1114
1187
  return {
1115
1188
  renderHome: renderHome, renderDetail: renderDetail, renderSettings: renderSettings,
1116
- settingsRoot: settingsRoot, backInSettings: backInSettings,
1189
+ settingsRoot: settingsRoot, backInSettings: backInSettings, setHomeTab: setHomeTab,
1117
1190
  renderLive: renderLive,
1118
1191
  renderSearch: renderSearch, renderEntry: renderEntry, backInDetail: backInDetail,
1119
1192
  homeDirty: homeDirty,
package/js/ytplay.js CHANGED
@@ -7,7 +7,7 @@
7
7
  var YTPlay = (function () {
8
8
  'use strict';
9
9
 
10
- var PROXY = 'http://localhost:8099';
10
+ var PROXY = 'http://127.0.0.1:8099'; // 127.0.0.1 (not "localhost") resolves reliably on Tizen
11
11
  var _open = false;
12
12
  var _onExit = null;
13
13
  var _idCache = {}; // ytPage -> { id, ts }
@@ -24,25 +24,26 @@ var YTPlay = (function () {
24
24
  return new Promise(function (resolve, reject) {
25
25
  function poll(left) {
26
26
  U.getText(PROXY + '/health', 2500).then(function () { resolve(); }, function () {
27
- if (left <= 0) { reject(new Error('proxy-timeout')); return; }
27
+ if (left <= 0) { reject(new Error('proxy-down')); return; }
28
28
  setTimeout(function () { poll(left - 1); }, 500);
29
29
  });
30
30
  }
31
31
  // Already running? (warm TV service, or a locally-run proxy in dev)
32
32
  U.getText(PROXY + '/health', 1500).then(function () { resolve(); }, function () {
33
- if (window.tizen && tizen.application) {
34
- try {
33
+ // Best-effort launch of the bundled <tizen:service>, then keep polling.
34
+ var launched = false;
35
+ try {
36
+ if (window.tizen && tizen.application && tizen.application.launchAppControl) {
35
37
  var pkgId = tizen.application.getCurrentApplication().appInfo.packageId;
36
38
  tizen.application.launchAppControl(
37
39
  new tizen.ApplicationControl('http://tizen.org/appcontrol/operation/service'),
38
40
  pkgId + '.NuvioProxy',
39
- function () { poll(40); }, // launched → wait for it to listen (~20s)
40
- function () { poll(40); } // may already be running
41
+ function () {}, function () {}
41
42
  );
42
- } catch (e) { poll(40); }
43
- } else {
44
- reject(new Error('no-service'));
45
- }
43
+ launched = true;
44
+ }
45
+ } catch (e) { /* fall through to polling */ }
46
+ poll(launched ? 40 : 10); // ~20s if we launched it; a short probe otherwise
46
47
  });
47
48
  });
48
49
  }
@@ -63,17 +64,6 @@ var YTPlay = (function () {
63
64
  }, function () { return ''; });
64
65
  }
65
66
 
66
- function onHwKey(e) {
67
- if (e && e.keyName === 'back') { if (e.preventDefault) { e.preventDefault(); } close(); }
68
- }
69
- function onKeyDown(e) {
70
- if (e && (e.keyCode === 10009 || e.keyCode === 461)) {
71
- if (e.preventDefault) { e.preventDefault(); }
72
- if (e.stopPropagation) { e.stopPropagation(); }
73
- close();
74
- }
75
- }
76
-
77
67
  function show(id) {
78
68
  var o = overlay();
79
69
  if (!o) { return; }
@@ -86,17 +76,13 @@ var YTPlay = (function () {
86
76
  o.className = '';
87
77
  U.byId('app').className = 'hidden';
88
78
  _open = true;
89
- // Hardware Back is delivered app-wide (tizenhwkey) even when the iframe has
90
- // focus; the keydown capture covers the moment before the iframe grabs focus.
91
- document.addEventListener('tizenhwkey', onHwKey, true);
92
- document.addEventListener('keydown', onKeyDown, true);
79
+ // Back is handled app-wide by keys.js (tizenhwkey) App.back() YTPlay.close(),
80
+ // which works even while this cross-origin iframe holds focus.
93
81
  }
94
82
 
95
83
  function close() {
96
84
  if (!_open) { return; }
97
85
  _open = false;
98
- document.removeEventListener('tizenhwkey', onHwKey, true);
99
- document.removeEventListener('keydown', onKeyDown, true);
100
86
  var o = overlay();
101
87
  if (o) { o.className = 'hidden'; U.clear(o); }
102
88
  U.byId('app').className = '';
@@ -116,10 +102,8 @@ var YTPlay = (function () {
116
102
  show(id);
117
103
  }, function (err) {
118
104
  U.spinner(false);
119
- var msg = (err && err.message === 'no-service')
120
- ? 'YouTube needs the leanback proxy. For desktop dev, run: node service/index.js'
121
- : 'Could not reach the YouTube proxy service (port 8099).';
122
- U.toast(msg, 5000);
105
+ U.toast('Couldn\'t reach the YouTube proxy (port 8099). Use the sideloaded Nuvio ' +
106
+ 'build so its background service is installed. (Desktop dev: run node service/index.js.)', 5500);
123
107
  });
124
108
  }
125
109
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuvio-tizen",
3
- "version": "1.9.4",
3
+ "version": "1.9.6",
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",