Skip to content
Merged
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
2 changes: 0 additions & 2 deletions frontend/viewer/src/lib/DialogsProvider.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<script lang="ts">
import NewEntryDialog from '$lib/entry-editor/NewEntryDialog.svelte';
import DeleteDialog from '$lib/entry-editor/DeleteDialog.svelte';
import AudioDialog from './components/audio/AudioDialog.svelte';
import {useDialogsService} from '$lib/services/dialogs-service';
import {useProjectContext} from '$lib/project-context.svelte';
const projectContext = useProjectContext();
Expand All @@ -14,6 +13,5 @@

{#if projectContext.maybeApi}
<NewEntryDialog/>
<AudioDialog/>
{/if}
<DeleteDialog bind:this={deleteDialog}/>
47 changes: 23 additions & 24 deletions frontend/viewer/src/lib/components/audio/AudioDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,35 @@
import {t} from 'svelte-i18n-lingui';
import {Button} from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import {useDialogsService} from '$lib/services/dialogs-service.js';
import {useBackHandler} from '$lib/utils/back-handler.svelte';
import {watch} from 'runed';
import AudioProvider from './audio-provider.svelte';
import AudioEditor from './audio-editor.svelte';
import {useLexboxApi} from '$lib/services/service-provider';
import {UploadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/UploadFileResult';
import {AppNotification} from '$lib/notifications/notifications';

let open = $state(false);
import type {Snippet} from 'svelte';
import {cn} from '$lib/utils';

let {
open = $bindable(false),
title = undefined,
onSubmit = () => {},
children = undefined
} : {
open: boolean,
title?: string,
onSubmit?: (audioId: string) => void,
children?: Snippet
} = $props();
useBackHandler({addToStack: () => open, onBack: () => open = false, key: 'audio-dialog'});
const dialogsService = useDialogsService();
dialogsService.invokeAudioDialog = getAudio;
const lexboxApi = useLexboxApi();

let submitting = $state(false);
let selectedFile = $state<File>();
let finalAudio = $state<File>();
const tooBig = $derived((finalAudio?.size ?? 0) > 10 * 1024 * 1024);

let requester: {
resolve: (mediaUri: string | undefined) => void
} | undefined;


async function getAudio() {
reset();
return new Promise<string | undefined>((resolve) => {
requester = {resolve};
open = true;
});
}

watch(() => open, () => {
if (!open) reset();
});
Expand All @@ -49,8 +45,6 @@
}

function reset() {
requester?.resolve(undefined);
requester = undefined;
clearAudio();
}

Expand All @@ -61,12 +55,11 @@

async function submitAudio() {
if (!selectedFile) throw new Error('No audio to upload');
if (!requester) throw new Error('No requester');

submitting = true;
try {
const audioId = await uploadAudio();
requester.resolve(audioId);
onSubmit(audioId);
close();
} finally {
submitting = false;
Expand All @@ -92,6 +85,7 @@
case UploadFileResult.Error:
throw new Error(response.errorMessage ?? $t`Unknown error`);
}
if (!response.mediaUri) throw new Error(`No mediaUri returned`);

return response.mediaUri;
}
Expand Down Expand Up @@ -138,10 +132,15 @@


<Dialog.Root bind:open>
<Dialog.DialogContent class="grid-rows-[auto_1fr_auto] sm:min-h-[min(calc(100%-16px),30rem)]">
<Dialog.DialogContent onOpenAutoFocus={(e) => e.preventDefault()} class={cn('sm:min-h-[min(calc(100%-16px),30rem)]',
children ? 'grid-rows-[auto_auto_1fr]' : 'grid-rows-[auto_1fr]')}>
<Dialog.DialogHeader>
<Dialog.DialogTitle>{$t`Add audio`}</Dialog.DialogTitle>
<Dialog.DialogTitle>{title || $t`Add audio`}</Dialog.DialogTitle>
</Dialog.DialogHeader>
{#if children}
<!-- Ensure children only occupy 1 grid row -->
<div>{@render children?.()}</div>
{/if}
{#if !selectedFile}
<AudioProvider {onFileSelected} {onRecordingComplete}/>
{:else}
Expand Down
8 changes: 3 additions & 5 deletions frontend/viewer/src/lib/components/audio/audio-editor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,9 @@
<span class="col-span-4">{$t`${finalAudio.type}`}</span>
</DevContent>
</span>
<!-- contain-size prevents wavesurfer from freaking out inside a grid
contain-inline-size would improve the height reactivity of the waveform, but
results in the waveform sometimes change its height unexpectedly -->
<!-- pb-8 ensures the timeline is in the bounds of the container -->
<div class="w-full grow max-h-32 pb-3 contain-size border-y">
<!-- contain-inline-size prevents wavesurfer from freaking out inside a grid -->
<!-- pb ensures the waveform timeline is in the bounds of this container -->
<div class="w-full h-32 pb-3 contain-inline-size border-y">
<Waveform audio={finalAudio} bind:playing bind:audioApi bind:duration showTimeline autoplay class="size-full"/>
</div>
<div class="flex gap-2">
Expand Down
23 changes: 13 additions & 10 deletions frontend/viewer/src/lib/components/audio/audio-provider.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,20 @@
{$t`Hold to record or\npress and release to start recording.`}
</span>
{/if}
<Recorder.Trigger bind:walkieTalkieMode />
<Recorder.Trigger autofocus bind:walkieTalkieMode />
</div>
</Recorder.Root>
</div>
</div>

<!-- Hidden file input -->
<input
bind:this={fileInputElement}
type="file"
accept="audio/*"
onchange={handleFileSelection}
class="hidden"
/>
<!--
Hidden file input.
Should not be at root level as it might trigger a margin/gap.
-->
<input
bind:this={fileInputElement}
type="file"
accept="audio/*"
onchange={handleFileSelection}
class="hidden"
/>
</div>
33 changes: 23 additions & 10 deletions frontend/viewer/src/lib/components/editor/field/field-root.svelte
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
<script lang="ts" module>
import {Context} from 'runed';

type FieldRootStateProps = {
import type {FieldId} from '$lib/entry-editor/field-data';
const fieldIdSymbol = Symbol('fw-lite-field-id');
class FieldRootState {
//require using the constructor when this type is used
private readonly [fieldIdSymbol] = true;
labelId: string;
};
fieldId? = $state<FieldId>();
label? = $state<string>();

constructor(labelId: string) {
this.labelId = labelId;
}
}

const fieldRootContext = new Context<FieldRootStateProps>('Field.Root');
const fieldRootContext = new Context<FieldRootState>('Field.Root');

export function usesFieldRoot(props: FieldRootStateProps): FieldRootStateProps {
export function usesFieldRoot(props: FieldRootState): FieldRootState {
return fieldRootContext.set(props);
}

type FieldTitleStateProps = FieldRootStateProps;
type FieldTitleStateProps = FieldRootState;

export function useFieldTitle(): FieldTitleStateProps {
return fieldRootContext.get();
}

type FieldBodyStateProps = FieldRootStateProps;
type FieldBodyStateProps = FieldRootState;
export function tryUseFieldBody(): FieldBodyStateProps | undefined {
return fieldRootContext.getOr(undefined);
}
Expand All @@ -28,19 +37,23 @@
import type {WithElementRef} from 'bits-ui';
import type {HTMLAttributes} from 'svelte/elements';

type FieldRootProps = WithElementRef<HTMLAttributes<HTMLDivElement>>;
type FieldRootProps = { fieldId?: FieldId } & WithElementRef<HTMLAttributes<HTMLDivElement>>;

const fieldLabelId = $props.id();
usesFieldRoot({labelId: fieldLabelId});
const fieldProps = usesFieldRoot(new FieldRootState(fieldLabelId));
$effect(() => {
fieldProps.fieldId = fieldId;
});

const {
class: className,
children,
fieldId = undefined,
ref = $bindable(null),
...restProps
}: FieldRootProps = $props();
</script>

<div class={cn('grid grid-cols-subgrid col-span-full items-baseline', className)} {...restProps}>
<div style="grid-area: {fieldId}" class={cn('grid grid-cols-subgrid col-span-full items-baseline', className)} {...restProps}>
{@render children?.()}
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@

const view = useCurrentView();
const label = $derived(pickViewText(name, $view.type));
$effect(() => {
stateProps.label = label;
});
const title = $derived(typeof name === 'string' ? undefined
: $view.type === 'fw-classic'
? $t`${name.lite} (FieldWorks Lite)`
Expand Down
39 changes: 29 additions & 10 deletions frontend/viewer/src/lib/components/field-editors/audio-input.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -50,26 +50,32 @@
import {formatDuration, normalizeDuration} from '$lib/components/ui/format';
import {t} from 'svelte-i18n-lingui';
import {ReadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/ReadFileResult';
import {useDialogsService} from '$lib/services/dialogs-service';
import * as ResponsiveMenu from '$lib/components/responsive-menu';
import AudioDialog from '$lib/components/audio/AudioDialog.svelte';
import {tryUseFieldBody} from '$lib/components/editor/field/field-root.svelte';
import {useSubjectContext} from '$lib/entry-editor/object-editors/subject-context';
import LexiconEditorPrimitive from '$lib/entry-editor/object-editors/LexiconEditorPrimitive.svelte';
import OverrideFields from '$lib/OverrideFields.svelte';

const handled = Symbol();
let {
loader = defaultLoader,
audioId = $bindable(),
onchange = () => {},
readonly = false,
wsLabel = undefined,
}: {
loader?: (audioId: string) => Promise<{stream: ReadableStream, filename: string} | undefined | typeof handled>,
audioId: string | undefined,
onchange?: (audioId: string | undefined) => void;
readonly?: boolean;
wsLabel?: string;
} = $props();

const projectContext = useProjectContext();
const api = $derived(projectContext?.maybeApi);
const supportsAudio = $derived(projectContext?.features.audio);
const dialogService = useDialogsService();
const fieldProps = tryUseFieldBody();

async function defaultLoader(audioId: string) {
if (!api) throw new Error('No api, unable to load audio');
Expand Down Expand Up @@ -217,12 +223,10 @@
});
let smallestUnit = $derived(totalLength.minutes > 0 ? 'seconds' as const : 'milliseconds' as const);

async function onGetAudioClick() {
const result = await dialogService.getAudio();
if (result) {
audioId = result;
onchange(audioId)
}
let audioDialogOpen = $state(false);
function onAudioDialogSubmit(newAudioId: string) {
audioId = newAudioId;
onchange(newAudioId);
}

function onRemoveAudio() {
Expand Down Expand Up @@ -264,11 +268,26 @@
return audio.error.code === MediaError.MEDIA_ERR_NETWORK &&
audio.error.message?.includes('demuxer seek failed');
}
let dialogTitle = $derived(fieldProps?.label && wsLabel ? `${fieldProps.label}: ${wsLabel}` : fieldProps?.label || wsLabel);
let subject = useSubjectContext();
</script>
{#if supportsAudio}
{#if !readonly}
<AudioDialog title={dialogTitle} bind:open={audioDialogOpen} onSubmit={onAudioDialogSubmit}>
{#if subject?.current}
<OverrideFields shownFields={fieldProps?.fieldId ? [fieldProps.fieldId] : []}>
<LexiconEditorPrimitive object={subject.current}/>
</OverrideFields>
{/if}
</AudioDialog>
{/if}
{#if !audioId}
{#if !readonly}
<Button variant="secondary" icon="i-mdi-microphone-plus" size="sm" iconProps={{class: 'size-5'}} onclick={onGetAudioClick}>
<Button variant="secondary"
icon="i-mdi-microphone-plus"
size="sm"
iconProps={{class: 'size-5'}}
onclick={() => audioDialogOpen = true}>
{$t`Add audio`}
</Button>
{:else}
Expand Down Expand Up @@ -322,7 +341,7 @@
</ResponsiveMenu.Trigger>
<ResponsiveMenu.Content>
{#if !readonly}
<ResponsiveMenu.Item icon="i-mdi-microphone-plus" onSelect={onGetAudioClick}>
<ResponsiveMenu.Item icon="i-mdi-microphone-plus" onSelect={() => audioDialogOpen = true}>
{$t`Replace audio`}
</ResponsiveMenu.Item>
<ResponsiveMenu.Item icon="i-mdi-delete" onSelect={onRemoveAudio}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
{:else}
<AudioInput
bind:audioId={value[ws.wsId]} onchange={() => onchange?.(ws.wsId, value[ws.wsId], value)}
wsLabel={ws.abbreviation}
{readonly} />
{/if}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
{:else}
<AudioInput
bind:audioId={() => getAudioId(value[ws.wsId]), audioId => setAudioId(audioId, ws.wsId)}
wsLabel={ws.abbreviation}
{readonly} />
{/if}
</div>
Expand Down
Loading
Loading