stremiok 2.0.0 → 2.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/js/view-home.js CHANGED
@@ -26,40 +26,116 @@
26
26
  { key: 'movie', icon: '🎬', label: 'Movies' },
27
27
  { key: 'series', icon: '📺', label: 'Series' },
28
28
  { key: 'tv', icon: '📡', label: 'IPTV' },
29
+ { key: 'guide', icon: '☰', label: 'Guide' }, // (the IPTV channels' TV guide as a grid, once one is read: see js/view-guide.js)
29
30
  { key: 'radio', icon: '📻', label: 'Radio' },
30
31
  { key: 'sport', icon: '⚽', label: 'Sports' },
32
+ { key: 'youtube', icon: '📹', label: 'YouTube' }, // (StremioK's YouTube catalogs, an addon's YouTube / channel ones: see isYouTubeCat)
31
33
  { key: 'local', icon: '💾', label: 'Local' } // (a USB drive plugged in: see LocalMedia)
32
34
  ];
33
35
  function hasLocal() { return typeof LocalMedia !== 'undefined' && LocalMedia.present(); }
36
+ function hasGuide() { return typeof EPG !== 'undefined' && !!EPG.hasAny && EPG.hasAny(); }
37
+
38
+ // The top menu's order (Settings › Appearance › Menu): Watching first and Settings last, the
39
+ // rest (the tabs, Discover, Search) in yours. A saved order is kept as far as it goes: an
40
+ // item it doesn't have (one added since) goes after the one it usually follows, and one
41
+ // that no longer exists is dropped.
42
+ var MENU_ITEMS = HOME_TABS.filter(function (t) { return t.key !== 'continue'; }).map(function (t) { return t.key; }).concat(['discover', 'search']);
43
+ function menuOrder() {
44
+ var out = [];
45
+ (Stremio.getPrefs().menuOrder || []).forEach(function (k) { if (MENU_ITEMS.indexOf(k) !== -1 && out.indexOf(k) === -1) { out.push(k); } });
46
+ MENU_ITEMS.forEach(function (k, i) {
47
+ if (out.indexOf(k) !== -1) { return; }
48
+ var at = 0;
49
+ for (var j = i - 1; j >= 0; j--) { var p = out.indexOf(MENU_ITEMS[j]); if (p !== -1) { at = p + 1; break; } }
50
+ out.splice(at, 0, k);
51
+ });
52
+ return out;
53
+ }
54
+ function moveMenuItem(key, dir) {
55
+ var o = menuOrder();
56
+ if (!U.moveIn(o, o.indexOf(key), dir)) { return false; }
57
+ Stremio.setPref('menuOrder', o);
58
+ return true;
59
+ }
60
+ // What the top menu shows, left to right, before Settings: Watching, then in your order the
61
+ // tabs showing (availableTabs), Discover and Search. [{ key, tab }] (tab: a Home tab's entry).
62
+ function menuItems() {
63
+ var tabs = availableTabs(), byKey = {}, out = [];
64
+ tabs.forEach(function (t) { byKey[t.key] = t; });
65
+ if (byKey['continue']) { out.push({ key: 'continue', tab: byKey['continue'] }); }
66
+ menuOrder().forEach(function (k) {
67
+ if (k === 'discover' || k === 'search') { out.push({ key: k }); }
68
+ else if (byKey[k]) { out.push({ key: k, tab: byKey[k] }); }
69
+ });
70
+ return out;
71
+ }
34
72
 
35
73
  // Which tabs to show in the top menu (needs the home catalogs; falls back to the
36
74
  // standard set before they've loaded so the menu is consistent everywhere).
37
- // Tabs you hid (Settings › Appearance › Home tabs) aren't shown; Watching always is.
75
+ // Tabs you hid (Settings › Appearance › Menu) aren't shown; Watching always is.
38
76
  function availableTabs() {
39
77
  var hidden = Stremio.getPrefs().hiddenTabs || [];
40
- function shown(t) { return t.key === 'continue' || hidden.indexOf(t.key) === -1; }
78
+ function shown(t) { return t.key === 'continue' || (hidden.indexOf(t.key) === -1 && (t.key !== 'guide' || hasGuide())); }
41
79
  if (_homeCatalogs === null || _homeFirstLoad) { // first load still running
42
80
  return HOME_TABS.filter(function (t) { return shown(t) && (t.key !== 'local' || hasLocal()); });
43
81
  }
44
82
  return HOME_TABS.filter(function (t) {
45
- return shown(t) && (t.key === 'continue' || t.key === 'calendar' || (t.key === 'local' ? hasLocal() : catsForTab(t.key).length > 0));
83
+ return shown(t) && (t.key === 'continue' || t.key === 'calendar' || t.key === 'guide' || (t.key === 'local' ? hasLocal() : catsForTab(t.key).length > 0));
46
84
  });
47
85
  }
48
86
 
49
- // Which cached catalogs belong to a tab. Sports and radio have dedicated tabs;
50
- // Live TV is the catch-all for every other live source (tv, channel, …).
51
- function catsForTab(key) {
52
- return (_homeCatalogs || []).filter(function (c) {
53
- if (key === 'movie') { return c.type === 'movie'; }
54
- if (key === 'series') { return c.type === 'series'; }
55
- if (key === 'sport') { return c.type === 'sport'; }
56
- if (key === 'radio') { return c.type === 'radio'; }
57
- if (key === 'tv') {
58
- return c.type !== 'movie' && c.type !== 'series' &&
59
- c.type !== 'sport' && c.type !== 'radio';
60
- }
61
- return false;
87
+ // YouTube's tab: StremioK's own YouTube catalogs (whatever type you gave one: "Podcasts"),
88
+ // and another addon's YouTube or channel catalogs (YouTubio's "YouTube", Stremio's "channel").
89
+ // (Home's copies of a catalog, and the ones saved for the next launch, keep only its name,
90
+ // type and titles: a YouTube one carries yt, as its addon isn't kept with them)
91
+ function isYouTubeCat(c) {
92
+ var m = c.addon && c.addon.manifest;
93
+ return !!c.yt || !!(m && m.id === 'org.stremiok.youtube') || /^(youtube|channel)$/i.test('' + c.type);
94
+ }
95
+ function copyCat(c, metas, more) { // (name, type, titles: and yt when it's one)
96
+ var o = { name: c.name, type: c.type, metas: metas };
97
+ if (c.yt) { o.yt = true; }
98
+ if (more) { Object.keys(more).forEach(function (k) { o[k] = more[k]; }); }
99
+ return o;
100
+ }
101
+ // Which cached catalogs belong to a tab (tabOf). Movies, series, sports, radio and YouTube go
102
+ // to their own tabs, live TV (LIVE_TYPES, IPTV playlists) to IPTV. Another type (a debrid
103
+ // library's "RealDebrid", "other") goes by what it lists (byItems); anything still unclear
104
+ // isn't on Home ('': Discover has it). ("anime" never gets here: see Stremio's discoverOnly.)
105
+ var LIVE_TYPES = { tv: 1, events: 1, event: 1, live: 1, livetv: 1, iptv: 1 };
106
+ var TITLE_IDS = /^(tt\d+|tmdb:|tvdb:|kitsu:|mal:|anilist:|anidb:)/i;
107
+ function tabOf(c) {
108
+ var n = (c.metas || []).length;
109
+ if (c._tab !== undefined && c._tabN === n) { return c._tab; } // (worked out again once it has titles)
110
+ var t = ('' + (c.type || '')).toLowerCase(), tab;
111
+ if (isYouTubeCat(c)) { tab = 'youtube'; }
112
+ else if (t === 'movie' || t === 'series' || t === 'radio') { tab = t; }
113
+ else if (t === 'sport' || t === 'sports') { tab = 'sport'; }
114
+ else if (LIVE_TYPES[t]) { tab = 'tv'; }
115
+ else { tab = byItems(c); }
116
+ c._tab = tab; c._tabN = n;
117
+ return tab;
118
+ }
119
+ // Mostly films or series (60% of its first 50 titles): that tab. Title ids, and "anime",
120
+ // "series" or "show" in its name or type: Series ("movie" / "film": Movies). Else ''.
121
+ function byItems(c) {
122
+ var ms = (c.metas || []).slice(0, 50), mv = 0, sr = 0, ids = 0;
123
+ if (!ms.length) { return ''; }
124
+ ms.forEach(function (m) {
125
+ var mt = ('' + ((m && m.type) || '')).toLowerCase();
126
+ if (mt === 'movie') { mv++; } else if (mt === 'series') { sr++; }
127
+ if (TITLE_IDS.test('' + ((m && m.id) || ''))) { ids++; }
62
128
  });
129
+ if (mv + sr >= ms.length * 0.6) { return mv > sr ? 'movie' : 'series'; }
130
+ if (ids >= ms.length * 0.6) {
131
+ var hint = (c.type + ' ' + (c.name || '') + ' ' + ((c.def && c.def.name) || '')).toLowerCase();
132
+ if (/movie|film|pel[ií]cula/.test(hint)) { return 'movie'; }
133
+ if (/anime|series|show|serie/.test(hint)) { return 'series'; }
134
+ }
135
+ return '';
136
+ }
137
+ function catsForTab(key) {
138
+ return (_homeCatalogs || []).filter(function (c) { return tabOf(c) === key; });
63
139
  }
64
140
  var _homeCatalogs = null; // [{name,type,metas}] — fetched once per session, filtered per tab
65
141
  var _homeTab = null; // active tab key
@@ -89,8 +165,11 @@
89
165
  // url: the addon ('local:iptv' for IPTV); none: nothing to fetch (only the set / order
90
166
  // of rows changed). Calls before the reload runs add up.
91
167
  var _reloadOnly = null; // { addon url: 1 } for the next reload, else a full one
168
+ var _homeAddons = {}; // { addon url: 1 } the addons Home's last load had catalogs from
92
169
  function refreshAddonRows(url) {
93
170
  if (_homeCatalogs === null) { clearCatalogCache(); return; } // nothing loaded yet: a full load anyway
171
+ // an addon Home didn't load rows from (the first playlist added: IPTV): a full load, its tab with it
172
+ if (url && !_homeAddons[url]) { clearCatalogCache(); return; }
94
173
  _reloadOnly = _reloadOnly || {};
95
174
  if (url) { _reloadOnly[url] = 1; }
96
175
  _homeDirty = true;
@@ -139,17 +218,17 @@
139
218
  if (bytes > _homeBytesMax) { return; }
140
219
  var metas = c.metas.map(trimMeta);
141
220
  metas.forEach(function (m) { bytes += sizeOf(m); });
142
- if (bytes <= _homeBytesMax) { cats.push({ name: c.name, type: c.type, metas: metas }); }
221
+ if (bytes <= _homeBytesMax) { cats.push(copyCat(c, metas)); }
143
222
  });
144
223
  if (U.bigStore && !_homeSaveFailed) {
145
224
  U.bigStore.set(HOME_CACHE_KEY, { sig: sig, cats: cats }).then(function (ok) { if (ok === false) { _homeSaveFailed = true; } });
146
225
  }
147
226
  U.store.set(HOME_SNAP_KEY, { sig: sig, cats: cats.slice(0, SNAP_ROWS).map(function (c) {
148
- return { name: c.name, type: c.type, metas: c.metas.slice(0, SNAP_CARDS).map(function (m) {
227
+ return copyCat(c, c.metas.slice(0, SNAP_CARDS).map(function (m) {
149
228
  var o = { id: m.id, type: m.type, name: m.name };
150
229
  SNAP_FIELDS.forEach(function (k) { if (m[k] != null) { o[k] = m[k]; } });
151
230
  return o;
152
- }) };
231
+ }));
153
232
  }) });
154
233
  }
155
234
  function useHomeCache() {
@@ -967,16 +1046,18 @@
967
1046
  showModal(overlay, details || watched);
968
1047
  }
969
1048
 
970
- // Local progress merged with Trakt playback (when connected), deduped, newest first.
1049
+ // Local progress merged with Trakt playback (when connected), deduped, newest first; a
1050
+ // place kept here that Trakt has you watching past since (another TV) left out.
971
1051
  // One card per show: its furthest episode (highest season, then episode), placed by
972
1052
  // the show's most recent activity. The others ride along as _dupes so Remove clears
973
1053
  // the whole show.
974
1054
  // byActivity: the most recent activity first, whatever order you gave the row (Move): the
975
1055
  // home bar's tiles (previewTiles).
976
1056
  function continueEntries(byActivity) {
977
- var local = Player.continueList();
1057
+ var local = Player.continueList(), traktOn = typeof Trakt !== 'undefined' && Trakt.isConnected();
1058
+ if (traktOn && Trakt.watchedPast) { local = local.filter(function (e) { return !Trakt.watchedPast(e); }); }
978
1059
  var merged = local.slice();
979
- if (typeof Trakt !== 'undefined' && Trakt.isConnected()) {
1060
+ if (traktOn) {
980
1061
  var seen = {};
981
1062
  local.forEach(function (e) { seen[e.type + ':' + e.id] = 1; });
982
1063
  Trakt.continueList().forEach(function (e) {
@@ -1139,7 +1220,7 @@
1139
1220
  // Rows for the active tab, from the once-per-session catalog cache. Catalog tabs
1140
1221
  // keep their built rows, so switching back doesn't rebuild every card; Continue
1141
1222
  // Watching is small and changes after every playback, so it's always rebuilt.
1142
- var rows = (_homeTab !== 'continue' && _homeTab !== 'local' && _homeTab !== 'calendar') ? _tabRows[_homeTab] : null;
1223
+ var rows = (_homeTab !== 'continue' && _homeTab !== 'local' && _homeTab !== 'calendar' && _homeTab !== 'guide') ? _tabRows[_homeTab] : null;
1143
1224
  if (_homeTab !== 'calendar' && _calCancel) { _calCancel.cancel(); _calCancel = null; } // (its titles' details still coming: not wanted)
1144
1225
  if (rows) {
1145
1226
  s.appendChild(rows);
@@ -1151,6 +1232,8 @@
1151
1232
  localRows(rows);
1152
1233
  } else if (_homeTab === 'calendar') {
1153
1234
  calendarRows(rows);
1235
+ } else if (_homeTab === 'guide') {
1236
+ V.guideGrid(rows);
1154
1237
  } else if (_homeTab === 'continue') {
1155
1238
  var entries = continueEntries();
1156
1239
  if (entries.length) {
@@ -1183,12 +1266,15 @@
1183
1266
  var again = keep ? findCard(s, keep) : null;
1184
1267
  var landed = false;
1185
1268
  var sameItem = (!firstCard && inMenu) ? menuItem(s, cur.textContent) : null; // the menu item focus was on
1186
- if (again || sameItem) { Focus.setScope(s, again || sameItem); } else { landed = focusHome(s, !firstCard && inMenu); }
1269
+ var rowsEl = s.querySelector('.rows'), fc = Focus.current();
1270
+ if (_homeTab === 'guide' && fc && rowsEl && rowsEl.contains(fc)) { landed = true; } // (the Guide drawn from what it had put focus back on its program)
1271
+ else if (again || sameItem) { Focus.setScope(s, again || sameItem); } else { landed = focusHome(s, !firstCard && inMenu); }
1187
1272
  if (_homeCatalogs === null || homeStale() || _reloadOnly) { loadHomeCatalogs(); }
1188
1273
  // No title to focus yet: try again when the rows arrive (Continue Watching and a
1189
1274
  // tab with no catalogs have nothing coming, so focus simply stays on the pill).
1190
- _focusFirstCard = (firstCard && !landed && _homeLoading && _homeTab !== 'continue' && _homeTab !== 'calendar') ? 'pending' : false;
1275
+ _focusFirstCard = (firstCard && !landed && _homeLoading && _homeTab !== 'continue' && _homeTab !== 'calendar' && _homeTab !== 'guide') ? 'pending' : false;
1191
1276
  _renderedSig = tabSig();
1277
+ _renderedMenu = menuSig();
1192
1278
  if (s.querySelector('.rows .card')) { U.mark('firstCard'); }
1193
1279
  // the strip describes the focused title only (empty while focus is in the menu)
1194
1280
  clearTimeout(_homeInfoTimer); _homeInfoTimer = null;
@@ -1203,6 +1289,7 @@
1203
1289
  function fillInfoBox(box, m) {
1204
1290
  if (!box) { return; }
1205
1291
  m = m || {};
1292
+ if (!m.logo && /^iptv:/.test(m.id || '') && typeof EPG !== 'undefined' && EPG.logo && EPG.logo(m.id)) { m = Object.assign({}, m, { logo: EPG.logo(m.id) }); } // (a channel: its guide's logo)
1206
1293
  var bits = [];
1207
1294
  if (m.releaseInfo) { bits.push(m.releaseInfo); } else if (m.year) { bits.push('' + m.year); }
1208
1295
  if (m.runtime) { bits.push(m.runtime); }
@@ -1220,8 +1307,34 @@
1220
1307
  } else {
1221
1308
  t.textContent = name;
1222
1309
  }
1310
+ var desc = m.description || '';
1311
+ var g = /^iptv:/.test(m.id || '') && typeof EPG !== 'undefined' ? EPG.now(m.id) : null; // a channel: what's on, from its TV guide
1312
+ if (g && g.now) {
1313
+ bits = ['Now ' + EPG.hhmm(g.now.start) + '–' + EPG.hhmm(g.now.stop) + ' ' + g.now.title];
1314
+ desc = (g.now.desc ? g.now.desc + ' ' : '') + (g.next ? 'Next ' + EPG.hhmm(g.next.start) + ' ' + g.next.title : '');
1315
+ }
1223
1316
  box.children[1].textContent = bits.join(' · ');
1224
- box.children[2].textContent = m.description || '';
1317
+ box.children[2].textContent = desc;
1318
+ // a program's picture from the TV guide (the Guide tab's focused one, a channel's now)
1319
+ var picSrc = m.picture || (g && g.now && g.now.picture) || '', pic = box.querySelector('.home-info-pic');
1320
+ if (!picSrc) { if (pic) { pic.className = 'home-info-pic hidden'; } box.classList.remove('has-pic'); return; }
1321
+ if (!pic) { pic = U.el('img', 'home-info-pic'); pic.onerror = function () { pic.className = 'home-info-pic hidden'; box.classList.remove('has-pic'); }; box.appendChild(pic); }
1322
+ pic.className = 'home-info-pic';
1323
+ box.classList.add('has-pic');
1324
+ if (pic.__src !== picSrc) { pic.__src = picSrc; U.deferImage(pic, U.cardImage(picSrc, 'still'), true); }
1325
+ }
1326
+ if (typeof EPG !== 'undefined') { // (the focused channel's: drawn again when its guide arrives)
1327
+ var _hadGuide = false;
1328
+ EPG.onChange(function () {
1329
+ var n = Focus.current();
1330
+ if (n && n.__meta && /^iptv:/.test(n.__meta.id || '') && scr('home').contains(n)) { fillHomeInfo(n.__meta); }
1331
+ // a guide read (or gone): the Guide tab comes (or goes) in the menu, at once
1332
+ var has = hasGuide();
1333
+ if (has !== _hadGuide) {
1334
+ _hadGuide = has;
1335
+ if (_current() === scr('home') && !isModalOpen() && !Player.isActive()) { renderHome(); }
1336
+ }
1337
+ });
1225
1338
  }
1226
1339
  Focus.onFocus(function (node) {
1227
1340
  if (!node || !scr('home').contains(node)) { return; }
@@ -1252,12 +1365,18 @@
1252
1365
  // Everything the tab on screen shows from the catalogs (which titles, in which rows),
1253
1366
  // to tell whether a refresh changed it. Continue Watching shows none of it.
1254
1367
  function shownSig() {
1255
- if (_homeTab === 'continue' || _homeTab === 'calendar') { return _homeTab; }
1368
+ if (_homeTab === 'continue' || _homeTab === 'calendar' || _homeTab === 'guide') { return _homeTab; }
1256
1369
  return _homeTab + '#' + catsForTab(_homeTab).map(function (c) {
1257
1370
  return c.name + ':' + c.metas.map(function (m) { return m.id + '/' + (m.poster || ''); }).join(',');
1258
1371
  }).join('|');
1259
1372
  }
1260
- var _renderedSig = '';
1373
+ var _renderedSig = '', _renderedMenu = '';
1374
+ // The tabs that aren't drawn from catalogs (Watching, Calendar, Guide, Local): catalogs that
1375
+ // arrive don't change them, only the top menu might (a tab coming). Redrawing one rebuilds it
1376
+ // whole (the Guide asks for its programs again): a flicker, so not unless the menu changed.
1377
+ function ownTab(t) { return t === 'continue' || t === 'calendar' || t === 'guide' || t === 'local'; }
1378
+ function menuSig() { return availableTabs().map(function (t) { return t.key; }).join(','); }
1379
+ function ownTabStill() { return ownTab(_homeTab) && menuSig() === _renderedMenu; }
1261
1380
  function tabSig() {
1262
1381
  return _homeTab + '#' + catsForTab(_homeTab).map(function (c) { return c.name + ':' + c.metas.length; }).join('|');
1263
1382
  }
@@ -1272,6 +1391,8 @@
1272
1391
  if (typeof IPTV !== 'undefined' && IPTV.onProgress) {
1273
1392
  IPTV.onProgress(function (n) { if (_homeFirstLoad) { U.spinner(true, channelsText(n)); } });
1274
1393
  }
1394
+ // The IPTV playlists read after your addons loaded (they aren't waited for): their rows join.
1395
+ if (typeof Stremio !== 'undefined' && Stremio.onIptvReady) { Stremio.onIptvReady(function () { refreshAddonRows('local:iptv'); }); }
1275
1396
  var PARTIAL_DELAY = 150; // coalesces catalogs that arrive together into one redraw
1276
1397
  function loadHomeCatalogs() {
1277
1398
  if (_homeLoading) { return; }
@@ -1295,23 +1416,28 @@
1295
1416
  _homeLoading = false;
1296
1417
  _homeFirstLoad = false;
1297
1418
  if (first) { U.spinner(false); }
1419
+ if (_reloadOnly && onHome()) { setTimeout(loadHomeCatalogs, 0); } // (rows asked for meanwhile: the IPTV ones, read after the addons)
1298
1420
  }
1299
1421
  function onHome() { return _current() === s && !isModalOpen() && !Player.isActive(); }
1300
1422
  Stremio.catalogsForHome().then(function (cats) {
1301
- var acc = cats.map(function (cat) { return { name: cat.name, type: cat.type, metas: [] }; });
1423
+ _homeAddons = {};
1424
+ cats.forEach(function (cat) { if (cat.addon && cat.addon.url) { _homeAddons[cat.addon.url] = 1; } });
1425
+ var acc = cats.map(function (cat) { return copyCat({ name: cat.name, type: cat.type, yt: isYouTubeCat(cat) }, []); });
1302
1426
  var arrived = cats.map(function () { return false; }), shown = 0, partialTimer = null;
1303
- // First load: publish the rows that have arrived in order (row 1..n), and redraw
1304
- // when that changes the tab on screen.
1427
+ // First load: publish each row as soon as it arrives, in its own place (a slower one above
1428
+ // it held by a "Loading…" placeholder, so nothing jumps when it comes), and redraw when
1429
+ // that changes the tab on screen.
1305
1430
  function partial() {
1306
- var n = 0;
1307
- while (n < arrived.length && arrived[n]) { n++; }
1431
+ var n = arrived.filter(Boolean).length, last = arrived.lastIndexOf(true);
1308
1432
  if (!first || n <= shown) { return; }
1309
1433
  shown = n;
1310
- _homeCatalogs = splitOtherCatalogs(acc.slice(0, n)).filter(function (c) { return c.metas && c.metas.length; });
1434
+ var now = acc.slice(0, last + 1).map(function (c, i) { return arrived[i] ? c : copyCat(c, [], { loading: true }); });
1435
+ _homeCatalogs = splitOtherCatalogs(now).filter(function (c) { return c.loading || (c.metas && c.metas.length); });
1311
1436
  if (partialTimer) { return; }
1312
1437
  partialTimer = setTimeout(function () {
1313
1438
  partialTimer = null;
1314
1439
  if (!_homeFirstLoad) { return; } // everything arrived meanwhile: drawn below
1440
+ if (ownTabStill()) { _tabRows = {}; return; } // (Watching, the Guide…: as they are)
1315
1441
  var keep = {};
1316
1442
  if (tabSig() === _renderedSig) { keep[_homeTab] = _tabRows[_homeTab]; }
1317
1443
  _tabRows = keep; // other tabs rebuild from the new rows when opened
@@ -1336,7 +1462,7 @@
1336
1462
  // The tab on screen is redrawn only if what it shows changed: redrawing rebuilds
1337
1463
  // every card (and reloads its artwork), which shows as a flicker. Continue
1338
1464
  // Watching doesn't come from catalogs at all. Other tabs rebuild when opened.
1339
- var same = before !== null && shownSig() === before;
1465
+ var same = (before !== null && shownSig() === before) || ownTabStill();
1340
1466
  var keep = {};
1341
1467
  if (same && _tabRows[_homeTab]) { keep[_homeTab] = _tabRows[_homeTab]; }
1342
1468
  _tabRows = keep;
@@ -1360,7 +1486,8 @@
1360
1486
  function splitOtherCatalogs(list) {
1361
1487
  var out = [];
1362
1488
  list.forEach(function (c) {
1363
- if (c.type !== 'other') { out.push({ name: c.name, type: c.type, metas: c.metas.slice(0, ROW_CARDS) }); return; }
1489
+ if (c.loading) { if (c.type !== 'other' || c.yt) { out.push(copyCat(c, [], { loading: true })); } return; } // (a placeholder: still loading)
1490
+ if (c.type !== 'other' || c.yt) { out.push(copyCat(c, c.metas.slice(0, ROW_CARDS))); return; } // (a YouTube one as it is, whatever its type)
1364
1491
  var movies = [], series = [];
1365
1492
  c.metas.forEach(function (m) { (SERIES_NAME.test(m.name || '') ? series : movies).push(m); });
1366
1493
  out.push({ name: otherRowName('Movies', c.name), type: 'movie', metas: movies.slice(0, ROW_CARDS) });
@@ -1379,6 +1506,7 @@
1379
1506
  for (var i = from; i < to; i++) {
1380
1507
  var c = cats[i];
1381
1508
  var row = buildRow(c.name, c.metas.map(function (m) { return posterCard(m, c.type); }));
1509
+ if (c.loading) { row.className += ' row-loading'; row.appendChild(U.el('div', 'row-empty', 'Loading…')); } // (its place kept: nothing to focus)
1382
1510
  rows.appendChild(row);
1383
1511
  built.push(row);
1384
1512
  }
@@ -1418,6 +1546,10 @@
1418
1546
  // ----- published for the other parts (and js/views.js) -----
1419
1547
  V.fillInfoBox = fillInfoBox;
1420
1548
  V.availableTabs = availableTabs;
1549
+ V.menuOrder = menuOrder;
1550
+ V.moveMenuItem = moveMenuItem;
1551
+ V.menuItems = menuItems;
1552
+ V.MENU_ITEMS = MENU_ITEMS;
1421
1553
  V.HOME_TABS = HOME_TABS;
1422
1554
  V.buildRow = buildRow;
1423
1555
  V.clearCatalogCache = clearCatalogCache;