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

449 lines
12 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>