Skip to content
Draft
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
12 changes: 12 additions & 0 deletions backend/FwLite/MiniLcm.Tests/WritingSystemIdTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ public void DetectsAudioWritingSystems(string code)
ws.IsAudio.Should().BeTrue();
}

[Theory]
[InlineData("en-Zxxx-x-audio", "en")]
[InlineData("seh-Zxxx-x-audio-var", "seh-x-var")]
[InlineData("lwl-Zxxx-x-majority-audio", "lwl-x-majority")]
[InlineData("lwl-Latn-x-majority", "lwl-x-majority")]
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The script is only relevant to text WS's. So, if there are multiple WSs where only the script differs we show all of them.

[InlineData("lwl-Latn", "lwl")]
public void CalculatesScriptlessEquivalent(string audioCode, string expectedScriptlessCode)
{
var ws = new WritingSystemId(audioCode);
ws.CodeWithoutScriptOrAudio.Should().Be(expectedScriptlessCode);
}

[Theory]
[InlineData("gx")]
[InlineData("oo")]
Expand Down
1 change: 1 addition & 0 deletions backend/FwLite/MiniLcm/Models/WritingSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public record WritingSystem: IObjectWithId<WritingSystem>, IOrderableNoId
public required Guid Id { get; set; }
public virtual required WritingSystemId WsId { get; set; }
public bool IsAudio => WsId.IsAudio;
public string CodeWithoutScriptOrAudio => WsId.CodeWithoutScriptOrAudio ?? WsId.Code;
public virtual required string Name { get; set; }
public virtual required string Abbreviation { get; set; }
public virtual required string Font { get; set; }
Expand Down
15 changes: 14 additions & 1 deletion backend/FwLite/MiniLcm/Models/WritingSystemId.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using SIL.WritingSystems;
Expand Down Expand Up @@ -35,6 +34,7 @@ public override void WriteAsPropertyName(Utf8JsonWriter writer, WritingSystemId
{
public string Code { get => field ?? "default"; init; }
public bool IsAudio { get; } = false;
public string? CodeWithoutScriptOrAudio { get; }

public static readonly WritingSystemId Default = "default";

Expand Down Expand Up @@ -62,6 +62,19 @@ public WritingSystemId(string code)
Code = code;
IsAudio = script?.Equals(WellKnownSubtags.AudioScript, StringComparison.OrdinalIgnoreCase) == true &&
variants?.Split('-').Any(v => v == WellKnownSubtags.AudioPrivateUse) == true;

if ((script is not null || IsAudio) && IetfLanguageTag.TryGetSubtags(code,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TryGetSubtags appears to be expensive in some cases perhaps that's, because in some cases it requires the SLDR to be initialized. I'm not sure when that's the case, but it's not the case for any of the test cases I wrote. So, perhaps it's never the case for codes that have a script 🙃

out var langSubtag,
out var scriptSubtag,
out var regionSubtag,
out var variantsSubtags))
{
CodeWithoutScriptOrAudio = IetfLanguageTag.Create(
langSubtag,
null, // remove script (e.g. Zxxxx (unwritten/audio), Latn, etc.)
regionSubtag,
variantsSubtags.Where(v => v.Code != WellKnownSubtags.AudioPrivateUse));
}
}
else
{
Expand Down
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
Loading
Loading