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 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 (v1)
12
+ ## What works
13
13
 
14
- - Loads user-configured **Stremio addons** (defaults to Cinemeta for catalogs + metadata).
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, no search UI, no subtitles yet (Stremio subtitle resource + AVPlay
105
- `setSubtitleFile` is the next addition).
106
- - On-screen keyboard for adding addons relies on the TV IME focusing the `<input>`.
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 &nbsp; &larr;/&rarr;: Seek &nbsp; 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': case 'DOWN': showOSD(false); return true;
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
- // Default: Cinemeta (official catalog + metadata addon). Stream addons are
10
- // added by the user in Settings this app hosts no content itself.
11
- var DEFAULT_ADDONS = ['https://v3-cinemeta.strem.io/manifest.json'];
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 sub = (stream._addon || '') + (playable ? '' : ' — needs torrent/debrid client (not supported)');
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
- U.toast('Removed. Reopening…');
298
- Stremio.removeAddon(url);
299
- setTimeout(renderSettings, 500);
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 () { input.focus(); };
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
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuvio-tizen",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
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",