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 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,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 &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,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': 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();
@@ -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 sub = (stream._addon || '') + (playable ? '' : ' β€” needs torrent/debrid client (not supported)');
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 () { input.focus(); };
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
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuvio-tizen",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",