Skip to content
Draft
1 change: 1 addition & 0 deletions packages/web/src/lib/actions/mod.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './focusable.ts'
export * from './scrollable.ts'
export * from './visible.ts'
20 changes: 20 additions & 0 deletions packages/web/src/lib/actions/visible.ts
Original file line number Diff line number Diff line change
@@ -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()
},
}
}
22 changes: 17 additions & 5 deletions packages/web/src/lib/keybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ type KeybindAction =
| 'ToggleVideoMute'
| 'ToggleMediaControls'
| 'ToggleSidebar'
| 'FilmstripNextItem'
| 'FilmstripPrevItem'
| 'FilmstripNextPage'
| 'FilmstripPrevPage'
| 'FilmstripEnd'
| 'FilmstripStart'
| 'NextFilmstrip'
| 'PrevFilmstrip'
| 'Star0'
| 'Star1'
| 'Star2'
Expand All @@ -34,7 +42,8 @@ type KeybindActionListener = (e: KeybindActionEvent) => void
export class Keybinds {
public emitter: EventTarget
public disabled: boolean
#keybind_mapper: Map<string, KeybindAction> | undefined
// a single key combo may trigger several actions, each listener decides if it applies in the current context
#keybind_mapper: Map<string, KeybindAction[]> | undefined
#config: Config | undefined

// this function is for some user ergonomics.
Expand Down Expand Up @@ -78,10 +87,13 @@ export class Keybinds {
this.disabled = false
this.#config = config

this.#keybind_mapper = new Map<string, KeybindAction>()
this.#keybind_mapper = new Map<string, KeybindAction[]>()
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)
}
}
}
Expand Down Expand Up @@ -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 }
Expand Down
14 changes: 12 additions & 2 deletions packages/web/src/lib/runes/media_list_rune.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MediaViewRune } from '.'
type Result =
| ReturnType<Forager['media']['search']>
| ReturnType<Forager['media']['group']>
| ReturnType<Forager['series']['search']>

interface SearchInput {
type: 'media'
Expand All @@ -15,13 +16,18 @@ interface GroupByInput {
type: 'group_by'
params: Parameters<Forager['media']['group']>[0]
}
interface SeriesInput {
type: 'series'
params: Parameters<Forager['series']['search']>[0]
}
interface FilesystemInput {
type: 'filesystem'
params: {}
}
export type Input =
| SearchInput
| GroupByInput
| SeriesInput
| FilesystemInput

interface MediaListState {
Expand All @@ -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<MediaListState>({
loading: true, // empty state acts like it is loading by default
Expand All @@ -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
Expand Down Expand Up @@ -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')
}
Expand Down
13 changes: 7 additions & 6 deletions packages/web/src/lib/runes/media_view_rune.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,13 @@ export class MediaGroupRune extends MediaViewRune {
grouped_state = $state<GroupState>({
media_list: []
})
/** search params for the media inside this group (used by the filmstrip view to paginate group entries) */
readonly entries_search_params: Parameters<BaseController['client']['forager']['media']['search']>[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}
Expand All @@ -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
})
Expand All @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions packages/web/src/lib/runes/settings_rune.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
39 changes: 28 additions & 11 deletions packages/web/src/lib/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand All @@ -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'),

Expand Down
Loading
Loading