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