/* * The TV's side of the web home screen: its apps, opening one, its inputs, its settings, the Back key, its time zone. * - LG webOS: the TV's service bus (Luna), the same calls LG's launcher makes. * - Samsung Tizen: Tizen's web APIs (tizen.application for the apps, tizen.tvwindow for the inputs). * - A desktop browser: a stand-in with sample apps, so the screen can be previewed and tested on a computer. * Plain ES5: older TVs run a Chromium from 2015-2017 (webOS 3 and 4, Tizen 3). */ var TV = (function () { 'use strict'; var webos = typeof window.PalmServiceBridge !== 'undefined'; var samsung = !webos && typeof window.tizen !== 'undefined' && !!window.tizen.application; var calls = []; // a bridge is collected if nothing holds it before its reply arrives /** One Luna call; done(result) with null on failure. */ function luna(uri, params, done) { if (!webos) { if (done) setTimeout(function () { done(null); }, 0); return; } var bridge = new window.PalmServiceBridge(); calls.push(bridge); var finished = false; bridge.onservicecallback = function (message) { if (finished) return; finished = true; var i = calls.indexOf(bridge); if (i >= 0) calls.splice(i, 1); var result = null; try { result = JSON.parse(message); } catch (e) {} if (done) done(result && result.returnValue !== false ? result : null); }; bridge.call(uri, JSON.stringify(params || {})); } // webOS 6 gives Developer Mode apps only the "public" group, which leaves out listLaunchPoints. There the list comes // from the TV's remote control API on 127.0.0.1:3000 (the one LG's phone app uses), which takes file:// pages. // The TV asks once to allow it; the key it gives back is kept. var SSAP_KEY = 'fmtv.ssapKey', SSAP_REFUSED = 'fmtv.ssapRefused'; /** One call to the remote control API; done(result) with null on failure. */ function ssap(uri, done) { var ws = null, finished = false, key = null; var timer = setTimeout(function () { finish(null); }, 90000); // time to answer the TV's prompt function finish(result) { if (finished) return; finished = true; clearTimeout(timer); try { ws.close(); } catch (e) {} done(result); } try { key = localStorage.getItem(SSAP_KEY); } catch (e) {} try { ws = new WebSocket('ws://127.0.0.1:3000'); } catch (e) { finish(null); return; } ws.onopen = function () { var payload = {forcePairing: false, pairingType: 'PROMPT', manifest: {manifestVersion: 1, appVersion: '1.0', permissions: ['READ_INSTALLED_APPS']}}; if (key) payload['client-key'] = key; ws.send(JSON.stringify({type: 'register', id: 'register', payload: payload})); }; ws.onmessage = function (event) { var m = null; try { m = JSON.parse(event.data); } catch (e) {} if (!m) return; if (m.type === 'registered') { try { localStorage.setItem(SSAP_KEY, m.payload['client-key']); } catch (e) {} ws.send(JSON.stringify({type: 'request', id: 'call', uri: 'ssap://' + uri})); } else if (m.id === 'call' || m.type === 'error') { if (m.id === 'register' && m.type === 'error') { try { localStorage.setItem(SSAP_REFUSED, '1'); } catch (e) {} // the person said no on the TV } finish(m.type !== 'error' && m.payload && m.payload.returnValue !== false ? m.payload : null); } }; ws.onerror = ws.onclose = function () { finish(null); }; } // LG inputs: the antenna and the four HDMI ports, with the apps LG opens for them. var INPUTS = [ {id: 'com.webos.app.livetv', key: 'live', label: null, symbol: 'live_tv'}, {id: 'com.webos.app.hdmi1', key: 'hdmi1', label: 'HDMI 1', symbol: 'hdmi'}, {id: 'com.webos.app.hdmi2', key: 'hdmi2', label: 'HDMI 2', symbol: 'hdmi'}, {id: 'com.webos.app.hdmi3', key: 'hdmi3', label: 'HDMI 3', symbol: 'hdmi'}, {id: 'com.webos.app.hdmi4', key: 'hdmi4', label: 'HDMI 4', symbol: 'hdmi'} ]; // Not shown among LG's apps: the inputs above, LG's own home and launchers. var HIDDEN = /^(com\.webos\.app\.(livetv|hdmi\d|home|homeconnect|discovery|launcher)|com\.webos\.app\.inputpicker)$/; // Not shown among Samsung's apps: Smart Hub's own pieces, the platform's tools and players (but its browser and // Netflix, which live there too), services, keyboards and ad players. The rest is what Smart Hub shows. // Tizen 4 also lists Samsung's own pieces: the YouTube engine's hosts (cobalt-*: "App", "Youtube", "YoutubeTV"), // Bixby, search, previews, the weather and notification popups, Apple TV's stand-in and engine. var SAMSUNG_HIDDEN = new RegExp('^(org\\.volt\\.|org\\.tizen\\.(?!browser$|netflix)|org\\.tizen\\.netflixlowmem$|ise-|ug-|d75857a5-)' + '|[Ss]ervice$|[Dd]aemon|Preview$|syspopup|[Hh]elper$|AdPlayer|AdSuite|[Kk]eyboard|\\.OTApp$' + '|^com\\.samsung\\.(calendar-account-manager|tv\\.(cobalt-|aria-(dummy|engine)|airplay-app|ambient-support|' + 'built-in-app-info-viewer|commonsearch|searchall|preview-|tv-scheduler|csfs|notification-app|mycontents|' + 'safLaunchBridge|shortcut|remote-workspace|voiceassistant|mybixby|weather-viewer))'); // Samsung apps whose iconPath is Tizen's generic pinwheel (a valid image, so onerror never fires): our own icons. // Seen on a 2018 NU7675 (Tizen 4); Smart Hub draws their real icons from a folder apps can't read. On a 2017 // MU6100 (Tizen 3) no iconPath loads at all, so common apps get a logo here: Simple Icons' (CC0), cropped and tinted // for the tiles, with a width and height so old WebKit sizes them like PNGs. var SAMSUNG_ICONS = { '9Ur5IzDKqV.TizenYouTube': 'icons/youtube.png', 'evKhCgZelL.AmazonIgnitionLauncher2': 'icons/prime.png', 'RN1MCdNq8t.Netflix': 'icons/netflix.svg', 'org.tizen.netflix-app': 'icons/netflix.svg', 'NvZ26VG9Yw.HBOGOLAG': 'icons/max.svg', 'rJeHak5zRg.Spotify': 'icons/spotify.svg', 'cexr1qp97S.Deezer': 'icons/deezer.svg', '5SLwh7m3En.TizenYouTubeKids': 'icons/youtubekids.svg', '4OAwqypfRT.RedBullTV': 'icons/redbull.svg', 'UCJa2s9hhC.TED': 'icons/ted.svg', 'QizQxC7CUf.PlayMovies': 'icons/googleplay.svg', 'vIutycUzou.SteamLink': 'icons/steam.svg', 'geUOM7qimf.FOXPlay': 'icons/fox.svg', 'AprZAARz4r.Jellyfin': 'icons/jellyfin.svg' }; // Names Smart Hub gets wrong: HBO's app has none (so it never reached the shelf), others are long or old. var SAMSUNG_TITLES = { 'NvZ26VG9Yw.HBOGOLAG': 'Max', 'rJeHak5zRg.Spotify': 'Spotify', 'evKhCgZelL.AmazonIgnitionLauncher2': 'Prime Video' }; // Samsung's settings menu on Tizen 3, which answers no "settings" app control. var SAMSUNG_MENU = 'org.tizen.menu'; // Samsung's live TV viewer: it shows whichever source the TV is on (the antenna, or the HDMI picked here). var SAMSUNG_VIEWER = 'org.tizen.tv-viewer'; var SAMPLE = [ ['netflix', 'Netflix', '#E50914'], ['youtube.leanback.v4', 'YouTube', '#FF0033'], ['amazon', 'Prime Video', '#1A98FF'], ['com.disney.disneyplus-prod', 'Disney+', '#1838C8'], ['com.hbo.hbomax', 'Max', '#6C3BE0'], ['com.apple.appletv', 'Apple TV', '#B8BCC6'], ['globoplaywebos', 'Globoplay', '#FF5A1F'], ['spotify-beehive', 'Spotify', '#1DB954'], ['cdp-30', 'Plex', '#E5A00D'], ['com.crunchyroll.webapp', 'Crunchyroll', '#F47521'], ['com.webos.app.browser', 'Web Browser', '#3A7BD5'], ['com.palm.app.settings', 'Settings', '#8E8E93'] ]; // Newer webOS (11 tested) denies listLaunchPoints to Developer Mode apps, but still answers getAppLoadStatus for // one app id: these are checked one by one there. Their icons can't be read, so the shelf shows their names. var KNOWN = [ ['netflix', 'Netflix'], ['youtube.leanback.v4', 'YouTube'], ['amazon', 'Prime Video'], ['com.disney.disneyplus-prod', 'Disney+'], ['com.wbd.stream', 'HBO Max'], ['com.hbo.hbomax', 'Max'], ['com.apple.appletv', 'Apple TV'], ['hulu', 'Hulu'], ['com.peacock.tv', 'Peacock'], ['com.nfl.app', 'NFL'], ['globoplaywebos', 'Globoplay'], ['spotify-beehive', 'Spotify'], ['tv.twitch.tv.starshot.lg', 'Twitch'], ['cdp-30', 'Plex'], ['org.jellyfin.webos', 'Jellyfin'], ['com.crunchyroll.webapp', 'Crunchyroll'], ['com.webos.app.browser', 'Web Browser'] ]; function knownApps(done) { var found = [], left = KNOWN.length, sent = false; function finish() { if (sent) return; sent = true; done(found.filter(function (x) { return x; })); } setTimeout(finish, 5000); // an id that never answers doesn't hold the rest KNOWN.forEach(function (k, i) { luna('luna://com.webos.applicationManager/getAppLoadStatus', {appId: k[0]}, function (res) { if (res && res.exist) found[i] = {id: k[0], title: k[1], icon: '', color: ''}; if (--left === 0) finish(); }); }); } var self = 'io.freemytv'; if (webos && window.PalmSystem && window.PalmSystem.identifier) self = window.PalmSystem.identifier.split(' ')[0]; if (samsung) { try { self = window.tizen.application.getCurrentApplication().appInfo.id; } catch (e) {} } // MARK: - Samsung function samsungApps(done) { try { window.tizen.application.getAppsInfo(function (list) { var out = []; for (var i = 0; i < list.length; i++) { var a = list[i]; if (!a.id || a.show === false || a.id === self || SAMSUNG_HIDDEN.test(a.id)) continue; var icon = a.iconPath || ''; if (icon && icon.indexOf('://') < 0) icon = 'file://' + icon; if (SAMSUNG_ICONS[a.id]) icon = SAMSUNG_ICONS[a.id]; out.push({id: a.id, title: SAMSUNG_TITLES[a.id] || a.name || a.id, icon: icon, color: ''}); } // The built-in Netflix stays only when the store's Netflix isn't installed. var store = out.some(function (x) { return /\.Netflix$/.test(x.id); }); if (store) out = out.filter(function (x) { return x.id.indexOf('org.tizen.netflix') !== 0; }); // Smart Hub keeps its own order, which isn't readable: the same alphabetical order as All Apps elsewhere. out.sort(function (x, y) { var a = x.title.toLowerCase(), b = y.title.toLowerCase(); return a < b ? -1 : a > b ? 1 : 0; }); done(out); }, function () { done([]); }); } catch (e) { done([]); } } function samsungLaunch(id, done) { try { window.tizen.application.launch(id, function () { if (done) done(true); }, function () { if (done) done(false); }); } catch (e) { if (done) done(false); } } /** * The video sources the TV reports ([{type: 'TV' | 'HDMI' | ..., number}]), or null when it doesn't tell. With `all`, * the disconnected ones too: Tizen 4 lists an HDMI device on standby as disconnected (even the one on screen), and * tvwindow.setSource still switches to it, as Samsung's own source menu does. */ function samsungSources(done, all) { try { window.tizen.systeminfo.getPropertyValue('VIDEOSOURCE', function (v) { done(v && v.connected ? (all ? v.connected.concat(v.disconnected || []) : v.connected) : null); }, function () { done(null); }); } catch (e) { done(null); } } /** Puts the TV on a source ("source:HDMI:2"), then brings the TV's viewer up to show it. */ function samsungSource(spec, done) { var parts = spec.split(':'), type = parts[1], number = +parts[2]; function show(ok) { if (!ok) { if (done) done(false); return; } samsungLaunch(SAMSUNG_VIEWER, function (shown) { if (!shown) { try { window.tizen.application.getCurrentApplication().hide(); } catch (e) {} } if (done) done(true); }); } samsungSources(function (sources) { var source = null; for (var i = 0; sources && i < sources.length; i++) { if (sources[i].type === type && sources[i].number === number) source = sources[i]; } if (!source) { show(type === 'TV'); return; } // the antenna: the viewer alone brings it up try { window.tizen.tvwindow.setSource(source, function () { show(true); }, function () { show(false); }); } catch (e) { show(false); } }, true); } function samsungInputs(done) { var list = [{id: 'source:TV:1', key: 'live', label: null, symbol: 'live_tv', connected: true}]; for (var n = 1; n <= 4; n++) list.push({id: 'source:HDMI:' + n, key: 'hdmi' + n, label: 'HDMI ' + n, symbol: 'hdmi', connected: true}); samsungSources(function (sources) { if (sources) { for (var i = 1; i < list.length; i++) list[i].connected = false; for (var j = 0; j < sources.length; j++) { var s = sources[j]; if (s.type === 'HDMI' && s.number >= 1 && s.number <= 4) list[s.number].connected = true; } } done(list); }); } /** Samsung's settings: whichever app answers the platform's "settings" request, else the TV's menu (Tizen 3). */ function samsungSettings(done) { function menu() { samsungLaunch(SAMSUNG_MENU, done); } try { var control = new window.tizen.ApplicationControl('http://tizen.org/appcontrol/operation/setting', null, null, null, []); window.tizen.application.launchAppControl(control, null, function () { if (done) done(true); }, menu); } catch (e) { menu(); } } // MARK: - The TV, whichever it is return { name: webos ? 'webos' : samsung ? 'tizen' : 'browser', /** Installed apps, as the TV's launcher lists them: [{id, title, icon, color}]. */ apps: function (done) { if (samsung) { samsungApps(done); return; } if (!webos) { done(SAMPLE.map(function (s) { return {id: s[0], title: s[1], icon: '', color: s[2]}; })); return; } function list(res) { var out = []; var points = res && res.launchPoints ? res.launchPoints : []; for (var i = 0; i < points.length; i++) { var p = points[i]; if (!p.id || p.id === self || HIDDEN.test(p.id)) continue; out.push({id: p.id, title: p.title || p.id, icon: p.largeIcon || p.icon || '', color: p.bgColor || p.iconColor || ''}); } return out; } luna('luna://com.webos.applicationManager/listLaunchPoints', {}, function (res) { if (res) { done(list(res)); return; } // Denied to Developer Mode apps (webOS 6 and newer): once the TV has allowed the remote control API, it lists // every app with its icon; until then, the known apps the TV says it has, while the TV asks once (an answer // only counts from the next load on, and a "no" isn't asked again). var paired = false, refused = false; try { paired = !!localStorage.getItem(SSAP_KEY); refused = !!localStorage.getItem(SSAP_REFUSED); } catch (e) {} if (paired) { ssap('com.webos.applicationManager/listLaunchPoints', function (res2) { if (res2) done(list(res2)); else knownApps(done); }); return; } knownApps(done); if (!refused) ssap('com.webos.applicationManager/listLaunchPoints', function () {}); }); }, /** Opens an app (or an input); done(ok). */ launch: function (id, params, done) { if (samsung) { if (id.indexOf('source:') === 0) samsungSource(id, done); else samsungLaunch(id, done); return; } if (!webos) { if (done) setTimeout(function () { done(true); }, 0); return; } luna('luna://com.webos.applicationManager/launch', params ? {id: id, params: params} : {id: id}, function (res) { if (done) done(!!res); }); }, /** The antenna and HDMI inputs; `connected` when the TV says a device is on (when it tells). */ inputs: function (done) { if (samsung) { samsungInputs(done); return; } var list = INPUTS.map(function (x) { return {id: x.id, key: x.key, label: x.label, symbol: x.symbol, connected: true}; }); if (!webos) { list[2].connected = false; list[3].connected = false; list[4].connected = false; done(list); return; } luna('luna://com.webos.service.eim/getAllInputStatus', {}, function (res) { var devices = res && res.devices ? res.devices : null; if (devices) { for (var i = 1; i < list.length; i++) { for (var j = 0; j < devices.length; j++) { var d = devices[j]; if (d.appId !== list[i].id) continue; list[i].connected = d.connected !== false; // A name the person gave the input on the TV ("PS5"), if any. if (d.label && !/^hdmi\s*\d$/i.test(d.label)) list[i].label = d.label; } } } done(list); }); }, /** The TV's own settings; done(ok). */ settings: function (done) { if (samsung) samsungSettings(done); else this.launch('com.palm.app.settings', null, done); }, /** Back on the first screen: back to wherever the person came from. */ back: function () { if (samsung) { try { window.tizen.application.getCurrentApplication().hide(); } catch (e) {} return; } if (window.PalmSystem && window.PalmSystem.platformBack) window.PalmSystem.platformBack(); }, /** The TV's time zone ("America/Sao_Paulo"), or ''. */ zone: function () { if (samsung) { try { return window.tizen.time.getLocalTimezone() || ''; } catch (e) {} } try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) { return ''; } }, /** Key codes: the arrows, OK and the TV's Back (LG 461, Samsung 10009). */ keys: {left: 37, up: 38, right: 39, down: 40, ok: 13, back: samsung ? 10009 : 461} }; })();