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 +1 -1
- package/css/style.css +9 -0
- package/index.html +11 -11
- package/js/app.js +18 -2
- package/js/focus.js +7 -4
- package/js/iptv.js +52 -19
- package/js/player.js +6 -3
- package/js/stremio.js +6 -1
- package/js/views.js +239 -101
- package/js/ytplay.js +29 -18
- package/package.json +2 -2
- package/service/index.js +4 -0
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.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.
|
|
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.
|
|
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.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,
|
|
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
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
+
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
|
-
|
|
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
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
var
|
|
108
|
-
|
|
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 () {
|
|
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
|
-
|
|
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() {
|
|
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
|
-
|
|
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
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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('
|
|
791
|
-
|
|
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
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, '
|
|
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
|
-
|
|
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://
|
|
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
|
-
//
|
|
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-
|
|
27
|
+
if (left <= 0) { reject(new Error('proxy-down')); return; }
|
|
25
28
|
setTimeout(function () { poll(left - 1); }, 500);
|
|
26
29
|
});
|
|
27
30
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
tizen.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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.
|
|
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 = {
|