diff --git a/packages/web/src/lib/actions/mod.ts b/packages/web/src/lib/actions/mod.ts index 29e86e44..7d802dd8 100644 --- a/packages/web/src/lib/actions/mod.ts +++ b/packages/web/src/lib/actions/mod.ts @@ -1,2 +1,3 @@ export * from './focusable.ts' export * from './scrollable.ts' +export * from './visible.ts' diff --git a/packages/web/src/lib/actions/visible.ts b/packages/web/src/lib/actions/visible.ts new file mode 100644 index 00000000..b8968b58 --- /dev/null +++ b/packages/web/src/lib/actions/visible.ts @@ -0,0 +1,20 @@ +/** invokes `on_visible` the first time the node scrolls into (or near) the viewport */ +export function visible(node: HTMLElement, on_visible: () => void) { + let callback = on_visible + const observer = new IntersectionObserver((entries) => { + if (entries.some(entry => entry.isIntersecting)) { + observer.disconnect() + callback() + } + }, {rootMargin: '200px'}) + observer.observe(node) + + return { + update(on_visible: () => void) { + callback = on_visible + }, + destroy() { + observer.disconnect() + }, + } +} diff --git a/packages/web/src/lib/keybinds.ts b/packages/web/src/lib/keybinds.ts index b1f28301..b04c5c95 100644 --- a/packages/web/src/lib/keybinds.ts +++ b/packages/web/src/lib/keybinds.ts @@ -18,6 +18,14 @@ type KeybindAction = | 'ToggleVideoMute' | 'ToggleMediaControls' | 'ToggleSidebar' + | 'FilmstripNextItem' + | 'FilmstripPrevItem' + | 'FilmstripNextPage' + | 'FilmstripPrevPage' + | 'FilmstripEnd' + | 'FilmstripStart' + | 'NextFilmstrip' + | 'PrevFilmstrip' | 'Star0' | 'Star1' | 'Star2' @@ -34,7 +42,8 @@ type KeybindActionListener = (e: KeybindActionEvent) => void export class Keybinds { public emitter: EventTarget public disabled: boolean - #keybind_mapper: Map | undefined + // a single key combo may trigger several actions, each listener decides if it applies in the current context + #keybind_mapper: Map | undefined #config: Config | undefined // this function is for some user ergonomics. @@ -78,10 +87,13 @@ export class Keybinds { this.disabled = false this.#config = config - this.#keybind_mapper = new Map() + this.#keybind_mapper = new Map() for (const [keyboard_action, keyboard_shortcuts] of Object.entries(this.#config.web.shortcuts)) { for (const keyboard_shortcut of keyboard_shortcuts) { - this.#keybind_mapper.set(this.#get_keybind_code(keyboard_shortcut), keyboard_action as KeybindAction) + const code = this.#get_keybind_code(keyboard_shortcut) + const actions = this.#keybind_mapper.get(code) ?? [] + actions.push(keyboard_action as KeybindAction) + this.#keybind_mapper.set(code, actions) } } } @@ -127,8 +139,8 @@ export class Keybinds { if (e.altKey) keys_down.push('Alt') keys_down.push(last_keycode) const code = this.#get_keybind_code(keys_down.join('-')) - const action = this.#keybind_mapper.get(code) - if (action) { + const actions = this.#keybind_mapper.get(code) ?? [] + for (const action of actions) { this.emitter.dispatchEvent(new CustomEvent(action, { detail: { data: { keyboard_event: e } diff --git a/packages/web/src/lib/runes/media_list_rune.svelte.ts b/packages/web/src/lib/runes/media_list_rune.svelte.ts index 9388acf7..a993fa89 100644 --- a/packages/web/src/lib/runes/media_list_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_list_rune.svelte.ts @@ -6,6 +6,7 @@ import { MediaViewRune } from '.' type Result = | ReturnType | ReturnType + | ReturnType interface SearchInput { type: 'media' @@ -15,6 +16,10 @@ interface GroupByInput { type: 'group_by' params: Parameters[0] } +interface SeriesInput { + type: 'series' + params: Parameters[0] +} interface FilesystemInput { type: 'filesystem' params: {} @@ -22,6 +27,7 @@ interface FilesystemInput { export type Input = | SearchInput | GroupByInput + | SeriesInput | FilesystemInput interface MediaListState { @@ -31,11 +37,11 @@ interface MediaListState { } export class MediaListRune extends Rune { - #saved_params_type: 'media' | 'group_by' = 'media' + #saved_params_type: 'media' | 'group_by' | 'series' = 'media' #saved_params: {} | undefined #prev_query_hash: string = '' #fetch_count = 0 - #has_more = true + #has_more = $state(true) #cursor: Result['cursor'] = undefined #state = $state({ loading: true, // empty state acts like it is loading by default @@ -51,6 +57,8 @@ export class MediaListRune extends Rune { get total() { return this.#state.content?.total ?? 0 } + get has_more() { return this.#has_more } + clear() { this.#has_more = true this.#cursor = undefined @@ -87,6 +95,8 @@ export class MediaListRune extends Rune { else if (params_type === 'group_by') { fetch_params.limit = fetch_params.limit ?? 30 content = await this.client.forager.media.group(fetch_params) + } else if (params_type === 'series') { + content = await this.client.forager.series.search(fetch_params) } else { throw new Error('unimplemented') } diff --git a/packages/web/src/lib/runes/media_view_rune.svelte.ts b/packages/web/src/lib/runes/media_view_rune.svelte.ts index b0b56bc3..c5432a0c 100644 --- a/packages/web/src/lib/runes/media_view_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_view_rune.svelte.ts @@ -150,12 +150,13 @@ export class MediaGroupRune extends MediaViewRune { grouped_state = $state({ media_list: [] }) + /** search params for the media inside this group (used by the filmstrip view to paginate group entries) */ + readonly entries_search_params: Parameters[0] protected constructor(client: BaseController['client'], media_response: MediaResponse, search_params: SearchParams) { - // debugger super(client, media_response) - const {group_by, cursor, ...merged_search_params} = search_params + const {group_by, cursor, limit, ...merged_search_params} = search_params if (group_by['tag_group'] !== undefined) { merged_search_params.query = {...merged_search_params.query} @@ -169,12 +170,12 @@ export class MediaGroupRune extends MediaViewRune { delete merged_search_params.sort_by delete merged_search_params.order } - merged_search_params.limit = 1 // TODO until we implement a filmstrip render, we only need one image } else { throw new Error(`unexpected search group`) } + this.entries_search_params = merged_search_params - this.client.forager.media.search(merged_search_params) + this.client.forager.media.search({...merged_search_params, limit: 1}) .then((result: {}) => { this.grouped_state.media_list = result.results }) @@ -193,10 +194,10 @@ export class MediaGroupRune extends MediaViewRune { } public img_fit_classes() { - if (this.grouped_state.media_list.length === 0) { + const media = this.grouped_state.media_list.at(0) + if (media === undefined || media.media_type !== 'media_file') { return "w-full h-full" } else { - const media = this.grouped_state.media_list[0] if (media.media_file.width > media.media_file.height) { // its long edge is wide return "w-full" 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/Filmstrip.svelte b/packages/web/src/routes/browse/components/Filmstrip.svelte new file mode 100644 index 00000000..e3bff582 --- /dev/null +++ b/packages/web/src/routes/browse/components/Filmstrip.svelte @@ -0,0 +1,349 @@ + + + + + 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/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:
diff --git a/packages/web/src/routes/browse/components/MediaView.svelte b/packages/web/src/routes/browse/components/MediaView.svelte index 23206b14..832095d2 100644 --- a/packages/web/src/routes/browse/components/MediaView.svelte +++ b/packages/web/src/routes/browse/components/MediaView.svelte @@ -74,9 +74,6 @@ }, }) - let filmstrip_thumbnails - let filmstrip_height = 50 - $effect(() => { if (!dialog.open && controller.runes.media_selections.current_selection.show) { dialog.show() @@ -140,6 +137,12 @@ bind:currentTime={animation_progress} controls={show_controls} use:video_loader={media_url} + onloadedmetadata={e => { + const seek_to = controller.runes.media_selections.take_seek_to() + if (seek_to !== undefined) { + e.currentTarget.currentTime = seek_to + } + }} > @@ -148,14 +151,6 @@ class="w-full h-1 absolute bottom-0" style="width: {animation_width}px" max={controller.runes.media_selections.current_selection.media_response.media_file.duration} value={animation_progress}> - {#if controller.runes.settings.ui.media_view.filmstrip.enabled} -
- {#each controller.runes.media_selections.current_selection.thumbnails.results as thumbnail, index (thumbnail.id)} -
- Thumbnail {index}
- {/each} -
- {/if} {:else if controller.runes.media_selections.current_selection.media_response.media_file.media_type === 'AUDIO'} ({type: 'none'}) @@ -70,6 +73,25 @@ export class MediaSelectionsRune extends Rune { } } + /** select a search result without opening it */ + public select_media(media_response: runes.MediaViewRune, result_index: number) { + this.#current_selection.result_index = result_index + // grouped results are not media references, so there are no media details to show for them + this.#current_selection.media_response = media_response.media_type === 'grouped' ? null : media_response + } + + /** + * open a specific media reference in the media view. + * The media reference does not need to be a top level search result (e.g. a series item or group entry), + * result_index refers to the search result it belongs to. + */ + public async open_entry(media_response: runes.MediaViewRune, result_index: number, options?: {seek_to?: number}) { + this.#current_selection.media_response = media_response + this.#current_selection.result_index = result_index + this.#current_selection.seek_to = options?.seek_to + await this.open_media() + } + public async view_media() { await this.#current_selection.media_response?.load_detailed_view() if (this.#current_selection.show) { @@ -90,6 +112,14 @@ export class MediaSelectionsRune extends Rune { public close_media = () => { this.#current_selection.show = false + this.#current_selection.seek_to = undefined + } + + /** consume the pending seek timestamp so it is only applied once */ + public take_seek_to(): number | undefined { + const seek_to = this.#current_selection.seek_to + this.#current_selection.seek_to = undefined + return seek_to } public async next_media(results: MediaResponse[]) {