nuvio-tizen 1.0.0 → 1.2.0
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/README.md +20 -6
- package/css/style.css +30 -0
- package/index.html +7 -0
- package/js/app.js +9 -2
- package/js/player.js +152 -4
- package/js/stremio.js +90 -5
- package/js/util.js +20 -1
- package/js/views.js +146 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,17 +9,29 @@ core behaviour, targeting 2017 Samsung TVs (Tizen 3.0, ~Chromium 47).
|
|
|
9
9
|
> metadata and streams) but is written as plain, old-Chromium-safe HTML/JS/CSS with
|
|
10
10
|
> Samsung's **AVPlay** API for video.
|
|
11
11
|
|
|
12
|
-
## What works
|
|
12
|
+
## What works
|
|
13
13
|
|
|
14
|
-
- Loads user-configured **Stremio addons
|
|
14
|
+
- Loads user-configured **Stremio addons**. Fresh-install defaults:
|
|
15
|
+
**Cinemeta** (catalogs + metadata), **WatchHub** (streams), **OpenSubtitles v3** (subtitles).
|
|
15
16
|
- Home screen with catalog rows + **Continue Watching**.
|
|
17
|
+
- **Search** (🔍 in the header) across every addon catalog that supports the `search` extra.
|
|
16
18
|
- Detail pages: movies (Play) and series (season/episode browser).
|
|
17
19
|
- **Stream aggregation** across all installed stream addons.
|
|
20
|
+
- **Subtitles from addons**: fetched per title/episode, SRT + VTT parsed and rendered as a
|
|
21
|
+
timed overlay; press **UP** during playback to pick a track (or turn them Off).
|
|
18
22
|
- Playback via **AVPlay** (HLS/DASH/MP4/progressive) with an HTML5 `<video>` fallback
|
|
19
23
|
when run in a desktop browser for development.
|
|
20
24
|
- OSD with seek (±10s / ±30s), resume, and per-title progress saved to `localStorage`.
|
|
21
25
|
- Full **D-pad + media-key** remote navigation (custom spatial focus engine).
|
|
22
|
-
- Settings screen to add/remove addon manifest URLs.
|
|
26
|
+
- Settings screen to add/remove addon manifest URLs (the add field is prefilled with `https://`).
|
|
27
|
+
|
|
28
|
+
> **Note on WatchHub:** it's a "where to watch" addon — its results are Rent/Buy/Subscription
|
|
29
|
+
> *links to external services*, not direct video URLs, so they show as non-playable. Add your
|
|
30
|
+
> own stream addon(s) that return HTTP/HLS URLs for actual playback.
|
|
31
|
+
|
|
32
|
+
> **Legacy addons:** this client speaks the **v3** (RESTful JSON) addon protocol only.
|
|
33
|
+
> Old `/stremio/v1` addons (e.g. `opensubtitles.strem.io/stremio/v1`) are **not** supported —
|
|
34
|
+
> use their v3 equivalents.
|
|
23
35
|
|
|
24
36
|
## Content / legal note
|
|
25
37
|
|
|
@@ -101,7 +113,9 @@ No `async`/`await`, no ES6 classes, no destructuring, no default params, no obje
|
|
|
101
113
|
- **Untested on the physical TV** — AVPlay quirks (some HLS variants, DRM) will need
|
|
102
114
|
device iteration. Start with the desktop `<video>` smoke test, then a known-good HTTP
|
|
103
115
|
MP4/HLS stream addon on the set.
|
|
104
|
-
- No Trakt sync
|
|
105
|
-
|
|
106
|
-
|
|
116
|
+
- No Trakt sync yet.
|
|
117
|
+
- Subtitles render as an **HTML overlay** (works across AVPlay + the HTML5 fallback and
|
|
118
|
+
avoids AVPlay's local-file requirement). Gzipped (`.gz`) subtitle URLs aren't decoded;
|
|
119
|
+
most addons (incl. OpenSubtitles v3) serve plain UTF-8 SRT, which works.
|
|
120
|
+
- On-screen keyboard for search / adding addons relies on the TV IME focusing the `<input>`.
|
|
107
121
|
- Catalog pagination ("load more") not yet wired (fetches first page only).
|
package/css/style.css
CHANGED
|
@@ -98,6 +98,36 @@ html, body {
|
|
|
98
98
|
#osd-times { display: flex; justify-content: space-between; margin-top: 10px; font-size: 20px; color: #cfd3dc; }
|
|
99
99
|
#osd-hint { margin-top: 14px; font-size: 18px; color: #8b93a5; }
|
|
100
100
|
|
|
101
|
+
/* --- Subtitles --- */
|
|
102
|
+
#subs { position: absolute; left: 0; right: 0; bottom: 130px; z-index: 18; text-align: center; padding: 0 140px; }
|
|
103
|
+
#subs span { display: inline-block; background: rgba(0,0,0,0.6); color: #fff; font-size: 36px; line-height: 1.4; padding: 4px 16px; border-radius: 6px; white-space: pre-wrap; text-shadow: 0 2px 3px #000; }
|
|
104
|
+
|
|
105
|
+
#sub-menu { position: absolute; right: 60px; bottom: 250px; width: 560px; max-height: 620px; overflow: hidden; z-index: 30; background: rgba(20,25,36,0.98); border-radius: 12px; padding: 18px; }
|
|
106
|
+
#sub-menu .sm-title { font-size: 24px; font-weight: bold; margin-bottom: 12px; color: #c7cbd4; }
|
|
107
|
+
#sub-menu .sm-list { max-height: 520px; overflow: hidden; }
|
|
108
|
+
#sub-menu .sm-item { padding: 12px 16px; font-size: 22px; border-radius: 8px; border: 3px solid transparent; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
109
|
+
#sub-menu .sm-item.sel { background: #818cf8; color: #0e1117; }
|
|
110
|
+
|
|
111
|
+
/* --- Search --- */
|
|
112
|
+
.search-wrap { position: absolute; top: 120px; left: 60px; right: 60px; bottom: 40px; }
|
|
113
|
+
.search-bar { display: flex; margin-bottom: 26px; }
|
|
114
|
+
#search-input { flex: 1; padding: 16px 20px; font-size: 26px; background: #0e1117; color: #fff; border: 4px solid #2a3346; border-radius: 8px; }
|
|
115
|
+
#search-input.focused { border-color: #818cf8; }
|
|
116
|
+
.search-go { margin-left: 16px; }
|
|
117
|
+
.search-results { position: absolute; top: 96px; left: 0; right: 0; bottom: 0; overflow: hidden; white-space: normal; }
|
|
118
|
+
.search-results .card { margin: 0 22px 24px 0; }
|
|
119
|
+
.search-empty { font-size: 24px; color: #8b93a5; margin-top: 30px; }
|
|
120
|
+
|
|
121
|
+
/* --- Confirm modal --- */
|
|
122
|
+
.modal-overlay { position: absolute; top: 0; left: 0; width: 1920px; height: 1080px; z-index: 45; background: rgba(0,0,0,0.72); display: flex; align-items: center; justify-content: center; }
|
|
123
|
+
.modal-box { width: 820px; background: #161b26; border: 2px solid #2a3346; border-radius: 16px; padding: 44px 48px; text-align: center; }
|
|
124
|
+
.modal-msg { font-size: 30px; line-height: 1.4; color: #e6e9ef; margin-bottom: 36px; }
|
|
125
|
+
.modal-row { display: flex; justify-content: center; }
|
|
126
|
+
.modal-btn { min-width: 200px; text-align: center; }
|
|
127
|
+
.pill.danger { background: #3a2130; }
|
|
128
|
+
.pill.danger.focusable.focused { background: #ef4444; color: #fff; }
|
|
129
|
+
.reset-item .a-name { color: #f0a5a5; }
|
|
130
|
+
|
|
101
131
|
/* --- Toast + spinner --- */
|
|
102
132
|
#toast { position: absolute; bottom: 60px; left: 50%; margin-left: -300px; width: 600px; text-align: center; z-index: 40; background: rgba(0,0,0,0.85); padding: 18px 24px; border-radius: 10px; font-size: 22px; }
|
|
103
133
|
#spinner { position: absolute; top: 0; left: 0; width: 1920px; height: 1080px; z-index: 50; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.3); }
|
package/index.html
CHANGED
|
@@ -15,9 +15,13 @@
|
|
|
15
15
|
<div id="app">
|
|
16
16
|
<div id="screen-home" class="screen"></div>
|
|
17
17
|
<div id="screen-detail" class="screen hidden"></div>
|
|
18
|
+
<div id="screen-search" class="screen hidden"></div>
|
|
18
19
|
<div id="screen-settings" class="screen hidden"></div>
|
|
19
20
|
</div>
|
|
20
21
|
|
|
22
|
+
<!-- Subtitle overlay (over the video plane, under the OSD) -->
|
|
23
|
+
<div id="subs" class="hidden"></div>
|
|
24
|
+
|
|
21
25
|
<!-- Player OSD overlay (over the video plane) -->
|
|
22
26
|
<div id="player-osd" class="hidden">
|
|
23
27
|
<div id="osd-title"></div>
|
|
@@ -26,6 +30,9 @@
|
|
|
26
30
|
<div id="osd-hint">OK: Play/Pause ←/→: Seek RETURN: Back</div>
|
|
27
31
|
</div>
|
|
28
32
|
|
|
33
|
+
<!-- Subtitle track picker (opened with UP during playback) -->
|
|
34
|
+
<div id="sub-menu" class="hidden"></div>
|
|
35
|
+
|
|
29
36
|
<div id="toast" class="hidden"></div>
|
|
30
37
|
<div id="spinner" class="hidden"><div class="spin"></div></div>
|
|
31
38
|
|
package/js/app.js
CHANGED
|
@@ -8,7 +8,7 @@ var App = (function () {
|
|
|
8
8
|
function screenEl(name) { return U.byId('screen-' + name); }
|
|
9
9
|
|
|
10
10
|
function showScreen(name) {
|
|
11
|
-
['home', 'detail', 'settings'].forEach(function (n) {
|
|
11
|
+
['home', 'detail', 'search', 'settings'].forEach(function (n) {
|
|
12
12
|
screenEl(n).className = 'screen' + (n === name ? '' : ' hidden');
|
|
13
13
|
});
|
|
14
14
|
_current = name;
|
|
@@ -29,8 +29,15 @@ var App = (function () {
|
|
|
29
29
|
Views.renderSettings();
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function openSearch() {
|
|
33
|
+
pushFrame();
|
|
34
|
+
showScreen('search');
|
|
35
|
+
Views.renderSearch();
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
function back() {
|
|
33
39
|
if (Player.isActive()) { Player.exit(); return; }
|
|
40
|
+
if (Views.isModalOpen()) { Views.closeModal(); return; }
|
|
34
41
|
if (_current === 'detail' && Views.backInDetail()) { return; }
|
|
35
42
|
var f = _stack.pop();
|
|
36
43
|
if (!f) { tryExitApp(); return; }
|
|
@@ -71,7 +78,7 @@ var App = (function () {
|
|
|
71
78
|
|
|
72
79
|
return {
|
|
73
80
|
start: start, back: back,
|
|
74
|
-
openDetail: openDetail, openSettings: openSettings
|
|
81
|
+
openDetail: openDetail, openSettings: openSettings, openSearch: openSearch
|
|
75
82
|
};
|
|
76
83
|
})();
|
|
77
84
|
|
package/js/player.js
CHANGED
|
@@ -10,6 +10,8 @@ var Player = (function () {
|
|
|
10
10
|
|
|
11
11
|
var videoEl = null; // html5 fallback
|
|
12
12
|
var objEl = null;
|
|
13
|
+
var subsEl = null;
|
|
14
|
+
var subMenuEl = null;
|
|
13
15
|
|
|
14
16
|
var _active = false;
|
|
15
17
|
var _onExit = null;
|
|
@@ -20,9 +22,19 @@ var Player = (function () {
|
|
|
20
22
|
var _osdTimer = null;
|
|
21
23
|
var _seekPending = 0;
|
|
22
24
|
|
|
25
|
+
// subtitles
|
|
26
|
+
var _subTracks = []; // [{lang,url,_addon}]
|
|
27
|
+
var _cues = null; // [{start,end,text}]
|
|
28
|
+
var _subOn = false;
|
|
29
|
+
var _subLabel = '';
|
|
30
|
+
var _menuOpen = false;
|
|
31
|
+
var _menuIdx = 0;
|
|
32
|
+
|
|
23
33
|
function init() {
|
|
24
34
|
objEl = U.byId('av-player');
|
|
25
35
|
videoEl = U.byId('html5-player');
|
|
36
|
+
subsEl = U.byId('subs');
|
|
37
|
+
subMenuEl = U.byId('sub-menu');
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
function isActive() { return _active; }
|
|
@@ -79,12 +91,34 @@ var Player = (function () {
|
|
|
79
91
|
_active = true; _cur = 0; _dur = 0;
|
|
80
92
|
_ctx.resumeSec = ctx.resumeSec || getResume(ctx.type, ctx.id);
|
|
81
93
|
|
|
94
|
+
// reset subtitle state
|
|
95
|
+
_subTracks = []; _cues = null; _subOn = false; _subLabel = '';
|
|
96
|
+
_menuOpen = false; _menuIdx = 0;
|
|
97
|
+
subsEl.className = 'hidden'; U.clear(subsEl);
|
|
98
|
+
subMenuEl.className = 'hidden';
|
|
99
|
+
updateHint();
|
|
100
|
+
|
|
82
101
|
U.byId('app').className = 'hidden';
|
|
83
102
|
document.body.style.background = 'transparent';
|
|
84
103
|
showOSD(true);
|
|
85
104
|
U.spinner(true);
|
|
86
105
|
|
|
87
106
|
if (useAV) { startAV(ctx.url); } else { startHTML5(ctx.url); }
|
|
107
|
+
|
|
108
|
+
// Fetch subtitle tracks in parallel; enables the UP menu when ready.
|
|
109
|
+
Stremio.getSubtitles(ctx.type, ctx.id).then(function (tracks) {
|
|
110
|
+
if (!_active) { return; }
|
|
111
|
+
_subTracks = tracks || [];
|
|
112
|
+
updateHint();
|
|
113
|
+
if (_subTracks.length) { U.toast(_subTracks.length + ' subtitle track(s) — press UP', 3000); }
|
|
114
|
+
}, function () {});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function updateHint() {
|
|
118
|
+
var base = 'OK: Play/Pause ←/→: Seek RETURN: Back';
|
|
119
|
+
if (_subTracks.length) { base += ' UP: Subtitles'; }
|
|
120
|
+
var h = U.byId('osd-hint');
|
|
121
|
+
if (h) { h.textContent = base; }
|
|
88
122
|
}
|
|
89
123
|
|
|
90
124
|
function startAV(url) {
|
|
@@ -98,7 +132,7 @@ var Player = (function () {
|
|
|
98
132
|
onbufferingstart: function () { U.spinner(true); },
|
|
99
133
|
onbufferingcomplete: function () { U.spinner(false); },
|
|
100
134
|
onstreamcompleted: function () { _cur = _dur; onEnded(); },
|
|
101
|
-
oncurrentplaytime: function (ms) { _cur = ms / 1000; renderOSD(); },
|
|
135
|
+
oncurrentplaytime: function (ms) { _cur = ms / 1000; renderOSD(); renderSubs(); },
|
|
102
136
|
onerror: function (err) { U.spinner(false); U.toast('Playback error: ' + err); setTimeout(exit, 1500); }
|
|
103
137
|
});
|
|
104
138
|
try { avplay.setStreamingProperty('ADAPTIVE_INFO', 'BITRATES=1'); } catch (e0) {}
|
|
@@ -128,7 +162,7 @@ var Player = (function () {
|
|
|
128
162
|
if (_ctx.resumeSec > 5) { try { videoEl.currentTime = _ctx.resumeSec; } catch (e) {} }
|
|
129
163
|
U.spinner(false); showOSD(false);
|
|
130
164
|
};
|
|
131
|
-
videoEl.ontimeupdate = function () { _cur = videoEl.currentTime; renderOSD(); };
|
|
165
|
+
videoEl.ontimeupdate = function () { _cur = videoEl.currentTime; renderOSD(); renderSubs(); };
|
|
132
166
|
videoEl.onended = onEnded;
|
|
133
167
|
videoEl.onerror = function () { U.toast('Playback error'); setTimeout(exit, 1500); };
|
|
134
168
|
var p = videoEl.play();
|
|
@@ -139,7 +173,7 @@ var Player = (function () {
|
|
|
139
173
|
function startTicker() {
|
|
140
174
|
if (_ticker) { clearInterval(_ticker); }
|
|
141
175
|
_ticker = setInterval(function () {
|
|
142
|
-
if (useAV) { try { _cur = avplay.getCurrentTime() / 1000; } catch (e) {} renderOSD(); }
|
|
176
|
+
if (useAV) { try { _cur = avplay.getCurrentTime() / 1000; } catch (e) {} renderOSD(); renderSubs(); }
|
|
143
177
|
saveProgress();
|
|
144
178
|
}, 5000);
|
|
145
179
|
}
|
|
@@ -174,7 +208,117 @@ var Player = (function () {
|
|
|
174
208
|
}
|
|
175
209
|
}
|
|
176
210
|
|
|
211
|
+
// ---------- subtitles ----------
|
|
212
|
+
// Parses SRT or VTT (both use HH:MM:SS,mmm / HH:MM:SS.mmm timing lines).
|
|
213
|
+
function parseCues(text) {
|
|
214
|
+
text = ('' + text).replace(/\r/g, '');
|
|
215
|
+
var blocks = text.split(/\n\n+/);
|
|
216
|
+
var cues = [];
|
|
217
|
+
var re = /(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})/;
|
|
218
|
+
for (var i = 0; i < blocks.length; i++) {
|
|
219
|
+
var lines = blocks[i].split('\n');
|
|
220
|
+
var ti = -1;
|
|
221
|
+
for (var j = 0; j < lines.length; j++) { if (lines[j].indexOf('-->') !== -1) { ti = j; break; } }
|
|
222
|
+
if (ti === -1) { continue; }
|
|
223
|
+
var m = lines[ti].match(re);
|
|
224
|
+
if (!m) { continue; }
|
|
225
|
+
var start = (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]) + (+m[4]) / 1000;
|
|
226
|
+
var end = (+m[5]) * 3600 + (+m[6]) * 60 + (+m[7]) + (+m[8]) / 1000;
|
|
227
|
+
var txt = lines.slice(ti + 1).join('\n').replace(/<[^>]+>/g, '').replace(/\{[^}]+\}/g, '');
|
|
228
|
+
if (txt) { cues.push({ start: start, end: end, text: txt }); }
|
|
229
|
+
}
|
|
230
|
+
cues.sort(function (a, b) { return a.start - b.start; });
|
|
231
|
+
return cues;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function renderSubs() {
|
|
235
|
+
if (!_subOn || !_cues) { return; }
|
|
236
|
+
var t = _cur;
|
|
237
|
+
var show = '';
|
|
238
|
+
// linear scan; cue counts are small enough for a TV
|
|
239
|
+
for (var i = 0; i < _cues.length; i++) {
|
|
240
|
+
if (_cues[i].start <= t && t <= _cues[i].end) { show = _cues[i].text; break; }
|
|
241
|
+
if (_cues[i].start > t) { break; }
|
|
242
|
+
}
|
|
243
|
+
if (show) {
|
|
244
|
+
subsEl.className = '';
|
|
245
|
+
var span = subsEl.firstChild;
|
|
246
|
+
if (!span) { span = document.createElement('span'); subsEl.appendChild(span); }
|
|
247
|
+
if (span.textContent !== show) { span.textContent = show; }
|
|
248
|
+
} else {
|
|
249
|
+
subsEl.className = 'hidden';
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function loadTrack(track) {
|
|
254
|
+
U.toast('Loading subtitles…', 1500);
|
|
255
|
+
U.getText(track.url, 15000).then(function (text) {
|
|
256
|
+
var cues = parseCues(text);
|
|
257
|
+
if (!cues.length) { U.toast('Could not parse subtitles'); return; }
|
|
258
|
+
_cues = cues; _subOn = true; _subLabel = track.lang || 'on';
|
|
259
|
+
renderSubs();
|
|
260
|
+
U.toast('Subtitles: ' + (track.lang || 'on'));
|
|
261
|
+
}, function () { U.toast('Failed to download subtitles'); });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function subsOff() {
|
|
265
|
+
_subOn = false; _cues = null; _subLabel = '';
|
|
266
|
+
subsEl.className = 'hidden'; U.clear(subsEl);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------- subtitle menu ----------
|
|
270
|
+
function openSubMenu() {
|
|
271
|
+
if (!_subTracks.length) { return; }
|
|
272
|
+
_menuOpen = true;
|
|
273
|
+
// index 0 = Off; tracks start at 1
|
|
274
|
+
_menuIdx = 0;
|
|
275
|
+
renderSubMenu();
|
|
276
|
+
subMenuEl.className = '';
|
|
277
|
+
showOSD(true);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function closeSubMenu() {
|
|
281
|
+
_menuOpen = false;
|
|
282
|
+
subMenuEl.className = 'hidden';
|
|
283
|
+
showOSD(false);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function renderSubMenu() {
|
|
287
|
+
U.clear(subMenuEl);
|
|
288
|
+
subMenuEl.appendChild(U.el('div', 'sm-title', 'Subtitles'));
|
|
289
|
+
var list = U.el('div', 'sm-list');
|
|
290
|
+
var items = [{ label: 'Off' + (_subOn ? '' : ' ✓'), track: null }];
|
|
291
|
+
_subTracks.forEach(function (tr) {
|
|
292
|
+
var mark = (_subOn && _subLabel === (tr.lang || 'on')) ? ' ✓' : '';
|
|
293
|
+
items.push({ label: (tr.lang || 'unknown') + ' · ' + (tr._addon || '') + mark, track: tr });
|
|
294
|
+
});
|
|
295
|
+
_menuItems = items;
|
|
296
|
+
items.forEach(function (it, i) {
|
|
297
|
+
var node = U.el('div', 'sm-item' + (i === _menuIdx ? ' sel' : ''), it.label);
|
|
298
|
+
list.appendChild(node);
|
|
299
|
+
});
|
|
300
|
+
subMenuEl.appendChild(list);
|
|
301
|
+
// keep selected item visible
|
|
302
|
+
var sel = list.children[_menuIdx];
|
|
303
|
+
if (sel && sel.scrollIntoView) { try { sel.scrollIntoView(false); } catch (e) {} }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
var _menuItems = [];
|
|
307
|
+
|
|
308
|
+
function menuKey(action) {
|
|
309
|
+
if (action === 'UP') { _menuIdx = Math.max(0, _menuIdx - 1); renderSubMenu(); return; }
|
|
310
|
+
if (action === 'DOWN') { _menuIdx = Math.min(_menuItems.length - 1, _menuIdx + 1); renderSubMenu(); return; }
|
|
311
|
+
if (action === 'OK') {
|
|
312
|
+
var it = _menuItems[_menuIdx];
|
|
313
|
+
if (!it || it.track === null) { subsOff(); } else { loadTrack(it.track); }
|
|
314
|
+
closeSubMenu();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (action === 'BACK' || action === 'STOP') { closeSubMenu(); return; }
|
|
318
|
+
}
|
|
319
|
+
|
|
177
320
|
function handleKey(action) {
|
|
321
|
+
if (_menuOpen) { menuKey(action); return true; }
|
|
178
322
|
showOSD(false);
|
|
179
323
|
switch (action) {
|
|
180
324
|
case 'OK': case 'PLAYPAUSE': togglePlay(); return true;
|
|
@@ -182,7 +326,8 @@ var Player = (function () {
|
|
|
182
326
|
case 'PAUSE': if (useAV) { try { avplay.pause(); } catch (e) {} } else { videoEl.pause(); } return true;
|
|
183
327
|
case 'RIGHT': case 'FF': seekBy(action === 'FF' ? 30 : 10); return true;
|
|
184
328
|
case 'LEFT': case 'REW': seekBy(action === 'REW' ? -30 : -10); return true;
|
|
185
|
-
case 'UP':
|
|
329
|
+
case 'UP': if (_subTracks.length) { openSubMenu(); } return true;
|
|
330
|
+
case 'DOWN': showOSD(false); return true;
|
|
186
331
|
case 'STOP': case 'BACK': exit(); return true;
|
|
187
332
|
}
|
|
188
333
|
return true;
|
|
@@ -195,6 +340,9 @@ var Player = (function () {
|
|
|
195
340
|
if (_ticker) { clearInterval(_ticker); _ticker = null; }
|
|
196
341
|
if (_osdTimer) { clearTimeout(_osdTimer); _osdTimer = null; }
|
|
197
342
|
hideOSD();
|
|
343
|
+
subsEl.className = 'hidden'; U.clear(subsEl);
|
|
344
|
+
subMenuEl.className = 'hidden'; _menuOpen = false;
|
|
345
|
+
_subOn = false; _cues = null; _subTracks = [];
|
|
198
346
|
if (useAV) {
|
|
199
347
|
try { avplay.stop(); } catch (e) {}
|
|
200
348
|
try { avplay.close(); } catch (e2) {}
|
package/js/stremio.js
CHANGED
|
@@ -6,9 +6,14 @@ var Stremio = (function () {
|
|
|
6
6
|
|
|
7
7
|
var ADDONS_KEY = 'nuvio.addons';
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
9
|
+
// Defaults for a fresh install. Cinemeta = catalogs + metadata,
|
|
10
|
+
// WatchHub = streams, OpenSubtitles v3 = subtitles. The user can add/remove
|
|
11
|
+
// more in Settings; this app hosts no content itself.
|
|
12
|
+
var DEFAULT_ADDONS = [
|
|
13
|
+
'https://v3-cinemeta.strem.io/manifest.json',
|
|
14
|
+
'https://watchhub.strem.io/manifest.json',
|
|
15
|
+
'https://opensubtitles-v3.strem.io/manifest.json'
|
|
16
|
+
];
|
|
12
17
|
|
|
13
18
|
function normalizeManifestUrl(input) {
|
|
14
19
|
var u = ('' + input).trim();
|
|
@@ -46,6 +51,12 @@ var Stremio = (function () {
|
|
|
46
51
|
_loaded = null;
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
// Clears the stored list so the fresh-install DEFAULT_ADDONS apply again.
|
|
55
|
+
function resetAddons() {
|
|
56
|
+
U.store.remove(ADDONS_KEY);
|
|
57
|
+
_loaded = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
// ---- Manifest loading (cached in-memory per session) ----
|
|
50
61
|
var _loaded = null;
|
|
51
62
|
|
|
@@ -177,6 +188,79 @@ var Stremio = (function () {
|
|
|
177
188
|
});
|
|
178
189
|
}
|
|
179
190
|
|
|
191
|
+
// ---- Subtitles ----
|
|
192
|
+
// Aggregates subtitle tracks from every addon that supports (type,id).
|
|
193
|
+
function getSubtitles(type, id) {
|
|
194
|
+
return loadAddons().then(function (addons) {
|
|
195
|
+
var providers = addons.filter(function (a) { return supports(a, 'subtitles', type, id); });
|
|
196
|
+
var tasks = providers.map(function (a) {
|
|
197
|
+
return function () {
|
|
198
|
+
var url = a.base + '/subtitles/' + encodeURIComponent(type) + '/' + encodeURIComponent(id) + '.json';
|
|
199
|
+
return U.getJSON(url, 12000).then(function (res) {
|
|
200
|
+
var arr = (res && res.subtitles) ? res.subtitles : [];
|
|
201
|
+
arr.forEach(function (s) { s._addon = a.manifest.name || 'Addon'; });
|
|
202
|
+
return arr;
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
return U.settleAll(tasks, 4).then(function (results) {
|
|
207
|
+
var seen = {}, out = [];
|
|
208
|
+
results.forEach(function (r) {
|
|
209
|
+
if (!r.ok) { return; }
|
|
210
|
+
r.value.forEach(function (s) {
|
|
211
|
+
if (!s || !s.url || seen[s.url]) { return; }
|
|
212
|
+
seen[s.url] = 1;
|
|
213
|
+
out.push(s);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
out.sort(function (a, b) { return ('' + (a.lang || '')).localeCompare('' + (b.lang || '')); });
|
|
217
|
+
return out;
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- Search ----
|
|
223
|
+
// Catalogs (across addons) that declare support for the "search" extra.
|
|
224
|
+
function getSearchCatalogs() {
|
|
225
|
+
return loadAddons().then(function (addons) {
|
|
226
|
+
var out = [];
|
|
227
|
+
addons.forEach(function (addon) {
|
|
228
|
+
(addon.manifest.catalogs || []).forEach(function (c) {
|
|
229
|
+
if (!c.type || !c.id) { return; }
|
|
230
|
+
var searchable = false;
|
|
231
|
+
if (c.extra && c.extra.length) {
|
|
232
|
+
searchable = c.extra.some(function (e) { return e && e.name === 'search'; });
|
|
233
|
+
}
|
|
234
|
+
if (!searchable && c.extraSupported) {
|
|
235
|
+
searchable = c.extraSupported.indexOf('search') !== -1;
|
|
236
|
+
}
|
|
237
|
+
if (searchable) { out.push({ addon: addon, type: c.type, id: c.id }); }
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
return out;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function search(query) {
|
|
245
|
+
var q = ('' + query).trim();
|
|
246
|
+
if (!q) { return Promise.resolve([]); }
|
|
247
|
+
return getSearchCatalogs().then(function (cats) {
|
|
248
|
+
var tasks = cats.map(function (cat) {
|
|
249
|
+
return function () { return getCatalog(cat, { search: q }); };
|
|
250
|
+
});
|
|
251
|
+
return U.settleAll(tasks, 4).then(function (results) {
|
|
252
|
+
var seen = {}, out = [];
|
|
253
|
+
results.forEach(function (r) {
|
|
254
|
+
if (!r.ok) { return; }
|
|
255
|
+
r.value.forEach(function (m) {
|
|
256
|
+
if (m && m.id && !seen[m.id]) { seen[m.id] = 1; out.push(m); }
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
return out;
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
180
264
|
// A stream is directly playable on the TV only if it exposes an http(s) URL.
|
|
181
265
|
// Torrent (infoHash) / external-only streams need an engine this app doesn't embed.
|
|
182
266
|
function isPlayable(stream) {
|
|
@@ -190,9 +274,10 @@ var Stremio = (function () {
|
|
|
190
274
|
|
|
191
275
|
return {
|
|
192
276
|
DEFAULT_ADDONS: DEFAULT_ADDONS,
|
|
193
|
-
getAddonUrls: getAddonUrls, addAddon: addAddon, removeAddon: removeAddon,
|
|
277
|
+
getAddonUrls: getAddonUrls, addAddon: addAddon, removeAddon: removeAddon, resetAddons: resetAddons,
|
|
194
278
|
loadAddons: loadAddons, listCatalogs: listCatalogs, getCatalog: getCatalog,
|
|
195
|
-
getMeta: getMeta, getStreams: getStreams,
|
|
279
|
+
getMeta: getMeta, getStreams: getStreams, getSubtitles: getSubtitles,
|
|
280
|
+
getSearchCatalogs: getSearchCatalogs, search: search,
|
|
196
281
|
isPlayable: isPlayable, streamLabel: streamLabel
|
|
197
282
|
};
|
|
198
283
|
})();
|
package/js/util.js
CHANGED
|
@@ -36,6 +36,25 @@ var U = (function () {
|
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/* GET raw text via XHR with timeout. Returns a Promise. */
|
|
40
|
+
function getText(url, timeoutMs) {
|
|
41
|
+
return new Promise(function (resolve, reject) {
|
|
42
|
+
var xhr = new XMLHttpRequest();
|
|
43
|
+
var done = false;
|
|
44
|
+
try { xhr.open('GET', url, true); } catch (e) { reject(e); return; }
|
|
45
|
+
xhr.timeout = timeoutMs || 15000;
|
|
46
|
+
xhr.onreadystatechange = function () {
|
|
47
|
+
if (xhr.readyState !== 4 || done) { return; }
|
|
48
|
+
done = true;
|
|
49
|
+
if (xhr.status >= 200 && xhr.status < 300) { resolve(xhr.responseText); }
|
|
50
|
+
else { reject(new Error('HTTP ' + xhr.status + ' for ' + url)); }
|
|
51
|
+
};
|
|
52
|
+
xhr.ontimeout = function () { if (!done) { done = true; reject(new Error('Timeout: ' + url)); } };
|
|
53
|
+
xhr.onerror = function () { if (!done) { done = true; reject(new Error('Network error: ' + url)); } };
|
|
54
|
+
try { xhr.send(); } catch (e3) { reject(e3); }
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
39
58
|
/* localStorage JSON helpers, all guarded (storage can throw / be unavailable). */
|
|
40
59
|
var store = {
|
|
41
60
|
get: function (key, fallback) {
|
|
@@ -103,7 +122,7 @@ var U = (function () {
|
|
|
103
122
|
}
|
|
104
123
|
|
|
105
124
|
return {
|
|
106
|
-
byId: byId, el: el, clear: clear, getJSON: getJSON,
|
|
125
|
+
byId: byId, el: el, clear: clear, getJSON: getJSON, getText: getText,
|
|
107
126
|
store: store, toast: toast, spinner: spinner,
|
|
108
127
|
fmtTime: fmtTime, settleAll: settleAll
|
|
109
128
|
};
|
package/js/views.js
CHANGED
|
@@ -5,13 +5,65 @@ var Views = (function () {
|
|
|
5
5
|
|
|
6
6
|
function scr(name) { return U.byId('screen-' + name); }
|
|
7
7
|
|
|
8
|
+
// ---------- confirm modal (D-pad friendly) ----------
|
|
9
|
+
var _modalOpen = false;
|
|
10
|
+
var _modalPrevFocus = null;
|
|
11
|
+
var _modalScope = null;
|
|
12
|
+
|
|
13
|
+
function confirm(message, confirmLabel, onYes) {
|
|
14
|
+
// defensive: never stack modals
|
|
15
|
+
var existing = document.querySelector('.modal-overlay');
|
|
16
|
+
if (existing) { existing.parentNode.removeChild(existing); }
|
|
17
|
+
var overlay = U.el('div', 'modal-overlay');
|
|
18
|
+
var box = U.el('div', 'modal-box');
|
|
19
|
+
box.appendChild(U.el('div', 'modal-msg', message));
|
|
20
|
+
var row = U.el('div', 'modal-row');
|
|
21
|
+
var no = U.el('div', 'pill focusable modal-btn', 'Cancel');
|
|
22
|
+
var yes = U.el('div', 'pill focusable modal-btn danger', confirmLabel || 'Confirm');
|
|
23
|
+
no.__onselect = function () { closeModal(); };
|
|
24
|
+
yes.__onselect = function () { closeModal(); onYes(); };
|
|
25
|
+
row.appendChild(no);
|
|
26
|
+
row.appendChild(yes);
|
|
27
|
+
box.appendChild(row);
|
|
28
|
+
overlay.appendChild(box);
|
|
29
|
+
document.body.appendChild(overlay);
|
|
30
|
+
|
|
31
|
+
_modalPrevFocus = Focus.current();
|
|
32
|
+
_modalScope = _current(); // remember which screen is under the modal
|
|
33
|
+
_modalOpen = true;
|
|
34
|
+
Focus.setScope(overlay, no); // default to Cancel (safe)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function closeModal() {
|
|
38
|
+
var o = document.querySelector('.modal-overlay');
|
|
39
|
+
if (o) { document.body.removeChild(o); }
|
|
40
|
+
_modalOpen = false;
|
|
41
|
+
if (_modalScope) { Focus.setScope(_modalScope, _modalPrevFocus); }
|
|
42
|
+
_modalScope = null; _modalPrevFocus = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _current() {
|
|
46
|
+
var names = ['home', 'detail', 'search', 'settings'];
|
|
47
|
+
for (var i = 0; i < names.length; i++) {
|
|
48
|
+
var e = scr(names[i]);
|
|
49
|
+
if (e && e.className.indexOf('hidden') === -1) { return e; }
|
|
50
|
+
}
|
|
51
|
+
return scr('settings');
|
|
52
|
+
}
|
|
53
|
+
|
|
8
54
|
// ---------- shared bits ----------
|
|
9
|
-
function header(withGear) {
|
|
55
|
+
function header(withGear, withSearch) {
|
|
10
56
|
var h = U.el('div', 'header');
|
|
11
57
|
var brand = U.el('div', 'brand');
|
|
12
58
|
brand.innerHTML = 'NUVIO<span class="dot">.</span>';
|
|
13
59
|
h.appendChild(brand);
|
|
14
60
|
h.appendChild(U.el('div', 'spacer'));
|
|
61
|
+
if (withSearch) {
|
|
62
|
+
var srch = U.el('div', 'gear focusable', '🔍');
|
|
63
|
+
srch.style.marginRight = '24px';
|
|
64
|
+
srch.__onselect = function () { App.openSearch(); };
|
|
65
|
+
h.appendChild(srch);
|
|
66
|
+
}
|
|
15
67
|
if (withGear) {
|
|
16
68
|
var g = U.el('div', 'gear focusable', '⚙');
|
|
17
69
|
g.__onselect = function () { App.openSettings(); };
|
|
@@ -40,7 +92,7 @@ var Views = (function () {
|
|
|
40
92
|
function renderHome() {
|
|
41
93
|
var s = scr('home');
|
|
42
94
|
U.clear(s);
|
|
43
|
-
s.appendChild(header(true));
|
|
95
|
+
s.appendChild(header(true, true));
|
|
44
96
|
var rows = U.el('div', 'rows');
|
|
45
97
|
s.appendChild(rows);
|
|
46
98
|
|
|
@@ -252,7 +304,13 @@ var Views = (function () {
|
|
|
252
304
|
var playable = Stremio.isPlayable(stream);
|
|
253
305
|
var node = U.el('div', 'stream focusable');
|
|
254
306
|
node.appendChild(document.createTextNode(Stremio.streamLabel(stream)));
|
|
255
|
-
var
|
|
307
|
+
var why = '';
|
|
308
|
+
if (!playable) {
|
|
309
|
+
if (stream.infoHash) { why = ' — torrent, needs a debrid/torrent client'; }
|
|
310
|
+
else if (stream.externalUrl) { why = ' — opens in an external app (not playable here)'; }
|
|
311
|
+
else { why = ' — not directly playable on the TV'; }
|
|
312
|
+
}
|
|
313
|
+
var sub = (stream._addon || '') + why;
|
|
256
314
|
node.appendChild(U.el('span', 's-sub', sub));
|
|
257
315
|
if (!playable) { node.style.opacity = '0.55'; }
|
|
258
316
|
node.__onselect = function () {
|
|
@@ -278,6 +336,61 @@ var Views = (function () {
|
|
|
278
336
|
return false;
|
|
279
337
|
}
|
|
280
338
|
|
|
339
|
+
// ================= SEARCH =================
|
|
340
|
+
function renderSearch() {
|
|
341
|
+
var s = scr('search');
|
|
342
|
+
U.clear(s);
|
|
343
|
+
s.appendChild(header(false, false));
|
|
344
|
+
|
|
345
|
+
var wrap = U.el('div', 'search-wrap');
|
|
346
|
+
var bar = U.el('div', 'search-bar');
|
|
347
|
+
|
|
348
|
+
var input = document.createElement('input');
|
|
349
|
+
input.id = 'search-input';
|
|
350
|
+
input.type = 'text';
|
|
351
|
+
input.className = 'focusable';
|
|
352
|
+
input.placeholder = 'Search movies & series…';
|
|
353
|
+
input.__onselect = function () { input.focus(); };
|
|
354
|
+
input.onkeydown = function (ev) { if (ev.keyCode === 13) { runSearch(input.value); } };
|
|
355
|
+
input.onchange = function () { runSearch(input.value); };
|
|
356
|
+
bar.appendChild(input);
|
|
357
|
+
|
|
358
|
+
var go = U.el('div', 'pill focusable search-go', 'Search');
|
|
359
|
+
go.__onselect = function () { runSearch(input.value); };
|
|
360
|
+
bar.appendChild(go);
|
|
361
|
+
wrap.appendChild(bar);
|
|
362
|
+
|
|
363
|
+
var results = U.el('div', 'search-results');
|
|
364
|
+
results.id = 'search-results';
|
|
365
|
+
results.appendChild(U.el('div', 'search-empty', 'Type a title and press OK on Search. Results come from every addon catalog that supports search.'));
|
|
366
|
+
wrap.appendChild(results);
|
|
367
|
+
|
|
368
|
+
s.appendChild(wrap);
|
|
369
|
+
Focus.setScope(s, input);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function runSearch(query) {
|
|
373
|
+
query = ('' + query).trim();
|
|
374
|
+
var results = U.byId('search-results');
|
|
375
|
+
if (!query) { return; }
|
|
376
|
+
U.clear(results);
|
|
377
|
+
U.spinner(true);
|
|
378
|
+
Stremio.search(query).then(function (metas) {
|
|
379
|
+
U.spinner(false);
|
|
380
|
+
U.clear(results);
|
|
381
|
+
if (!metas.length) {
|
|
382
|
+
results.appendChild(U.el('div', 'search-empty', 'No results for “' + query + '”.'));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
metas.slice(0, 60).forEach(function (m) { results.appendChild(posterCard(m, m.type || 'movie')); });
|
|
386
|
+
Focus.setScope(scr('search'), results.querySelector('.card'));
|
|
387
|
+
}, function () {
|
|
388
|
+
U.spinner(false);
|
|
389
|
+
U.clear(results);
|
|
390
|
+
results.appendChild(U.el('div', 'search-empty', 'Search failed. Check your network.'));
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
281
394
|
// ================= SETTINGS =================
|
|
282
395
|
function renderSettings() {
|
|
283
396
|
var s = scr('settings');
|
|
@@ -293,27 +406,49 @@ var Views = (function () {
|
|
|
293
406
|
var item = U.el('div', 'addon-item focusable');
|
|
294
407
|
item.appendChild(U.el('div', 'a-name', loaded ? (loaded.manifest.name || 'Addon') : '(failed to load)'));
|
|
295
408
|
item.appendChild(U.el('div', 'a-url', url));
|
|
409
|
+
var name = loaded ? (loaded.manifest.name || 'Addon') : 'this addon';
|
|
296
410
|
item.__onselect = function () {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
411
|
+
confirm('Remove "' + name + '"?', 'Remove', function () {
|
|
412
|
+
Stremio.removeAddon(url);
|
|
413
|
+
U.toast('Removed');
|
|
414
|
+
setTimeout(renderSettings, 300);
|
|
415
|
+
});
|
|
300
416
|
};
|
|
301
417
|
wrap.appendChild(item);
|
|
302
418
|
});
|
|
303
419
|
|
|
420
|
+
// Reset to defaults
|
|
421
|
+
var reset = U.el('div', 'addon-item focusable reset-item');
|
|
422
|
+
reset.appendChild(U.el('div', 'a-name', '↺ Reset to default addons'));
|
|
423
|
+
reset.appendChild(U.el('div', 'a-url', 'Removes all addons and restores Cinemeta, WatchHub, OpenSubtitles v3'));
|
|
424
|
+
reset.__onselect = function () {
|
|
425
|
+
confirm('Reset to default addons? This removes all addons you’ve added.', 'Reset', function () {
|
|
426
|
+
Stremio.resetAddons();
|
|
427
|
+
U.toast('Addons reset to default');
|
|
428
|
+
setTimeout(renderSettings, 300);
|
|
429
|
+
});
|
|
430
|
+
};
|
|
431
|
+
wrap.appendChild(reset);
|
|
432
|
+
|
|
304
433
|
// Add-addon input
|
|
305
434
|
var addRow = U.el('div', 'addon-item focusable');
|
|
306
435
|
addRow.appendChild(U.el('div', 'a-name', '+ Add addon (focus, then type a manifest URL)'));
|
|
307
436
|
var input = document.createElement('input');
|
|
308
437
|
input.type = 'text';
|
|
438
|
+
input.value = 'https://';
|
|
309
439
|
input.placeholder = 'https://addon.example/manifest.json';
|
|
310
440
|
input.style.cssText = 'width:100%;margin-top:10px;padding:12px;font-size:20px;background:#0e1117;color:#fff;border:2px solid #2a3346;border-radius:6px;';
|
|
311
441
|
input.onchange = function () {
|
|
312
|
-
var v = input.value;
|
|
313
|
-
if (v) { Stremio.addAddon(v); U.toast('Addon added'); setTimeout(renderSettings, 500); }
|
|
442
|
+
var v = ('' + input.value).trim();
|
|
443
|
+
if (v && v !== 'https://') { Stremio.addAddon(v); U.toast('Addon added'); setTimeout(renderSettings, 500); }
|
|
314
444
|
};
|
|
315
445
|
addRow.appendChild(input);
|
|
316
|
-
addRow.__onselect = function () {
|
|
446
|
+
addRow.__onselect = function () {
|
|
447
|
+
input.focus();
|
|
448
|
+
// place cursor at the end so the user types after https://
|
|
449
|
+
var n = input.value.length;
|
|
450
|
+
try { input.setSelectionRange(n, n); } catch (e) {}
|
|
451
|
+
};
|
|
317
452
|
wrap.appendChild(addRow);
|
|
318
453
|
|
|
319
454
|
wrap.appendChild(U.el('div', 'hint',
|
|
@@ -325,6 +460,7 @@ var Views = (function () {
|
|
|
325
460
|
|
|
326
461
|
return {
|
|
327
462
|
renderHome: renderHome, renderDetail: renderDetail, renderSettings: renderSettings,
|
|
328
|
-
backInDetail: backInDetail
|
|
463
|
+
renderSearch: renderSearch, backInDetail: backInDetail,
|
|
464
|
+
isModalOpen: function () { return _modalOpen; }, closeModal: closeModal
|
|
329
465
|
};
|
|
330
466
|
})();
|