nuvio-tizen 1.9.1 → 1.9.5

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.5" 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,12 @@ 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
+ .home-header .hchip.active { background: #2a3346; color: #fff; opacity: 1; }
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.0">
7
+ <link rel="stylesheet" href="css/style.css?v=1.9.5">
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.5"></script>
54
+ <script src="js/util.js?v=1.9.5"></script>
55
+ <script src="js/iptv.js?v=1.9.5"></script>
56
+ <script src="js/stremio.js?v=1.9.5"></script>
57
+ <script src="js/keys.js?v=1.9.5"></script>
58
+ <script src="js/focus.js?v=1.9.5"></script>
59
+ <script src="js/player.js?v=1.9.5"></script>
60
+ <script src="js/ytplay.js?v=1.9.5"></script>
61
+ <script src="js/views.js?v=1.9.5"></script>
62
+ <script src="js/app.js?v=1.9.5"></script>
63
63
  </body>
64
64
  </html>
package/js/app.js CHANGED
@@ -23,6 +23,22 @@ var App = (function () {
23
23
  Views.renderDetail(type, id, preview, target);
24
24
  }
25
25
 
26
+ // Go to a Home tab from the shared top menu (works from any screen).
27
+ function goHome(tab) {
28
+ Views.setHomeTab(tab);
29
+ _stack = []; // Home is the root
30
+ showScreen('home');
31
+ Views.renderHome();
32
+ }
33
+
34
+ // Live TV: skip the info page — load sources and (if a single one) play immediately.
35
+ function openLive(type, id, preview) {
36
+ if (!id) { return; }
37
+ pushFrame();
38
+ showScreen('detail');
39
+ Views.renderLive(type, id, preview);
40
+ }
41
+
26
42
  function openSettings() {
27
43
  pushFrame();
28
44
  showScreen('settings');
@@ -125,8 +141,8 @@ var App = (function () {
125
141
 
126
142
  return {
127
143
  start: start, back: back,
128
- openDetail: openDetail, openSettings: openSettings, openSearch: openSearch,
129
- openEntry: openEntry
144
+ openDetail: openDetail, openLive: openLive, goHome: goHome,
145
+ openSettings: openSettings, openSearch: openSearch, openEntry: openEntry
130
146
  };
131
147
  })();
132
148
 
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
@@ -610,10 +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
+ 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
617
620
  return b;
618
621
  }
619
622
  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
@@ -77,35 +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 = focusFirstRowItem;
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 = focusFirstRowItem;
97
- h.appendChild(g);
98
- }
99
88
  return h;
100
89
  }
101
90
 
102
- // DOWN from the header lands on the first item of the first row
103
- // (Continue Watching when present), not the geometrically-nearest card.
104
- function focusFirstRowItem() {
105
- var rows = scr('home').querySelector('.rows');
106
- if (!rows) { return false; }
107
- var first = rows.querySelector('.focusable');
108
- if (first) { Focus.setFocus(first); return true; }
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
+ });
134
+ }
135
+
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
+ }
109
144
  return false;
110
145
  }
111
146
 
@@ -122,21 +157,59 @@ var Views = (function () {
122
157
  c.appendChild(U.el('div', 'fallback', meta.name || meta.title || ''));
123
158
  }
124
159
  c.appendChild(U.el('div', 'cap', meta.name || meta.title || ''));
125
- c.__onselect = function () { App.openDetail(meta.type || fallbackType, meta.id, meta); };
160
+ c.__onselect = function () {
161
+ var t = meta.type || fallbackType;
162
+ // Live TV catalogs (anything that isn't movies/series) skip the info page and
163
+ // go straight to Play — auto-playing when there's a single source.
164
+ if (fallbackType && fallbackType !== 'movie' && fallbackType !== 'series') {
165
+ App.openLive(t, meta.id, meta);
166
+ } else {
167
+ App.openDetail(t, meta.id, meta);
168
+ }
169
+ };
126
170
  return c;
127
171
  }
128
172
 
129
173
  // ================= HOME =================
130
- var _catalogCache = null; // [{name,type,metas}] avoids refetch on re-render
174
+ // Home is split into tabs (pills at the top). Continue Watching is local; the
175
+ // other three are the catalog rows of that Stremio type, in the user's
176
+ // Home-Feeds order/visibility. A tab only appears when it has content.
177
+ var HOME_TABS = [
178
+ { key: 'continue', icon: '▶', label: 'Continue Watching' },
179
+ { key: 'movie', icon: '🎬', label: 'Movies' },
180
+ { key: 'series', icon: '📺', label: 'Series' },
181
+ { key: 'tv', icon: '📡', label: 'Live TV' },
182
+ { key: 'sport', icon: '⚽', label: 'Sports' }
183
+ ];
184
+
185
+ // Which cached catalogs belong to a tab. Sports is its own tab (type 'sport');
186
+ // Live TV is the catch-all for every other live source (tv, channel, …) — anything
187
+ // that isn't movies, series or sport.
188
+ function catsForTab(key) {
189
+ return (_homeCatalogs || []).filter(function (c) {
190
+ if (key === 'movie') { return c.type === 'movie'; }
191
+ if (key === 'series') { return c.type === 'series'; }
192
+ if (key === 'sport') { return c.type === 'sport'; }
193
+ if (key === 'tv') { return c.type !== 'movie' && c.type !== 'series' && c.type !== 'sport'; }
194
+ return false;
195
+ });
196
+ }
197
+ var _homeCatalogs = null; // [{name,type,metas}] — fetched once per session, filtered per tab
198
+ var _homeTab = null; // active tab key
131
199
  var _homeDirty = false; // set when Continue Watching / poster / addons changed
200
+ var _focusTopPill = false; // next home render should focus the active tab pill
132
201
 
133
202
  function markHomeDirty() { _homeDirty = true; }
134
203
  function homeDirty() { return _homeDirty; }
135
- function clearCatalogCache() { _catalogCache = null; _homeDirty = true; }
204
+ function clearCatalogCache() { _homeCatalogs = null; _homeDirty = true; }
136
205
 
137
- function focusHome(s) {
206
+ function focusHome(s, preferPill) {
207
+ if (preferPill) {
208
+ var active = s.querySelector('.tab-pill.active');
209
+ if (active) { Focus.setScope(s, active); return; }
210
+ }
138
211
  var firstCard = s.querySelector('.rows .card.focusable');
139
- Focus.setScope(s, firstCard || undefined);
212
+ Focus.setScope(s, firstCard || s.querySelector('.tab-pill.active') || undefined);
140
213
  }
141
214
 
142
215
  function continueCard(e) {
@@ -162,60 +235,62 @@ var Views = (function () {
162
235
  return c;
163
236
  }
164
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
+
165
241
  function renderHome() {
166
242
  _homeDirty = false;
167
243
  var s = scr('home');
168
244
  U.clear(s);
169
- s.appendChild(header(true, true));
245
+
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
+ }
251
+
252
+ s.appendChild(buildTopMenu(_homeTab));
170
253
  var rows = U.el('div', 'rows');
171
254
  s.appendChild(rows);
172
255
 
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)));
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
+ }
177
268
  }
178
-
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);
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 () {
289
+ U.spinner(false);
290
+ U.toast('Failed to load addons. Check network / Settings.');
291
+ _homeCatalogs = [];
185
292
  });
186
- focusHome(s);
187
- return;
188
293
  }
189
-
190
- U.spinner(true);
191
- 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) {
199
- 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
- });
205
- };
206
- });
207
- 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); }
213
- });
214
- }, function (err) {
215
- U.spinner(false);
216
- U.toast('Failed to load addons. Check network / Settings.');
217
- focusHome(s);
218
- });
219
294
  }
220
295
 
221
296
  function buildRow(title, cards) {
@@ -237,6 +312,30 @@ var Views = (function () {
237
312
  // ================= DETAIL =================
238
313
  var _d = null; // { type, id, meta, mode, season }
239
314
 
315
+ // Live TV entry: load sources and play the single one directly, else show the
316
+ // source list (no info page). Reuses the streams flow.
317
+ function renderLive(type, id, preview) {
318
+ _d = { type: type, id: id, meta: preview || { id: id, type: type }, mode: 'streams',
319
+ season: null, preview: preview || null, target: null,
320
+ streamId: id, streamLabel: (preview && (preview.name || preview.title)) || '', streams: null };
321
+ var s = scr('detail');
322
+ U.clear(s);
323
+ s.appendChild(U.el('div', 'detail-hero'));
324
+ U.spinner(true);
325
+ Stremio.getStreams(type, id).then(function (list) {
326
+ U.spinner(false);
327
+ _d.streams = list;
328
+ var playable = list.filter(Stremio.isPlayable);
329
+ if (!playable.length) { U.toast('No playable source for this channel'); App.back(); return; }
330
+ if (playable.length === 1) {
331
+ _d.exitToBack = true; // exiting playback returns straight to the grid
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');
@@ -246,19 +345,26 @@ var Views = (function () {
246
345
  Stremio.getMeta(type, id).then(function (meta) {
247
346
  _d.meta = meta;
248
347
  U.spinner(false);
249
- drawDetail();
348
+ afterMeta();
250
349
  }, function (err) {
251
350
  U.spinner(false);
252
351
  // Fallback: stream-only addons (e.g. live TV/sports) may expose no meta
253
352
  // resource. Use the catalog preview so the title still opens & plays.
254
353
  if (_d.preview) {
255
354
  _d.meta = Object.assign({ id: id, type: type }, _d.preview);
256
- drawDetail();
355
+ afterMeta();
257
356
  } else {
258
357
  U.toast('Could not load details');
259
358
  App.back();
260
359
  }
261
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
+ }
262
368
  }
263
369
 
264
370
  function drawDetail() {
@@ -517,8 +623,14 @@ var Views = (function () {
517
623
 
518
624
  function startPlayback(stream, videoId, label) {
519
625
  markHomeDirty(); // progress/watched will change → refresh Continue Watching on return
626
+ // Exit handler: single-source live channels return straight to the grid (skipping
627
+ // the source/info pages we skipped on the way in); everything else redraws detail.
628
+ var exitToBack = !!(_d && _d.exitToBack);
629
+ // Single-source live: skip the (already-skipped) source/info pages and go
630
+ // straight back to the home grid, on the tab we came from.
631
+ var onExit = function () { if (exitToBack) { App.goHome(); } else { drawDetail(); } };
520
632
  // YouTube-live IPTV channels play through the leanback proxy overlay, not AVPlay.
521
- if (stream._yt) { YTPlay.open(stream, function () { drawDetail(); }); return; }
633
+ if (stream._yt) { YTPlay.open(stream, onExit); return; }
522
634
  var ctx = { url: stream.url, type: _d.type, id: videoId, name: label,
523
635
  poster: _d.meta.poster, streams: playableSources(), _headers: stream._headers };
524
636
  if (_d.type === 'series' && _d.meta.videos) {
@@ -531,7 +643,7 @@ var Views = (function () {
531
643
  ctx.playNext = function () { playEpisode(ord[idx + 1]); };
532
644
  }
533
645
  }
534
- Player.play(ctx, function () { drawDetail(); });
646
+ Player.play(ctx, onExit);
535
647
  }
536
648
 
537
649
  // Load a specific episode's sources and auto-play the first playable one.
@@ -568,7 +680,7 @@ var Views = (function () {
568
680
  function renderSearch() {
569
681
  var s = scr('search');
570
682
  U.clear(s);
571
- s.appendChild(header(false, false));
683
+ s.appendChild(buildTopMenu('search')); // shared top menu (UP reaches it; no Back button)
572
684
 
573
685
  var wrap = U.el('div', 'search-wrap');
574
686
  var bar = U.el('div', 'search-bar');
@@ -587,10 +699,6 @@ var Views = (function () {
587
699
  var go = U.el('div', 'pill focusable search-go', 'Search');
588
700
  go.__onselect = function () { runSearch(input.value); };
589
701
  bar.appendChild(go);
590
-
591
- var back = U.el('div', 'pill focusable search-go', '← Back');
592
- back.__onselect = function () { App.back(); };
593
- bar.appendChild(back);
594
702
  wrap.appendChild(bar);
595
703
 
596
704
  var results = U.el('div', 'search-results');
@@ -745,6 +853,12 @@ var Views = (function () {
745
853
 
746
854
  function settingsRoot() { _settingsSection = 'menu'; renderSettings(); }
747
855
 
856
+ // Focus the first item in the settings content (not the top menu) so UP reaches the menu.
857
+ function focusSettings() {
858
+ var s = scr('settings');
859
+ Focus.setScope(s, s.querySelector('.settings-wrap .focusable') || undefined);
860
+ }
861
+
748
862
  // Return true if BACK stepped a settings section back to the menu.
749
863
  function backInSettings() {
750
864
  if (_settingsSection !== 'menu') { _settingsSection = 'menu'; renderSettings(); return true; }
@@ -754,7 +868,7 @@ var Views = (function () {
754
868
  function renderSettings() {
755
869
  var s = scr('settings');
756
870
  U.clear(s);
757
- s.appendChild(header(false, false));
871
+ s.appendChild(buildTopMenu('settings')); // shared top menu (UP reaches it; no Back button)
758
872
  var wrap = U.el('div', 'settings-wrap');
759
873
  s.appendChild(wrap);
760
874
  if (_settingsSection === 'addons') { settingsAddons(wrap); }
@@ -783,13 +897,12 @@ var Views = (function () {
783
897
  function settingsMenu(wrap) {
784
898
  wrap.appendChild(U.el('h2', null, 'Settings'));
785
899
  wrap.appendChild(sectionRow('Addons', 'Catalog, stream & subtitle addons', 'addons'));
786
- wrap.appendChild(sectionRow('Live TV (IPTV)', 'Load channels from an M3U playlist URL', 'iptv'));
787
900
  wrap.appendChild(sectionRow('Integrations', 'API keys (OMDb, TMDB, MDBList)', 'integrations'));
901
+ wrap.appendChild(sectionRow('Live TV (IPTV)', 'Load channels from an M3U playlist URL', 'iptv'));
788
902
  wrap.appendChild(sectionRow('Subtitle Options', 'Default language, size, style', 'subtitles'));
789
903
  wrap.appendChild(sectionRow('Home Feeds', 'Order & show/hide the home rows', 'feeds'));
790
- wrap.appendChild(sectionRow('Display', 'Poster orientation', 'display'));
791
- wrap.appendChild(backRow());
792
- Focus.setScope(scr('settings'));
904
+ wrap.appendChild(sectionRow('Posters', 'Poster orientation', 'display'));
905
+ focusSettings();
793
906
  }
794
907
 
795
908
  function settingsAddons(wrap) {
@@ -840,10 +953,38 @@ var Views = (function () {
840
953
  };
841
954
  wrap.appendChild(addRow);
842
955
 
956
+ // Add several addons at once from a plain-text list URL — one addon/manifest
957
+ // URL per line. Handy for long URLs that are painful to type on the TV.
958
+ var listRow = U.el('div', 'addon-item focusable add-row');
959
+ listRow.appendChild(U.el('div', 'a-name', '+ Add from list URL'));
960
+ listRow.appendChild(U.el('div', 'a-url', 'Fetch a .txt of addon URLs (one per line) and add them all'));
961
+ listRow.__onselect = function () {
962
+ App.openEntry({
963
+ title: 'Add addons from a list URL',
964
+ hint: 'Enter the URL of a text file with one addon/manifest URL per line (lines starting with # are ignored), then Confirm.',
965
+ value: 'https://', placeholder: 'https://example.com/addons.txt',
966
+ validate: function (v) { return (!v || v === 'https://' || v.indexOf('://') === -1 || v.length < 12) ? 'Enter a full list URL' : null; },
967
+ onConfirm: function (v) {
968
+ U.toast('Fetching addon list…', 5000);
969
+ U.getText(v, 15000).then(function (text) {
970
+ var added = 0;
971
+ text.split(/\r?\n/).forEach(function (line) {
972
+ var u = line.trim();
973
+ if (!u || u.charAt(0) === '#' || u.indexOf('://') === -1) { return; }
974
+ if (Stremio.addAddon(u)) { added++; }
975
+ });
976
+ clearCatalogCache();
977
+ U.toast(added ? ('Added ' + added + ' addon(s)') : 'No addon URLs found in that file', 3500);
978
+ App.back(); renderSettings();
979
+ }, function () { U.toast('Could not fetch that list URL (check the URL/CORS).', 4000); });
980
+ }
981
+ });
982
+ };
983
+ wrap.appendChild(listRow);
984
+
843
985
  wrap.appendChild(U.el('div', 'hint',
844
986
  'Nuvio hosts no content — add Stremio addon manifest URLs for catalogs, streams and subtitles.'));
845
- wrap.appendChild(backRow());
846
- Focus.setScope(scr('settings'));
987
+ focusSettings();
847
988
  });
848
989
  }
849
990
 
@@ -881,10 +1022,11 @@ var Views = (function () {
881
1022
  reload.appendChild(U.el('div', 'a-name', '↻ Reload playlist'));
882
1023
  reload.appendChild(U.el('div', 'a-url', 'Re-fetch channels from the M3U URL now'));
883
1024
  reload.__onselect = function () {
884
- Stremio.iptvSetUrl(url); // re-sets same URL, clears cache
885
- clearCatalogCache();
886
- U.toast('Reloading channels…');
887
- setTimeout(renderSettings, 300);
1025
+ U.toast('Reloading channels…', 4000);
1026
+ Stremio.iptvReload().then(function () {
1027
+ clearCatalogCache();
1028
+ renderSettings();
1029
+ });
888
1030
  };
889
1031
  wrap.appendChild(reload);
890
1032
 
@@ -905,8 +1047,7 @@ var Views = (function () {
905
1047
  wrap.appendChild(U.el('div', 'hint',
906
1048
  'Channels are grouped by the playlist\'s group-title into Live TV rows on Home. ' +
907
1049
  'Direct HLS streams play via AVPlay; YouTube channels play via the built-in leanback proxy.'));
908
- wrap.appendChild(backRow());
909
- Focus.setScope(scr('settings'));
1050
+ focusSettings();
910
1051
  }
911
1052
 
912
1053
  function maskKey(v) { v = '' + v; return v.length <= 4 ? '••••' : ('••••' + v.slice(-4)); }
@@ -933,8 +1074,7 @@ var Views = (function () {
933
1074
  wrap.appendChild(keyRow('TMDB API key (v3)', 'tmdb', 'TMDB v3 API key', Stremio.tmdbValidate));
934
1075
  wrap.appendChild(keyRow('MDBList API key', 'mdblist', 'MDBList API key', Stremio.mdblistValidate));
935
1076
  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.'));
936
- wrap.appendChild(backRow());
937
- Focus.setScope(scr('settings'));
1077
+ focusSettings();
938
1078
  }
939
1079
 
940
1080
  function settingsSubtitles(wrap) {
@@ -942,13 +1082,12 @@ var Views = (function () {
942
1082
  wrap.appendChild(subPickRow('Default language', SUB_LANGS, 'lang'));
943
1083
  wrap.appendChild(subCycleRow('Size', SUB_SIZES, 'size'));
944
1084
  wrap.appendChild(subCycleRow('Style', SUB_STYLES, 'style'));
945
- wrap.appendChild(backRow());
946
- Focus.setScope(scr('settings'));
1085
+ focusSettings();
947
1086
  }
948
1087
 
949
1088
  var POSTER_SHAPES = [['portrait', 'Portrait'], ['landscape', 'Landscape']];
950
1089
  function settingsDisplay(wrap) {
951
- wrap.appendChild(U.el('h2', null, 'Display'));
1090
+ wrap.appendChild(U.el('h2', null, 'Posters'));
952
1091
  var row = U.el('div', 'addon-item focusable cfg-row');
953
1092
  var name = U.el('div', 'a-name');
954
1093
  function render() { name.textContent = 'Poster orientation: ' + labelFor(POSTER_SHAPES, Stremio.getPrefs().posterShape); }
@@ -962,8 +1101,7 @@ var Views = (function () {
962
1101
  };
963
1102
  wrap.appendChild(row);
964
1103
  wrap.appendChild(U.el('div', 'hint', 'Landscape uses wide artwork when available (falls back to the poster).'));
965
- wrap.appendChild(backRow());
966
- Focus.setScope(scr('settings'));
1104
+ focusSettings();
967
1105
  }
968
1106
 
969
1107
  // ----- Home Feeds ordering -----
@@ -996,7 +1134,6 @@ var Views = (function () {
996
1134
  frow.appendChild(name);
997
1135
  wrap.appendChild(frow);
998
1136
  });
999
- wrap.appendChild(backRow());
1000
1137
 
1001
1138
  // restore focus to the control that triggered the last change
1002
1139
  var pref = null;
@@ -1013,7 +1150,8 @@ var Views = (function () {
1013
1150
 
1014
1151
  return {
1015
1152
  renderHome: renderHome, renderDetail: renderDetail, renderSettings: renderSettings,
1016
- settingsRoot: settingsRoot, backInSettings: backInSettings,
1153
+ settingsRoot: settingsRoot, backInSettings: backInSettings, setHomeTab: setHomeTab,
1154
+ renderLive: renderLive,
1017
1155
  renderSearch: renderSearch, renderEntry: renderEntry, backInDetail: backInDetail,
1018
1156
  homeDirty: homeDirty,
1019
1157
  isModalOpen: function () { return _modalOpen; }, closeModal: closeModal
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 }
@@ -15,35 +15,48 @@ 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
- if (left <= 0) { reject(new Error('proxy-timeout')); return; }
27
+ if (left <= 0) { reject(new Error('proxy-down')); 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
+ // 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) {
37
+ var pkgId = tizen.application.getCurrentApplication().appInfo.packageId;
38
+ tizen.application.launchAppControl(
39
+ new tizen.ApplicationControl('http://tizen.org/appcontrol/operation/service'),
40
+ pkgId + '.NuvioProxy',
41
+ function () {}, function () {}
42
+ );
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
47
+ });
37
48
  });
38
49
  }
39
50
 
40
51
  // A youtube.com/<channel>/live (or /watch?v=) URL -> the current live video id.
52
+ // The page is fetched THROUGH the proxy's /cors-bypass so it works both on the TV
53
+ // and in a desktop dev browser (youtube.com sends no CORS header of its own).
41
54
  function resolveVideoId(page) {
42
55
  var direct = ('' + page).match(/[?&]v=([A-Za-z0-9_-]{11})/);
43
56
  if (direct) { return Promise.resolve(direct[1]); }
44
57
  var hit = _idCache[page];
45
58
  if (hit && (Date.now() - hit.ts) < ID_TTL) { return Promise.resolve(hit.id); }
46
- return U.getText(page, 12000).then(function (html) {
59
+ return U.getText(PROXY + '/cors-bypass/' + page, 12000).then(function (html) {
47
60
  var m = html.match(/"videoId":"([A-Za-z0-9_-]{11})"/);
48
61
  var id = m ? m[1] : '';
49
62
  if (id) { _idCache[page] = { id: id, ts: Date.now() }; }
@@ -104,10 +117,8 @@ var YTPlay = (function () {
104
117
  show(id);
105
118
  }, function (err) {
106
119
  U.spinner(false);
107
- 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
+ U.toast('Couldn\'t reach the YouTube proxy (port 8099). Use the sideloaded Nuvio ' +
121
+ 'build so its background service is installed. (Desktop dev: run node service/index.js.)', 5500);
111
122
  });
112
123
  }
113
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuvio-tizen",
3
- "version": "1.9.1",
3
+ "version": "1.9.5",
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 = {