From cecbbf70fe750261439a379d227bea9d0bbe3124 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 21:40:24 +0000 Subject: [PATCH 1/7] impl(web): media_list.organization and media_list.filmstrip.rows settings Replaces the unused media_view.filmstrip config (still accepted and discarded so existing generated configs keep parsing). Adds footer controls to toggle tiles vs filmstrip and pick the filmstrip row count at runtime. Co-authored-by: kaisea.rpi --- .../web/src/lib/runes/settings_rune.svelte.ts | 19 +++++++++ packages/web/src/lib/server/config.ts | 39 +++++++++++++----- .../routes/browse/components/Footer.svelte | 41 ++++++++++++++++++- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/packages/web/src/lib/runes/settings_rune.svelte.ts b/packages/web/src/lib/runes/settings_rune.svelte.ts index ffa75c32..bc5ddb98 100644 --- a/packages/web/src/lib/runes/settings_rune.svelte.ts +++ b/packages/web/src/lib/runes/settings_rune.svelte.ts @@ -5,6 +5,8 @@ import type { BaseController } from '$lib/base_controller.ts' interface MutatableSettings { 'ui.media_list.thumbnail_size': Config['web']['ui_defaults']['media_list']['thumbnail_size'] 'ui.media_list.thumbnail_shape': Config['web']['ui_defaults']['media_list']['thumbnail_shape'] + 'ui.media_list.organization': Config['web']['ui_defaults']['media_list']['organization'] + 'ui.media_list.filmstrip.rows': Config['web']['ui_defaults']['media_list']['filmstrip']['rows'] 'ui.media_view.fit.mode': Config['web']['ui_defaults']['media_view']['fit']['mode'] 'ui.search.advanced_filters.hide': Config['web']['ui_defaults']['search']['advanced_filters']['hide'] 'ui.sidebar.hide': Config['web']['ui_defaults']['sidebar']['hide'] @@ -36,6 +38,17 @@ export class SettingsRune extends Rune { this.ui.media_list.thumbnail_shape = value break } + case 'ui.media_list.organization': { + this.ui.media_list.organization = value + break + } + case 'ui.media_list.filmstrip.rows': { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`Unexpected value '${value}' for path '${path}'`) + } + this.ui.media_list.filmstrip.rows = value + break + } case 'ui.media_view.fit.mode': { this.ui.media_view.fit.mode = value break @@ -70,6 +83,12 @@ export class SettingsRune extends Rune { case 'ui.media_list.thumbnail_shape': { return this.ui.media_list.thumbnail_shape } + case 'ui.media_list.organization': { + return this.ui.media_list.organization + } + case 'ui.media_list.filmstrip.rows': { + return this.ui.media_list.filmstrip.rows + } case 'ui.search.advanced_filters.hide': { return this.ui.search.advanced_filters.hide } diff --git a/packages/web/src/lib/server/config.ts b/packages/web/src/lib/server/config.ts index a41dd73c..0ffcc037 100644 --- a/packages/web/src/lib/server/config.ts +++ b/packages/web/src/lib/server/config.ts @@ -38,7 +38,11 @@ export const PackagesConfig = z.object({ media_list: z.object({ thumbnail_size: z.number().default(110), - thumbnail_shape: z.enum(['square', 'original']).prefault('original') + thumbnail_shape: z.enum(['square', 'original']).prefault('original'), + organization: z.enum(['tiles', 'filmstrip']).default('tiles'), + filmstrip: z.object({ + rows: z.number().int().min(1).default(1), + }).strict().prefault({}), }).strict().prefault({}), sidebar: z.object({ @@ -51,16 +55,20 @@ export const PackagesConfig = z.object({ }).strict().prefault({}), }).strict().prefault({}), - media_view: z.object({ - fit: z.object({ - mode: z.enum(['original', 'fill']).default('original'), - edge: z.enum(['width', 'height']).default('height'), - }).strict().prefault({}), - filmstrip: z.object({ - enabled: z.boolean().default(false), - thumbnail_size: z.number().default(100), - }).strict().optional().transform(f => f ?? {enabled: false}) - }).strict().prefault({}), + media_view: z.preprocess( + // `media_view.filmstrip` was removed but `forager init` wrote it into existing config files, so it is discarded rather than rejected + media_view => { + if (media_view === null || typeof media_view !== 'object' || !('filmstrip' in media_view)) return media_view + const { filmstrip: _legacy_filmstrip, ...rest } = media_view + return rest + }, + z.object({ + fit: z.object({ + mode: z.enum(['original', 'fill']).default('original'), + edge: z.enum(['width', 'height']).default('height'), + }).strict().prefault({}), + }).strict(), + ).prefault({}), }).strict().prefault({}), shortcuts: z.object({ @@ -85,6 +93,15 @@ export const PackagesConfig = z.object({ ToggleVideoMute: Keybind('KeyM'), ToggleMediaControls: Keybind('Ctrl-C'), + FilmstripNextItem: Keybind('ArrowRight'), + FilmstripPrevItem: Keybind('ArrowLeft'), + FilmstripNextPage: Keybind('PageDown'), + FilmstripPrevPage: Keybind('PageUp'), + FilmstripEnd: Keybind('Ctrl-ArrowRight'), + FilmstripStart: Keybind('Ctrl-ArrowLeft'), + NextFilmstrip: Keybind('Ctrl-PageDown'), + PrevFilmstrip: Keybind('Ctrl-PageUp'), + Search: Keybind('Slash'), AddTag: Keybind('Ctrl-M'), diff --git a/packages/web/src/routes/browse/components/Footer.svelte b/packages/web/src/routes/browse/components/Footer.svelte index a8a63399..9e97ec38 100644 --- a/packages/web/src/routes/browse/components/Footer.svelte +++ b/packages/web/src/routes/browse/components/Footer.svelte @@ -4,6 +4,13 @@ let {controller, height = $bindable()}: {controller: BrowseController, height: number} = $props() + const MAX_FILMSTRIP_ROW_OPTION = 6 + // the configured row count is always selectable, even if it is above the default options + const filmstrip_row_options = $derived(Array.from( + {length: Math.max(MAX_FILMSTRIP_ROW_OPTION, controller.runes.settings.ui.media_list.filmstrip.rows)}, + (_, index) => index + 1 + )) + function human_readable_number(n: number) { const number_string = n.toString() @@ -54,7 +61,7 @@ bind:clientHeight={height} > -
+
{controller.runes.settings.ui.media_list.thumbnail_size}px
+
+ layout: + + {#if controller.runes.settings.ui.media_list.organization === 'filmstrip'} + + + {/if} +
+
- tiles: + shape: + +
media_selections.select_media(result, result_index)} + onscroll={measure} + onscrollend={() => { + target_left = undefined + measure() + }} + > + {#if kind === 'single'} +
+ +
+ {:else if kind === 'video'} +
+ {#each video_thumbnails as thumbnail (thumbnail.id)} + + {/each} +
+ {:else} +
+ {#each entries_list?.results ?? [] as entry, entry_index (entry.media_reference.id)} + + {/each} + {#if entries_list === undefined || entries_list.loading} +
Loading...
+ {/if} +
+ {/if} +
+ + +
+
+ + diff --git a/packages/web/src/routes/browse/components/FilmstripList.svelte b/packages/web/src/routes/browse/components/FilmstripList.svelte new file mode 100644 index 00000000..d6b58237 --- /dev/null +++ b/packages/web/src/routes/browse/components/FilmstripList.svelte @@ -0,0 +1,44 @@ + + +
+ {#each media_list.results as result, result_index (result.media_type === 'grouped' ? `group:${result.group_metadata.value}` : `media:${result.media_reference.id}`)} + + {/each} +
+{#if media_list.loading} +
Loading...
+{/if} diff --git a/packages/web/src/routes/browse/components/MediaList.svelte b/packages/web/src/routes/browse/components/MediaList.svelte index f2a08670..393478b6 100644 --- a/packages/web/src/routes/browse/components/MediaList.svelte +++ b/packages/web/src/routes/browse/components/MediaList.svelte @@ -1,13 +1,20 @@
media_selections.open_entry(result, result_index)} > Thumbnail for {result.media_reference.title ?? 'media'} @@ -283,7 +292,7 @@ - +
- +
+ + +
+
+ +
+ {#if result.media_type === 'media_file'} + {#if result.media_file.media_type === 'VIDEO'} + + {#if result.media_file.audio} + + {/if} + {:else if result.media_file.media_type === 'IMAGE' && result.media_file.content_type === 'image/gif'} + + {:else if result.media_file.media_type === 'IMAGE'} + + {:else if result.media_file.media_type === 'AUDIO'} + + {/if} + {result.media_reference.title ?? path.basename(result.media_file.filepath)} + {#if result.media_file.media_type === 'VIDEO'} + {human_readable_duration(result.media_file.duration)} + {/if} + {:else if result.media_type === 'media_series'} + + {result.media_reference.media_series_name ?? result.media_reference.title ?? 'Series'} + ({result.media_reference.media_series_length}) + {:else if result.media_type === 'grouped'} + + {result.group_metadata.value} + ({result.group_metadata.count}) + {/if}