nuvio-tizen 1.0.0 β 1.1.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 +20 -0
- package/index.html +7 -0
- package/js/app.js +8 -2
- package/js/player.js +152 -4
- package/js/stremio.js +83 -4
- package/js/util.js +20 -1
- package/js/views.js +80 -7
- 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,26 @@ 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
|
+
|
|
101
121
|
/* --- Toast + spinner --- */
|
|
102
122
|
#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
123
|
#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,6 +29,12 @@ 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; }
|
|
34
40
|
if (_current === 'detail' && Views.backInDetail()) { return; }
|
|
@@ -71,7 +77,7 @@ var App = (function () {
|
|
|
71
77
|
|
|
72
78
|
return {
|
|
73
79
|
start: start, back: back,
|
|
74
|
-
openDetail: openDetail, openSettings: openSettings
|
|
80
|
+
openDetail: openDetail, openSettings: openSettings, openSearch: openSearch
|
|
75
81
|
};
|
|
76
82
|
})();
|
|
77
83
|
|
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();
|
|
@@ -177,6 +182,79 @@ var Stremio = (function () {
|
|
|
177
182
|
});
|
|
178
183
|
}
|
|
179
184
|
|
|
185
|
+
// ---- Subtitles ----
|
|
186
|
+
// Aggregates subtitle tracks from every addon that supports (type,id).
|
|
187
|
+
function getSubtitles(type, id) {
|
|
188
|
+
return loadAddons().then(function (addons) {
|
|
189
|
+
var providers = addons.filter(function (a) { return supports(a, 'subtitles', type, id); });
|
|
190
|
+
var tasks = providers.map(function (a) {
|
|
191
|
+
return function () {
|
|
192
|
+
var url = a.base + '/subtitles/' + encodeURIComponent(type) + '/' + encodeURIComponent(id) + '.json';
|
|
193
|
+
return U.getJSON(url, 12000).then(function (res) {
|
|
194
|
+
var arr = (res && res.subtitles) ? res.subtitles : [];
|
|
195
|
+
arr.forEach(function (s) { s._addon = a.manifest.name || 'Addon'; });
|
|
196
|
+
return arr;
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
return U.settleAll(tasks, 4).then(function (results) {
|
|
201
|
+
var seen = {}, out = [];
|
|
202
|
+
results.forEach(function (r) {
|
|
203
|
+
if (!r.ok) { return; }
|
|
204
|
+
r.value.forEach(function (s) {
|
|
205
|
+
if (!s || !s.url || seen[s.url]) { return; }
|
|
206
|
+
seen[s.url] = 1;
|
|
207
|
+
out.push(s);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
out.sort(function (a, b) { return ('' + (a.lang || '')).localeCompare('' + (b.lang || '')); });
|
|
211
|
+
return out;
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- Search ----
|
|
217
|
+
// Catalogs (across addons) that declare support for the "search" extra.
|
|
218
|
+
function getSearchCatalogs() {
|
|
219
|
+
return loadAddons().then(function (addons) {
|
|
220
|
+
var out = [];
|
|
221
|
+
addons.forEach(function (addon) {
|
|
222
|
+
(addon.manifest.catalogs || []).forEach(function (c) {
|
|
223
|
+
if (!c.type || !c.id) { return; }
|
|
224
|
+
var searchable = false;
|
|
225
|
+
if (c.extra && c.extra.length) {
|
|
226
|
+
searchable = c.extra.some(function (e) { return e && e.name === 'search'; });
|
|
227
|
+
}
|
|
228
|
+
if (!searchable && c.extraSupported) {
|
|
229
|
+
searchable = c.extraSupported.indexOf('search') !== -1;
|
|
230
|
+
}
|
|
231
|
+
if (searchable) { out.push({ addon: addon, type: c.type, id: c.id }); }
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
return out;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function search(query) {
|
|
239
|
+
var q = ('' + query).trim();
|
|
240
|
+
if (!q) { return Promise.resolve([]); }
|
|
241
|
+
return getSearchCatalogs().then(function (cats) {
|
|
242
|
+
var tasks = cats.map(function (cat) {
|
|
243
|
+
return function () { return getCatalog(cat, { search: q }); };
|
|
244
|
+
});
|
|
245
|
+
return U.settleAll(tasks, 4).then(function (results) {
|
|
246
|
+
var seen = {}, out = [];
|
|
247
|
+
results.forEach(function (r) {
|
|
248
|
+
if (!r.ok) { return; }
|
|
249
|
+
r.value.forEach(function (m) {
|
|
250
|
+
if (m && m.id && !seen[m.id]) { seen[m.id] = 1; out.push(m); }
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
return out;
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
180
258
|
// A stream is directly playable on the TV only if it exposes an http(s) URL.
|
|
181
259
|
// Torrent (infoHash) / external-only streams need an engine this app doesn't embed.
|
|
182
260
|
function isPlayable(stream) {
|
|
@@ -192,7 +270,8 @@ var Stremio = (function () {
|
|
|
192
270
|
DEFAULT_ADDONS: DEFAULT_ADDONS,
|
|
193
271
|
getAddonUrls: getAddonUrls, addAddon: addAddon, removeAddon: removeAddon,
|
|
194
272
|
loadAddons: loadAddons, listCatalogs: listCatalogs, getCatalog: getCatalog,
|
|
195
|
-
getMeta: getMeta, getStreams: getStreams,
|
|
273
|
+
getMeta: getMeta, getStreams: getStreams, getSubtitles: getSubtitles,
|
|
274
|
+
getSearchCatalogs: getSearchCatalogs, search: search,
|
|
196
275
|
isPlayable: isPlayable, streamLabel: streamLabel
|
|
197
276
|
};
|
|
198
277
|
})();
|
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
|
@@ -6,12 +6,18 @@ var Views = (function () {
|
|
|
6
6
|
function scr(name) { return U.byId('screen-' + name); }
|
|
7
7
|
|
|
8
8
|
// ---------- shared bits ----------
|
|
9
|
-
function header(withGear) {
|
|
9
|
+
function header(withGear, withSearch) {
|
|
10
10
|
var h = U.el('div', 'header');
|
|
11
11
|
var brand = U.el('div', 'brand');
|
|
12
12
|
brand.innerHTML = 'NUVIO<span class="dot">.</span>';
|
|
13
13
|
h.appendChild(brand);
|
|
14
14
|
h.appendChild(U.el('div', 'spacer'));
|
|
15
|
+
if (withSearch) {
|
|
16
|
+
var srch = U.el('div', 'gear focusable', 'π');
|
|
17
|
+
srch.style.marginRight = '24px';
|
|
18
|
+
srch.__onselect = function () { App.openSearch(); };
|
|
19
|
+
h.appendChild(srch);
|
|
20
|
+
}
|
|
15
21
|
if (withGear) {
|
|
16
22
|
var g = U.el('div', 'gear focusable', 'β');
|
|
17
23
|
g.__onselect = function () { App.openSettings(); };
|
|
@@ -40,7 +46,7 @@ var Views = (function () {
|
|
|
40
46
|
function renderHome() {
|
|
41
47
|
var s = scr('home');
|
|
42
48
|
U.clear(s);
|
|
43
|
-
s.appendChild(header(true));
|
|
49
|
+
s.appendChild(header(true, true));
|
|
44
50
|
var rows = U.el('div', 'rows');
|
|
45
51
|
s.appendChild(rows);
|
|
46
52
|
|
|
@@ -252,7 +258,13 @@ var Views = (function () {
|
|
|
252
258
|
var playable = Stremio.isPlayable(stream);
|
|
253
259
|
var node = U.el('div', 'stream focusable');
|
|
254
260
|
node.appendChild(document.createTextNode(Stremio.streamLabel(stream)));
|
|
255
|
-
var
|
|
261
|
+
var why = '';
|
|
262
|
+
if (!playable) {
|
|
263
|
+
if (stream.infoHash) { why = ' β torrent, needs a debrid/torrent client'; }
|
|
264
|
+
else if (stream.externalUrl) { why = ' β opens in an external app (not playable here)'; }
|
|
265
|
+
else { why = ' β not directly playable on the TV'; }
|
|
266
|
+
}
|
|
267
|
+
var sub = (stream._addon || '') + why;
|
|
256
268
|
node.appendChild(U.el('span', 's-sub', sub));
|
|
257
269
|
if (!playable) { node.style.opacity = '0.55'; }
|
|
258
270
|
node.__onselect = function () {
|
|
@@ -278,6 +290,61 @@ var Views = (function () {
|
|
|
278
290
|
return false;
|
|
279
291
|
}
|
|
280
292
|
|
|
293
|
+
// ================= SEARCH =================
|
|
294
|
+
function renderSearch() {
|
|
295
|
+
var s = scr('search');
|
|
296
|
+
U.clear(s);
|
|
297
|
+
s.appendChild(header(false, false));
|
|
298
|
+
|
|
299
|
+
var wrap = U.el('div', 'search-wrap');
|
|
300
|
+
var bar = U.el('div', 'search-bar');
|
|
301
|
+
|
|
302
|
+
var input = document.createElement('input');
|
|
303
|
+
input.id = 'search-input';
|
|
304
|
+
input.type = 'text';
|
|
305
|
+
input.className = 'focusable';
|
|
306
|
+
input.placeholder = 'Search movies & seriesβ¦';
|
|
307
|
+
input.__onselect = function () { input.focus(); };
|
|
308
|
+
input.onkeydown = function (ev) { if (ev.keyCode === 13) { runSearch(input.value); } };
|
|
309
|
+
input.onchange = function () { runSearch(input.value); };
|
|
310
|
+
bar.appendChild(input);
|
|
311
|
+
|
|
312
|
+
var go = U.el('div', 'pill focusable search-go', 'Search');
|
|
313
|
+
go.__onselect = function () { runSearch(input.value); };
|
|
314
|
+
bar.appendChild(go);
|
|
315
|
+
wrap.appendChild(bar);
|
|
316
|
+
|
|
317
|
+
var results = U.el('div', 'search-results');
|
|
318
|
+
results.id = 'search-results';
|
|
319
|
+
results.appendChild(U.el('div', 'search-empty', 'Type a title and press OK on Search. Results come from every addon catalog that supports search.'));
|
|
320
|
+
wrap.appendChild(results);
|
|
321
|
+
|
|
322
|
+
s.appendChild(wrap);
|
|
323
|
+
Focus.setScope(s, input);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function runSearch(query) {
|
|
327
|
+
query = ('' + query).trim();
|
|
328
|
+
var results = U.byId('search-results');
|
|
329
|
+
if (!query) { return; }
|
|
330
|
+
U.clear(results);
|
|
331
|
+
U.spinner(true);
|
|
332
|
+
Stremio.search(query).then(function (metas) {
|
|
333
|
+
U.spinner(false);
|
|
334
|
+
U.clear(results);
|
|
335
|
+
if (!metas.length) {
|
|
336
|
+
results.appendChild(U.el('div', 'search-empty', 'No results for β' + query + 'β.'));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
metas.slice(0, 60).forEach(function (m) { results.appendChild(posterCard(m, m.type || 'movie')); });
|
|
340
|
+
Focus.setScope(scr('search'), results.querySelector('.card'));
|
|
341
|
+
}, function () {
|
|
342
|
+
U.spinner(false);
|
|
343
|
+
U.clear(results);
|
|
344
|
+
results.appendChild(U.el('div', 'search-empty', 'Search failed. Check your network.'));
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
281
348
|
// ================= SETTINGS =================
|
|
282
349
|
function renderSettings() {
|
|
283
350
|
var s = scr('settings');
|
|
@@ -306,14 +373,20 @@ var Views = (function () {
|
|
|
306
373
|
addRow.appendChild(U.el('div', 'a-name', '+ Add addon (focus, then type a manifest URL)'));
|
|
307
374
|
var input = document.createElement('input');
|
|
308
375
|
input.type = 'text';
|
|
376
|
+
input.value = 'https://';
|
|
309
377
|
input.placeholder = 'https://addon.example/manifest.json';
|
|
310
378
|
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
379
|
input.onchange = function () {
|
|
312
|
-
var v = input.value;
|
|
313
|
-
if (v) { Stremio.addAddon(v); U.toast('Addon added'); setTimeout(renderSettings, 500); }
|
|
380
|
+
var v = ('' + input.value).trim();
|
|
381
|
+
if (v && v !== 'https://') { Stremio.addAddon(v); U.toast('Addon added'); setTimeout(renderSettings, 500); }
|
|
314
382
|
};
|
|
315
383
|
addRow.appendChild(input);
|
|
316
|
-
addRow.__onselect = function () {
|
|
384
|
+
addRow.__onselect = function () {
|
|
385
|
+
input.focus();
|
|
386
|
+
// place cursor at the end so the user types after https://
|
|
387
|
+
var n = input.value.length;
|
|
388
|
+
try { input.setSelectionRange(n, n); } catch (e) {}
|
|
389
|
+
};
|
|
317
390
|
wrap.appendChild(addRow);
|
|
318
391
|
|
|
319
392
|
wrap.appendChild(U.el('div', 'hint',
|
|
@@ -325,6 +398,6 @@ var Views = (function () {
|
|
|
325
398
|
|
|
326
399
|
return {
|
|
327
400
|
renderHome: renderHome, renderDetail: renderDetail, renderSettings: renderSettings,
|
|
328
|
-
backInDetail: backInDetail
|
|
401
|
+
renderSearch: renderSearch, backInDetail: backInDetail
|
|
329
402
|
};
|
|
330
403
|
})();
|