nuvio-tizen 1.9.5 → 1.9.7
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/index.html +11 -11
- package/js/app.js +10 -1
- package/js/keys.js +9 -0
- package/js/views.js +37 -1
- package/js/ytplay.js +26 -64
- package/package.json +1 -1
- package/service/index.js +31 -3
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.7" 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/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.7">
|
|
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.7"></script>
|
|
54
|
+
<script src="js/util.js?v=1.9.7"></script>
|
|
55
|
+
<script src="js/iptv.js?v=1.9.7"></script>
|
|
56
|
+
<script src="js/stremio.js?v=1.9.7"></script>
|
|
57
|
+
<script src="js/keys.js?v=1.9.7"></script>
|
|
58
|
+
<script src="js/focus.js?v=1.9.7"></script>
|
|
59
|
+
<script src="js/player.js?v=1.9.7"></script>
|
|
60
|
+
<script src="js/ytplay.js?v=1.9.7"></script>
|
|
61
|
+
<script src="js/views.js?v=1.9.7"></script>
|
|
62
|
+
<script src="js/app.js?v=1.9.7"></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
|
|
|
@@ -58,6 +59,7 @@ var App = (function () {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
function back() {
|
|
62
|
+
if (typeof YTPlay !== 'undefined' && YTPlay.isOpen && YTPlay.isOpen()) { YTPlay.close(); return; }
|
|
61
63
|
if (Player.isActive()) { Player.exit(); return; }
|
|
62
64
|
if (Views.isModalOpen()) { Views.closeModal(); return; }
|
|
63
65
|
if (_current === 'detail' && Views.backInDetail()) { return; }
|
|
@@ -102,7 +104,14 @@ var App = (function () {
|
|
|
102
104
|
else { Focus.clickCurrent(); }
|
|
103
105
|
return true;
|
|
104
106
|
}
|
|
105
|
-
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
|
+
}
|
|
106
115
|
default: return true;
|
|
107
116
|
}
|
|
108
117
|
}
|
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/views.js
CHANGED
|
@@ -526,6 +526,29 @@ var Views = (function () {
|
|
|
526
526
|
}
|
|
527
527
|
|
|
528
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
|
+
|
|
529
552
|
function openStreams(videoId, label) {
|
|
530
553
|
_d.mode = 'streams';
|
|
531
554
|
_d.streamId = videoId;
|
|
@@ -534,7 +557,7 @@ var Views = (function () {
|
|
|
534
557
|
drawDetail();
|
|
535
558
|
U.spinner(true);
|
|
536
559
|
Stremio.getStreams(_d.type, videoId).then(function (list) {
|
|
537
|
-
_d.streams = list;
|
|
560
|
+
_d.streams = sortStreams(list);
|
|
538
561
|
U.spinner(false);
|
|
539
562
|
if (_d.mode === 'streams') { drawDetail(); }
|
|
540
563
|
}, function () {
|
|
@@ -544,6 +567,11 @@ var Views = (function () {
|
|
|
544
567
|
});
|
|
545
568
|
}
|
|
546
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
|
+
|
|
547
575
|
function streamActionsBar() {
|
|
548
576
|
var row = U.el('div', 'btn-row');
|
|
549
577
|
row.appendChild(U.el('div', 'detail-meta', 'Sources for ' + (_d.streamLabel || '')));
|
|
@@ -588,6 +616,14 @@ var Views = (function () {
|
|
|
588
616
|
};
|
|
589
617
|
listScroll.appendChild(node);
|
|
590
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
|
+
|
|
591
627
|
panel.appendChild(listScroll);
|
|
592
628
|
return panel;
|
|
593
629
|
}
|
package/js/ytplay.js
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
/* YouTube leanback playback. Global namespace: YTPlay
|
|
2
|
-
YouTube-live IPTV channels can't be extracted to a direct URL on-device
|
|
3
|
-
|
|
4
|
-
localhost proxy (service/index.js) that
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
YouTube-live IPTV channels can't be extracted to a direct URL on-device. We play
|
|
3
|
+
them through YouTube's own leanback TV app (youtube.com/tv) behind a bundled
|
|
4
|
+
localhost proxy (service/index.js) that strips X-Frame-Options/CSP and injects an
|
|
5
|
+
ad-block userscript — the same approach TizenTube uses.
|
|
6
|
+
|
|
7
|
+
IMPORTANT: leanback must run in the app's MAIN FRAME. On Tizen the platform media
|
|
8
|
+
APIs (webapis/AVPlay) that leanback needs to actually decode video are injected only
|
|
9
|
+
into the main frame, never a cross-origin iframe — in an iframe the UI works but the
|
|
10
|
+
picture stays black and Play does nothing. So we navigate the whole webview to the
|
|
11
|
+
proxy (like TizenTube standalone) and pass a return URL; the proxy injects a Back
|
|
12
|
+
handler that navigates back to Nuvio. */
|
|
7
13
|
var YTPlay = (function () {
|
|
8
14
|
'use strict';
|
|
9
15
|
|
|
10
16
|
var PROXY = 'http://127.0.0.1:8099'; // 127.0.0.1 (not "localhost") resolves reliably on Tizen
|
|
11
|
-
var _open = false;
|
|
12
|
-
var _onExit = null;
|
|
13
17
|
var _idCache = {}; // ytPage -> { id, ts }
|
|
14
18
|
var ID_TTL = 5 * 60 * 1000; // live video ids rotate; keep short
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
20
|
+
// Resolve once the proxy answers /health; launch the bundled <tizen:service> if needed.
|
|
23
21
|
function ensureService() {
|
|
24
22
|
return new Promise(function (resolve, reject) {
|
|
25
23
|
function poll(left) {
|
|
@@ -28,9 +26,7 @@ var YTPlay = (function () {
|
|
|
28
26
|
setTimeout(function () { poll(left - 1); }, 500);
|
|
29
27
|
});
|
|
30
28
|
}
|
|
31
|
-
// Already running? (warm TV service, or a locally-run proxy in dev)
|
|
32
29
|
U.getText(PROXY + '/health', 1500).then(function () { resolve(); }, function () {
|
|
33
|
-
// Best-effort launch of the bundled <tizen:service>, then keep polling.
|
|
34
30
|
var launched = false;
|
|
35
31
|
try {
|
|
36
32
|
if (window.tizen && tizen.application && tizen.application.launchAppControl) {
|
|
@@ -49,8 +45,7 @@ var YTPlay = (function () {
|
|
|
49
45
|
}
|
|
50
46
|
|
|
51
47
|
// A youtube.com/<channel>/live (or /watch?v=) URL -> the current live video id.
|
|
52
|
-
//
|
|
53
|
-
// and in a desktop dev browser (youtube.com sends no CORS header of its own).
|
|
48
|
+
// Fetched THROUGH the proxy's /cors-bypass so it works on the TV and in dev.
|
|
54
49
|
function resolveVideoId(page) {
|
|
55
50
|
var direct = ('' + page).match(/[?&]v=([A-Za-z0-9_-]{11})/);
|
|
56
51
|
if (direct) { return Promise.resolve(direct[1]); }
|
|
@@ -64,49 +59,7 @@ var YTPlay = (function () {
|
|
|
64
59
|
}, function () { return ''; });
|
|
65
60
|
}
|
|
66
61
|
|
|
67
|
-
function
|
|
68
|
-
if (e && e.keyName === 'back') { if (e.preventDefault) { e.preventDefault(); } close(); }
|
|
69
|
-
}
|
|
70
|
-
function onKeyDown(e) {
|
|
71
|
-
if (e && (e.keyCode === 10009 || e.keyCode === 461)) {
|
|
72
|
-
if (e.preventDefault) { e.preventDefault(); }
|
|
73
|
-
if (e.stopPropagation) { e.stopPropagation(); }
|
|
74
|
-
close();
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function show(id) {
|
|
79
|
-
var o = overlay();
|
|
80
|
-
if (!o) { return; }
|
|
81
|
-
U.clear(o);
|
|
82
|
-
var iframe = document.createElement('iframe');
|
|
83
|
-
iframe.setAttribute('allow', 'autoplay; encrypted-media');
|
|
84
|
-
iframe.src = PROXY + '/tv#/watch?v=' + encodeURIComponent(id);
|
|
85
|
-
iframe.onload = function () { try { iframe.focus(); } catch (e) {} };
|
|
86
|
-
o.appendChild(iframe);
|
|
87
|
-
o.className = '';
|
|
88
|
-
U.byId('app').className = 'hidden';
|
|
89
|
-
_open = true;
|
|
90
|
-
// Hardware Back is delivered app-wide (tizenhwkey) even when the iframe has
|
|
91
|
-
// focus; the keydown capture covers the moment before the iframe grabs focus.
|
|
92
|
-
document.addEventListener('tizenhwkey', onHwKey, true);
|
|
93
|
-
document.addEventListener('keydown', onKeyDown, true);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function close() {
|
|
97
|
-
if (!_open) { return; }
|
|
98
|
-
_open = false;
|
|
99
|
-
document.removeEventListener('tizenhwkey', onHwKey, true);
|
|
100
|
-
document.removeEventListener('keydown', onKeyDown, true);
|
|
101
|
-
var o = overlay();
|
|
102
|
-
if (o) { o.className = 'hidden'; U.clear(o); }
|
|
103
|
-
U.byId('app').className = '';
|
|
104
|
-
var cb = _onExit; _onExit = null;
|
|
105
|
-
if (cb) { try { cb(); } catch (e) {} } // redraw detail -> restores focus scope
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function open(stream, onExit) {
|
|
109
|
-
_onExit = onExit || null;
|
|
62
|
+
function open(stream /*, onExit */) {
|
|
110
63
|
var page = stream.ytPage || stream.url;
|
|
111
64
|
U.spinner(true);
|
|
112
65
|
ensureService().then(function () {
|
|
@@ -114,13 +67,22 @@ var YTPlay = (function () {
|
|
|
114
67
|
}).then(function (id) {
|
|
115
68
|
U.spinner(false);
|
|
116
69
|
if (!id) { U.toast('Could not find the live stream for this channel', 3500); return; }
|
|
117
|
-
|
|
118
|
-
|
|
70
|
+
// Navigate the MAIN FRAME to leanback (needed for real playback on Tizen).
|
|
71
|
+
// nvret = where to return on Back; the hash carries the video so youtube.com/tv
|
|
72
|
+
// doesn't 303-redirect and drop our query param.
|
|
73
|
+
var ret = location.href;
|
|
74
|
+
window.location.href = PROXY + '/tv?nvret=' + encodeURIComponent(ret) +
|
|
75
|
+
'#/watch?v=' + encodeURIComponent(id);
|
|
76
|
+
}, function () {
|
|
119
77
|
U.spinner(false);
|
|
120
78
|
U.toast('Couldn\'t reach the YouTube proxy (port 8099). Use the sideloaded Nuvio ' +
|
|
121
79
|
'build so its background service is installed. (Desktop dev: run node service/index.js.)', 5500);
|
|
122
80
|
});
|
|
123
81
|
}
|
|
124
82
|
|
|
125
|
-
|
|
83
|
+
// Kept for API compatibility (playback now navigates away rather than overlaying).
|
|
84
|
+
function close() {}
|
|
85
|
+
function isOpen() { return false; }
|
|
86
|
+
|
|
87
|
+
return { open: open, close: close, isOpen: isOpen };
|
|
126
88
|
})();
|
package/package.json
CHANGED
package/service/index.js
CHANGED
|
@@ -42,10 +42,24 @@ function hostAllowed(h) {
|
|
|
42
42
|
|
|
43
43
|
function proxyPrefix() { return 'http://localhost:' + PORT + '/cors-bypass/'; }
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
// A Back-key handler injected into the leanback page so pressing Return goes back to
|
|
46
|
+
// Nuvio (nvret = the app URL Nuvio navigated from). Runs in leanback's top frame, where
|
|
47
|
+
// tizenhwkey fires. Capture phase + stopImmediatePropagation so leanback doesn't also
|
|
48
|
+
// handle it. Falls back to history.back() if no return URL was supplied.
|
|
49
|
+
function backHandlerScript(nvret) {
|
|
50
|
+
return '<script>(function(){var r=' + JSON.stringify(nvret || '') + ';' +
|
|
51
|
+
'function go(e){try{e.preventDefault();e.stopImmediatePropagation();}catch(x){}' +
|
|
52
|
+
'if(r){try{window.location.href=r;return;}catch(x2){}}try{history.back();}catch(x3){}}' +
|
|
53
|
+
'window.addEventListener("tizenhwkey",function(e){if(e&&e.keyName==="back")go(e);},true);' +
|
|
54
|
+
'window.addEventListener("keydown",function(e){if(e&&(e.keyCode===10009||e.keyCode===461))go(e);},true);' +
|
|
55
|
+
'})();</script>';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rewriteText(text, isTvHtml, nvret) {
|
|
46
59
|
var p = proxyPrefix();
|
|
47
60
|
if (isTvHtml) {
|
|
48
|
-
var inject = '
|
|
61
|
+
var inject = (nvret ? backHandlerScript(nvret) : '') +
|
|
62
|
+
'<script>window.__tizenTubeStandaloneVersion="nuvio";</script>' +
|
|
49
63
|
'<script src="' + USERSCRIPT_CDN + '?ver=' + Date.now() +
|
|
50
64
|
'" onerror="this.onerror=null;this.src=\'' + USERSCRIPT_FALLBACK + '\'"></script>';
|
|
51
65
|
if (/<body[^>]*>/i.test(text)) {
|
|
@@ -143,6 +157,20 @@ function handle(req, res) {
|
|
|
143
157
|
if (!hostAllowed(pu.hostname)) { res.writeHead(403); res.end('host not allowed'); return; }
|
|
144
158
|
|
|
145
159
|
var isTvHtml = (!isCors && pathOnly === '/tv');
|
|
160
|
+
// Nuvio passes its return URL as ?nvret= on the /tv request so we can inject a
|
|
161
|
+
// Back handler that navigates back to the app.
|
|
162
|
+
var nvret = '';
|
|
163
|
+
if (isTvHtml) {
|
|
164
|
+
var qmatch = req.url.split('?')[1];
|
|
165
|
+
if (qmatch) {
|
|
166
|
+
var parts = qmatch.split('&');
|
|
167
|
+
for (var qi = 0; qi < parts.length; qi++) {
|
|
168
|
+
if (parts[qi].indexOf('nvret=') === 0) {
|
|
169
|
+
try { nvret = decodeURIComponent(parts[qi].slice('nvret='.length)); } catch (e) {}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
146
174
|
|
|
147
175
|
var headers = {};
|
|
148
176
|
for (var k in req.headers) {
|
|
@@ -196,7 +224,7 @@ function handle(req, res) {
|
|
|
196
224
|
pres.on('end', function () {
|
|
197
225
|
decompress(Buffer.concat(chunks), enc, function (err, buf) {
|
|
198
226
|
if (err) { res.writeHead(502); res.end('decompress error'); return; }
|
|
199
|
-
var text = rewriteText(buf.toString('utf8'), isTvHtml);
|
|
227
|
+
var text = rewriteText(buf.toString('utf8'), isTvHtml, nvret);
|
|
200
228
|
// new Buffer(): Buffer.from() doesn't exist on the TV's old Node (< 4.5).
|
|
201
229
|
var body = new Buffer(text, 'utf8');
|
|
202
230
|
outHeaders['Content-Length'] = body.length;
|