nuvio-tizen 1.9.1 → 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,8 @@ 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
16
  var _status = { url: '', count: 0, groups: 0, error: '' }; // last load result, for Settings
17
17
 
18
18
  function getUrl() { return U.store.get(URL_KEY, '') || ''; }
@@ -21,9 +21,13 @@ var IPTV = (function () {
21
21
  function setUrl(u) {
22
22
  u = ('' + (u || '')).trim();
23
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
24
25
  reset();
25
26
  }
26
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
+
27
31
  function slug(s) {
28
32
  var v = ('' + s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
29
33
  return v.slice(0, 40) || 'x';
@@ -72,7 +76,7 @@ var IPTV = (function () {
72
76
  return channels;
73
77
  }
74
78
 
75
- function build(channels) {
79
+ function build(channels, url) {
76
80
  var groups = [], gseen = {}, byId = {};
77
81
  channels.forEach(function (c) {
78
82
  byId[c.id] = c;
@@ -81,31 +85,60 @@ var IPTV = (function () {
81
85
  var catalogs = groups.map(function (g) {
82
86
  return { type: 'tv', id: 'iptv_' + slug(g), name: g, extra: [{ name: 'search' }], _group: g };
83
87
  });
84
- 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 };
85
89
  }
86
90
 
87
- function load() {
88
- 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) {
89
103
  var url = getUrl();
90
104
  _status = { url: url, count: 0, groups: 0, error: '' };
91
- if (!url) { _cache = build([]); return Promise.resolve(_cache); }
92
- return U.getText(url, 15000).then(function (text) {
93
- _cache = build(parseM3U(text));
94
- _status.count = _cache.channels.length;
95
- _status.groups = _cache.groups.length;
96
- if (!_cache.channels.length) {
97
- // Fetched, but nothing parsed — usually the URL returned an error/HTML page
98
- // (e.g. a "not found" body) rather than an M3U.
99
- _status.error = 'Fetched, but no channels parsed (is the URL returning the .m3u file?)';
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);
100
113
  }
114
+ }
115
+ return U.getText(url, 15000).then(function (text) {
116
+ writePersist(url, text);
117
+ _cache = build(parseM3U(text), url);
118
+ syncStatus();
101
119
  return _cache;
102
120
  }, function (e) {
103
- _status.error = (e && e.message) ? e.message : 'Could not fetch the playlist URL';
104
- _cache = build([]); // keep the app usable if the playlist is unreachable
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();
105
137
  return _cache;
106
138
  });
107
139
  }
108
140
 
141
+ function reload() { return load(true); }
109
142
  function status() { return _status; }
110
143
 
111
144
  function toMeta(c) {
@@ -154,7 +187,7 @@ var IPTV = (function () {
154
187
  // A local addon object shaped like the HTTP addons in Stremio.loadAddons().
155
188
  // `__local` tells Stremio to route resource calls here instead of over HTTP.
156
189
  function synthAddon() {
157
- var d = _cache || build([]);
190
+ var d = _cache || build([], getUrl());
158
191
  return {
159
192
  __local: true, url: 'local:iptv', base: 'local:iptv',
160
193
  manifest: {
@@ -167,6 +200,6 @@ var IPTV = (function () {
167
200
 
168
201
  return {
169
202
  getUrl: getUrl, setUrl: setUrl, isEnabled: isEnabled, reset: reset, status: status,
170
- load: load, catalog: catalog, meta: meta, streams: streams, synthAddon: synthAddon
203
+ load: load, reload: reload, catalog: catalog, meta: meta, streams: streams, synthAddon: synthAddon
171
204
  };
172
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
@@ -120,6 +120,11 @@ var Stremio = (function () {
120
120
  function iptvGetUrl() { return _hasIptv ? IPTV.getUrl() : ''; }
121
121
  function iptvLoad() { return _hasIptv ? IPTV.load() : Promise.resolve(null); }
122
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
+ }
123
128
 
124
129
  // ---- Resource matching ----
125
130
  function resourceEntries(addon) {
@@ -547,6 +552,6 @@ var Stremio = (function () {
547
552
  isPlayable: isPlayable, streamLabel: streamLabel, streamName: streamName,
548
553
  streamSize: streamSize, streamDescription: streamDescription,
549
554
  iptvSetUrl: iptvSetUrl, iptvGetUrl: iptvGetUrl,
550
- iptvLoad: iptvLoad, iptvStatus: iptvStatus
555
+ iptvLoad: iptvLoad, iptvStatus: iptvStatus, iptvReload: iptvReload
551
556
  };
552
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');
@@ -881,10 +980,11 @@ var Views = (function () {
881
980
  reload.appendChild(U.el('div', 'a-name', '↻ Reload playlist'));
882
981
  reload.appendChild(U.el('div', 'a-url', 'Re-fetch channels from the M3U URL now'));
883
982
  reload.__onselect = function () {
884
- Stremio.iptvSetUrl(url); // re-sets same URL, clears cache
885
- clearCatalogCache();
886
- U.toast('Reloading channels…');
887
- setTimeout(renderSettings, 300);
983
+ U.toast('Reloading channels…', 4000);
984
+ Stremio.iptvReload().then(function () {
985
+ clearCatalogCache();
986
+ renderSettings();
987
+ });
888
988
  };
889
989
  wrap.appendChild(reload);
890
990
 
@@ -1014,6 +1114,7 @@ var Views = (function () {
1014
1114
  return {
1015
1115
  renderHome: renderHome, renderDetail: renderDetail, renderSettings: renderSettings,
1016
1116
  settingsRoot: settingsRoot, backInSettings: backInSettings,
1117
+ renderLive: renderLive,
1017
1118
  renderSearch: renderSearch, renderEntry: renderEntry, backInDetail: backInDetail,
1018
1119
  homeDirty: homeDirty,
1019
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.1",
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 = {