nuvio-tizen 1.8.13 → 1.9.1

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.

Potentially problematic release.


This version of nuvio-tizen might be problematic. Click here for more details.

package/README.md CHANGED
@@ -47,6 +47,26 @@ core behaviour, targeting 2017 Samsung TVs (Tizen 3.0, ~Chromium 47).
47
47
  - Settings: add addons on a **dedicated page** (field kept above the on-screen keyboard;
48
48
  nothing is saved unless you press Confirm), remove with a confirmation prompt, and
49
49
  **Reset to default addons**.
50
+ - **Live TV (IPTV)**: point Settings → *Live TV (IPTV)* at an **M3U/M3U8 playlist URL**.
51
+ Channels are grouped by `group-title` into `tv`-type **Live TV** rows on Home (searchable,
52
+ orderable like any feed). **Direct HTTP(S) HLS** channels play natively via AVPlay
53
+ (per-channel `http-user-agent` / `http-referrer` from the playlist are applied best-effort).
54
+ **YouTube** channels (`youtube.com/...`) play via YouTube's own **leanback TV app** behind a
55
+ bundled localhost proxy — see below.
56
+
57
+ ### YouTube (leanback) channels
58
+
59
+ M3U entries that point at `youtube.com`/`youtu.be` can't be turned into a direct stream URL
60
+ on-device (YouTube blocks that) and `youtube.com/tv` can't be framed directly
61
+ (`X-Frame-Options`). Nuvio bundles a small **localhost proxy service** (`service/index.js`,
62
+ a `<tizen:service>` on `127.0.0.1:8099`) that serves `youtube.com/tv` with `X-Frame-Options`
63
+ and CSP stripped, rewrites media/asset URLs, and injects an ad-block userscript — then Nuvio
64
+ plays it in a fullscreen leanback overlay. Press **RETURN/Back** to return to Nuvio.
65
+
66
+ > Credit: the proxy design, URL-rewrite rules and the injected ad-block userscript are from
67
+ > **[TizenTube](https://github.com/KrX3D/TizenTube)** (`@krx3d/tizentube2`, MIT). Nuvio ports a
68
+ > trimmed, dependency-free (Node-core only) version of its standalone proxy and additionally
69
+ > strips `X-Frame-Options` so it can run inside an in-app overlay.
50
70
 
51
71
  > **Note on WatchHub:** it's a "where to watch" addon — its results are Rent/Buy/Subscription
52
72
  > *links to external services*, not direct video URLs, so they show as non-playable. Add your
@@ -64,6 +84,10 @@ come from whatever **stream addons you add yourself** in Settings. Streams that
64
84
  a torrent `infoHash` (no HTTP URL) are listed but not playable — AVPlay needs a direct
65
85
  HTTP(S)/HLS/DASH URL, and this app embeds no torrent/debrid engine.
66
86
 
87
+ The **IPTV** feature likewise hosts no content: you supply your own M3U playlist URL. The
88
+ bundled leanback proxy only relays requests to YouTube's own servers on the TV's behalf (to
89
+ strip framing headers); it stores nothing and adds no content.
90
+
67
91
  ---
68
92
 
69
93
  ## Install path A — TizenBrew (recommended, no cert signing)
package/config.xml CHANGED
@@ -1,17 +1,28 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
2
  <widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
3
- id="http://nuvio.tizen/NuvioTizen" version="1.8.13" viewmodes="maximized">
4
- <tizen:application id="Nuvio00001.Nuvio" package="Nuvio00001" required_version="2.4"/>
3
+ id="http://nuvio.tizen/NuvioTizen" version="1.9.0" viewmodes="maximized">
4
+ <tizen:application id="Nuvio00001.Nuvio" package="Nuvio00001" required_version="3.0"/>
5
5
  <content src="index.html"/>
6
6
  <feature name="http://tizen.org/feature/screen.size.all"/>
7
+ <!-- Node service (leanback proxy) -->
8
+ <feature name="http://tizen.org/feature/web.service"/>
7
9
  <icon src="icon.png"/>
8
10
  <name>Nuvio</name>
9
11
  <tizen:privilege name="http://tizen.org/privilege/internet"/>
10
12
  <tizen:privilege name="http://tizen.org/privilege/tv.inputdevice"/>
11
13
  <tizen:privilege name="http://tizen.org/privilege/application.launch"/>
12
- <!-- Cross-origin XHR to any addon host -->
14
+ <tizen:privilege name="http://developer.samsung.com/privilege/network.public"/>
15
+ <!-- Cross-origin XHR to any addon host; framing the localhost leanback proxy -->
13
16
  <access origin="*" subdomains="true"/>
17
+ <tizen:allow-navigation href="*"/>
14
18
  <tizen:profile name="tv"/>
19
+ <!-- Bundled leanback proxy service (see service/index.js). Launched on demand
20
+ by js/ytplay.js to serve youtube.com/tv with X-Frame-Options/CSP stripped. -->
21
+ <tizen:service id="Nuvio00001.NuvioProxy">
22
+ <tizen:content src="service/index.js"/>
23
+ <tizen:name>NuvioProxy</tizen:name>
24
+ <tizen:description>Nuvio leanback proxy</tizen:description>
25
+ </tizen:service>
15
26
  <tizen:setting screen-orientation="landscape" context-menu="disable"
16
27
  background-support="disable" encryption="disable"
17
28
  install-location="auto" hwkey-event="enable"/>
package/css/style.css CHANGED
@@ -209,3 +209,7 @@ html, body {
209
209
  .spin { width: 70px; height: 70px; border: 7px solid rgba(255,255,255,0.2); border-top-color: #818cf8; border-radius: 50%; -webkit-animation: sp 0.9s linear infinite; animation: sp 0.9s linear infinite; }
210
210
  @-webkit-keyframes sp { to { -webkit-transform: rotate(360deg); } }
211
211
  @keyframes sp { to { transform: rotate(360deg); } }
212
+
213
+ /* YouTube leanback overlay (IPTV YouTube channels via the localhost proxy) */
214
+ #yt-leanback { position: absolute; top: 0; left: 0; width: 1920px; height: 1080px; z-index: 46; background: #000; }
215
+ #yt-leanback iframe { width: 1920px; height: 1080px; border: 0; display: block; background: #000; }
package/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=1920, height=1080, user-scalable=no">
6
6
  <title>Nuvio</title>
7
- <link rel="stylesheet" href="css/style.css?v=1.8.13">
7
+ <link rel="stylesheet" href="css/style.css?v=1.9.0">
8
8
  </head>
9
9
  <body>
10
10
  <!-- CSS-applied sentinel (kept off-screen); app.js waits for its width before rendering -->
@@ -42,18 +42,23 @@
42
42
  <!-- Skip intro/recap/credits prompt (TheIntroDB) -->
43
43
  <div id="skip-prompt" class="hidden"></div>
44
44
 
45
+ <!-- YouTube leanback overlay (IPTV YouTube channels, via the localhost proxy) -->
46
+ <div id="yt-leanback" class="hidden"></div>
47
+
45
48
  <div id="toast" class="hidden"></div>
46
49
  <div id="spinner" class="hidden"><div class="spin"></div></div>
47
50
 
48
- <!-- Load order matters: polyfills -> util -> data -> input -> player -> views -> app -->
51
+ <!-- Load order matters: polyfills -> util -> iptv -> data -> input -> player -> ytplay -> views -> app -->
49
52
  <!-- ?v= busts the TV webview cache on every release (keep in sync with package.json) -->
50
- <script src="js/polyfills.js?v=1.8.13"></script>
51
- <script src="js/util.js?v=1.8.13"></script>
52
- <script src="js/stremio.js?v=1.8.13"></script>
53
- <script src="js/keys.js?v=1.8.13"></script>
54
- <script src="js/focus.js?v=1.8.13"></script>
55
- <script src="js/player.js?v=1.8.13"></script>
56
- <script src="js/views.js?v=1.8.13"></script>
57
- <script src="js/app.js?v=1.8.13"></script>
53
+ <script src="js/polyfills.js?v=1.9.0"></script>
54
+ <script src="js/util.js?v=1.9.0"></script>
55
+ <script src="js/iptv.js?v=1.9.0"></script>
56
+ <script src="js/stremio.js?v=1.9.0"></script>
57
+ <script src="js/keys.js?v=1.9.0"></script>
58
+ <script src="js/focus.js?v=1.9.0"></script>
59
+ <script src="js/player.js?v=1.9.0"></script>
60
+ <script src="js/ytplay.js?v=1.9.0"></script>
61
+ <script src="js/views.js?v=1.9.0"></script>
62
+ <script src="js/app.js?v=1.9.0"></script>
58
63
  </body>
59
64
  </html>
package/js/iptv.js ADDED
@@ -0,0 +1,172 @@
1
+ /* IPTV / M3U support. Global namespace: IPTV
2
+ Parses a user-supplied M3U playlist into Stremio-shaped `tv` catalogs, so the
3
+ rest of the app (home rows, search, detail, player) treats live channels exactly
4
+ like any other addon's content. Exposed to Stremio as a local synthetic addon
5
+ (see synthAddon()); Stremio.loadAddons / getCatalog / getMeta / getStreams route
6
+ `iptv:` ids here instead of over HTTP.
7
+
8
+ Direct HTTP(S) streams play through AVPlay. YouTube channels are tagged `_yt` and
9
+ handed to YTPlay (leanback proxy) at playback time. */
10
+ var IPTV = (function () {
11
+ 'use strict';
12
+
13
+ var URL_KEY = 'nuvio.iptv.url';
14
+ var CACHE_MS = 10 * 60 * 1000;
15
+ var _cache = null; // { ts, channels, groups, byId, catalogs }
16
+ var _status = { url: '', count: 0, groups: 0, error: '' }; // last load result, for Settings
17
+
18
+ function getUrl() { return U.store.get(URL_KEY, '') || ''; }
19
+ function isEnabled() { return !!getUrl(); }
20
+ function reset() { _cache = null; }
21
+ function setUrl(u) {
22
+ u = ('' + (u || '')).trim();
23
+ if (u) { U.store.set(URL_KEY, u); } else { U.store.remove(URL_KEY); }
24
+ reset();
25
+ }
26
+
27
+ function slug(s) {
28
+ var v = ('' + s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
29
+ return v.slice(0, 40) || 'x';
30
+ }
31
+
32
+ function attr(line, re) { var m = line.match(re); return m ? m[1] : ''; }
33
+
34
+ // Returns [{ id, name, logo, group, tvgId, ua, ref, url, _yt }]
35
+ function parseM3U(text) {
36
+ var lines = ('' + text).split(/\r?\n/);
37
+ var channels = [];
38
+ var cur = null;
39
+ var seen = {};
40
+ for (var i = 0; i < lines.length; i++) {
41
+ var s = lines[i].replace(/^\s+/, '');
42
+ if (s.indexOf('#EXTINF') === 0) {
43
+ cur = {
44
+ name: (s.slice(s.lastIndexOf(',') + 1).trim()) || 'Channel',
45
+ logo: attr(s, /tvg-logo="([^"]*)"/),
46
+ group: attr(s, /group-title="([^"]*)"/) || 'Live TV',
47
+ tvgId: attr(s, /tvg-id="([^"]*)"/),
48
+ ua: attr(s, /http-user-agent="([^"]*)"/),
49
+ ref: attr(s, /http-referrer="([^"]*)"/),
50
+ url: ''
51
+ };
52
+ } else if (s.indexOf('#EXTVLCOPT:http-user-agent=') === 0) {
53
+ if (cur) { cur.ua = s.slice('#EXTVLCOPT:http-user-agent='.length).trim(); }
54
+ } else if (s.indexOf('#EXTVLCOPT:http-referrer=') === 0) {
55
+ if (cur) { cur.ref = s.slice('#EXTVLCOPT:http-referrer='.length).trim(); }
56
+ } else if (s && s.charAt(0) !== '#') {
57
+ if (cur) {
58
+ var u = s.trim();
59
+ if (/^https?:\/\//i.test(u)) {
60
+ cur.url = u;
61
+ cur._yt = /(^|\.)youtube\.com\//i.test(u) || /(^|\.)youtu\.be\//i.test(u);
62
+ var base = 'iptv:' + slug(cur.tvgId || cur.name);
63
+ var id = base, n = 2;
64
+ while (seen[id]) { id = base + '-' + n; n++; }
65
+ seen[id] = 1; cur.id = id;
66
+ channels.push(cur);
67
+ }
68
+ cur = null;
69
+ }
70
+ }
71
+ }
72
+ return channels;
73
+ }
74
+
75
+ function build(channels) {
76
+ var groups = [], gseen = {}, byId = {};
77
+ channels.forEach(function (c) {
78
+ byId[c.id] = c;
79
+ if (!gseen[c.group]) { gseen[c.group] = 1; groups.push(c.group); }
80
+ });
81
+ var catalogs = groups.map(function (g) {
82
+ return { type: 'tv', id: 'iptv_' + slug(g), name: g, extra: [{ name: 'search' }], _group: g };
83
+ });
84
+ return { ts: Date.now(), channels: channels, groups: groups, byId: byId, catalogs: catalogs };
85
+ }
86
+
87
+ function load() {
88
+ if (_cache && (Date.now() - _cache.ts) < CACHE_MS) { return Promise.resolve(_cache); }
89
+ var url = getUrl();
90
+ _status = { url: url, count: 0, groups: 0, error: '' };
91
+ if (!url) { _cache = build([]); return Promise.resolve(_cache); }
92
+ return U.getText(url, 15000).then(function (text) {
93
+ _cache = build(parseM3U(text));
94
+ _status.count = _cache.channels.length;
95
+ _status.groups = _cache.groups.length;
96
+ if (!_cache.channels.length) {
97
+ // Fetched, but nothing parsed — usually the URL returned an error/HTML page
98
+ // (e.g. a "not found" body) rather than an M3U.
99
+ _status.error = 'Fetched, but no channels parsed (is the URL returning the .m3u file?)';
100
+ }
101
+ return _cache;
102
+ }, function (e) {
103
+ _status.error = (e && e.message) ? e.message : 'Could not fetch the playlist URL';
104
+ _cache = build([]); // keep the app usable if the playlist is unreachable
105
+ return _cache;
106
+ });
107
+ }
108
+
109
+ function status() { return _status; }
110
+
111
+ function toMeta(c) {
112
+ return {
113
+ id: c.id, type: 'tv', name: c.name,
114
+ poster: c.logo || '', background: c.logo || '', logo: c.logo || '',
115
+ posterShape: 'landscape', description: c.group
116
+ };
117
+ }
118
+
119
+ function catalog(catId, extra) {
120
+ return load().then(function (d) {
121
+ var cat = null;
122
+ for (var i = 0; i < d.catalogs.length; i++) {
123
+ if (d.catalogs[i].id === catId) { cat = d.catalogs[i]; break; }
124
+ }
125
+ if (!cat) { return []; }
126
+ var q = (extra && extra.search) ? ('' + extra.search).toLowerCase() : '';
127
+ var out = [];
128
+ d.channels.forEach(function (c) {
129
+ if (c.group !== cat._group) { return; }
130
+ if (q && c.name.toLowerCase().indexOf(q) === -1) { return; }
131
+ out.push(toMeta(c));
132
+ });
133
+ return out;
134
+ });
135
+ }
136
+
137
+ function meta(id) {
138
+ return load().then(function (d) { var c = d.byId[id]; return c ? toMeta(c) : null; });
139
+ }
140
+
141
+ function streams(id) {
142
+ return load().then(function (d) {
143
+ var c = d.byId[id];
144
+ if (!c) { return []; }
145
+ if (c._yt) {
146
+ return [{ url: c.url, _yt: true, ytPage: c.url, name: c.name, title: 'YouTube · ' + c.group }];
147
+ }
148
+ var st = { url: c.url, name: c.name, title: 'IPTV · ' + c.group };
149
+ if (c.ua || c.ref) { st._headers = { ua: c.ua || '', ref: c.ref || '' }; }
150
+ return [st];
151
+ });
152
+ }
153
+
154
+ // A local addon object shaped like the HTTP addons in Stremio.loadAddons().
155
+ // `__local` tells Stremio to route resource calls here instead of over HTTP.
156
+ function synthAddon() {
157
+ var d = _cache || build([]);
158
+ return {
159
+ __local: true, url: 'local:iptv', base: 'local:iptv',
160
+ manifest: {
161
+ id: 'org.nuvio.iptv', name: 'IPTV', version: '1.0.0',
162
+ types: ['tv'], resources: ['catalog', 'meta', 'stream'],
163
+ idPrefixes: ['iptv:'], catalogs: d.catalogs
164
+ }
165
+ };
166
+ }
167
+
168
+ return {
169
+ getUrl: getUrl, setUrl: setUrl, isEnabled: isEnabled, reset: reset, status: status,
170
+ load: load, catalog: catalog, meta: meta, streams: streams, synthAddon: synthAddon
171
+ };
172
+ })();
package/js/player.js CHANGED
@@ -228,6 +228,13 @@ var Player = (function () {
228
228
  } catch (e) {}
229
229
  try {
230
230
  avplay.open(url);
231
+ // Best-effort per-stream HTTP headers (some IPTV channels need a UA / Referer).
232
+ // Guarded: unsupported properties simply throw and are ignored.
233
+ try {
234
+ var h = _ctx && _ctx._headers;
235
+ if (h && h.ua) { avplay.setStreamingProperty('USER_AGENT', h.ua); }
236
+ if (h && h.ref) { avplay.setStreamingProperty('CUSTOM_MESSAGE', 'Referer:' + h.ref); }
237
+ } catch (eh) {}
231
238
  avplay.setListener({
232
239
  onbufferingstart: function () { _buffering = true; U.spinner(true); },
233
240
  onbufferingcomplete: function () {
package/js/stremio.js CHANGED
@@ -84,6 +84,8 @@ var Stremio = (function () {
84
84
  // ---- Manifest loading (cached in-memory per session) ----
85
85
  var _loaded = null;
86
86
 
87
+ var _hasIptv = (typeof IPTV !== 'undefined');
88
+
87
89
  function loadAddons() {
88
90
  if (_loaded) { return Promise.resolve(_loaded); }
89
91
  var urls = getAddonUrls();
@@ -94,14 +96,31 @@ var Stremio = (function () {
94
96
  });
95
97
  };
96
98
  });
97
- return U.settleAll(tasks, 4).then(function (results) {
99
+ // Load the IPTV playlist first (if configured) so its synthetic addon's
100
+ // catalogs reflect the parsed channel groups.
101
+ var pre = (_hasIptv && IPTV.isEnabled()) ? IPTV.load() : Promise.resolve(null);
102
+ return pre.then(function () {
103
+ return U.settleAll(tasks, 4);
104
+ }).then(function (results) {
98
105
  var addons = [];
99
106
  results.forEach(function (r) { if (r.ok && r.value.manifest) { addons.push(r.value); } });
107
+ // Prepend so the Live TV rows sort first on Home and are never cut by the
108
+ // home-page row cap (the user can still reorder them in Home Feeds).
109
+ if (_hasIptv && IPTV.isEnabled()) { addons.unshift(IPTV.synthAddon()); }
100
110
  _loaded = addons;
101
111
  return addons;
102
112
  });
103
113
  }
104
114
 
115
+ // Point Nuvio's IPTV playlist at a new M3U URL and force a reload.
116
+ function iptvSetUrl(url) {
117
+ if (_hasIptv) { IPTV.setUrl(url); }
118
+ _loaded = null;
119
+ }
120
+ function iptvGetUrl() { return _hasIptv ? IPTV.getUrl() : ''; }
121
+ function iptvLoad() { return _hasIptv ? IPTV.load() : Promise.resolve(null); }
122
+ function iptvStatus() { return _hasIptv ? IPTV.status() : { error: 'IPTV unavailable' }; }
123
+
105
124
  // ---- Resource matching ----
106
125
  function resourceEntries(addon) {
107
126
  var res = (addon.manifest.resources) || [];
@@ -174,6 +193,7 @@ var Stremio = (function () {
174
193
  }
175
194
 
176
195
  function getCatalog(cat, extra) {
196
+ if (cat.addon && cat.addon.__local) { return IPTV.catalog(cat.id, extra); }
177
197
  return U.getJSON(buildCatalogUrl(cat.addon, cat.type, cat.id, extra), 12000)
178
198
  .then(function (res) { return (res && res.metas) ? res.metas : []; });
179
199
  }
@@ -243,6 +263,12 @@ var Stremio = (function () {
243
263
 
244
264
  // ---- Meta ----
245
265
  function getMeta(type, id) {
266
+ if (/^iptv:/.test(id)) {
267
+ return IPTV.meta(id).then(function (m) {
268
+ if (!m) { throw new Error('IPTV channel not found'); }
269
+ return m;
270
+ });
271
+ }
246
272
  return loadAddons().then(function (addons) {
247
273
  var providers = addons.filter(function (a) { return supports(a, 'meta', type, id); });
248
274
  if (!providers.length) { return Promise.reject(new Error('No meta addon for ' + type)); }
@@ -263,6 +289,7 @@ var Stremio = (function () {
263
289
  // ---- Streams ----
264
290
  // Aggregates stream lists from every addon that supports the (type,id).
265
291
  function getStreams(type, id) {
292
+ if (/^iptv:/.test(id)) { return IPTV.streams(id); }
266
293
  return loadAddons().then(function (addons) {
267
294
  var providers = addons.filter(function (a) { return supports(a, 'stream', type, id); });
268
295
  var tasks = providers.map(function (a) {
@@ -518,6 +545,8 @@ var Stremio = (function () {
518
545
  getMeta: getMeta, getStreams: getStreams, getSubtitles: getSubtitles,
519
546
  getSearchCatalogs: getSearchCatalogs, search: search,
520
547
  isPlayable: isPlayable, streamLabel: streamLabel, streamName: streamName,
521
- streamSize: streamSize, streamDescription: streamDescription
548
+ streamSize: streamSize, streamDescription: streamDescription,
549
+ iptvSetUrl: iptvSetUrl, iptvGetUrl: iptvGetUrl,
550
+ iptvLoad: iptvLoad, iptvStatus: iptvStatus
522
551
  };
523
552
  })();
package/js/views.js CHANGED
@@ -517,8 +517,10 @@ var Views = (function () {
517
517
 
518
518
  function startPlayback(stream, videoId, label) {
519
519
  markHomeDirty(); // progress/watched will change → refresh Continue Watching on return
520
+ // YouTube-live IPTV channels play through the leanback proxy overlay, not AVPlay.
521
+ if (stream._yt) { YTPlay.open(stream, function () { drawDetail(); }); return; }
520
522
  var ctx = { url: stream.url, type: _d.type, id: videoId, name: label,
521
- poster: _d.meta.poster, streams: playableSources() };
523
+ poster: _d.meta.poster, streams: playableSources(), _headers: stream._headers };
522
524
  if (_d.type === 'series' && _d.meta.videos) {
523
525
  var ord = orderedEpisodes(_d.meta);
524
526
  var idx = ord.findIndex(function (v) { return v.id === videoId; });
@@ -756,6 +758,7 @@ var Views = (function () {
756
758
  var wrap = U.el('div', 'settings-wrap');
757
759
  s.appendChild(wrap);
758
760
  if (_settingsSection === 'addons') { settingsAddons(wrap); }
761
+ else if (_settingsSection === 'iptv') { settingsIptv(wrap); }
759
762
  else if (_settingsSection === 'integrations') { settingsIntegrations(wrap); }
760
763
  else if (_settingsSection === 'subtitles') { settingsSubtitles(wrap); }
761
764
  else if (_settingsSection === 'feeds') { settingsFeeds(wrap); }
@@ -780,6 +783,7 @@ var Views = (function () {
780
783
  function settingsMenu(wrap) {
781
784
  wrap.appendChild(U.el('h2', null, 'Settings'));
782
785
  wrap.appendChild(sectionRow('Addons', 'Catalog, stream & subtitle addons', 'addons'));
786
+ wrap.appendChild(sectionRow('Live TV (IPTV)', 'Load channels from an M3U playlist URL', 'iptv'));
783
787
  wrap.appendChild(sectionRow('Integrations', 'API keys (OMDb, TMDB, MDBList)', 'integrations'));
784
788
  wrap.appendChild(sectionRow('Subtitle Options', 'Default language, size, style', 'subtitles'));
785
789
  wrap.appendChild(sectionRow('Home Feeds', 'Order & show/hide the home rows', 'feeds'));
@@ -843,6 +847,68 @@ var Views = (function () {
843
847
  });
844
848
  }
845
849
 
850
+ function settingsIptv(wrap) {
851
+ wrap.appendChild(U.el('h2', null, 'Live TV (IPTV)'));
852
+ var url = Stremio.iptvGetUrl ? Stremio.iptvGetUrl() : '';
853
+
854
+ // Live load status — turns a silent fetch failure into a visible message.
855
+ if (url && Stremio.iptvLoad) {
856
+ var statusEl = U.el('div', 'hint', 'Checking playlist…');
857
+ wrap.appendChild(statusEl);
858
+ Stremio.iptvLoad().then(function () {
859
+ var st = Stremio.iptvStatus();
860
+ if (st.error) { statusEl.textContent = '⚠ ' + st.error; }
861
+ else { statusEl.textContent = '✓ Loaded ' + st.count + ' channels in ' + st.groups + ' group(s).'; }
862
+ });
863
+ }
864
+
865
+ var set = U.el('div', 'addon-item focusable');
866
+ set.appendChild(U.el('div', 'a-name', url ? 'M3U playlist' : '+ Set M3U playlist URL'));
867
+ set.appendChild(U.el('div', 'a-url', url || 'Not set — press OK to enter an M3U URL'));
868
+ set.__onselect = function () {
869
+ App.openEntry({
870
+ title: 'IPTV playlist URL',
871
+ hint: 'Type an M3U/M3U8 playlist URL, then Confirm. RETURN cancels — nothing is saved unless you confirm.',
872
+ value: url || 'https://', placeholder: 'https://example.com/playlist.m3u',
873
+ validate: function (v) { return (!v || v === 'https://' || v.indexOf('://') === -1 || v.length < 12) ? 'Enter a full M3U URL' : null; },
874
+ onConfirm: function (v) { Stremio.iptvSetUrl(v); clearCatalogCache(); U.toast('Playlist saved'); App.back(); renderSettings(); }
875
+ });
876
+ };
877
+ wrap.appendChild(set);
878
+
879
+ if (url) {
880
+ var reload = U.el('div', 'addon-item focusable');
881
+ reload.appendChild(U.el('div', 'a-name', '↻ Reload playlist'));
882
+ reload.appendChild(U.el('div', 'a-url', 'Re-fetch channels from the M3U URL now'));
883
+ reload.__onselect = function () {
884
+ Stremio.iptvSetUrl(url); // re-sets same URL, clears cache
885
+ clearCatalogCache();
886
+ U.toast('Reloading channels…');
887
+ setTimeout(renderSettings, 300);
888
+ };
889
+ wrap.appendChild(reload);
890
+
891
+ var rm = U.el('div', 'addon-item focusable reset-item');
892
+ rm.appendChild(U.el('div', 'a-name', '✕ Remove playlist'));
893
+ rm.appendChild(U.el('div', 'a-url', 'Clears the IPTV channels from Nuvio'));
894
+ rm.__onselect = function () {
895
+ confirm('Remove the IPTV playlist?', 'Remove', function () {
896
+ Stremio.iptvSetUrl('');
897
+ clearCatalogCache();
898
+ U.toast('Playlist removed');
899
+ setTimeout(renderSettings, 300);
900
+ });
901
+ };
902
+ wrap.appendChild(rm);
903
+ }
904
+
905
+ wrap.appendChild(U.el('div', 'hint',
906
+ 'Channels are grouped by the playlist\'s group-title into Live TV rows on Home. ' +
907
+ 'Direct HLS streams play via AVPlay; YouTube channels play via the built-in leanback proxy.'));
908
+ wrap.appendChild(backRow());
909
+ Focus.setScope(scr('settings'));
910
+ }
911
+
846
912
  function maskKey(v) { v = '' + v; return v.length <= 4 ? '••••' : ('••••' + v.slice(-4)); }
847
913
 
848
914
  function keyRow(title, key, placeholder, validator) {
package/js/ytplay.js ADDED
@@ -0,0 +1,115 @@
1
+ /* YouTube leanback playback. Global namespace: YTPlay
2
+ YouTube-live IPTV channels can't be extracted to a direct URL on-device and
3
+ youtube.com/tv can't be framed (X-Frame-Options). So we launch a bundled
4
+ localhost proxy (service/index.js) that serves youtube.com/tv with those
5
+ headers stripped, and load it in a fullscreen overlay iframe — the same
6
+ leanback approach TizenTube uses. See service/index.js for the proxy. */
7
+ var YTPlay = (function () {
8
+ 'use strict';
9
+
10
+ var PROXY = 'http://localhost:8099';
11
+ var _open = false;
12
+ var _onExit = null;
13
+ var _idCache = {}; // ytPage -> { id, ts }
14
+ var ID_TTL = 5 * 60 * 1000; // live video ids rotate; keep short
15
+
16
+ function overlay() { return U.byId('yt-leanback'); }
17
+
18
+ // Launch the proxy service (idempotent) and resolve once it answers /health.
19
+ function ensureService() {
20
+ return new Promise(function (resolve, reject) {
21
+ if (!(window.tizen && tizen.application)) { reject(new Error('no-service')); return; }
22
+ function poll(left) {
23
+ U.getText(PROXY + '/health', 2500).then(function () { resolve(); }, function () {
24
+ if (left <= 0) { reject(new Error('proxy-timeout')); return; }
25
+ setTimeout(function () { poll(left - 1); }, 500);
26
+ });
27
+ }
28
+ try {
29
+ var pkgId = tizen.application.getCurrentApplication().appInfo.packageId;
30
+ tizen.application.launchAppControl(
31
+ new tizen.ApplicationControl('http://tizen.org/appcontrol/operation/service'),
32
+ pkgId + '.NuvioProxy',
33
+ function () { poll(40); }, // launched → wait for it to listen (~20s)
34
+ function () { poll(40); } // may already be running
35
+ );
36
+ } catch (e) { poll(40); }
37
+ });
38
+ }
39
+
40
+ // A youtube.com/<channel>/live (or /watch?v=) URL -> the current live video id.
41
+ function resolveVideoId(page) {
42
+ var direct = ('' + page).match(/[?&]v=([A-Za-z0-9_-]{11})/);
43
+ if (direct) { return Promise.resolve(direct[1]); }
44
+ var hit = _idCache[page];
45
+ if (hit && (Date.now() - hit.ts) < ID_TTL) { return Promise.resolve(hit.id); }
46
+ return U.getText(page, 12000).then(function (html) {
47
+ var m = html.match(/"videoId":"([A-Za-z0-9_-]{11})"/);
48
+ var id = m ? m[1] : '';
49
+ if (id) { _idCache[page] = { id: id, ts: Date.now() }; }
50
+ return id;
51
+ }, function () { return ''; });
52
+ }
53
+
54
+ function onHwKey(e) {
55
+ if (e && e.keyName === 'back') { if (e.preventDefault) { e.preventDefault(); } close(); }
56
+ }
57
+ function onKeyDown(e) {
58
+ if (e && (e.keyCode === 10009 || e.keyCode === 461)) {
59
+ if (e.preventDefault) { e.preventDefault(); }
60
+ if (e.stopPropagation) { e.stopPropagation(); }
61
+ close();
62
+ }
63
+ }
64
+
65
+ function show(id) {
66
+ var o = overlay();
67
+ if (!o) { return; }
68
+ U.clear(o);
69
+ var iframe = document.createElement('iframe');
70
+ iframe.setAttribute('allow', 'autoplay; encrypted-media');
71
+ iframe.src = PROXY + '/tv#/watch?v=' + encodeURIComponent(id);
72
+ iframe.onload = function () { try { iframe.focus(); } catch (e) {} };
73
+ o.appendChild(iframe);
74
+ o.className = '';
75
+ U.byId('app').className = 'hidden';
76
+ _open = true;
77
+ // Hardware Back is delivered app-wide (tizenhwkey) even when the iframe has
78
+ // focus; the keydown capture covers the moment before the iframe grabs focus.
79
+ document.addEventListener('tizenhwkey', onHwKey, true);
80
+ document.addEventListener('keydown', onKeyDown, true);
81
+ }
82
+
83
+ function close() {
84
+ if (!_open) { return; }
85
+ _open = false;
86
+ document.removeEventListener('tizenhwkey', onHwKey, true);
87
+ document.removeEventListener('keydown', onKeyDown, true);
88
+ var o = overlay();
89
+ if (o) { o.className = 'hidden'; U.clear(o); }
90
+ U.byId('app').className = '';
91
+ var cb = _onExit; _onExit = null;
92
+ if (cb) { try { cb(); } catch (e) {} } // redraw detail -> restores focus scope
93
+ }
94
+
95
+ function open(stream, onExit) {
96
+ _onExit = onExit || null;
97
+ var page = stream.ytPage || stream.url;
98
+ U.spinner(true);
99
+ ensureService().then(function () {
100
+ return resolveVideoId(page);
101
+ }).then(function (id) {
102
+ U.spinner(false);
103
+ if (!id) { U.toast('Could not find the live stream for this channel', 3500); return; }
104
+ show(id);
105
+ }, function (err) {
106
+ U.spinner(false);
107
+ var msg = (err && err.message === 'no-service')
108
+ ? 'YouTube channels need the on-TV proxy (not available in this browser).'
109
+ : 'Could not start the YouTube proxy service.';
110
+ U.toast(msg, 4500);
111
+ });
112
+ }
113
+
114
+ return { open: open, close: close, isOpen: function () { return _open; } };
115
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuvio-tizen",
3
- "version": "1.8.13",
3
+ "version": "1.9.1",
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",
@@ -9,6 +9,7 @@
9
9
  "index.html",
10
10
  "css",
11
11
  "js",
12
+ "service",
12
13
  "icon.png",
13
14
  "config.xml",
14
15
  "README.md"
@@ -0,0 +1,225 @@
1
+ "use strict";
2
+ /*
3
+ * Nuvio leanback proxy — Tizen <tizen:service> (Node ~4.x engine).
4
+ *
5
+ * Lets Nuvio play YouTube-live IPTV channels through YouTube's own leanback TV
6
+ * app (youtube.com/tv) inside an in-app overlay. youtube.com/tv sends
7
+ * X-Frame-Options: SAMEORIGIN and CSP, so it can't be framed directly; this
8
+ * localhost proxy strips those, rewrites media/asset URLs through /cors-bypass/,
9
+ * and injects TizenTube's ad-block userscript.
10
+ *
11
+ * Adapted from TizenTube (@krx3d/tizentube2, MIT — https://github.com/KrX3D/TizenTube):
12
+ * the /tv + /cors-bypass proxy design, the URL-rewrite rules, and the userscript
13
+ * injection are ported from its standalone/service/index.js. Reduced to Node core
14
+ * modules only (no express/node-fetch) so it needs no bundling, and additionally
15
+ * strips X-Frame-Options so Nuvio can frame it in an overlay.
16
+ *
17
+ * ES5 only — the Tizen service engine is an old Node (no let/const/arrow/templates).
18
+ */
19
+
20
+ var http = require('http');
21
+ var https = require('https');
22
+ var zlib = require('zlib');
23
+ var urllib = require('url');
24
+
25
+ var PORT = 8099;
26
+ var USERSCRIPT_CDN = 'https://cdn.jsdelivr.net/npm/@krx3d/tizentube2/dist/userScript.js';
27
+ var USERSCRIPT_FALLBACK = 'https://unpkg.com/@krx3d/tizentube2/dist/userScript.js';
28
+ var ALLOWED = ['googlevideo.com', 'youtube.com', 'gstatic.com', 'google.com',
29
+ 'googleapis.com', 'googleusercontent.com', 'ggpht.com'];
30
+
31
+ function hostAllowed(h) {
32
+ if (!h) { return false; }
33
+ for (var i = 0; i < ALLOWED.length; i++) {
34
+ var a = ALLOWED[i];
35
+ if (h === a || h.slice(-(a.length + 1)) === '.' + a) { return true; }
36
+ }
37
+ return false;
38
+ }
39
+
40
+ function proxyPrefix() { return 'http://localhost:' + PORT + '/cors-bypass/'; }
41
+
42
+ function rewriteText(text, isTvHtml) {
43
+ var p = proxyPrefix();
44
+ if (isTvHtml) {
45
+ var inject = '<script>window.__tizenTubeStandaloneVersion="nuvio";</script>' +
46
+ '<script src="' + USERSCRIPT_CDN + '?ver=' + Date.now() +
47
+ '" onerror="this.onerror=null;this.src=\'' + USERSCRIPT_FALLBACK + '\'"></script>';
48
+ if (/<body[^>]*>/i.test(text)) {
49
+ text = text.replace(/<body[^>]*>/i, function (b) { return b + inject; });
50
+ } else {
51
+ text = inject + text;
52
+ }
53
+ }
54
+ text = text.replace(/https:\/\/([a-zA-Z0-9-.]+)\.googlevideo\.com/g, p + 'https://$1.googlevideo.com');
55
+ text = text.replace(/https:\\\/\\\/([a-zA-Z0-9-.]+)\.googlevideo\.com/g,
56
+ 'http:\\/\\/localhost:' + PORT + '\\/cors-bypass\\/https:\\/\\/$1.googlevideo.com');
57
+ text = text.replace(/"\/\/([a-zA-Z0-9-.]+)\.googlevideo\.com/g, '"' + p + 'https://$1.googlevideo.com');
58
+ text = text.replace(/https:\/\/www\.gstatic\.com/g, p + 'https://www.gstatic.com');
59
+ text = text.replace(/http:\/\/www\.gstatic\.com/g, p + 'https://www.gstatic.com');
60
+ text = text.replace(/"\/\/www\.gstatic\.com/g, '"' + p + 'https://www.gstatic.com');
61
+ text = text.replace(/\(\/\/www\.gstatic\.com/g, '(' + p + 'https://www.gstatic.com');
62
+ text = text.replace(/https:\/\/yt3\.ggpht\.com/g, p + 'https://yt3.ggpht.com');
63
+ text = text.replace(/https:\/\/clients1\.google\.com/g, p + 'https://clients1.google.com');
64
+ text = text.replace(/http:\/\/clients1\.google\.com/g, p + 'https://clients1.google.com');
65
+ text = text.replace(/"\/\/clients1\.google\.com/g, '"' + p + 'https://clients1.google.com');
66
+ text = text.replace('Set(["www.youtube.com","accounts.google.com"]);',
67
+ 'Set(["www.youtube.com", "accounts.google.com", "localhost"]);');
68
+ text = text.replace(/:document\.location\.toString\(\)/g,
69
+ ':document.location.toString().replace("http://localhost:' + PORT + '", "https://www.youtube.com")');
70
+ text = text.replace(/euri:[^,]+,/g,
71
+ 'euri:document.location.toString().replace("http://localhost:' + PORT + '", "https://www.youtube.com"),');
72
+ text = text.replace(/https:\/\/s\.youtube\.com/g, p + 'https://s.youtube.com');
73
+ text = text.replace(/redirector\.googlevideo\.com/g, p + 'https://redirector.googlevideo.com');
74
+ text = text.replace(/this.scheme="https"/, 'this.scheme="http"');
75
+ text = text.replace(/https:\/\/jnn-pa\.googleapis\.com/g, p + 'https://jnn-pa.googleapis.com');
76
+ text = text.replace(/https:\/\/yt3\.googleusercontent\.com/g, p + 'https://yt3.googleusercontent.com');
77
+ text = text.replace(/"\/\/yt3\.googleusercontent\.com/g, '"' + p + 'https://yt3.googleusercontent.com');
78
+ text = text.replace(/=window\.location\.href;/,
79
+ '=window.location.href.replace("http://localhost:' + PORT + '", "https://www.youtube.com");');
80
+ text = text.replace(/=document\.location\.href/,
81
+ '=document.location.href.replace("http://localhost:' + PORT + '", "https://www.youtube.com")');
82
+ return text;
83
+ }
84
+
85
+ function rewriteCookies(arr) {
86
+ if (!arr) { return arr; }
87
+ if (!(arr instanceof Array)) { arr = [arr]; }
88
+ var out = [];
89
+ for (var i = 0; i < arr.length; i++) {
90
+ out.push(String(arr[i])
91
+ .replace(/^__Secure-/i, '__LocalSecure-')
92
+ .replace(/^__Host-/i, '__LocalHost-')
93
+ .replace(/Domain=[^;]+/i, 'Domain=localhost')
94
+ .replace(/;\s*Secure/i, '')
95
+ .replace(/;\s*SameSite=None/i, ''));
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function isTextType(ct) {
101
+ ct = ct || '';
102
+ return ct.indexOf('text/html') !== -1 || ct.indexOf('application/json') !== -1 ||
103
+ ct.indexOf('javascript') !== -1 || ct.indexOf('text/css') !== -1;
104
+ }
105
+
106
+ function decompress(buf, enc, cb) {
107
+ enc = (enc || '').toLowerCase();
108
+ if (enc === 'gzip') { zlib.gunzip(buf, cb); return; }
109
+ if (enc === 'deflate') {
110
+ zlib.inflate(buf, function (e, r) { if (e) { zlib.inflateRaw(buf, cb); } else { cb(null, r); } });
111
+ return;
112
+ }
113
+ cb(null, buf);
114
+ }
115
+
116
+ var SKIP_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding',
117
+ 'content-security-policy', 'x-frame-options', 'alt-svc'];
118
+
119
+ function handle(req, res) {
120
+ res.setHeader('Access-Control-Allow-Origin', '*');
121
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
122
+ res.setHeader('Access-Control-Allow-Headers', '*');
123
+ if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
124
+
125
+ var pathOnly = req.url.split('?')[0];
126
+ if (pathOnly === '/health') { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('ok'); return; }
127
+
128
+ var isCors = req.url.indexOf('/cors-bypass/') === 0;
129
+ var target;
130
+ if (isCors) {
131
+ var raw = req.url.substring('/cors-bypass/'.length);
132
+ target = raw.indexOf('http') === 0 ? raw : ('https://' + raw);
133
+ } else {
134
+ target = 'https://www.youtube.com' + req.url;
135
+ }
136
+
137
+ var pu;
138
+ try { pu = urllib.parse(target); } catch (e) { res.writeHead(403); res.end('bad url'); return; }
139
+ if (pu.protocol !== 'https:' && pu.protocol !== 'http:') { res.writeHead(403); res.end('bad protocol'); return; }
140
+ if (!hostAllowed(pu.hostname)) { res.writeHead(403); res.end('host not allowed'); return; }
141
+
142
+ var isTvHtml = (!isCors && pathOnly === '/tv');
143
+
144
+ var headers = {};
145
+ for (var k in req.headers) {
146
+ if (!Object.prototype.hasOwnProperty.call(req.headers, k)) { continue; }
147
+ if (k === 'cookie') {
148
+ headers[k] = String(req.headers[k])
149
+ .replace(/__LocalSecure-/g, '__Secure-')
150
+ .replace(/__LocalHost-/g, '__Host-');
151
+ } else if (k !== 'host' && k !== 'accept-encoding') {
152
+ headers[k] = req.headers[k];
153
+ }
154
+ }
155
+ headers.host = pu.host;
156
+ headers.origin = 'https://www.youtube.com';
157
+ if (headers.referer) { headers.referer = 'https://www.youtube.com/tv'; }
158
+ headers['accept-encoding'] = 'gzip, deflate';
159
+
160
+ var mod = pu.protocol === 'http:' ? http : https;
161
+ var options = {
162
+ hostname: pu.hostname,
163
+ port: pu.port || (pu.protocol === 'http:' ? 80 : 443),
164
+ path: pu.path,
165
+ method: req.method,
166
+ headers: headers,
167
+ maxHeaderSize: 5 * 1024 * 1024 // ignored on old Node; helps where supported (YT sends big headers)
168
+ };
169
+
170
+ var preq = mod.request(options, function (pres) {
171
+ var outHeaders = {};
172
+ for (var hk in pres.headers) {
173
+ if (!Object.prototype.hasOwnProperty.call(pres.headers, hk)) { continue; }
174
+ var lk = hk.toLowerCase();
175
+ if (SKIP_HEADERS.indexOf(lk) !== -1) { continue; }
176
+ if (lk === 'set-cookie') { outHeaders[hk] = rewriteCookies(pres.headers[hk]); continue; }
177
+ if (lk === 'location') {
178
+ outHeaders[hk] = String(pres.headers[hk])
179
+ .replace(/https?:\/\/(www\.)?youtube\.com/gi, 'http://localhost:' + PORT);
180
+ continue;
181
+ }
182
+ outHeaders[hk] = pres.headers[hk];
183
+ }
184
+ outHeaders['Access-Control-Allow-Origin'] = '*';
185
+
186
+ var enc = pres.headers['content-encoding'];
187
+ var ct = pres.headers['content-type'] || '';
188
+
189
+ if (isTextType(ct)) {
190
+ var chunks = [];
191
+ pres.on('data', function (c) { chunks.push(c); });
192
+ pres.on('end', function () {
193
+ decompress(Buffer.concat(chunks), enc, function (err, buf) {
194
+ if (err) { res.writeHead(502); res.end('decompress error'); return; }
195
+ var text = rewriteText(buf.toString('utf8'), isTvHtml);
196
+ // new Buffer(): Buffer.from() doesn't exist on the TV's old Node (< 4.5).
197
+ var body = new Buffer(text, 'utf8');
198
+ outHeaders['Content-Length'] = body.length;
199
+ res.writeHead(pres.statusCode, outHeaders);
200
+ res.end(body);
201
+ });
202
+ });
203
+ pres.on('error', function () { if (!res.headersSent) { res.writeHead(502); } res.end(); });
204
+ } else {
205
+ res.writeHead(pres.statusCode, outHeaders);
206
+ var e2 = (enc || '').toLowerCase();
207
+ if (e2 === 'gzip') { pres.pipe(zlib.createGunzip()).pipe(res); }
208
+ else if (e2 === 'deflate') { pres.pipe(zlib.createInflate()).pipe(res); }
209
+ else { pres.pipe(res); }
210
+ }
211
+ });
212
+
213
+ preq.on('error', function (e) {
214
+ if (!res.headersSent) { res.writeHead(502); }
215
+ try { res.end('proxy error'); } catch (e3) {}
216
+ });
217
+
218
+ req.pipe(preq);
219
+ }
220
+
221
+ var server = http.createServer(handle);
222
+ server.on('error', function (err) { try { console.error('proxy listen error', err); } catch (e) {} });
223
+ server.listen(PORT, '127.0.0.1', function () {
224
+ try { console.log('Nuvio leanback proxy on 127.0.0.1:' + PORT); } catch (e) {}
225
+ });