upend/webui/src/lib/components/display/blobs/AudioViewer.svelte

432 lines
11 KiB
Svelte

<script lang="ts">
import { debounce, throttle } from 'lodash';
import { onMount } from 'svelte';
import type { IValue } from '@upnd/upend/types';
import type WaveSurfer from 'wavesurfer.js';
import type { Region, RegionParams } from 'wavesurfer.js/src/plugin/regions';
import api from '$lib/api';
import { TimeFragment } from '../../../util/fragments/time';
import Icon from '../../utils/Icon.svelte';
import Selector from '../../utils/Selector.svelte';
import UpObject from '../../display/UpObject.svelte';
import Spinner from '../../utils/Spinner.svelte';
import IconButton from '../../../components/utils/IconButton.svelte';
import LabelBorder from '../../../components/utils/LabelBorder.svelte';
import { i18n } from '../../../i18n';
import { ATTR_LABEL } from '@upnd/upend/constants';
import debug from 'debug';
const dbg = debug('kestrel:AudioViewer');
export let address: string;
export let detail: boolean;
let editable = false;
let containerEl: HTMLDivElement;
let timelineEl: HTMLDivElement;
let loaded = false;
let wavesurfer: WaveSurfer;
// Zoom handling
let zoom = 1;
const setZoom = throttle((level: number) => {
wavesurfer.zoom(level);
}, 250);
$: if (zoom && wavesurfer) setZoom(zoom);
// Annotations
const DEFAULT_ANNOTATION_COLOR = '#cb4b16';
type UpRegion = Region & { data: IValue };
let currentAnnotation: UpRegion | undefined;
async function loadAnnotations() {
const entity = await api.fetchEntity(address);
entity.backlinks
.filter((e) => e.attribute == 'ANNOTATES')
.forEach(async (e) => {
const annotation = await api.fetchEntity(e.entity);
if (annotation.get('W3C_FRAGMENT_SELECTOR')) {
const fragment = TimeFragment.parse(String(annotation.get('W3C_FRAGMENT_SELECTOR')));
if (fragment) {
wavesurfer.addRegion({
id: `ws-region-${e.entity}`,
color: annotation.get('COLOR') || DEFAULT_ANNOTATION_COLOR,
attributes: {
'upend-address': annotation.address,
label: annotation.get(ATTR_LABEL)
},
data: (annotation.attr['NOTE'] || [])[0]?.value,
...fragment
} as RegionParams);
}
}
});
}
$: if (wavesurfer) {
if (editable) {
wavesurfer.enableDragSelection({ color: DEFAULT_ANNOTATION_COLOR });
} else {
wavesurfer.disableDragSelection();
}
Object.values(wavesurfer.regions.list).forEach((region) => {
region.update({ drag: editable, resize: editable });
});
}
async function updateAnnotation(region: Region) {
dbg('Updating annotation %o', region);
let entity = region.attributes['upend-address'];
// Newly created
if (!entity) {
let [_, newEntity] = await api.putEntry({
entity: {
t: 'Uuid'
}
});
entity = newEntity;
const nextAnnotationIndex = Object.values(wavesurfer.regions.list).length;
const label = `Annotation #${nextAnnotationIndex}`;
region.update({
attributes: { label }
// incorrect types, `update()` does take `attributes`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
if (region.attributes['label']) {
await api.putEntityAttribute(entity, ATTR_LABEL, {
t: 'String',
c: region.attributes['label']
});
}
await api.putEntityAttribute(entity, 'ANNOTATES', {
t: 'Address',
c: address
});
await api.putEntityAttribute(entity, 'W3C_FRAGMENT_SELECTOR', {
t: 'String',
c: new TimeFragment(region.start, region.end).toString()
});
if (region.color !== DEFAULT_ANNOTATION_COLOR) {
await api.putEntityAttribute(entity, 'COLOR', {
t: 'String',
c: region.color
});
}
if (Object.values(region.data).length) {
await api.putEntityAttribute(entity, 'NOTE', region.data as IValue);
}
region.update({
attributes: {
'upend-address': entity
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
const updateAnnotationDebounced = debounce(updateAnnotation, 250);
async function deleteAnnotation(region: Region) {
if (region.attributes['upend-address']) {
await api.deleteEntry(region.attributes['upend-address']);
}
}
let rootEl: HTMLElement;
onMount(async () => {
const WaveSurfer = await import('wavesurfer.js');
const TimelinePlugin = await import('wavesurfer.js/src/plugin/timeline');
const RegionsPlugin = await import('wavesurfer.js/src/plugin/regions');
const timelineColor = getComputedStyle(document.documentElement).getPropertyValue(
'--foreground'
);
wavesurfer = WaveSurfer.default.create({
container: containerEl,
waveColor: '#dc322f',
progressColor: '#991c1a',
responsive: true,
backend: 'MediaElement',
mediaControls: true,
normalize: true,
xhr: { cache: 'force-cache' },
plugins: [
TimelinePlugin.default.create({
container: timelineEl,
primaryColor: timelineColor,
primaryFontColor: timelineColor,
secondaryColor: timelineColor,
secondaryFontColor: timelineColor
}),
RegionsPlugin.default.create({})
]
});
wavesurfer.on('ready', () => {
dbg('wavesurfer ready');
loaded = true;
loadAnnotations();
});
wavesurfer.on('region-created', async (region: UpRegion) => {
dbg('wavesurfer region-created', region);
// Updating here, because if `drag` and `resize` are passed during adding,
// updating no longer works.
region.update({ drag: editable, resize: editable });
// If the region was created from the UI
if (!region.attributes['upend-address']) {
await updateAnnotation(region);
// currentAnnotation = region;
}
});
wavesurfer.on('region-updated', (region: UpRegion) => {
// dbg("wavesurfer region-updated", region);
currentAnnotation = region;
});
wavesurfer.on('region-update-end', (region: UpRegion) => {
dbg('wavesurfer region-update-end', region);
updateAnnotation(region);
currentAnnotation = region;
});
wavesurfer.on('region-removed', (region: UpRegion) => {
dbg('wavesurfer region-removed', region);
currentAnnotation = null;
deleteAnnotation(region);
});
// wavesurfer.on("region-in", (region: UpRegion) => {
// dbg("wavesurfer region-in", region);
// currentAnnotation = region;
// });
// wavesurfer.on("region-out", (region: UpRegion) => {
// dbg("wavesurfer region-out", region);
// if (currentAnnotation.id === region.id) {
// currentAnnotation = undefined;
// }
// });
wavesurfer.on('region-click', (region: UpRegion, _ev: MouseEvent) => {
dbg('wavesurfer region-click', region);
currentAnnotation = region;
});
wavesurfer.on('region-dblclick', (region: UpRegion, _ev: MouseEvent) => {
dbg('wavesurfer region-dblclick', region);
currentAnnotation = region;
setTimeout(() => wavesurfer.setCurrentTime(region.start));
});
try {
const peaksReq = await fetch(`${api.apiUrl}/thumb/${address}?mime=audio&type=json`);
const peaks = await peaksReq.json();
wavesurfer.load(`${api.apiUrl}/raw/${address}`, peaks.data);
} catch (e) {
console.warn(`Failed to load peaks: ${e}`);
const entity = await api.fetchEntity(address);
if (
(parseInt(String(entity.get('FILE_SIZE'))) || 0) < 20_000_000 ||
confirm(
$i18n.t(
'File is large (>20 MiB) and UpEnd failed to load waveform from server. Generating the waveform locally may slow down your browser. Do you wish to proceed anyway?'
)
)
) {
console.warn(`Failed to load peaks, falling back to client-side render...`);
wavesurfer.load(`${api.apiUrl}/raw/${address}`);
}
}
const drawBufferThrottled = throttle(() => wavesurfer.drawBuffer(), 200);
const resizeObserver = new ResizeObserver((_entries) => {
drawBufferThrottled();
});
resizeObserver.observe(rootEl);
});
</script>
<div class="audio" class:editable bind:this={rootEl}>
{#if !loaded}
<Spinner centered />
{/if}
{#if loaded}
<header>
<IconButton
name="edit"
title={$i18n.t('Toggle Edit Mode')}
on:click={() => (editable = !editable)}
active={editable}
>
{$i18n.t('Annotate')}
</IconButton>
<div class="zoom">
<Icon name="zoom-out" />
<input type="range" min="1" max="50" bind:value={zoom} />
<Icon name="zoom-in" />
</div>
</header>
{/if}
<div class="wavesurfer-timeline" bind:this={timelineEl} class:hidden={!detail} />
<div class="wavesurfer" bind:this={containerEl} />
{#if currentAnnotation}
<LabelBorder>
<span slot="header">{$i18n.t('Annotation')}</span>
{#if currentAnnotation.attributes['upend-address']}
<UpObject link address={currentAnnotation.attributes['upend-address']} />
{/if}
<div class="baseControls">
<div class="regionControls">
<div class="start">
Start: <input
type="number"
value={Math.round(currentAnnotation.start * 100) / 100}
disabled={!editable}
on:input={(ev) => {
currentAnnotation.update({
start: parseInt(ev.currentTarget.value)
});
updateAnnotationDebounced(currentAnnotation);
}}
/>
</div>
<div class="end">
End: <input
type="number"
value={Math.round(currentAnnotation.end * 100) / 100}
disabled={!editable}
on:input={(ev) => {
currentAnnotation.update({
end: parseInt(ev.currentTarget.value)
});
updateAnnotationDebounced(currentAnnotation);
}}
/>
</div>
<div class="color">
Color: <input
type="color"
value={currentAnnotation.color || DEFAULT_ANNOTATION_COLOR}
disabled={!editable}
on:input={(ev) => {
currentAnnotation.update({ color: ev.currentTarget.value });
updateAnnotation(currentAnnotation);
}}
/>
</div>
</div>
{#if editable}
<div class="existControls">
<IconButton outline name="trash" on:click={() => currentAnnotation.remove()} />
<!-- <div class="button">
<Icon name="check" />
</div> -->
</div>
{/if}
</div>
<div class="content">
{#key currentAnnotation}
<Selector
types={['String', 'Address']}
initial={currentAnnotation.data}
disabled={!editable}
on:input={(ev) => {
currentAnnotation.update({ data: ev.detail });
updateAnnotation(currentAnnotation);
}}
/>
{/key}
</div>
</LabelBorder>
{/if}
</div>
<style lang="scss">
@use '../../../styles/colors';
.audio {
width: 100%;
}
header {
display: flex;
justify-content: space-between;
& > * {
flex-basis: 50%;
}
.zoom {
display: flex;
align-items: baseline;
input {
flex-grow: 1;
margin: 0 0.5em 1em 0.5em;
}
}
}
.baseControls,
.content {
margin: 0.5em 0;
}
.baseControls,
.regionControls,
.existControls {
display: flex;
gap: 0.5em;
}
.baseControls {
justify-content: space-between;
}
.regionControls div {
display: flex;
align-items: center;
gap: 0.25em;
}
input[type='number'] {
width: 6em;
}
.hidden {
display: none;
}
:global(.audio:not(.editable) .wavesurfer-handle) {
display: none;
}
:global(.wavesurfer-handle) {
background: var(--foreground-lightest) !important;
}
:global(.wavesurfer-region) {
opacity: 0.5;
}
</style>