nuvio-tizen 1.9.0 → 1.9.4

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.0" viewmodes="maximized">
3
+ id="http://nuvio.tizen/NuvioTizen" version="1.9.4" 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
@@ -213,3 +213,11 @@ html, body {
213
213
  /* YouTube leanback overlay (IPTV YouTube channels via the localhost proxy) */
214
214
  #yt-leanback { position: absolute; top: 0; left: 0; width: 1920px; height: 1080px; z-index: 46; background: #000; }
215
215
  #yt-leanback iframe { width: 1920px; height: 1080px; border: 0; display: block; background: #000; }
216
+
217
+ /* Home top row: tab pills + Search + Settings on the left, NUVIO logo on the right */
218
+ .home-header .tabbar-inline { display: flex; align-items: center; }
219
+ .tab-pill { display: inline-flex; align-items: center; font-size: 22px; padding: 8px 20px; margin-right: 12px; border-radius: 28px; background: #1a1f2b; color: #aeb6c4; border: 4px solid transparent; white-space: nowrap; }
220
+ .tab-ic { margin-left: 8px; font-size: 22px; }
221
+ .tab-pill.active { background: #2a3346; color: #fff; }
222
+ .tab-pill.focusable.focused { background: #818cf8; color: #0e1117; border-color: #fff; }
223
+ .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.0">
7
+ <link rel="stylesheet" href="css/style.css?v=1.9.4">
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.0"></script>
54
- <script src="js/util.js?v=1.9.0"></script>
55
- <script src="js/iptv.js?v=1.9.0"></script>
56
- <script src="js/stremio.js?v=1.9.0"></script>
57
- <script src="js/keys.js?v=1.9.0"></script>
58
- <script src="js/focus.js?v=1.9.0"></script>
59
- <script src="js/player.js?v=1.9.0"></script>
60
- <script src="js/ytplay.js?v=1.9.0"></script>
61
- <script src="js/views.js?v=1.9.0"></script>
62
- <script src="js/app.js?v=1.9.0"></script>
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>
63
63
  </body>
64
64
  </html>
package/js/app.js CHANGED
@@ -23,6 +23,14 @@ var App = (function () {
23
23
  Views.renderDetail(type, id, preview, target);
24
24
  }
25
25
 
26
+ // Live TV: skip the info page — load sources and (if a single one) play immediately.
27
+ function openLive(type, id, preview) {
28
+ if (!id) { return; }
29
+ pushFrame();
30
+ showScreen('detail');
31
+ Views.renderLive(type, id, preview);
32
+ }
33
+
26
34
  function openSettings() {
27
35
  pushFrame();
28
36
  showScreen('settings');
@@ -125,7 +133,7 @@ var App = (function () {
125
133
 
126
134
  return {
127
135
  start: start, back: back,
128
- openDetail: openDetail, openSettings: openSettings, openSearch: openSearch,
136
+ openDetail: openDetail, openLive: openLive, openSettings: openSettings, openSearch: openSearch,
129
137
  openEntry: openEntry
130
138
  };
131
139
  })();
package/js/focus.js CHANGED
@@ -107,12 +107,15 @@ var Focus = (function () {
107
107
  }
108
108
  }
109
109
  } else {
110
- // same row only (absolute vertical tolerance), then nearest horizontally.
111
- // No jump to another row/header at a row end — just don't move.
112
- var ROW_TOL = 140;
110
+ // Horizontal (LEFT/RIGHT): only elements on the SAME visual row — they must
111
+ // actually overlap the current element vertically. This guarantees L/R never
112
+ // drifts up or down into another row (grids or the top menu); at a row end it
113
+ // simply doesn't move. Then pick the nearest one horizontally.
113
114
  for (var m = 0; m < cands.length; m++) {
114
115
  var d = cands[m];
115
- if (d.cross > ROW_TOL) { continue; }
116
+ var b = d.node.getBoundingClientRect();
117
+ var overlap = Math.min(b.bottom, r.bottom) - Math.max(b.top, r.top);
118
+ if (overlap < Math.min(b.height, r.height) * 0.5) { continue; } // not the same row
116
119
  if (d.primary < bestSel || (d.primary === bestSel && d.cross < bestTie)) {
117
120
  best = d.node; bestSel = d.primary; bestTie = d.cross;
118
121
  }
package/js/iptv.js CHANGED
@@ -11,8 +11,9 @@ var IPTV = (function () {
11
11
  'use strict';
12
12
 
13
13
  var URL_KEY = 'nuvio.iptv.url';
14
- var CACHE_MS = 10 * 60 * 1000;
15
- var _cache = null; // { ts, channels, groups, byId, catalogs }
14
+ var M3U_KEY = 'nuvio.iptv.m3u'; // persisted { url, text } — fetched once, kept until manual reload
15
+ var _cache = null; // { url, ts, channels, groups, byId, catalogs }
16
+ var _status = { url: '', count: 0, groups: 0, error: '' }; // last load result, for Settings
16
17
 
17
18
  function getUrl() { return U.store.get(URL_KEY, '') || ''; }
18
19
  function isEnabled() { return !!getUrl(); }
@@ -20,9 +21,13 @@ var IPTV = (function () {
20
21
  function setUrl(u) {
21
22
  u = ('' + (u || '')).trim();
22
23
  if (u) { U.store.set(URL_KEY, u); } else { U.store.remove(URL_KEY); }
24
+ U.store.remove(M3U_KEY); // drop the saved playlist; next load re-fetches
23
25
  reset();
24
26
  }
25
27
 
28
+ function readPersist() { return U.store.get(M3U_KEY, null); }
29
+ function writePersist(url, text) { U.store.set(M3U_KEY, { url: url, text: text }); }
30
+
26
31
  function slug(s) {
27
32
  var v = ('' + s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
28
33
  return v.slice(0, 40) || 'x';
@@ -71,7 +76,7 @@ var IPTV = (function () {
71
76
  return channels;
72
77
  }
73
78
 
74
- function build(channels) {
79
+ function build(channels, url) {
75
80
  var groups = [], gseen = {}, byId = {};
76
81
  channels.forEach(function (c) {
77
82
  byId[c.id] = c;
@@ -80,22 +85,62 @@ var IPTV = (function () {
80
85
  var catalogs = groups.map(function (g) {
81
86
  return { type: 'tv', id: 'iptv_' + slug(g), name: g, extra: [{ name: 'search' }], _group: g };
82
87
  });
83
- return { ts: Date.now(), channels: channels, groups: groups, byId: byId, catalogs: catalogs };
88
+ return { url: url || '', ts: Date.now(), channels: channels, groups: groups, byId: byId, catalogs: catalogs };
84
89
  }
85
90
 
86
- function load() {
87
- if (_cache && (Date.now() - _cache.ts) < CACHE_MS) { return Promise.resolve(_cache); }
91
+ function syncStatus() {
92
+ _status.count = _cache.channels.length;
93
+ _status.groups = _cache.groups.length;
94
+ if (_cache.url && !_cache.channels.length && !_status.error) {
95
+ _status.error = 'No channels parsed (is the URL returning the .m3u file?)';
96
+ }
97
+ }
98
+
99
+ // The playlist is fetched ONCE and saved to localStorage; later loads (incl. app
100
+ // restarts) parse the saved copy with no network. Pass force=true (Settings →
101
+ // Reload / Set URL) to re-fetch and re-save.
102
+ function load(force) {
88
103
  var url = getUrl();
89
- if (!url) { _cache = build([]); return Promise.resolve(_cache); }
104
+ _status = { url: url, count: 0, groups: 0, error: '' };
105
+ if (!url) { _cache = build([], ''); return Promise.resolve(_cache); }
106
+ if (!force && _cache && _cache.url === url) { syncStatus(); return Promise.resolve(_cache); }
107
+ if (!force) {
108
+ var p = readPersist();
109
+ if (p && p.url === url && typeof p.text === 'string') {
110
+ _cache = build(parseM3U(p.text), url);
111
+ syncStatus();
112
+ return Promise.resolve(_cache);
113
+ }
114
+ }
90
115
  return U.getText(url, 15000).then(function (text) {
91
- _cache = build(parseM3U(text));
116
+ writePersist(url, text);
117
+ _cache = build(parseM3U(text), url);
118
+ syncStatus();
92
119
  return _cache;
93
- }, function () {
94
- _cache = build([]); // keep the app usable if the playlist is unreachable
120
+ }, function (e) {
121
+ var msg = (e && e.message) ? e.message : 'Could not fetch the playlist URL';
122
+ // status 0 / network error on a URL that opens fine in a browser almost
123
+ // always means the server sent no Access-Control-Allow-Origin header.
124
+ if (/HTTP 0\b/.test(msg) || /Network error/i.test(msg)) {
125
+ msg += ' — the playlist server likely isn\'t sending an ' +
126
+ 'Access-Control-Allow-Origin header (CORS). Serve the .m3u with CORS enabled.';
127
+ }
128
+ _status.error = msg;
129
+ // Fall back to any previously-saved copy so a failed refresh doesn't wipe channels.
130
+ var prev = readPersist();
131
+ if (prev && prev.url === url && typeof prev.text === 'string') {
132
+ _cache = build(parseM3U(prev.text), url);
133
+ } else {
134
+ _cache = build([], url);
135
+ }
136
+ syncStatus();
95
137
  return _cache;
96
138
  });
97
139
  }
98
140
 
141
+ function reload() { return load(true); }
142
+ function status() { return _status; }
143
+
99
144
  function toMeta(c) {
100
145
  return {
101
146
  id: c.id, type: 'tv', name: c.name,
@@ -142,7 +187,7 @@ var IPTV = (function () {
142
187
  // A local addon object shaped like the HTTP addons in Stremio.loadAddons().
143
188
  // `__local` tells Stremio to route resource calls here instead of over HTTP.
144
189
  function synthAddon() {
145
- var d = _cache || build([]);
190
+ var d = _cache || build([], getUrl());
146
191
  return {
147
192
  __local: true, url: 'local:iptv', base: 'local:iptv',
148
193
  manifest: {
@@ -154,7 +199,7 @@ var IPTV = (function () {
154
199
  }
155
200
 
156
201
  return {
157
- getUrl: getUrl, setUrl: setUrl, isEnabled: isEnabled, reset: reset,
158
- load: load, catalog: catalog, meta: meta, streams: streams, synthAddon: synthAddon
202
+ getUrl: getUrl, setUrl: setUrl, isEnabled: isEnabled, reset: reset, status: status,
203
+ load: load, reload: reload, catalog: catalog, meta: meta, streams: streams, synthAddon: synthAddon
159
204
  };
160
205
  })();
package/js/player.js CHANGED
@@ -614,6 +614,7 @@ var Player = (function () {
614
614
  if (!_isLive) { b.push({ label: '↺ Restart', run: function () { seekTo(0); closeBar(); } }); }
615
615
  if (_ctx.hasPrev) { b.push({ label: '⏮ Previous', run: function () { closeBar(); _ctx.playPrev(); } }); }
616
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
618
  return b;
618
619
  }
619
620
  function openBar() {
package/js/stremio.js CHANGED
@@ -104,7 +104,9 @@ var Stremio = (function () {
104
104
  }).then(function (results) {
105
105
  var addons = [];
106
106
  results.forEach(function (r) { if (r.ok && r.value.manifest) { addons.push(r.value); } });
107
- if (_hasIptv && IPTV.isEnabled()) { addons.push(IPTV.synthAddon()); }
107
+ // Prepend so the Live TV rows sort first on Home and are never cut by the
108
+ // home-page row cap (the user can still reorder them in Home Feeds).
109
+ if (_hasIptv && IPTV.isEnabled()) { addons.unshift(IPTV.synthAddon()); }
108
110
  _loaded = addons;
109
111
  return addons;
110
112
  });
@@ -116,6 +118,13 @@ var Stremio = (function () {
116
118
  _loaded = null;
117
119
  }
118
120
  function iptvGetUrl() { return _hasIptv ? IPTV.getUrl() : ''; }
121
+ function iptvLoad() { return _hasIptv ? IPTV.load() : Promise.resolve(null); }
122
+ function iptvStatus() { return _hasIptv ? IPTV.status() : { error: 'IPTV unavailable' }; }
123
+ // Force a re-fetch of the playlist (Settings → Reload) and rebuild the addon list.
124
+ function iptvReload() {
125
+ _loaded = null;
126
+ return _hasIptv ? IPTV.reload() : Promise.resolve(null);
127
+ }
119
128
 
120
129
  // ---- Resource matching ----
121
130
  function resourceEntries(addon) {
@@ -542,6 +551,7 @@ var Stremio = (function () {
542
551
  getSearchCatalogs: getSearchCatalogs, search: search,
543
552
  isPlayable: isPlayable, streamLabel: streamLabel, streamName: streamName,
544
553
  streamSize: streamSize, streamDescription: streamDescription,
545
- iptvSetUrl: iptvSetUrl, iptvGetUrl: iptvGetUrl
554
+ iptvSetUrl: iptvSetUrl, iptvGetUrl: iptvGetUrl,
555
+ iptvLoad: iptvLoad, iptvStatus: iptvStatus, iptvReload: iptvReload
546
556
  };
547
557
  })();
package/js/views.js CHANGED
@@ -87,20 +87,27 @@ var Views = (function () {
87
87
  var srch = U.el('div', 'gear hchip focusable', 'Search 🔍');
88
88
  srch.style.marginRight = '20px';
89
89
  srch.__onselect = function () { App.openSearch(); };
90
- srch.__navDOWN = focusFirstRowItem;
90
+ srch.__navDOWN = focusActiveTabOrRow;
91
91
  h.appendChild(srch);
92
92
  }
93
93
  if (withGear) {
94
94
  var g = U.el('div', 'gear hchip focusable', 'Settings ⚙');
95
95
  g.__onselect = function () { App.openSettings(); };
96
- g.__navDOWN = focusFirstRowItem;
96
+ g.__navDOWN = focusActiveTabOrRow;
97
97
  h.appendChild(g);
98
98
  }
99
99
  return h;
100
100
  }
101
101
 
102
- // DOWN from the header lands on the first item of the first row
103
- // (Continue Watching when present), not the geometrically-nearest card.
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();
108
+ }
109
+
110
+ // DOWN from a tab pill lands on the first item of the first row.
104
111
  function focusFirstRowItem() {
105
112
  var rows = scr('home').querySelector('.rows');
106
113
  if (!rows) { return false; }
@@ -122,21 +129,58 @@ var Views = (function () {
122
129
  c.appendChild(U.el('div', 'fallback', meta.name || meta.title || ''));
123
130
  }
124
131
  c.appendChild(U.el('div', 'cap', meta.name || meta.title || ''));
125
- c.__onselect = function () { App.openDetail(meta.type || fallbackType, meta.id, meta); };
132
+ c.__onselect = function () {
133
+ var t = meta.type || fallbackType;
134
+ // Live TV catalogs (anything that isn't movies/series) skip the info page and
135
+ // go straight to Play — auto-playing when there's a single source.
136
+ if (fallbackType && fallbackType !== 'movie' && fallbackType !== 'series') {
137
+ App.openLive(t, meta.id, meta);
138
+ } else {
139
+ App.openDetail(t, meta.id, meta);
140
+ }
141
+ };
126
142
  return c;
127
143
  }
128
144
 
129
145
  // ================= HOME =================
130
- var _catalogCache = null; // [{name,type,metas}] avoids refetch on re-render
146
+ // Home is split into tabs (pills at the top). Continue Watching is local; the
147
+ // other three are the catalog rows of that Stremio type, in the user's
148
+ // Home-Feeds order/visibility. A tab only appears when it has content.
149
+ var HOME_TABS = [
150
+ { key: 'continue', icon: '⏵', label: 'Continue Watching' },
151
+ { key: 'movie', icon: '🎬', label: 'Movies' },
152
+ { key: 'series', icon: '📺', label: 'Series' },
153
+ { key: 'tv', icon: '📡', label: 'Live TV' },
154
+ { key: 'sport', icon: '⚽', label: 'Sports' }
155
+ ];
156
+
157
+ // Which cached catalogs belong to a tab. Sports is its own tab (type 'sport');
158
+ // Live TV is the catch-all for every other live source (tv, channel, …) — anything
159
+ // that isn't movies, series or sport.
160
+ function catsForTab(key) {
161
+ return (_homeCatalogs || []).filter(function (c) {
162
+ if (key === 'movie') { return c.type === 'movie'; }
163
+ if (key === 'series') { return c.type === 'series'; }
164
+ if (key === 'sport') { return c.type === 'sport'; }
165
+ if (key === 'tv') { return c.type !== 'movie' && c.type !== 'series' && c.type !== 'sport'; }
166
+ return false;
167
+ });
168
+ }
169
+ var _homeCatalogs = null; // [{name,type,metas}] — fetched once per session, filtered per tab
170
+ var _homeTab = null; // active tab key
131
171
  var _homeDirty = false; // set when Continue Watching / poster / addons changed
132
172
 
133
173
  function markHomeDirty() { _homeDirty = true; }
134
174
  function homeDirty() { return _homeDirty; }
135
- function clearCatalogCache() { _catalogCache = null; _homeDirty = true; }
175
+ function clearCatalogCache() { _homeCatalogs = null; _homeDirty = true; }
136
176
 
137
- function focusHome(s) {
177
+ function focusHome(s, preferPill) {
178
+ if (preferPill) {
179
+ var active = s.querySelector('.tab-pill.active');
180
+ if (active) { Focus.setScope(s, active); return; }
181
+ }
138
182
  var firstCard = s.querySelector('.rows .card.focusable');
139
- Focus.setScope(s, firstCard || undefined);
183
+ Focus.setScope(s, firstCard || s.querySelector('.tab-pill.active') || undefined);
140
184
  }
141
185
 
142
186
  function continueCard(e) {
@@ -166,55 +210,87 @@ var Views = (function () {
166
210
  _homeDirty = false;
167
211
  var s = scr('home');
168
212
  U.clear(s);
169
- s.appendChild(header(true, true));
213
+
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);
231
+
170
232
  var rows = U.el('div', 'rows');
171
233
  s.appendChild(rows);
172
234
 
173
- // Continue watching (always fresh it's local)
174
- var cont = Player.continueList();
175
- if (cont.length) {
176
- rows.appendChild(buildRow('Continue Watching · hold OK to remove', cont.map(continueCard)));
177
- }
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
+ }
178
244
 
179
- // Catalog rows: use cache for an instant re-render; fetch only when cold.
180
- if (_catalogCache) {
181
- _catalogCache.forEach(function (c) {
182
- var row = buildRow(c.name, c.metas.map(function (m) { return posterCard(m, c.type); }));
183
- row.setAttribute('data-filled', '1');
184
- rows.appendChild(row);
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);
185
253
  });
186
- focusHome(s);
187
- return;
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
+ }
267
+ }
268
+ focusHome(s, preferPill);
188
269
  }
189
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);
190
276
  U.spinner(true);
191
277
  Stremio.catalogsForHome().then(function (cats) {
192
- var pick = cats.slice(0, 15);
193
- var rowNodes = pick.map(function (cat) { var ph = buildRow(cat.name, []); rows.appendChild(ph); return ph; });
194
- focusHome(s);
195
- U.spinner(false);
196
-
197
- var cache = pick.map(function (cat) { return { name: cat.name, type: cat.type, metas: [] }; });
198
- var tasks = pick.map(function (cat, i) {
278
+ var acc = cats.map(function (cat) { return { name: cat.name, type: cat.type, metas: [] }; });
279
+ var tasks = cats.map(function (cat, i) {
199
280
  return function () {
200
- return Stremio.getCatalog(cat).then(function (metas) {
201
- var slice = metas.slice(0, 24);
202
- cache[i].metas = slice;
203
- fillRow(rowNodes[i], slice, cat.type);
204
- });
281
+ return Stremio.getCatalog(cat).then(function (metas) { acc[i].metas = metas.slice(0, 24); });
205
282
  };
206
283
  });
207
284
  U.settleAll(tasks, 3).then(function () {
208
- for (var i = rowNodes.length - 1; i >= 0; i--) {
209
- if (!rowNodes[i].getAttribute('data-filled')) { rows.removeChild(rowNodes[i]); }
210
- }
211
- _catalogCache = cache.filter(function (c) { return c.metas && c.metas.length; });
212
- if (!Focus.current()) { focusHome(s); }
285
+ _homeCatalogs = acc.filter(function (c) { return c.metas && c.metas.length; });
286
+ U.spinner(false);
287
+ paint(false);
213
288
  });
214
- }, function (err) {
289
+ }, function () {
215
290
  U.spinner(false);
216
291
  U.toast('Failed to load addons. Check network / Settings.');
217
- focusHome(s);
292
+ _homeCatalogs = [];
293
+ paint(false);
218
294
  });
219
295
  }
220
296
 
@@ -237,6 +313,29 @@ var Views = (function () {
237
313
  // ================= DETAIL =================
238
314
  var _d = null; // { type, id, meta, mode, season }
239
315
 
316
+ // Live TV entry: load sources and play the single one directly, else show the
317
+ // source list (no info page). Reuses the streams flow.
318
+ function renderLive(type, id, preview) {
319
+ _d = { type: type, id: id, meta: preview || { id: id, type: type }, mode: 'streams',
320
+ season: null, preview: preview || null, target: null,
321
+ streamId: id, streamLabel: (preview && (preview.name || preview.title)) || '', streams: null };
322
+ var s = scr('detail');
323
+ U.clear(s);
324
+ s.appendChild(U.el('div', 'detail-hero'));
325
+ U.spinner(true);
326
+ Stremio.getStreams(type, id).then(function (list) {
327
+ U.spinner(false);
328
+ _d.streams = list;
329
+ var playable = list.filter(Stremio.isPlayable);
330
+ if (!playable.length) { U.toast('No playable source for this channel'); App.back(); return; }
331
+ if (playable.length === 1) {
332
+ startPlayback(playable[0], id, _d.streamLabel || _d.meta.name || 'Live');
333
+ } else {
334
+ drawDetail(); // multiple sources → straight to the source list
335
+ }
336
+ }, function () { U.spinner(false); U.toast('Could not load sources'); App.back(); });
337
+ }
338
+
240
339
  function renderDetail(type, id, preview, target) {
241
340
  _d = { type: type, id: id, meta: null, mode: 'info', season: null, preview: preview || null, target: target || null };
242
341
  var s = scr('detail');
@@ -851,6 +950,17 @@ var Views = (function () {
851
950
  wrap.appendChild(U.el('h2', null, 'Live TV (IPTV)'));
852
951
  var url = Stremio.iptvGetUrl ? Stremio.iptvGetUrl() : '';
853
952
 
953
+ // Live load status — turns a silent fetch failure into a visible message.
954
+ if (url && Stremio.iptvLoad) {
955
+ var statusEl = U.el('div', 'hint', 'Checking playlist…');
956
+ wrap.appendChild(statusEl);
957
+ Stremio.iptvLoad().then(function () {
958
+ var st = Stremio.iptvStatus();
959
+ if (st.error) { statusEl.textContent = '⚠ ' + st.error; }
960
+ else { statusEl.textContent = '✓ Loaded ' + st.count + ' channels in ' + st.groups + ' group(s).'; }
961
+ });
962
+ }
963
+
854
964
  var set = U.el('div', 'addon-item focusable');
855
965
  set.appendChild(U.el('div', 'a-name', url ? 'M3U playlist' : '+ Set M3U playlist URL'));
856
966
  set.appendChild(U.el('div', 'a-url', url || 'Not set — press OK to enter an M3U URL'));
@@ -870,10 +980,11 @@ var Views = (function () {
870
980
  reload.appendChild(U.el('div', 'a-name', '↻ Reload playlist'));
871
981
  reload.appendChild(U.el('div', 'a-url', 'Re-fetch channels from the M3U URL now'));
872
982
  reload.__onselect = function () {
873
- Stremio.iptvSetUrl(url); // re-sets same URL, clears cache
874
- clearCatalogCache();
875
- U.toast('Reloading channels…');
876
- setTimeout(renderSettings, 300);
983
+ U.toast('Reloading channels…', 4000);
984
+ Stremio.iptvReload().then(function () {
985
+ clearCatalogCache();
986
+ renderSettings();
987
+ });
877
988
  };
878
989
  wrap.appendChild(reload);
879
990
 
@@ -1003,6 +1114,7 @@ var Views = (function () {
1003
1114
  return {
1004
1115
  renderHome: renderHome, renderDetail: renderDetail, renderSettings: renderSettings,
1005
1116
  settingsRoot: settingsRoot, backInSettings: backInSettings,
1117
+ renderLive: renderLive,
1006
1118
  renderSearch: renderSearch, renderEntry: renderEntry, backInDetail: backInDetail,
1007
1119
  homeDirty: homeDirty,
1008
1120
  isModalOpen: function () { return _modalOpen; }, closeModal: closeModal
package/js/ytplay.js CHANGED
@@ -15,35 +15,47 @@ var YTPlay = (function () {
15
15
 
16
16
  function overlay() { return U.byId('yt-leanback'); }
17
17
 
18
- // Launch the proxy service (idempotent) and resolve once it answers /health.
18
+ // Resolve once the proxy answers /health. Order:
19
+ // 1. Probe /health first — covers a warm service (TV) and a dev run of the proxy
20
+ // under Node in a desktop browser (`node service/index.js`).
21
+ // 2. On Tizen, if it's not up, launch the <tizen:service> and poll.
22
+ // 3. Otherwise (browser, no proxy running) reject with a dev hint.
19
23
  function ensureService() {
20
24
  return new Promise(function (resolve, reject) {
21
- if (!(window.tizen && tizen.application)) { reject(new Error('no-service')); return; }
22
25
  function poll(left) {
23
26
  U.getText(PROXY + '/health', 2500).then(function () { resolve(); }, function () {
24
27
  if (left <= 0) { reject(new Error('proxy-timeout')); return; }
25
28
  setTimeout(function () { poll(left - 1); }, 500);
26
29
  });
27
30
  }
28
- try {
29
- var pkgId = tizen.application.getCurrentApplication().appInfo.packageId;
30
- tizen.application.launchAppControl(
31
- new tizen.ApplicationControl('http://tizen.org/appcontrol/operation/service'),
32
- pkgId + '.NuvioProxy',
33
- function () { poll(40); }, // launched → wait for it to listen (~20s)
34
- function () { poll(40); } // may already be running
35
- );
36
- } catch (e) { poll(40); }
31
+ // Already running? (warm TV service, or a locally-run proxy in dev)
32
+ U.getText(PROXY + '/health', 1500).then(function () { resolve(); }, function () {
33
+ if (window.tizen && tizen.application) {
34
+ try {
35
+ var pkgId = tizen.application.getCurrentApplication().appInfo.packageId;
36
+ tizen.application.launchAppControl(
37
+ new tizen.ApplicationControl('http://tizen.org/appcontrol/operation/service'),
38
+ pkgId + '.NuvioProxy',
39
+ function () { poll(40); }, // launched → wait for it to listen (~20s)
40
+ function () { poll(40); } // may already be running
41
+ );
42
+ } catch (e) { poll(40); }
43
+ } else {
44
+ reject(new Error('no-service'));
45
+ }
46
+ });
37
47
  });
38
48
  }
39
49
 
40
50
  // A youtube.com/<channel>/live (or /watch?v=) URL -> the current live video id.
51
+ // The page is fetched THROUGH the proxy's /cors-bypass so it works both on the TV
52
+ // and in a desktop dev browser (youtube.com sends no CORS header of its own).
41
53
  function resolveVideoId(page) {
42
54
  var direct = ('' + page).match(/[?&]v=([A-Za-z0-9_-]{11})/);
43
55
  if (direct) { return Promise.resolve(direct[1]); }
44
56
  var hit = _idCache[page];
45
57
  if (hit && (Date.now() - hit.ts) < ID_TTL) { return Promise.resolve(hit.id); }
46
- return U.getText(page, 12000).then(function (html) {
58
+ return U.getText(PROXY + '/cors-bypass/' + page, 12000).then(function (html) {
47
59
  var m = html.match(/"videoId":"([A-Za-z0-9_-]{11})"/);
48
60
  var id = m ? m[1] : '';
49
61
  if (id) { _idCache[page] = { id: id, ts: Date.now() }; }
@@ -105,9 +117,9 @@ var YTPlay = (function () {
105
117
  }, function (err) {
106
118
  U.spinner(false);
107
119
  var msg = (err && err.message === 'no-service')
108
- ? 'YouTube channels need the on-TV proxy (not available in this browser).'
109
- : 'Could not start the YouTube proxy service.';
110
- U.toast(msg, 4500);
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);
111
123
  });
112
124
  }
113
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuvio-tizen",
3
- "version": "1.9.0",
3
+ "version": "1.9.4",
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",
@@ -26,4 +26,4 @@
26
26
  ],
27
27
  "evaluateScriptOnDocumentStart": false,
28
28
  "license": "MIT"
29
- }
29
+ }
package/service/index.js CHANGED
@@ -23,6 +23,9 @@ var zlib = require('zlib');
23
23
  var urllib = require('url');
24
24
 
25
25
  var PORT = 8099;
26
+ // Force a Samsung Tizen SMART-TV user-agent upstream so youtube.com always serves the
27
+ // leanback TV app (otherwise a non-TV UA gets "you are being directed to youtube.com").
28
+ var TV_UA = 'Mozilla/5.0 (SMART-TV; LINUX; Tizen 6.0) AppleWebKit/537.36 (KHTML, like Gecko) 76.0.3809.146/6.0 TV Safari/537.36';
26
29
  var USERSCRIPT_CDN = 'https://cdn.jsdelivr.net/npm/@krx3d/tizentube2/dist/userScript.js';
27
30
  var USERSCRIPT_FALLBACK = 'https://unpkg.com/@krx3d/tizentube2/dist/userScript.js';
28
31
  var ALLOWED = ['googlevideo.com', 'youtube.com', 'gstatic.com', 'google.com',
@@ -156,6 +159,7 @@ function handle(req, res) {
156
159
  headers.origin = 'https://www.youtube.com';
157
160
  if (headers.referer) { headers.referer = 'https://www.youtube.com/tv'; }
158
161
  headers['accept-encoding'] = 'gzip, deflate';
162
+ headers['user-agent'] = TV_UA; // ensure leanback is served regardless of the caller's UA
159
163
 
160
164
  var mod = pu.protocol === 'http:' ? http : https;
161
165
  var options = {