2022-02-14 13:27:51 +01:00
< script lang = "ts" >
2022-03-08 23:24:11 +01:00
import { debounce , throttle } from "lodash";
2022-02-14 14:57:35 +01:00
import { onMount } from "svelte";
2023-10-07 11:06:45 +02:00
import type { IValue } from "@upnd/upend/types";
2022-02-14 14:57:35 +01:00
import type WaveSurfer from "wavesurfer.js";
2022-03-08 23:24:11 +01:00
import type { Region , RegionParams } from "wavesurfer.js/src/plugin/regions";
2023-05-22 20:57:06 +02:00
import api from "../../../lib/api";
2022-03-08 23:24:11 +01:00
import { TimeFragment } from "../../../util/fragments/time";
2022-02-17 17:27:52 +01:00
import Icon from "../../utils/Icon.svelte";
2022-03-08 23:24:11 +01:00
import Selector from "../../utils/Selector.svelte";
2023-03-05 21:01:31 +01:00
import UpObject from "../../display/UpObject.svelte";
2022-02-14 13:27:51 +01:00
import Spinner from "../../utils/Spinner.svelte";
2023-09-05 20:57:01 +02:00
import IconButton from "../../../components/utils/IconButton.svelte";
import LabelBorder from "../../../components/utils/LabelBorder.svelte";
2022-10-25 21:47:17 +02:00
import { i18n } from "../../../i18n";
2023-10-07 11:06:45 +02:00
import { ATTR_LABEL } from "@upnd/upend/constants";
2023-09-03 10:39:07 +02:00
import debug from "debug";
const dbg = debug("kestrel:AudioViewer");
2022-02-14 13:27:51 +01:00
export let address: string;
2022-02-14 14:57:35 +01:00
export let detail: boolean;
2023-09-01 19:52:49 +02:00
2023-09-05 20:57:01 +02:00
let editable = false;
2022-02-14 13:27:51 +01:00
2022-02-14 14:57:35 +01:00
let containerEl: HTMLDivElement;
2022-02-17 17:14:56 +01:00
let timelineEl: HTMLDivElement;
2022-02-14 14:57:35 +01:00
let loaded = false;
let wavesurfer: WaveSurfer;
2022-03-08 23:24:11 +01:00
// Zoom handling
2023-09-05 20:57:01 +02:00
let zoom = 1;
2022-02-17 17:27:52 +01:00
const setZoom = throttle((level: number) => {
wavesurfer.zoom(level);
}, 250);
$: if (zoom & & wavesurfer) setZoom(zoom);
2022-03-08 23:24:11 +01:00
// Annotations
const DEFAULT_ANNOTATION_COLOR = "#cb4b16";
2022-08-01 22:40:45 +02:00
type UpRegion = Region & { data : IValue } ;
let currentAnnotation: UpRegion | undefined;
2022-03-08 23:24:11 +01:00
async function loadAnnotations() {
2023-05-22 20:57:06 +02:00
const entity = await api.fetchEntity(address);
2022-03-08 23:24:11 +01:00
entity.backlinks
.filter((e) => e.attribute == "ANNOTATES")
.forEach(async (e) => {
2023-05-22 20:57:06 +02:00
const annotation = await api.fetchEntity(e.entity);
2022-03-08 23:24:11 +01:00
if (annotation.get("W3C_FRAGMENT_SELECTOR")) {
const fragment = TimeFragment.parse(
2023-08-25 23:35:29 +02:00
String(annotation.get("W3C_FRAGMENT_SELECTOR")),
2022-03-08 23:24:11 +01:00
);
if (fragment) {
wavesurfer.addRegion({
id: `ws-region-${ e . entity } `,
color: annotation.get("COLOR") || DEFAULT_ANNOTATION_COLOR,
2023-03-05 21:01:31 +01:00
attributes: {
"upend-address": annotation.address,
2023-06-24 16:26:14 +02:00
label: annotation.get(ATTR_LABEL),
2023-03-05 21:01:31 +01:00
},
data: (annotation.attr["NOTE"] || [])[0]?.value,
2022-03-08 23:24:11 +01:00
...fragment,
} as RegionParams);
}
}
});
}
$: if (wavesurfer) {
2022-03-10 20:35:29 +01:00
if (editable) {
2022-03-08 23:24:11 +01:00
wavesurfer.enableDragSelection({ color : DEFAULT_ANNOTATION_COLOR } );
} else {
wavesurfer.disableDragSelection();
}
2023-03-05 21:01:31 +01:00
Object.values(wavesurfer.regions.list).forEach((region) => {
region.update({ drag : editable , resize : editable } );
});
2022-03-08 23:24:11 +01:00
}
async function updateAnnotation(region: Region) {
2023-09-07 21:32:39 +02:00
dbg("Updating annotation %o", region);
2023-03-05 21:01:31 +01:00
let entity = region.attributes["upend-address"];
// Newly created
if (!entity) {
2023-05-22 20:57:06 +02:00
let [_, newEntity] = await api.putEntry({
2023-03-05 21:01:31 +01:00
entity: {
t: "Uuid",
},
});
entity = newEntity;
const nextAnnotationIndex = Object.values(wavesurfer.regions.list).length;
const label = `Annotation #${ nextAnnotationIndex } `;
region.update({
2023-04-23 22:57:36 +02:00
attributes: { label } ,
2023-08-25 23:35:29 +02:00
// incorrect types, `update()` does take `attributes`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
2023-03-05 21:01:31 +01:00
}
2023-09-07 21:32:39 +02:00
if (region.attributes["label"]) {
await api.putEntityAttribute(entity, ATTR_LABEL, {
t: "String",
c: region.attributes["label"],
});
}
2023-03-05 21:01:31 +01:00
2023-05-22 20:57:06 +02:00
await api.putEntityAttribute(entity, "ANNOTATES", {
2023-03-05 21:01:31 +01:00
t: "Address",
c: address,
});
2023-05-22 20:57:06 +02:00
await api.putEntityAttribute(entity, "W3C_FRAGMENT_SELECTOR", {
2022-03-08 23:24:11 +01:00
t: "String",
c: new TimeFragment(region.start, region.end).toString(),
});
2023-03-05 21:05:44 +01:00
if (region.color !== DEFAULT_ANNOTATION_COLOR) {
2023-05-22 20:57:06 +02:00
await api.putEntityAttribute(entity, "COLOR", {
2023-03-05 21:05:44 +01:00
t: "String",
c: region.color,
});
}
2023-03-05 21:01:31 +01:00
if (Object.values(region.data).length) {
2023-05-22 20:57:06 +02:00
await api.putEntityAttribute(entity, "NOTE", region.data as IValue);
2023-03-05 21:01:31 +01:00
}
2023-04-23 22:57:36 +02:00
region.update({
attributes: {
"upend-address": entity,
},
2023-08-25 23:35:29 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2023-04-23 22:57:36 +02:00
} as any);
2022-03-08 23:24:11 +01:00
}
const updateAnnotationDebounced = debounce(updateAnnotation, 250);
async function deleteAnnotation(region: Region) {
2023-03-05 21:01:31 +01:00
if (region.attributes["upend-address"]) {
2023-05-22 20:57:06 +02:00
await api.deleteEntry(region.attributes["upend-address"]);
2023-03-05 21:01:31 +01:00
}
2022-03-08 23:24:11 +01:00
}
2023-03-07 19:42:55 +01:00
let rootEl: HTMLElement;
2022-02-14 14:57:35 +01:00
onMount(async () => {
const WaveSurfer = await import("wavesurfer.js");
2022-02-17 17:14:56 +01:00
const TimelinePlugin = await import("wavesurfer.js/src/plugin/timeline");
2022-03-08 23:24:11 +01:00
const RegionsPlugin = await import("wavesurfer.js/src/plugin/regions");
2022-02-17 17:14:56 +01:00
const timelineColor = getComputedStyle(
2023-08-25 23:35:29 +02:00
document.documentElement,
2022-02-17 17:14:56 +01:00
).getPropertyValue("--foreground");
2022-02-14 14:57:35 +01:00
wavesurfer = WaveSurfer.default.create({
container: containerEl,
waveColor: "#dc322f",
progressColor: "#991c1a",
responsive: true,
backend: "MediaElement",
mediaControls: true,
2022-09-19 22:27:20 +02:00
normalize: true,
2022-02-15 01:21:23 +01:00
xhr: { cache : "force-cache" } ,
2022-02-17 17:14:56 +01:00
plugins: [
TimelinePlugin.default.create({
container: timelineEl,
primaryColor: timelineColor,
primaryFontColor: timelineColor,
secondaryColor: timelineColor,
secondaryFontColor: timelineColor,
}),
2022-03-08 23:24:11 +01:00
RegionsPlugin.default.create({} ),
2022-02-17 17:14:56 +01:00
],
2022-02-14 14:57:35 +01:00
});
2022-03-08 23:24:11 +01:00
2022-02-14 14:57:35 +01:00
wavesurfer.on("ready", () => {
2023-09-03 10:39:07 +02:00
dbg("wavesurfer ready");
2023-03-05 21:01:31 +01:00
2022-02-14 14:57:35 +01:00
loaded = true;
2022-03-08 23:24:11 +01:00
loadAnnotations();
});
2023-03-05 21:01:31 +01:00
wavesurfer.on("region-created", async (region: UpRegion) => {
2023-09-03 10:39:07 +02:00
dbg("wavesurfer region-created", region);
2023-03-05 21:01:31 +01:00
// 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;
2022-03-08 23:24:11 +01:00
}
});
2022-08-01 22:40:45 +02:00
wavesurfer.on("region-updated", (region: UpRegion) => {
2023-09-03 10:39:07 +02:00
// dbg("wavesurfer region-updated", region);
2023-03-05 21:01:31 +01:00
2022-03-08 23:24:11 +01:00
currentAnnotation = region;
});
2022-08-01 22:40:45 +02:00
wavesurfer.on("region-update-end", (region: UpRegion) => {
2023-09-03 10:39:07 +02:00
dbg("wavesurfer region-update-end", region);
2023-03-05 21:01:31 +01:00
2022-03-08 23:24:11 +01:00
updateAnnotation(region);
currentAnnotation = region;
});
2022-08-01 22:40:45 +02:00
wavesurfer.on("region-removed", (region: UpRegion) => {
2023-09-03 10:39:07 +02:00
dbg("wavesurfer region-removed", region);
2023-03-05 21:01:31 +01:00
currentAnnotation = null;
2022-03-08 23:24:11 +01:00
deleteAnnotation(region);
2022-02-14 14:57:35 +01:00
});
2022-03-08 23:24:11 +01:00
2023-03-05 21:01:31 +01:00
// wavesurfer.on("region-in", (region: UpRegion) => {
2023-09-03 10:39:07 +02:00
// dbg("wavesurfer region-in", region);
2022-03-08 23:24:11 +01:00
2023-03-05 21:01:31 +01:00
// currentAnnotation = region;
// });
// wavesurfer.on("region-out", (region: UpRegion) => {
2023-09-03 10:39:07 +02:00
// dbg("wavesurfer region-out", region);
2023-03-05 21:01:31 +01:00
// if (currentAnnotation.id === region.id) {
// currentAnnotation = undefined;
// }
// });
wavesurfer.on("region-click", (region: UpRegion, _ev: MouseEvent) => {
2023-09-03 10:39:07 +02:00
dbg("wavesurfer region-click", region);
2023-03-05 21:01:31 +01:00
currentAnnotation = region;
2022-03-08 23:24:11 +01:00
});
2022-08-01 22:40:45 +02:00
wavesurfer.on("region-dblclick", (region: UpRegion, _ev: MouseEvent) => {
2023-09-03 10:39:07 +02:00
dbg("wavesurfer region-dblclick", region);
2023-03-05 21:01:31 +01:00
2022-03-08 23:24:11 +01:00
currentAnnotation = region;
setTimeout(() => wavesurfer.setCurrentTime(region.start));
});
2022-09-19 22:27:20 +02:00
try {
2022-10-25 21:47:17 +02:00
const peaksReq = await fetch(
2023-08-25 23:35:29 +02:00
`${ api . apiUrl } /thumb/${ address } ?mime=audio& type=json`,
2022-10-25 21:47:17 +02:00
);
2022-09-19 22:27:20 +02:00
const peaks = await peaksReq.json();
2023-05-22 20:57:06 +02:00
wavesurfer.load(`${ api . apiUrl } /raw/${ address } `, peaks.data);
2022-09-19 22:27:20 +02:00
} catch (e) {
2022-10-21 21:39:41 +02:00
console.warn(`Failed to load peaks: ${ e } `);
2023-05-22 20:57:06 +02:00
const entity = await api.fetchEntity(address);
2022-10-21 21:39:41 +02:00
if (
2023-04-23 22:57:36 +02:00
(parseInt(String(entity.get("FILE_SIZE"))) || 0) < 20_000_000 | |
2022-10-21 21:39:41 +02:00
confirm(
2022-10-25 21:47:17 +02:00
$i18n.t(
2023-08-25 23:35:29 +02:00
"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?",
),
2022-10-21 21:39:41 +02:00
)
) {
console.warn(
2023-08-25 23:35:29 +02:00
`Failed to load peaks, falling back to client-side render...`,
2022-10-21 21:39:41 +02:00
);
2023-05-22 20:57:06 +02:00
wavesurfer.load(`${ api . apiUrl } /raw/${ address } `);
2022-10-21 21:39:41 +02:00
}
2022-09-19 22:27:20 +02:00
}
2023-03-07 19:42:55 +01:00
const drawBufferThrottled = throttle(() => wavesurfer.drawBuffer(), 200);
const resizeObserver = new ResizeObserver((_entries) => {
drawBufferThrottled();
});
resizeObserver.observe(rootEl);
2022-02-14 14:57:35 +01:00
});
2022-02-14 13:27:51 +01:00
< / script >
2023-03-07 19:42:55 +01:00
< div class = "audio" class:editable bind:this = { rootEl } >
2022-02-14 14:57:35 +01:00
{ #if ! loaded }
< Spinner centered / >
2022-02-14 13:27:51 +01:00
{ /if }
2022-02-17 17:27:52 +01:00
{ #if loaded }
< header >
2023-09-05 20:57:01 +02:00
< 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 >
2022-02-17 17:27:52 +01:00
< / header >
{ /if }
2022-02-17 17:33:31 +01:00
< div
class="wavesurfer-timeline"
bind:this={ timelineEl }
class:hidden={ ! detail }
/>
2022-02-14 14:57:35 +01:00
< div class = "wavesurfer" bind:this = { containerEl } / >
2022-03-08 23:24:11 +01:00
{ #if currentAnnotation }
2023-09-05 20:57:01 +02:00
< LabelBorder >
< span slot = "header" > { $i18n . t ( "Annotation" )} </ span >
2023-03-05 21:01:31 +01:00
{ #if currentAnnotation . attributes [ "upend-address" ]}
< UpObject
link
address={ currentAnnotation . attributes [ "upend-address" ]}
/>
{ /if }
2022-03-08 23:24:11 +01:00
< div class = "baseControls" >
< div class = "regionControls" >
< div class = "start" >
Start: < input
type="number"
value={ Math . round ( currentAnnotation . start * 100 ) / 100 }
2022-03-10 20:35:29 +01:00
disabled={ ! editable }
2022-03-08 23:24:11 +01:00
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 }
2022-03-10 20:35:29 +01:00
disabled={ ! editable }
2022-03-08 23:24:11 +01:00
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 }
2022-03-10 20:35:29 +01:00
disabled={ ! editable }
2022-03-08 23:24:11 +01:00
on:input={( ev ) => {
currentAnnotation.update({ color : ev.currentTarget.value } );
updateAnnotation(currentAnnotation);
}}
/>
< / div >
< / div >
2022-03-10 20:35:29 +01:00
{ #if editable }
< div class = "existControls" >
2023-09-07 21:12:43 +02:00
< IconButton
outline
name="trash"
on:click={() => currentAnnotation . remove ()}
/>
2022-03-10 20:35:29 +01:00
<!-- <div class="button">
2022-03-08 23:24:11 +01:00
< Icon name = "check" / >
< / div > -->
2022-03-10 20:35:29 +01:00
< / div >
{ /if }
2022-03-08 23:24:11 +01:00
< / div >
< div class = "content" >
{ #key currentAnnotation }
< Selector
2023-11-17 19:20:31 +01:00
types={[ "String" , "Address" ]}
initial={ currentAnnotation . data }
2022-03-10 20:35:29 +01:00
disabled={ ! editable }
2022-03-08 23:24:11 +01:00
on:input={( ev ) => {
currentAnnotation.update({ data : ev.detail } );
updateAnnotation(currentAnnotation);
}}
/>
{ /key }
< / div >
2023-09-05 20:57:01 +02:00
< / LabelBorder >
2022-03-08 23:24:11 +01:00
{ /if }
2022-02-14 13:27:51 +01:00
< / div >
< style lang = "scss" >
2022-03-08 23:24:11 +01:00
@use "../../../styles/colors";
2022-02-14 13:27:51 +01:00
.audio {
2022-02-14 14:57:35 +01:00
width: 100%;
2022-02-14 13:27:51 +01:00
}
2022-02-17 17:27:52 +01:00
header {
display: flex;
2023-09-05 20:57:01 +02:00
justify-content: space-between;
2022-02-17 17:27:52 +01:00
& > * {
flex-basis: 50%;
}
.zoom {
display: flex;
2022-03-08 23:24:11 +01:00
align-items: baseline;
2022-02-17 17:27:52 +01:00
input {
flex-grow: 1;
2022-02-17 17:33:31 +01:00
margin: 0 0.5em 1em 0.5em;
2022-02-17 17:27:52 +01:00
}
}
}
2022-02-17 17:33:31 +01:00
2023-09-05 20:57:01 +02:00
.baseControls,
.content {
margin: 0.5em 0;
}
2022-03-08 23:24:11 +01:00
2023-09-05 20:57:01 +02:00
.baseControls,
.regionControls,
.existControls {
display: flex;
gap: 0.5em;
}
2022-03-08 23:24:11 +01:00
2023-09-05 20:57:01 +02:00
.baseControls {
justify-content: space-between;
}
2022-03-08 23:24:11 +01:00
2023-09-05 20:57:01 +02:00
.regionControls div {
display: flex;
align-items: center;
gap: 0.25em;
}
2022-03-08 23:24:11 +01:00
2023-09-05 20:57:01 +02:00
input[type="number"] {
width: 6em;
2022-03-08 23:24:11 +01:00
}
2022-02-17 17:33:31 +01:00
.hidden {
display: none;
}
2022-03-08 23:24:11 +01:00
2023-03-05 21:01:31 +01:00
:global(.audio:not(.editable) .wavesurfer-handle) {
display: none;
}
2022-03-08 23:24:11 +01:00
:global(.wavesurfer-handle) {
background: var(--foreground-lightest) !important;
}
:global(.wavesurfer-region) {
opacity: 0.5;
}
2022-02-14 13:27:51 +01:00
< / style >