summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-15 17:01:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-15 17:01:06 +0200
commit459fc93e98f23e326c2fa77fe86ba74c2bae77f0 (patch)
treef506a127cc3e0779c4d8cf9f27fe659d81bb787e /packages/meshbay-hub/src
parentfce0942cb7d81dbdbae70e50b0164fead7e8bc49 (diff)
downloadmeshbay-459fc93e98f23e326c2fa77fe86ba74c2bae77f0.tar.gz
feat(hub): Videos and Music list their cards a page at a time
Previous/next arrows in the pinned toolbar, on group pages and in Search. Page size is an account preference (Settings → Defaults), 50 by default, 10 to 200 in steps of 10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js44
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/pager.js89
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/settings-page.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js165
19 files changed, 324 insertions, 77 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index a74f6af..991a4f7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -1198,6 +1198,7 @@ ALLOWED_PREF_KEYS = frozenset([
"notifications_disabled",
"default_tab",
"music_keep_screen_on",
+ "media_page_size",
])
# `default_tab:<group_id>`, which is what the SPA writes (group-page.js). The
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 51e39dc..4de33b9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -760,7 +760,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// wire too, so nothing anywhere carries them.
...perAppDirectories,
tmdbConfig,
- musicbrainzConfig, onPlayQueue,
+ musicbrainzConfig, onPlayQueue, userPrefs,
};
return html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
index 90d9194..f45d339 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
@@ -64,6 +64,8 @@ const ICON_PATHS = {
'M14.5 6.5l3 3'],
check: ['M4.5 12.5l5 5 10-11'],
chevron: ['M6 9.5l6 6 6-6'],
+ 'chevron-left': ['M14.5 6l-6 6 6 6'],
+ 'chevron-right': ['M9.5 6l6 6-6 6'],
close: ['M6 6l12 12M18 6L6 18'],
chat: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'],
folder: ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z'],
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 6e5afa6..c705588 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -256,6 +256,9 @@ export default {
'video.filter_all': 'All',
'video.filter_movies': 'Movies',
'video.filter_series': 'Series',
+ 'pager.range': '{from}–{to} von {total}',
+ 'pager.previous': 'Vorherige Seite',
+ 'pager.next': 'Nächste Seite',
'video.empty': 'Keine Videos gefunden.',
'video.no_root_configured': 'Für diese Gruppe ist noch kein Videos-Stammordner festgelegt — ein Operator kann in den Einstellungen einen auswählen.',
'music.no_root_configured': 'Für diese Gruppe ist noch kein Musik-Stammordner festgelegt — ein Operator kann in den Einstellungen einen auswählen.',
@@ -365,6 +368,8 @@ export default {
'settings.defaults': 'Standardwerte',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
+ 'settings.media_page_size': 'Einträge pro Seite in Videos und Musik',
+ 'settings.media_page_size_hint': 'Wie viele Poster oder Alben auf einmal angezeigt werden; die Pfeile in der Werkzeugleiste blättern weiter.',
'settings.music_keep_screen_on': 'Keep screen on during music playback',
'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing.',
'settings.node_pins': 'Node-Identitäten',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 7ffbc28..4f0fff2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -254,6 +254,9 @@ export default {
'video.filter_all': 'All',
'video.filter_movies': 'Movies',
'video.filter_series': 'Series',
+ 'pager.range': '{from}–{to} of {total}',
+ 'pager.previous': 'Previous page',
+ 'pager.next': 'Next page',
'video.empty': 'No videos found.',
'video.no_root_configured': 'No Videos root folder is set for this group yet — an operator can choose one in Settings.',
'music.no_root_configured': 'No Music root folder is set for this group yet — an operator can choose one in Settings.',
@@ -362,6 +365,8 @@ export default {
'settings.defaults': 'Defaults',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
+ 'settings.media_page_size': 'Items per page in Videos and Music',
+ 'settings.media_page_size_hint': 'How many posters or albums are shown at once; the arrows in the toolbar move to the next batch.',
'settings.music_keep_screen_on': 'Keep screen on during music playback',
'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing.',
'settings.node_pins': 'Node identities',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index e675e10..fa0b0b2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -254,6 +254,9 @@ export default {
'video.filter_all': 'All',
'video.filter_movies': 'Movies',
'video.filter_series': 'Series',
+ 'pager.range': '{from}–{to} de {total}',
+ 'pager.previous': 'Página anterior',
+ 'pager.next': 'Página siguiente',
'video.empty': 'No se encontraron vídeos.',
'video.no_root_configured': 'Aún no se ha definido una carpeta raíz de Vídeos para este grupo — un operador puede elegir una en Configuración.',
'music.no_root_configured': 'Aún no se ha definido una carpeta raíz de Música para este grupo — un operador puede elegir una en Configuración.',
@@ -363,6 +366,8 @@ export default {
'settings.defaults': 'Valores predeterminados',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
+ 'settings.media_page_size': 'Elementos por página en Vídeos y Música',
+ 'settings.media_page_size_hint': 'Cuántos pósters o álbumes se muestran a la vez; las flechas de la barra de herramientas pasan al siguiente grupo.',
'settings.music_keep_screen_on': 'Keep screen on during music playback',
'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing.',
'settings.node_pins': 'Identidades de los nodes',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index cc0892d..b4145ce 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -255,6 +255,9 @@ export default {
'video.filter_all': 'Tous',
'video.filter_movies': 'Films',
'video.filter_series': 'Séries',
+ 'pager.range': '{from}–{to} sur {total}',
+ 'pager.previous': 'Page précédente',
+ 'pager.next': 'Page suivante',
'video.empty': 'Aucune vidéo trouvée.',
'video.no_root_configured': "Aucun dossier racine des Vidéos n'est encore défini pour ce groupe — un opérateur peut en choisir un dans les Paramètres.",
'music.no_root_configured': 'Aucun dossier racine de Musique n\'est encore défini pour ce groupe — un opérateur peut en choisir un dans les Paramètres.',
@@ -364,6 +367,8 @@ export default {
'settings.defaults': 'Valeurs par défaut',
'settings.default_tab': 'Onglet par défaut',
'settings.default_tab_hint': 'L\'onglet qui s\'ouvre en premier quand vous entrez dans un groupe.',
+ 'settings.media_page_size': 'Éléments par page dans Vidéos et Musique',
+ 'settings.media_page_size_hint': 'Nombre d\'affiches ou d\'albums affichés à la fois ; les flèches de la barre d\'outils passent à la tranche suivante.',
'settings.music_keep_screen_on': 'Garder l\'écran allumé pendant l\'écoute',
'settings.music_keep_screen_on_hint': 'Empêche le téléphone de se verrouiller automatiquement pendant qu\'un morceau joue.',
'settings.node_pins': 'Identités des nodes',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 7ccaff4..673f0cc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -255,6 +255,9 @@ export default {
'video.filter_all': 'All',
'video.filter_movies': 'Movies',
'video.filter_series': 'Series',
+ 'pager.range': '{from}–{to} di {total}',
+ 'pager.previous': 'Pagina precedente',
+ 'pager.next': 'Pagina successiva',
'video.empty': 'Nessun video trovato.',
'video.no_root_configured': 'Per questo gruppo non è ancora impostata una cartella radice di Video — un operatore può sceglierne una nelle Impostazioni.',
'music.no_root_configured': 'Per questo gruppo non è ancora impostata una cartella radice di Musica — un operatore può sceglierne una nelle Impostazioni.',
@@ -364,6 +367,8 @@ export default {
'settings.defaults': 'Valori predefiniti',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
+ 'settings.media_page_size': 'Elementi per pagina in Video e Musica',
+ 'settings.media_page_size_hint': 'Quanti poster o album vengono mostrati alla volta; le frecce nella barra degli strumenti passano al gruppo successivo.',
'settings.music_keep_screen_on': 'Keep screen on during music playback',
'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing.',
'settings.node_pins': 'Identità dei node',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index aaec812..c36f2c9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -252,6 +252,9 @@ export default {
'video.filter_all': 'All',
'video.filter_movies': 'Movies',
'video.filter_series': 'Series',
+ 'pager.range': '{from}–{to} / {total}',
+ 'pager.previous': '前のページ',
+ 'pager.next': '次のページ',
'video.empty': '動画が見つかりません。',
'video.no_root_configured': 'このグループにはまだ動画のルートフォルダが設定されていません — 操作者が設定画面で選択できます。',
'music.no_root_configured': 'このグループにはまだ音楽のルートフォルダが設定されていません — 操作者が設定画面で選択できます。',
@@ -361,6 +364,8 @@ export default {
'settings.defaults': 'デフォルト',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
+ 'settings.media_page_size': 'ビデオと音楽の1ページあたりの表示数',
+ 'settings.media_page_size_hint': '一度に表示するポスターやアルバムの数。ツールバーの矢印で次のまとまりに移動します。',
'settings.music_keep_screen_on': 'Keep screen on during music playback',
'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing.',
'settings.node_pins': 'node の識別情報',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 3049cef..180c1bb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -256,6 +256,9 @@ export default {
'video.filter_all': 'All',
'video.filter_movies': 'Movies',
'video.filter_series': 'Series',
+ 'pager.range': '{from}–{to} van {total}',
+ 'pager.previous': 'Vorige pagina',
+ 'pager.next': 'Volgende pagina',
'video.empty': "Geen video's gevonden.",
'video.no_root_configured': "Er is nog geen hoofdmap voor Video's ingesteld voor deze groep — een operator kan er een kiezen bij Instellingen.",
'music.no_root_configured': 'Er is nog geen hoofdmap voor Muziek ingesteld voor deze groep — een operator kan er een kiezen bij Instellingen.',
@@ -365,6 +368,8 @@ export default {
'settings.defaults': 'Standaardwaarden',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
+ 'settings.media_page_size': 'Items per pagina in Video\'s en Muziek',
+ 'settings.media_page_size_hint': 'Hoeveel posters of albums tegelijk worden getoond; de pijlen in de werkbalk gaan naar de volgende reeks.',
'settings.music_keep_screen_on': 'Keep screen on during music playback',
'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing.',
'settings.node_pins': 'Node-identiteiten',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 8922d0a..180cdbe 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -261,6 +261,9 @@ export default {
'video.filter_all': 'All',
'video.filter_movies': 'Movies',
'video.filter_series': 'Series',
+ 'pager.range': '{from}–{to} z {total}',
+ 'pager.previous': 'Poprzednia strona',
+ 'pager.next': 'Następna strona',
'video.empty': 'Nie znaleziono żadnych filmów.',
'video.no_root_configured': 'Dla tej grupy nie wybrano jeszcze katalogu głównego Wideo — operator może go wybrać w Ustawieniach.',
'music.no_root_configured': 'Dla tej grupy nie wybrano jeszcze katalogu głównego Muzyki — operator może go wybrać w Ustawieniach.',
@@ -376,6 +379,8 @@ export default {
'settings.defaults': 'Wartości domyślne',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
+ 'settings.media_page_size': 'Elementy na stronę w Wideo i Muzyce',
+ 'settings.media_page_size_hint': 'Ile plakatów lub albumów jest wyświetlanych naraz; strzałki na pasku narzędzi przechodzą do kolejnej porcji.',
'settings.music_keep_screen_on': 'Keep screen on during music playback',
'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing.',
'settings.node_pins': 'Tożsamości nodes',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 460a902..2ae26b2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -256,6 +256,9 @@ export default {
'video.filter_all': 'All',
'video.filter_movies': 'Movies',
'video.filter_series': 'Series',
+ 'pager.range': '{from}–{to} de {total}',
+ 'pager.previous': 'Página anterior',
+ 'pager.next': 'Próxima página',
'video.empty': 'Nenhum vídeo encontrado.',
'video.no_root_configured': 'Ainda não há uma pasta raiz de Vídeos definida para este grupo — um operador pode escolher uma em Configurações.',
'music.no_root_configured': 'Ainda não há uma pasta raiz de Música definida para este grupo — um operador pode escolher uma em Configurações.',
@@ -365,6 +368,8 @@ export default {
'settings.defaults': 'Padrões',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
+ 'settings.media_page_size': 'Itens por página em Vídeos e Música',
+ 'settings.media_page_size_hint': 'Quantos pôsteres ou álbuns são exibidos de uma vez; as setas na barra de ferramentas passam para o próximo lote.',
'settings.music_keep_screen_on': 'Keep screen on during music playback',
'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing.',
'settings.node_pins': 'Identidades dos nodes',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index b8ab4ef..6c0ec37 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -249,6 +249,9 @@ export default {
'video.filter_all': 'All',
'video.filter_movies': 'Movies',
'video.filter_series': 'Series',
+ 'pager.range': '{from}–{to} / 共 {total}',
+ 'pager.previous': '上一页',
+ 'pager.next': '下一页',
'video.empty': '未找到视频。',
'video.no_root_configured': '此群组尚未设置视频根目录 — 操作员可以在设置中选择一个。',
'music.no_root_configured': '此群组尚未设置音乐根目录 — 操作员可以在设置中选择一个。',
@@ -357,6 +360,8 @@ export default {
'settings.defaults': '默认值',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
+ 'settings.media_page_size': '视频和音乐每页显示数量',
+ 'settings.media_page_size_hint': '一次显示多少张海报或专辑;工具栏中的箭头可切换到下一批。',
'settings.music_keep_screen_on': 'Keep screen on during music playback',
'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing.',
'settings.node_pins': 'node 身份',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
index f622b5d..60a7b00 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -6,6 +6,7 @@ import { Icon } from './icon.js';
import { MediaThumb, LazyTile } from './video-app.js';
import { formatTime } from './music-player.js';
import { SourceTag } from './group-name.js';
+import { usePager, Pager, pageSizeFrom } from './pager.js';
// -- Music --------------------------------------------------------------------
//
@@ -324,8 +325,16 @@ function MusicDetailModal({ album, transportRef, gekRef, musicbrainzEnabled, onC
// -- Mode A: album grid -----------------------------------------------------
-function AlbumGrid({ artists, transportRef, gekRef, musicbrainzEnabled, onPlayQueue }) {
+// `units` is one page of `{ artist, album }`. An artist whose albums straddle
+// two pages gets its heading on both.
+function AlbumGrid({ units, transportRef, gekRef, musicbrainzEnabled, onPlayQueue }) {
const [detail, setDetail] = useState(null); // the album object
+ const artists = [];
+ for (const u of units) {
+ const prev = artists[artists.length - 1];
+ if (prev && prev.artist === u.artist) prev.albums.push(u.album);
+ else artists.push({ artist: u.artist, albums: [u.album] });
+ }
return html`
${artists.map((a) => html`
@@ -435,12 +444,8 @@ function FlatArtistFolder({ artist, onPlayQueue }) {
`;
}
-function FlatList({ tracks, artists, onPlayQueue }) {
- const items = [
- ...tracks.map((tr) => ({ key: tr.display_title || tr.name, kind: 'track', track: tr })),
- ...artists.map((a) => ({ key: a.artist, kind: 'artist', artist: a })),
- ].sort((a, b) => a.key.localeCompare(b.key));
-
+// `items` is one page of rows, already sorted by MusicApp.
+function FlatList({ items, onPlayQueue }) {
return html`
<div class="video-flat-list">
${items.map((it) => it.kind === 'track'
@@ -456,7 +461,7 @@ function FlatList({ tracks, artists, onPlayQueue }) {
function MusicApp({
groupId, transportRef, gekRef, status, entries, availableEntries,
musicDirectories, musicbrainzConfig, onPlayQueue,
- hideFilter,
+ hideFilter, userPrefs, pageResetKey,
}) {
const [mode, setMode] = useState(loadViewMode);
const [filter, setFilter] = useState('');
@@ -488,6 +493,24 @@ function MusicApp({
const filteredTracks = useMemo(() => (!needle ? tracks : tracks.filter(
(tr) => (tr.display_title || tr.name).toLowerCase().includes(needle))), [tracks, needle]);
+ // What this mode draws, in drawing order: albums under their artists in the
+ // grid, and in the flat list loose tracks and artist folders sorted together.
+ const units = useMemo(() => {
+ if (mode === 'grid') {
+ return filteredArtists.flatMap((a) => a.albums.map((album) => ({ artist: a.artist, album })));
+ }
+ return [
+ ...filteredTracks.map((tr) => ({ key: tr.display_title || tr.name, kind: 'track', track: tr })),
+ ...filteredArtists.map((a) => ({ key: a.artist, kind: 'artist', artist: a })),
+ ].sort((a, b) => a.key.localeCompare(b.key));
+ }, [mode, filteredArtists, filteredTracks]);
+
+ const pager = usePager(units.length, pageSizeFrom(userPrefs),
+ `${groupId}|${mode}|${needle}|${pageResetKey || ''}`);
+ const pageUnits = useMemo(() => {
+ return units.slice(pager.start, pager.end);
+ }, [units, pager.start, pager.end]);
+
const empty = albums.length === 0 && tracks.length === 0;
return html`
@@ -510,6 +533,7 @@ function MusicApp({
onClick=${() => setModeAndSave('flat')}>
${t('music.mode_flat')}
</button>
+ <${Pager} pager=${pager} />
${!hideFilter && html`<div class="tb-search">
<${Icon} name="search" />
<input type="text" placeholder="${t('group.filter')}"
@@ -521,9 +545,9 @@ function MusicApp({
<p class="page-message">${t('group.empty_filter')}</p>
`}
${!empty && mode === 'grid'
- ? html`<${AlbumGrid} artists=${filteredArtists} transportRef=${transportRef} gekRef=${gekRef}
+ ? html`<${AlbumGrid} units=${pageUnits} transportRef=${transportRef} gekRef=${gekRef}
musicbrainzEnabled=${musicbrainzEnabled} onPlayQueue=${onPlayQueue} />`
- : !empty && html`<${FlatList} tracks=${filteredTracks} artists=${filteredArtists}
+ : !empty && html`<${FlatList} items=${pageUnits}
onPlayQueue=${onPlayQueue} />`}
`}
`;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/pager.js b/packages/meshbay-hub/src/meshbay_hub/static/pager.js
new file mode 100644
index 0000000..a5c165b
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/pager.js
@@ -0,0 +1,89 @@
+import {
+ html, useState, useRef, useCallback,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+
+// ── Pages of cards ───────────────────────────────────────────────────────────
+//
+// Videos and Music draw one card per film, show or album, and a library of a
+// few thousand is a very long scroll. They show a slice at a time instead,
+// with arrows in their pinned toolbar. The slice is cut from the list exactly
+// as it would have been drawn, so paging never reorders anything.
+//
+// The size is the reader's own preference, kept on the hub with the others
+// (Settings → Defaults). `PAGE_SIZE_PREF` must be in the hub's
+// `ALLOWED_PREF_KEYS`, or saving it is a 400 nobody sees.
+
+export const PAGE_SIZE_PREF = 'media_page_size';
+export const PAGE_SIZE_DEFAULT = 50;
+export const PAGE_SIZE_STEP = 10;
+export const PAGE_SIZE_MAX = 200;
+
+// Anything that is not a multiple of the step within range reads as the
+// default: the value is a string the hub stores without looking at it.
+export function pageSizeFrom(userPrefs) {
+ const n = Number(userPrefs && userPrefs[PAGE_SIZE_PREF]);
+ if (!Number.isInteger(n) || n < PAGE_SIZE_STEP || n > PAGE_SIZE_MAX || n % PAGE_SIZE_STEP) {
+ return PAGE_SIZE_DEFAULT;
+ }
+ return n;
+}
+
+// Clamped rather than reset when the list shrinks under the reader — Search
+// loses a group's cards while it reconnects — so the page comes back once
+// they do.
+export function pageBounds(total, size, page) {
+ const last = Math.max(0, Math.ceil(total / size) - 1);
+ const current = Math.min(Math.max(0, page), last);
+ return { page: current, last, start: current * size, end: Math.min(total, (current + 1) * size) };
+}
+
+// `resetKey` names what the list is (group, mode, filter, query): a different
+// list starts on its first page. Compared during render rather than reset by an
+// effect, which would draw the old page of the new list for one frame.
+export function usePager(total, size, resetKey) {
+ const key = `${resetKey}|${size}`;
+ const [state, setState] = useState({ key, page: 0 });
+ const bounds = pageBounds(total, size, state.key === key ? state.page : 0);
+ const setPage = useCallback((page) => {
+ setState({ key, page });
+ }, [key]);
+ return { ...bounds, total, size, setPage };
+}
+
+// A new page starts at the top of the list, not wherever the previous one was
+// scrolled to. Only ever upwards, and only as far as the list's first row
+// sitting under the pinned toolbar, so a reader who has not scrolled is not
+// moved. The toolbar is the pager's parent and the list its next sibling —
+// the structure style.css's "Sticky chrome" already requires.
+function keepListInView(el) {
+ const toolbar = el && el.parentElement;
+ const list = toolbar && toolbar.nextElementSibling;
+ if (!list) return;
+ const gap = parseFloat(getComputedStyle(toolbar).marginBottom) || 0;
+ const target = window.scrollY + list.getBoundingClientRect().top
+ - toolbar.getBoundingClientRect().bottom - gap;
+ if (target < window.scrollY) window.scrollTo(0, Math.max(0, target));
+}
+
+export function Pager({ pager }) {
+ const ref = useRef(null);
+ if (pager.total <= pager.size) return null;
+ const go = (page) => { keepListInView(ref.current); pager.setPage(page); };
+ return html`
+ <div class="tb-pager" ref=${ref}>
+ <button class="tb-btn tb-btn-icon" disabled=${pager.page === 0}
+ title=${t('pager.previous')} aria-label=${t('pager.previous')}
+ onClick=${() => go(pager.page - 1)}>
+ <${Icon} name="chevron-left" /></button>
+ <span class="tb-pager-range">
+ ${t('pager.range', { from: pager.start + 1, to: pager.end, total: pager.total })}
+ </span>
+ <button class="tb-btn tb-btn-icon" disabled=${pager.page === pager.last}
+ title=${t('pager.next')} aria-label=${t('pager.next')}
+ onClick=${() => go(pager.page + 1)}>
+ <${Icon} name="chevron-right" /></button>
+ </div>
+ `;
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
index 45a436e..3759650 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -776,6 +776,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
tmdbConfig=${{ enabled: true }}
isNodeAdmin=${false}
onNeedConn=${connectGroup}
+ userPrefs=${userPrefs} pageResetKey=${q}
hideFilter=${true} />
`}
@@ -789,6 +790,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
musicDirectories=${[SEARCH_AUDIO_ROOT]}
musicbrainzConfig=${{ enabled: true }}
onPlayQueue=${handleMusicPlay}
+ userPrefs=${userPrefs} pageResetKey=${q}
hideFilter=${true} />
`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
index c6a73da..51d99cd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
@@ -6,6 +6,12 @@ import * as downloads from './downloads.js';
import * as platform from './platform.js';
import { hubFetch } from './hub-client.js';
import { APPS } from './apps.js';
+import {
+ PAGE_SIZE_PREF, PAGE_SIZE_DEFAULT, PAGE_SIZE_STEP, PAGE_SIZE_MAX, pageSizeFrom,
+} from './pager.js';
+
+const PAGE_SIZES = Array.from(
+ { length: PAGE_SIZE_MAX / PAGE_SIZE_STEP }, (_, i) => (i + 1) * PAGE_SIZE_STEP);
export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) {
const [locale, setLoc] = useState(getLocale);
@@ -19,6 +25,7 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange
// e.g. to ride out the WebRTC screen-lock reconnect gap without waiting
// on the automatic recovery at all.
const [keepScreenOnAudio, setKeepScreenOnAudio] = useState(false);
+ const [pageSize, setPageSize] = useState(PAGE_SIZE_DEFAULT);
const onLocaleChange = useCallback((e) => {
const code = e.target.value;
@@ -37,6 +44,7 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange
if (prefs.notifications_disabled === 'true') setGlobalMute(true);
if (prefs.default_tab) setDefaultTab(prefs.default_tab);
if (prefs.music_keep_screen_on === 'true') setKeepScreenOnAudio(true);
+ setPageSize(pageSizeFrom(prefs));
})
.catch(() => {});
}, [user.token]);
@@ -97,6 +105,18 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange
} catch { setDefaultTab(defaultTab); }
}, [defaultTab, user.token, onPrefsChange]);
+ const changePageSize = useCallback(async (e) => {
+ const val = String(e.target.value);
+ setPageSize(Number(val));
+ try {
+ await hubFetch(`/v1/users/me/preferences/${PAGE_SIZE_PREF}`, {
+ method: 'PUT', token: user.token,
+ body: { value: val },
+ });
+ if (onPrefsChange) onPrefsChange({ [PAGE_SIZE_PREF]: val });
+ } catch { setPageSize(pageSize); }
+ }, [pageSize, user.token, onPrefsChange]);
+
const [dlMode, setDlMode] = useState(() => downloads.getMode());
const [dlDir, setDlDir] = useState(null);
const [dlError, setDlError] = useState('');
@@ -249,6 +269,14 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange
</div>
<p class="settings-hint">${t('settings.default_tab_hint')}</p>
<div class="settings-row">
+ <span class="settings-label">${t('settings.media_page_size')}</span>
+ <select class="settings-select" value=${pageSize}
+ onChange=${changePageSize}>
+ ${PAGE_SIZES.map((n) => html`<option key=${n} value=${n}>${n}</option>`)}
+ </select>
+ </div>
+ <p class="settings-hint">${t('settings.media_page_size_hint')}</p>
+ <div class="settings-row">
<span class="settings-label">${t('settings.music_keep_screen_on')}</span>
<label class="toggle-switch">
<input type="checkbox" checked=${keepScreenOnAudio}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index bae1a82..049db0f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -3613,6 +3613,24 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
}
.video-toolbar .tb-search { margin-left: auto; }
+/* The page arrows (pager.js): pushed to the right edge, just before the filter
+ when there is one — on a phone the filter wraps onto its own line and the
+ arrows stay with the controls. */
+.video-toolbar .tb-pager { margin-left: auto; }
+.video-toolbar .tb-pager + .tb-search { margin-left: 0; }
+.tb-pager {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+.tb-pager-range {
+ font-size: 0.83em;
+ color: var(--text-dim);
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
+
/* All/Movies/Series — a segmented control, not three separate .tb-btn: the
three are mutually exclusive (unlike the view-mode buttons, which read
fine standalone), and grouping them under one shared border is what says
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index 18b99af..d4af4ef 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -5,6 +5,7 @@ import { t } from './i18n.js';
import { Icon } from './icon.js';
import { formatSize, pipelinedDownload } from './file-utils.js';
import { SourceTag } from './group-name.js';
+import { usePager, Pager, pageSizeFrom } from './pager.js';
// ── Videos ───────────────────────────────────────────────────────────────────
//
@@ -845,60 +846,61 @@ function VideoDetailModal({
`;
}
+// Two raw groups (grouped by parsed display_title, §4.1) resolving to the
+// same confident TMDB id are almost certainly one show whose seasons
+// were released under differently-named folders — confirmed live: one
+// operator's show had its two seasons parsed as two spellings by
+// two different release groups, showing as two identical-looking cards
+// once both matched the same real show (§3.4/V6). Merged here once both
+// are actually known — never required for the fallback to work: a group
+// with no confident match yet, or ever, still shows on its own, exactly
+// the generic per-folder display needs.
+//
+// Called by VideoApp rather than by the grid: a page is cut from the merged
+// list, so a show has to be one card before it is counted. A merge still needs
+// both halves to have been on screen, since only a mounted card reports its
+// match.
+function mergeShowsByTmdb(shows, metaByGroup) {
+ const byTmdbId = new Map();
+ const standalone = [];
+ for (const s of shows) {
+ const meta = metaByGroup[s.title];
+ const tmdbId = meta && meta.confidence && meta.tmdb_id;
+ if (tmdbId) {
+ if (!byTmdbId.has(tmdbId)) byTmdbId.set(tmdbId, []);
+ byTmdbId.get(tmdbId).push(s);
+ } else {
+ standalone.push([s]);
+ }
+ }
+ return [...byTmdbId.values(), ...standalone].map((groups) => {
+ const episodes = groups.flatMap((g) => g.episodes);
+ return {
+ // groups[0].title, not a joined string of every constituent's
+ // title: a fresh key here would make this a brand-new PosterCard
+ // (and LazyTile) the instant a second raw group merges into an
+ // already-visible one — throwing away its already-fired
+ // IntersectionObserver and already-resolved metadata/poster for no
+ // reason, and reintroducing exactly the flash the "ready" gating
+ // above exists to prevent. groups[0].title is already unique
+ // (raw titles are, via groupVideoEntries' showsByTitle) and, for
+ // the overwhelmingly common unmerged case, is the same key the
+ // card already had — so nothing about this changes when no merge
+ // ever happens.
+ key: groups[0].title,
+ title: groups[0].title,
+ episodes,
+ seasons: buildSeasons(episodes),
+ };
+ });
+}
+
+// `units` is one page of cards, in drawing order: `{ kind: 'movie', entry }`
+// or `{ kind: 'show', show }`.
function PosterGrid({
- movies, shows, transportRef, gekRef, onPreview, tmdbEnabled, isNodeAdmin, onNeedConn,
+ units, transportRef, gekRef, onPreview, tmdbEnabled, isNodeAdmin, onNeedConn, onMetaResolved,
}) {
const [detail, setDetail] = useState(null); // { title, repEntry, show? }
- // raw (per-folder-parsed-title) show title -> its own resolved media_meta_resp.
- const [metaByGroup, setMetaByGroup] = useState({});
-
- const handleMetaResolved = useCallback((groupKey, meta) => {
- setMetaByGroup((prev) => (prev[groupKey] === meta ? prev : { ...prev, [groupKey]: meta }));
- }, []);
-
- // Two raw groups (grouped by parsed display_title, §4.1) resolving to the
- // same confident TMDB id are almost certainly one show whose seasons
- // were released under differently-named folders — confirmed live: one
- // operator's show had its two seasons parsed as two spellings by
- // two different release groups, showing as two identical-looking cards
- // once both matched the same real show (§3.4/V6). Merged here once both
- // are actually known — never required for the fallback to work: a group
- // with no confident match yet, or ever, still shows on its own, exactly
- // the generic per-folder display needs.
- const mergedShows = useMemo(() => {
- const byTmdbId = new Map();
- const standalone = [];
- for (const s of shows) {
- const meta = metaByGroup[s.title];
- const tmdbId = meta && meta.confidence && meta.tmdb_id;
- if (tmdbId) {
- if (!byTmdbId.has(tmdbId)) byTmdbId.set(tmdbId, []);
- byTmdbId.get(tmdbId).push(s);
- } else {
- standalone.push([s]);
- }
- }
- return [...byTmdbId.values(), ...standalone].map((groups) => {
- const episodes = groups.flatMap((g) => g.episodes);
- return {
- // groups[0].title, not a joined string of every constituent's
- // title: a fresh key here would make this a brand-new PosterCard
- // (and LazyTile) the instant a second raw group merges into an
- // already-visible one — throwing away its already-fired
- // IntersectionObserver and already-resolved metadata/poster for no
- // reason, and reintroducing exactly the flash the "ready" gating
- // above exists to prevent. groups[0].title is already unique
- // (raw titles are, via groupVideoEntries' showsByTitle) and, for
- // the overwhelmingly common unmerged case, is the same key the
- // card already had — so nothing about this changes when no merge
- // ever happens.
- key: groups[0].title,
- title: groups[0].title,
- episodes,
- seasons: buildSeasons(episodes),
- };
- });
- }, [shows, metaByGroup]);
const openDetail = (title, repEntry, show) => setDetail({ title, repEntry, show });
const detailTRef = detail && detail.repEntry._tRef ? detail.repEntry._tRef : transportRef;
@@ -910,8 +912,11 @@ function PosterGrid({
return html`
<div class="video-grid">
- ${movies.map((e) => html`
- <${LazyTile} key=${e.id}>
+ ${units.map((u) => {
+ if (u.kind === 'movie') {
+ const e = u.entry;
+ return html`
+ <${LazyTile} key=${u.key}>
<${PosterCard} title=${e.display_title || e.name}
subtitle=${formatDuration(e.duration)} repEntry=${e}
groupKey=${`movie:${e.id}`}
@@ -926,8 +931,9 @@ function PosterGrid({
? openDetail(e.display_title || e.name, e, null)
: onPreview(e))} />
</${LazyTile}>
- `)}
- ${mergedShows.map((s) => {
+ `;
+ }
+ const s = u.show;
// Prefer an episode that actually has a thumbnail over blindly
// episodes[0]: if that specific file's enrichment hasn't produced
// one yet (or failed), the card showed an empty placeholder even
@@ -945,13 +951,13 @@ function PosterGrid({
? (singleSeason === 0 ? t('video.specials') : t('video.season_n', { n: singleSeason }))
: t('video.n_episodes', { n: s.episodes.length });
return html`
- <${LazyTile} key=${s.key}>
+ <${LazyTile} key=${u.key}>
<${PosterCard} title=${s.title}
subtitle=${subtitle}
repEntry=${repEntry}
sourceEntries=${s.episodes}
groupKey=${s.title}
- onMetaResolved=${handleMetaResolved}
+ onMetaResolved=${onMetaResolved}
onNeedConn=${onNeedConn} tmdbEnabled=${tmdbEnabled}
transportRef=${transportRef} gekRef=${gekRef}
onOpen=${() => openDetail(s.title, repEntry, s)} />
@@ -1042,12 +1048,8 @@ function FlatShowFolder({ show, transportRef, gekRef, onPreview, onNeedConn }) {
`;
}
-function FlatList({ movies, shows, transportRef, gekRef, onPreview, onNeedConn }) {
- const items = [
- ...movies.map((e) => ({ key: e.display_title || e.name, kind: 'movie', entry: e })),
- ...shows.map((s) => ({ key: s.title, kind: 'show', show: s })),
- ].sort((a, b) => a.key.localeCompare(b.key));
-
+// `items` is one page of rows, already sorted by VideoApp.
+function FlatList({ items, transportRef, gekRef, onPreview, onNeedConn }) {
return html`
<div class="video-flat-list">
${items.map((it) => it.kind === 'movie'
@@ -1066,10 +1068,12 @@ function FlatList({ movies, shows, transportRef, gekRef, onPreview, onNeedConn }
function VideoApp({
groupId, transportRef, gekRef, status, entries, availableEntries, onPreview,
videoDirectories, tmdbConfig, isNodeAdmin,
- hideFilter, onNeedConn,
+ hideFilter, onNeedConn, userPrefs, pageResetKey,
}) {
const [mode, setMode] = useState(loadViewMode);
const [filter, setFilter] = useState('');
+ // raw (per-folder-parsed-title) show title -> its own resolved media_meta_resp.
+ const [metaByGroup, setMetaByGroup] = useState({});
// Which of movies/shows to show at all — independent of the text filter
// below, and applied first: a title match within a type nobody asked to
// see is still not what "Movies only" means.
@@ -1095,6 +1099,34 @@ function VideoApp({
const filteredShows = useMemo(() => (typeFilter === 'movies' ? [] : !needle ? shows : shows.filter(
(s) => s.title.toLowerCase().includes(needle))), [shows, needle, typeFilter]);
+ const handleMetaResolved = useCallback((groupKey, meta) => {
+ setMetaByGroup((prev) => (prev[groupKey] === meta ? prev : { ...prev, [groupKey]: meta }));
+ }, []);
+ const mergedShows = useMemo(() => {
+ return mergeShowsByTmdb(filteredShows, metaByGroup);
+ }, [filteredShows, metaByGroup]);
+
+ // Everything this mode would draw, in the order it draws it, so a page is
+ // exactly a stretch of what the reader would otherwise have scrolled. The
+ // flat list sorts films and shows together and does not merge.
+ const units = useMemo(() => {
+ const movieUnits = filteredMovies.map((e) => ({
+ kind: 'movie', key: `movie:${e.id}`, sortKey: e.display_title || e.name, entry: e }));
+ if (mode === 'poster') {
+ return [...movieUnits,
+ ...mergedShows.map((s) => ({ kind: 'show', key: `show:${s.key}`, show: s }))];
+ }
+ return [...movieUnits,
+ ...filteredShows.map((s) => ({ kind: 'show', key: `show:${s.title}`, sortKey: s.title, show: s }))]
+ .sort((a, b) => a.sortKey.localeCompare(b.sortKey));
+ }, [mode, filteredMovies, filteredShows, mergedShows]);
+
+ const pager = usePager(units.length, pageSizeFrom(userPrefs),
+ `${groupId}|${mode}|${typeFilter}|${needle}|${pageResetKey || ''}`);
+ const pageUnits = useMemo(() => {
+ return units.slice(pager.start, pager.end);
+ }, [units, pager.start, pager.end]);
+
return html`
${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
<p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
@@ -1129,6 +1161,7 @@ function VideoApp({
${t('video.filter_series')}
</button>
</div>
+ <${Pager} pager=${pager} />
${!hideFilter && html`<div class="tb-search">
<${Icon} name="search" />
<input type="text" placeholder="${t('group.filter')}"
@@ -1139,10 +1172,10 @@ function VideoApp({
<p class="page-message">${needle ? t('group.empty_filter') : t('video.empty')}</p>
`}
${mode === 'poster'
- ? html`<${PosterGrid} movies=${filteredMovies} shows=${filteredShows}
+ ? html`<${PosterGrid} units=${pageUnits} onMetaResolved=${handleMetaResolved}
transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
tmdbEnabled=${tmdbEnabled} isNodeAdmin=${isNodeAdmin} onNeedConn=${onNeedConn} />`
- : html`<${FlatList} movies=${filteredMovies} shows=${filteredShows}
+ : html`<${FlatList} items=${pageUnits}
transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
onNeedConn=${onNeedConn} />`}
`}