432 lines
11 KiB
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>
|