Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
357 changes: 168 additions & 189 deletions deno.lock

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion packages/core/src/actions/lib/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ abstract class Actions<Events extends EmitterEvents = {}> extends Emitter<Events
}

if (media_reference.media_series_reference) {
throw new Error('unimplemented')
const result = this.get_media_series_response({ series_id: media_reference.id }, thumbnail_limit)
return result
} else {
if (media_file === undefined) {
media_file = this.models.MediaFile.select_one({media_reference_id: media_reference.id}, {or_raise: true})
Expand All @@ -265,6 +266,18 @@ abstract class Actions<Events extends EmitterEvents = {}> extends Emitter<Events
}
}

protected get_media_series_response(params: outputs.SeriesGet, thumbnail_limit?: number): MediaSeriesResponse {
const media_reference = this.models.MediaReference.select_one_media_series({id: params.series_id, media_series_name: params.series_name})
const tags = this.models.Tag.select_all({media_reference_id: media_reference.id})
return {
media_type: 'media_series',
media_reference,
tags,
thumbnails: this.models.MediaThumbnail.select_many({series_id: media_reference.id, limit: thumbnail_limit ?? 0}),
}
}


protected tag_create(tag: z.output<typeof parsers.Tag>) {
const group = tag.group ?? ''
const color = get_hash_color(group, 'hsl')
Expand Down
19 changes: 4 additions & 15 deletions packages/core/src/actions/series_actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Actions, type MediaSeriesResponse, type SeriesSearchResponse, type UpdateEditor } from '~/actions/lib/base.ts'
import { inputs, outputs, parsers } from '~/inputs/mod.ts'
import { inputs, parsers } from '~/inputs/mod.ts'
import type * as result_types from '~/models/lib/result_types.ts'
import * as errors from '~/lib/errors.ts'
import * as torm from '@torm/sqlite'
Expand Down Expand Up @@ -55,7 +55,7 @@ class SeriesActions extends Actions {

const transaction_result = transaction()
// no thumbnails should exist yet, so we give it limit: 0
return this.#get_media_series_response({series_id: transaction_result.media_reference.id })
return this.get_media_series_response({series_id: transaction_result.media_reference.id })
}

public update = (series_id: number, media_info?: inputs.MediaInfo, tags?: inputs.Tag[], editing?: UpdateEditor): MediaSeriesResponse => {
Expand Down Expand Up @@ -88,7 +88,7 @@ class SeriesActions extends Actions {
})

transaction()
return this.#get_media_series_response({series_id: parsed.series_id})
return this.get_media_series_response({series_id: parsed.series_id})
}

public upsert = (media_info?: inputs.MediaSeriesInfo, tags?: inputs.Tag[]): MediaSeriesResponse => {
Expand Down Expand Up @@ -149,7 +149,7 @@ class SeriesActions extends Actions {

public get = (params: inputs.SeriesGet): MediaSeriesResponse => {
const parsed = parsers.SeriesGet.parse(params)
return this.#get_media_series_response(parsed)
return this.get_media_series_response(parsed)
}

public search = (params: inputs.SeriesSearch): result_types.PaginatedResult<SeriesSearchResponse> => {
Expand Down Expand Up @@ -194,17 +194,6 @@ class SeriesActions extends Actions {
}
}

#get_media_series_response(params: outputs.SeriesGet): MediaSeriesResponse {
const media_reference = this.models.MediaReference.select_one_media_series({id: params.series_id, media_series_name: params.series_name})
const tags = this.models.Tag.select_all({media_reference_id: media_reference.id})
return {
media_type: 'media_series',
media_reference,
tags,
thumbnails: this.models.MediaThumbnail.select_many({series_id: media_reference.id, limit: 0}),
}
}

#map_series_records_to_responses(records: ReturnType<Actions['models']['MediaReference']['select_many_series']>, thumbnail_limit: number, keypoint_tag_id: number | undefined): SeriesSearchResponse[] {
const results: SeriesSearchResponse[] = records.results.map(row => {
const tags = this.models.Tag.select_all({media_reference_id: row.id})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script lang="ts" module>
import type { BaseController } from '$lib/base_controller.ts'
import type { MediaListRune, SettingsRune, MediaSelectionsRune, create_focuser, create_dimensional_rune } from '$lib/runes/index.ts'

// Define the shape of a media browser controller without importing concrete implementations
export interface MediaBrowserController extends BaseController {
runes: {
media_list: MediaListRune
focus: ReturnType<typeof create_focuser>
dimensions: ReturnType<typeof create_dimensional_rune>
settings: SettingsRune
media_selections: MediaSelectionsRune
queryparams: any // Different queryparams types for browse vs series
}
}
</script>

<script lang="ts">
import MediaDetails from './MediaDetails.svelte'
import MediaView from './MediaView.svelte'
import Footer from './Footer.svelte'
import type { Snippet } from 'svelte'

interface Props {
controller: MediaBrowserController
header: Snippet<[{ controller: MediaBrowserController }]>
media_list: Snippet<[{ controller: MediaBrowserController }]>
}

let { controller, header, media_list }: Props = $props()
let { dimensions } = controller.runes
</script>

<div class="h-dvh">
{@render header({ controller })}
<div class="grid grid-cols-[auto_1fr]">
<MediaDetails {controller} />
<div class="relative">
<MediaView {controller} />
{@render media_list({ controller })}
</div>
</div>
<Footer {controller} bind:height={dimensions.heights.footer} />
</div>

<svelte:window
on:keydown={controller.keybinds.handler}
bind:innerHeight={dimensions.heights.screen}
/>
124 changes: 124 additions & 0 deletions packages/web/src/lib/components/browse_media/MediaTile.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<script lang="ts">
import type { MediaBrowserController } from './MediaBrowserLayout.svelte'
import * as theme from '$lib/theme.ts'
import { focusable, scrollable } from '$lib/actions/mod.ts'
import Icon from '$lib/components/Icon.svelte'
import * as icons from '$lib/icons/mod.ts'
import type { Snippet } from 'svelte'
import type { MediaViewRune } from '$lib/runes/media_view_rune.svelte.ts'

interface Props {
controller: MediaBrowserController
result: MediaViewRune
result_index: number
metadata?: Snippet<[{ result: MediaViewRune }]>
}

let { controller, result, result_index, metadata }: Props = $props()
const { settings, media_selections } = controller.runes

const icon_size = 14
const icon_color = theme.colors.green[200]

function human_readable_duration(seconds: number) {
if (seconds < 100) {
return `${seconds.toFixed(1)}s`
}
const minutes = seconds / 60
if (minutes < 60) {
return `${minutes.toFixed(1)}m`
}
const hours = minutes / 60
return `${hours.toFixed(2)}h`
}
</script>

<div>
<div
type="button"
class="inline-flex items-center justify-center p-1
outline-none"
use:scrollable={media_selections.current_selection.result_index === result_index}
>
<div
class="grid gap-[5px] grid-rows-[1fr_max-content] h-full w-full items-center"
style="width:{settings.ui.media_list.thumbnail_size}px"
>
<div
class="flex justify-center items-center overflow-hidden focus:outline-none"
style="width:{settings.ui.media_list.thumbnail_size}px; height: {settings.ui.media_list.thumbnail_size}px"
onkeydown={e => {
if (e.key === 'Enter') {
media_selections.set_current_selection(result, result_index)
}
}}
role="button"
tabindex="0"
use:focusable={!media_selections.current_selection.show && media_selections.current_selection.result_index === result_index}
onclick={e => media_selections.set_current_selection(result, result_index)}
>
{#if settings.ui.media_list.thumbnail_shape === 'original'}
<img
class={[
"shadow shadow-gray-700 rounded-md hover:shadow-slate-400 hover:border-slate-400 hover:border hover:border-2",
result.img_fit_classes(),
result_index !== media_selections.current_selection.result_index
&& "shadow-slate-900 shadow-green-300",
result_index === media_selections.current_selection.result_index
&& "hover:border-slate-400 border-green-300 border border-2 hover:shadow-slate-400 shadow-green-300",
]}
src={result.preview_thumbnail}
alt="Thumbnail for media reference {result.media_reference?.id}"/>
{:else}
<img
class={[
"w-full h-full object-cover shadow shadow-gray-700 rounded-md hover:shadow-slate-400 hover:border-slate-400 hover:border hover:border-2",
result.img_fit_classes(),
result_index !== media_selections.current_selection.result_index
&& "hover:shadow-slate-400 shadow-slate-900 shadow-green-300",
result_index === media_selections.current_selection.result_index
&& "hover:border-slate-400 border-green-300 border border-2 hover:shadow-slate-400 shadow-green-300",
]}
src={result.preview_thumbnail}
alt="Thumbnail for media reference {result.media_reference?.id}"/>
{/if}
</div>

<!-- info chips -->
<div class="flex text-xs text-gray-400 justify-between p-0.5">
{#if result.media_type === 'media_file'}
{#if result.media_file.media_type === 'VIDEO'}
<span class="flex">
<Icon data={icons.PlayCircle} fill={icon_color} stroke="none" size={icon_size} />
{#if result.media_file.audio}
<Icon data={icons.Music} fill={icon_color} stroke="none" size={icon_size} />
{/if}
</span>
<span>{human_readable_duration(result.media_file.duration)}</span>
{:else if result.media_file.media_type === 'IMAGE' && result.media_file.content_type === 'image/gif'}
<Icon data={icons.Gif} fill={icon_color} stroke="none" size={icon_size} />
{:else if result.media_file.media_type === 'IMAGE'}
<Icon data={icons.Photo} fill={icon_color} stroke="none" size={icon_size} />
{:else if result.media_file.media_type === 'AUDIO'}
<Icon data={icons.Music} fill={icon_color} stroke="none" size={icon_size} />
{:else}
unknown media type {result.media_file.media_type}
{/if}
{:else if result.media_type === 'media_series'}
<Icon data={icons.Copy} fill={icon_color} stroke="none" size={icon_size} />
<span>({result.media_reference.media_series_length})</span>
{:else if result.media_type === 'grouped'}
<Icon data={icons.Copy} fill={icon_color} stroke="none" size={icon_size} />
<span>{result.group_metadata.count}</span>
{:else}
UNEXPECTED MEDIA TYPE {result.media_type}
{/if}
</div>

<!-- custom metadata slot -->
{#if metadata}
{@render metadata({ result })}
{/if}
</div>
</div>
</div>
Loading
Loading