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