extract draggable list to own component, avoid having whole file download in memory
Mwebclient/src/App.tsx
@@ -11,11 +11,11 @@ import { getSrc } from "./offline";
import { MediaFormat } from "music-server-shared/types";
import PlaylistManager from "./components/PlaylistManager";
//TODO: Rework FileListing to be a Map, update offline structures as well, paths as string[]
//TODO: remember scroll position for parent directories
//TODO: better touch controls?
//TODO: Add documentation to functions
//TODO: allow syncing playlists to server
//TODO: Rework FileListing to be a Map, update offline structures as well
//TODO: button to show metadata
//TODO: show cover of playing item
//TODO: delete confirmation?
Awebclient/src/components/DraggableList.tsx
@@ -0,0 +1,184 @@
import { type Accessor, type Setter, createSignal, onMount, onCleanup, type JSX, For, createMemo } from "solid-js";
type DropPosition = "before" | "after" | null;
interface DraggableListProps<T> {
items: Accessor<T[]>;
setItems: Setter<T[]>;
children: (
item: T,
index: number,
handlers: {
dragHandlers: {
draggable: boolean;
onDragStart: (e: DragEvent) => void;
onTouchStart: (e: TouchEvent) => void;
};
isDragging: Accessor<boolean>;
dropPosition: Accessor<DropPosition>;
},
) => JSX.Element;
}
export function DraggableList<T>(props: DraggableListProps<T>) {
const [draggingItem, setDraggingItem] = createSignal<T | null>(null);
const [dragStartIndex, setDragStartIndex] = createSignal<number | null>(null);
const [dragOverIndex, setDragOverIndex] = createSignal<number | null>(null);
let listRef!: HTMLDivElement;
let rafId: number | null = null;
let lastClientY = 0;
let scrollInterval: undefined | number;
function scheduleUpdate(clientY: number) {
lastClientY = clientY;
if (rafId != null) return;
rafId = requestAnimationFrame(() => {
updateDragOverIndex(lastClientY);
rafId = null;
});
}
function startAutoScrollLoop() {
function step() {
const rect = listRef.getBoundingClientRect();
const edgeThreshold = 75;
const scrollAmount = (distance: number) => Math.min(15, 1 + (distance / edgeThreshold) * 5);
if (lastClientY < rect.top + edgeThreshold) {
const distance = rect.top + edgeThreshold - lastClientY;
listRef.scrollTop -= scrollAmount(distance);
} else if (lastClientY > rect.bottom - edgeThreshold) {
const distance = lastClientY - (rect.bottom - edgeThreshold);
listRef.scrollTop += scrollAmount(distance);
}
scheduleUpdate(lastClientY);
}
scrollInterval = setInterval(step, 10);
}
function stopAutoScrollLoop() {
clearInterval(scrollInterval);
scrollInterval = undefined;
}
function moveDraggedItem() {
const items = props.items();
const movedItem = draggingItem();
const startIdx = dragStartIndex();
const dropIdx = dragOverIndex();
if (movedItem && startIdx != null && dropIdx != null && startIdx !== dropIdx) {
const newItems = [...items];
newItems.splice(startIdx, 1);
const adjustedIndex = startIdx < dropIdx ? dropIdx - 1 : dropIdx;
newItems.splice(adjustedIndex, 0, movedItem);
props.setItems(newItems);
}
}
function endDrag(move: boolean) {
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
stopAutoScrollLoop();
if (move) moveDraggedItem();
setDraggingItem(null);
setDragStartIndex(null);
setDragOverIndex(null);
}
function updateDragOverIndex(clientY: number) {
const items = Array.from(listRef.querySelectorAll(".draggable-item")) as HTMLElement[];
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
setDragOverIndex(i);
return;
}
}
setDragOverIndex(items.length);
}
function handleDragStart(item: T, index: number, dragElement: HTMLElement | null, e: DragEvent) {
if (!dragElement) return;
setDraggingItem(() => item);
setDragStartIndex(index);
if (!e.dataTransfer) return;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setDragImage(dragElement, dragElement.offsetWidth / 20, dragElement.offsetHeight / 2);
console.log(e.dataTransfer);
}
function handleTouchStart(item: T, index: number, e: TouchEvent) {
setDraggingItem(() => item);
setDragStartIndex(index);
lastClientY = e.touches[0].clientY;
}
function handleTouchMove(e: TouchEvent) {
if (!draggingItem()) return;
if (!scrollInterval) startAutoScrollLoop();
e.preventDefault();
scheduleUpdate(e.touches[0].clientY);
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
scheduleUpdate(e.clientY);
if (!e.dataTransfer) return;
e.dataTransfer.dropEffect = "move";
}
function handleDrop(e: DragEvent) {
e.preventDefault();
endDrag(true);
}
onMount(() => {
const cancelDrag = () => endDrag(false);
const doDrag = () => endDrag(true);
listRef.addEventListener("touchmove", handleTouchMove, { passive: false });
listRef.addEventListener("touchend", doDrag);
listRef.addEventListener("touchcancel", cancelDrag);
onCleanup(() => {
listRef?.removeEventListener("touchmove", handleTouchMove);
listRef?.removeEventListener("touchend", doDrag);
listRef?.removeEventListener("touchcancel", cancelDrag);
stopAutoScrollLoop();
});
});
return (
<div
class="draggable-list"
ref={listRef}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={() => endDrag(true)}
>
<For each={props.items()}>
{(item, index) => {
const isDragging = createMemo(() => draggingItem === item);
const dropPosition = createMemo(() => {
const overIdx = dragOverIndex();
return overIdx === index() ? "before" : overIdx === index() + 1 ? "after" : null;
});
return props.children(item, index(), {
dragHandlers: {
draggable: true,
onDragStart: (e) => handleDragStart(item, index(), (e.currentTarget as HTMLElement)?.parentElement, e),
onTouchStart: (e) => handleTouchStart(item, index(), e),
},
isDragging: isDragging,
dropPosition: dropPosition,
});
}}
</For>
<div class="drop-area-end" />
</div>
);
}
Mwebclient/src/components/Playlist.css
@@ -11,6 +11,55 @@
justify-content: end;
}
}
& > .draggable-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
& > .playlist-item {
padding: 0.5rem;
background-color: var(--color-c);
display: flex;
align-items: center;
gap: 1.5rem;
border-width: 0;
border-color: var(--color-d);
div {
display: flex;
align-items: center;
}
span {
flex-grow: 1;
}
&.active {
background-color: var(--color-c-highlight);
font-weight: bold;
}
&:not(.drop-target-top-border):not(.drop-target-bottom-border):hover {
background-color: var(--color-c-highlight);
}
&.drop-target-top-border,
&.drop-target-bottom-border {
border-style: solid;
}
&.drop-target-top-border {
border-top-width: 2px;
}
&.drop-target-bottom-border {
border-bottom-width: 2px;
}
}
}
}
.playlist-header {
@@ -36,61 +85,11 @@
}
}
.playlist {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.playlist-item {
padding: 0.5rem;
background-color: var(--color-c);
display: flex;
align-items: center;
gap: 1.5rem;
border-width: 0;
border-color: var(--color-d);
div {
display: flex;
align-items: center;
}
span {
flex-grow: 1;
}
&.active {
background-color: var(--color-c-highlight);
font-weight: bold;
}
&:not(.drop-target-top-border):not(.drop-target-bottom-border):hover {
background-color: var(--color-c-highlight);
}
}
.playlist-drag-btn {
cursor: grab;
height: 100%;
}
.playlist-item.drop-target-top-border,
.playlist-item.drop-target-bottom-border {
border-style: solid;
}
.playlist-item.drop-target-top-border {
border-top-width: 2px;
}
.playlist-item.drop-target-bottom-border {
border-bottom-width: 2px;
}
.drop-area-end {
flex-grow: 1;
min-height: 2rem;
Mwebclient/src/components/Playlist.tsx
@@ -1,10 +1,10 @@
import { type Accessor, For, type Setter, createSignal, onMount, onCleanup, createEffect } from "solid-js";
import type { Accessor, Setter } from "solid-js";
import "./Playlist.css";
import type { PlayerState, PlaylistItem } from "../types";
import { formatTime, toast } from "../utils";
import { ClearSVG, DownloadSVG, DragSVG, SaveSVG } from "./SVG";
import Toastify from "toastify-js";
import { savePlaylist } from "../offline";
import { DraggableList } from "./DraggableList";
interface PlaylistProps {
currentDir: Accessor<string>;
@@ -17,52 +17,6 @@ interface PlaylistProps {
}
export default function Playlist(props: PlaylistProps) {
const [draggingItem, setDraggingItem] = createSignal<PlaylistItem | null>(null);
const [dragStartIndex, setDragStartIndex] = createSignal<number | null>(null);
const [dragOverIndex, setDragOverIndex] = createSignal<number | null>(null);
let playlistRef!: HTMLDivElement;
//used for throttling drag updates
let rafId: number | null = null;
function scheduleUpdate(clientY: number) {
lastClientY = clientY;
if (rafId != null) return;
rafId = requestAnimationFrame(() => {
updateDragOverIndex(lastClientY);
rafId = null;
});
}
// For auto-scroll during drag
let lastClientY = 0;
let scrollInterval: undefined | number;
function startAutoScrollLoop() {
function step() {
const rect = playlistRef.getBoundingClientRect();
const edgeThreshold = 75;
// Scroll faster the closer to the edge
const scrollAmount = (distance: number) => Math.min(15, 1 + (distance / edgeThreshold) * 5);
if (lastClientY < rect.top + edgeThreshold) {
const distance = rect.top + edgeThreshold - lastClientY;
playlistRef.scrollTop -= scrollAmount(distance);
} else if (lastClientY > rect.bottom - edgeThreshold) {
const distance = lastClientY - (rect.bottom - edgeThreshold);
playlistRef.scrollTop += scrollAmount(distance);
}
scheduleUpdate(lastClientY);
}
scrollInterval = setInterval(step, 10);
}
function stopAutoScrollLoop() {
clearInterval(scrollInterval);
scrollInterval = undefined;
}
function removeFromPlaylist(id: string) {
const currentTrack = props.playerState().currentTrack;
const newPlaylist = props.playlist().filter((i) => i.id !== id);
@@ -75,75 +29,6 @@ export default function Playlist(props: PlaylistProps) {
props.setPlaylist(newPlaylist);
}
function moveDraggedItem() {
const playlist = props.playlist();
const movedItem = draggingItem();
const startIdx = dragStartIndex();
const dropIdx = dragOverIndex();
if (movedItem && startIdx != null && dropIdx != null && startIdx !== dropIdx) {
const newItems = [...playlist];
newItems.splice(startIdx, 1);
const adjustedIndex = startIdx < dropIdx ? dropIdx - 1 : dropIdx;
newItems.splice(adjustedIndex, 0, movedItem);
props.setPlaylist(newItems);
}
}
function endDrag(move: boolean) {
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
stopAutoScrollLoop();
if (move) moveDraggedItem();
setDraggingItem(null);
setDragStartIndex(null);
setDragOverIndex(null);
}
function updateDragOverIndex(clientY: number) {
const items = Array.from(playlistRef.querySelectorAll(".playlist-item")) as HTMLElement[];
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
setDragOverIndex(i);
return;
}
}
setDragOverIndex(items.length);
}
function onDragStart(item: PlaylistItem, index: number, dragElement: HTMLElement, e: DragEvent) {
setDraggingItem(item);
setDragStartIndex(index);
if (!e.dataTransfer) return;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setDragImage(dragElement, dragElement.offsetWidth / 20, dragElement.offsetHeight / 2);
}
function onDragOver(e: DragEvent) {
e.preventDefault();
scheduleUpdate(e.clientY);
if (!e.dataTransfer) return;
e.dataTransfer.dropEffect = "move";
}
function onDrop(e: DragEvent) {
e.preventDefault();
endDrag(true);
}
function onTouchStart(item: PlaylistItem, index: number, e: TouchEvent) {
setDraggingItem(item);
setDragStartIndex(index);
lastClientY = e.touches[0].clientY;
}
function onTouchMove(e: TouchEvent) {
if (!draggingItem()) return;
if (!scrollInterval) startAutoScrollLoop();
e.preventDefault();
scheduleUpdate(e.touches[0].clientY);
}
function browserDownload(name: string, path: string) {
const a = document.createElement("a");
a.href = `/download/${path}`;
@@ -152,20 +37,18 @@ export default function Playlist(props: PlaylistProps) {
a.click();
document.body.removeChild(a);
}
async function browserDownloadAll() {
try {
toast("Started playlist download", "info");
const playlist = props.playlist();
const filePaths = playlist.map((item) => {
return item.path;
});
const filePaths = playlist.map((item) => item.path);
const form = document.createElement("form");
form.method = "POST";
form.action = "/download-playlist";
const input = document.createElement("input");
input.type = "hidden";
input.name = "files";
//needed to support stream download with complex body in request
input.value = btoa(encodeURIComponent(JSON.stringify(filePaths)));
form.appendChild(input);
document.body.appendChild(form);
@@ -176,21 +59,6 @@ export default function Playlist(props: PlaylistProps) {
}
}
onMount(() => {
const cancelDrag = () => endDrag(false);
const doDrag = () => endDrag(true);
playlistRef.addEventListener("touchmove", onTouchMove, { passive: false });
playlistRef.addEventListener("touchend", doDrag);
playlistRef.addEventListener("touchcancel", cancelDrag);
onCleanup(() => {
playlistRef?.removeEventListener("touchmove", onTouchMove);
playlistRef?.removeEventListener("touchend", doDrag);
playlistRef?.removeEventListener("touchcancel", cancelDrag);
stopAutoScrollLoop();
});
});
let dialogElement!: HTMLDialogElement;
let playlistNameInput!: HTMLInputElement;
let allowOverwriteCheckbox!: HTMLInputElement;
@@ -245,59 +113,50 @@ export default function Playlist(props: PlaylistProps) {
<ClearSVG />
</button>
</div>
<div class="playlist" ref={playlistRef} onDragOver={onDragOver} onDrop={onDrop} onDragEnd={() => endDrag(true)}>
<For each={props.playlist()}>
{(item, index) => {
const isActive = () => props.playerState().currentTrack?.id === item.id;
const isDragging = () => draggingItem()?.id === item.id;
const droppingBefore = () => dragOverIndex() === index() && dragStartIndex() !== index();
const droppingAfter = () => dragOverIndex() === index() + 1 && dragStartIndex() !== index();
return (
<div
classList={{
"playlist-item": true,
active: isActive(),
"drop-target-top-border": droppingBefore(),
"drop-target-bottom-border": droppingAfter(),
<DraggableList<PlaylistItem> items={props.playlist} setItems={props.setPlaylist}>
{(item, _, { dragHandlers, isDragging, dropPosition }) => {
const isActive = () => props.playerState().currentTrack?.id === item.id;
return (
<div
classList={{
"playlist-item": true,
"draggable-item": true,
active: isActive(),
"drop-target-top-border": dropPosition() === "before",
"drop-target-bottom-border": dropPosition() === "after",
}}
style={{ opacity: isDragging() ? 0.5 : 1 }}
onClick={() => props.playTrack(item)}
>
<div class="playlist-drag-btn" {...dragHandlers}>
<DragSVG />
</div>
<span>{item.name}</span>
<div class="spacer" />
{formatTime(item.duration)}
<button
title={"Download this file in its original format to your file system"}
onClick={(e) => {
e.stopPropagation();
browserDownload(item.name, item.path);
}}
style={{ opacity: isDragging() ? 0.5 : 1 }}
onClick={() => props.playTrack(item)}
>
<div
class="playlist-drag-btn"
draggable="true"
onDragStart={(e) => onDragStart(item, index(), e.currentTarget.parentElement as HTMLElement, e)}
onTouchStart={(e) => onTouchStart(item, index(), e)}
>
<DragSVG />
</div>
<span>{item.name}</span>
<div class="spacer" />
{formatTime(item.duration)}
<button
title={"Download this file in its original format to your file system"}
onClick={(e) => {
e.stopPropagation();
browserDownload(item.name, item.path);
}}
>
<DownloadSVG />
</button>
<button
title={`Remove ${item.name}`}
onClick={(e) => {
e.stopPropagation();
removeFromPlaylist(item.id);
}}
>
<ClearSVG />
</button>
</div>
);
}}
</For>
<div class="drop-area-end" />
</div>
<DownloadSVG />
</button>
<button
title={`Remove ${item.name}`}
onClick={(e) => {
e.stopPropagation();
removeFromPlaylist(item.id);
}}
>
<ClearSVG />
</button>
</div>
);
}}
</DraggableList>
</div>
);
}
Mwebclient/src/offline.ts
@@ -180,7 +180,7 @@ export async function getCover(dir: string, offline: boolean) {
return undefined;
}
const blob = await response.blob();
db.put(CoverStore, { path: dir, data: new Uint8Array(await blob.arrayBuffer()) });
db.put(CoverStore, { path: dir, data: blob });
return URL.createObjectURL(blob);
}
@@ -192,7 +192,7 @@ export const clearCoverCache = async (path: string) => {
export async function getSrc(path: string, options: AppOptions) {
const db = await openDB();
const dbResult = (await db.get(FileBlobStore, path)) as StoredFileBlob;
if (dbResult) return URL.createObjectURL(new Blob([dbResult.data]));
if (dbResult) return URL.createObjectURL(dbResult.data);
if (options.enableTranscoding) {
return `${joinPath("/transcode", path)}?bitrate=${options.bitrate}&format=${options.format}`;
@@ -263,11 +263,12 @@ async function downloadFile(
if (typeof progress === "object" && "bytes" in progress) return; // download already in progress
const db = await openDB();
let mimeType: string | undefined;
try {
setProgress({ bytes: 0 });
downloadSemaphore.setMaxConcurrency(options.enableTranscoding ? options.maxTranscodes : options.maxDownloads);
const chunks: Uint8Array[] = [];
let blob: Blob;
try {
await downloadSemaphore.acquire();
let res: Response;
@@ -276,15 +277,18 @@ async function downloadFile(
} else {
res = await fetch(joinPath("/download", path));
}
//let the browser do the actual download
const blobPromise = res.clone().blob();
if (!res.ok || !res.body) throw res.statusText;
//we just iterate over the chunks to get the current progress
const reader = res.body.getReader();
mimeType = res.headers.get("content-type") ?? undefined;
let received = 0;
let lastUpdate = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
const now = Date.now();
@@ -292,15 +296,14 @@ async function downloadFile(
lastUpdate = now;
setProgress({ bytes: received });
}
blob = await blobPromise;
} finally {
downloadSemaphore.release();
}
const data = concatChunks(chunks);
await db.put(FileBlobStore, {
path,
data,
data: blob,
} as StoredFileBlob);
await db.put(FileMetadataStore, {
path,
@@ -351,7 +354,7 @@ export async function download(
interface StoredFileBlob {
path: string;
data: Uint8Array;
data: Blob;
}
interface StoredFileMetadata {
@@ -361,7 +364,7 @@ interface StoredFileMetadata {
interface StoredCover {
path: string;
data: Uint8Array | null;
data: Blob | null;
}
//separate blobs and metadata, so we don't have to load all file contents at startup, which would consume huge amounts of ram, and be very slow
@@ -455,15 +458,3 @@ function wrapDB(db: IDBDatabase): DatabaseWrapper {
}),
};
}
function concatChunks(chunks: Uint8Array[]): Uint8Array {
let total = 0;
for (const c of chunks) total += c.length;
const result = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
result.set(c, offset);
offset += c.length;
}
return result;
}