diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-15 17:01:06 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-15 17:01:06 +0200 |
| commit | 459fc93e98f23e326c2fa77fe86ba74c2bae77f0 (patch) | |
| tree | f506a127cc3e0779c4d8cf9f27fe659d81bb787e /packages | |
| parent | fce0942cb7d81dbdbae70e50b0164fead7e8bc49 (diff) | |
| download | meshbay-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')
24 files changed, 419 insertions, 82 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} />`} `} diff --git a/packages/meshbay-hub/tests/harness/sticky_header_probe.py b/packages/meshbay-hub/tests/harness/sticky_header_probe.py index 320a2d4..ee1e61b 100755 --- a/packages/meshbay-hub/tests/harness/sticky_header_probe.py +++ b/packages/meshbay-hub/tests/harness/sticky_header_probe.py @@ -185,7 +185,11 @@ for (let i = 0; i < 40; i++) add({ name: `${ARTISTS[i % ARTISTS.length]} au Zenith (2019) 1080p.mkv`, path: 'films', type: 'video', display_title: `${ARTISTS[i % ARTISTS.length]} au Zenith` }); -for (let a = 0; a < 20; a++) +// Forty films and forty albums against a page of thirty, so Videos and Music +// draw their page arrows in the toolbar being measured (pager.js) — a band +// that only ever gets them past fifty would never be measured with them. +const PAGE_SIZE = '30'; +for (let a = 0; a < 40; a++) for (let t = 0; t < 2; t++) add({ name: `${t + 1} track.flac`, path: `musique/${ARTISTS[a % ARTISTS.length]} ${a}/disque ${t}`, @@ -319,7 +323,7 @@ const settle = () => new Promise((r) => render(html`<${GroupPage} groupId="g1" token="t" username="me" userId="u1" group=${{ id: 'g1', name: 'un groupe', owner_username: 'me', is_admin: false }} - userPrefs=${{ default_tab: 'files' }} />`, + userPrefs=${{ default_tab: 'files', media_page_size: PAGE_SIZE }} />`, document.getElementById('root')); } else { render(html`<${SearchPage} token="t" username="me" userId="u1" @@ -329,7 +333,7 @@ const settle = () => new Promise((r) => not. */''} groups=${[{ id: 'g1', name: 'musique_de_la_maison_2024', owner_username: 'cbesson' }]} - userPrefs=${{}} />`, document.getElementById('root')); + userPrefs=${{ media_page_size: PAGE_SIZE }} />`, document.getElementById('root')); // Results only exist once an index has arrived; the view toggle is drawn // with them. const field = await waitFor('.search-bar input'); @@ -377,6 +381,8 @@ const settle = () => new Promise((r) => bottomScrollY: Math.round(scrollY), scrollY: scrolledBy, room: Math.round(room), overflowX: overflowX(), + // Whether the toolbar carried its page arrows while it was measured. + pager: !!document.querySelector('.video-toolbar > .tb-pager'), // Whether the Search page's group column is on screen. It is dropped at // phone widths, where the file name needs every pixel it can get. groupColumn: (() => { diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index 3e9c968..28ec93e 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -35,7 +35,7 @@ APP = STATIC / "app.js" STATIC_FILES = [ "app.js", "group-page.js", "chat-app.js", "files-app.js", "video-player.js", "video-app.js", "music-app.js", "music-player.js", - "photos-app.js", + "photos-app.js", "pager.js", "group-settings.js", # The per-app settings architecture (docs/refactor-groups.md §3). Reached # through the apps.js registry rather than imported by name, so a file diff --git a/packages/meshbay-hub/tests/test_media_pager.py b/packages/meshbay-hub/tests/test_media_pager.py new file mode 100644 index 0000000..a8a0569 --- /dev/null +++ b/packages/meshbay-hub/tests/test_media_pager.py @@ -0,0 +1,71 @@ +""" +Videos and Music show their cards a page at a time (`pager.js`). + +The page size is a hub preference, so two things can go wrong without any +error on screen: a stored value the client misreads, and a key the hub refuses +— the Settings select would snap back and nothing would say why. The functions +are read out of `pager.js` rather than copied, and the key out of both files. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" +PAGER = ROOT / "static" / "pager.js" +USERS = ROOT / "api" / "users.py" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not PAGER.exists(), + reason="node or the SPA sources are not available") + + +@pytest.fixture(scope="module") +def source(): + text = PAGER.read_text(encoding="utf-8") + consts = re.findall(r"^export const PAGE_SIZE_\w+ = [^;]+;", text, re.M) + funcs = [re.search(rf"^export function {name}\(.*?^\}}", text, re.M | re.S) + for name in ("pageSizeFrom", "pageBounds")] + assert len(consts) == 4 and all(funcs), "pager.js no longer has what this test reads" + return "\n".join(consts + [m.group(0) for m in funcs]).replace("export ", "") + + +def _run(tmp_path, source, expr): + script = tmp_path / "case.js" + script.write_text(f"{source}\nconsole.log(JSON.stringify({expr}));") + out = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True) + return json.loads(out.stdout) + + +@pytest.mark.parametrize("stored, expected", [ + (None, 50), ("", 50), ("30", 30), ("10", 10), ("200", 200), + ("0", 50), ("210", 50), ("35", 50), ("abc", 50), ("-10", 50), +]) +def test_page_size_from_preference(tmp_path, source, stored, expected): + prefs = {} if stored is None else {"media_page_size": stored} + assert _run(tmp_path, source, f"pageSizeFrom({json.dumps(prefs)})") == expected + + +@pytest.mark.parametrize("total, size, page, expected", [ + (0, 50, 0, {"page": 0, "last": 0, "start": 0, "end": 0}), + (120, 50, 0, {"page": 0, "last": 2, "start": 0, "end": 50}), + (120, 50, 2, {"page": 2, "last": 2, "start": 100, "end": 120}), + (100, 50, 1, {"page": 1, "last": 1, "start": 50, "end": 100}), + # The list shrank under the reader: the last page that exists, not an empty one. + (60, 50, 4, {"page": 1, "last": 1, "start": 50, "end": 60}), + (60, 50, -1, {"page": 0, "last": 1, "start": 0, "end": 50}), +]) +def test_page_bounds(tmp_path, source, total, size, page, expected): + assert _run(tmp_path, source, f"pageBounds({total}, {size}, {page})") == expected + + +def test_hub_accepts_the_key_the_client_writes(): + key = re.search(r"^export const PAGE_SIZE_PREF = '([^']+)';", + PAGER.read_text(encoding="utf-8"), re.M).group(1) + allowed = re.search(r"ALLOWED_PREF_KEYS = frozenset\(\[(.*?)\]\)", + USERS.read_text(encoding="utf-8"), re.S).group(1) + assert f'"{key}"' in allowed diff --git a/packages/meshbay-hub/tests/test_sticky_header.py b/packages/meshbay-hub/tests/test_sticky_header.py index 32b8124..741b57f 100644 --- a/packages/meshbay-hub/tests/test_sticky_header.py +++ b/packages/meshbay-hub/tests/test_sticky_header.py @@ -78,6 +78,19 @@ def test_every_view_was_reached(measured): f"expected {EXPECTED_CASES} measurements, got {len(measured)}") +def test_media_toolbars_were_measured_with_their_page_arrows(measured): + """ + Videos and Music put previous/next arrows in the pinned toolbar once a + library is longer than a page (`pager.js`). The fixture sets a page smaller + than its library so every measurement above covers the toolbar with them; + this fails the fixture if that stops being true. + """ + media = [c for c in measured if c["name"].split(" @")[0].endswith(("videos", "music"))] + assert media, "no Videos or Music case was measured" + bare = [c["name"] for c in media if not c.get("pager")] + assert not bare, "measured without page arrows: " + ", ".join(bare) + + def test_the_page_really_scrolled(measured): """ Everything below compares a scrolled page with an unscrolled one. A view diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index f780073..d13cfd4 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -31,7 +31,7 @@ CREATE_GROUP = STATIC / "create-group-page.js" SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js", STATIC / "video-player.js", STATIC / "video-app.js", STATIC / "music-app.js", STATIC / "music-player.js", - STATIC / "photos-app.js", + STATIC / "photos-app.js", STATIC / "pager.js", STATIC / "group-settings.js", # Same reason as test_hook_ordering's STATIC_FILES: these are # reached through the registry, so leaving one out here means it |