Refresh processing tracks live, apply publish and delete optimistically and guard unsaved album edits

This commit is contained in:
2026-09-25 01:52:27 +02:00
parent 9f592cfdb8
commit f3ed68856c
8 changed files with 266 additions and 53 deletions
@@ -11,9 +11,10 @@
interface Props {
tracks: LibraryTrack[];
memberIds: string[];
onchange?: () => void;
}
let { tracks, memberIds }: Props = $props();
let { tracks, memberIds, onchange }: Props = $props();
const [send, receive] = transferBetweenLists();
@@ -28,10 +29,12 @@
function add(track: LibraryTrack) {
currentIds = [...currentIds, track.id];
onchange?.();
}
function remove(track: LibraryTrack) {
currentIds = currentIds.filter((id) => id !== track.id);
onchange?.();
}
</script>
@@ -1,4 +1,5 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import type { Album, LibraryTrack } from '$lib/types';
import { albumCover } from '$lib/utils/hrefs';
import AlbumForm from '$lib/components/ui/forms/AlbumForm.svelte';
@@ -20,8 +21,20 @@
const isNew = $derived(album === null);
let confirmingDelete = $state(false);
let dirty = $state(false);
beforeNavigate((navigation) => {
if (!dirty || confirmingDelete) return;
if (!confirm('Tienes cambios sin guardar. ¿Salir sin guardar?')) navigation.cancel();
});
function saved() {
dirty = false;
onClose();
}
</script>
<div oninput={() => (dirty = true)} role="presentation">
<AlbumForm
action="?/saveAlbum"
layout="split"
@@ -39,11 +52,11 @@
submitLabel={isNew ? 'Crear álbum' : 'Guardar cambios'}
submittingLabel={isNew ? 'Creando…' : 'Guardando…'}
onCancel={onClose}
onSuccess={onClose}
onSuccess={saved}
>
{#snippet aside()}
<input type="hidden" name="albumId" value={album?.id ?? ''} />
<AlbumTrackPicker {tracks} {memberIds} />
<AlbumTrackPicker {tracks} {memberIds} onchange={() => (dirty = true)} />
{/snippet}
{#snippet footer()}
{#if album}
@@ -53,6 +66,7 @@
{/if}
{/snippet}
</AlbumForm>
</div>
{#if album}
<ConfirmDialog
@@ -1,13 +1,16 @@
<script lang="ts" module>
const FADE = { duration: 200 };
const SEARCH_DEBOUNCE_MS = 300;
const REFRESH_MIN_MS = 3000;
const REFRESH_MAX_MS = 30000;
</script>
<script lang="ts">
import { tick } from 'svelte';
import { tick, untrack } from 'svelte';
import type { SubmitFunction } from '@sveltejs/kit';
import { fade } from 'svelte/transition';
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { goto, invalidateAll } from '$app/navigation';
import Upload from '@lucide/svelte/icons/upload';
import Music from '@lucide/svelte/icons/music';
import SearchX from '@lucide/svelte/icons/search-x';
@@ -62,14 +65,103 @@
let publishValue = $state('true');
let timer: ReturnType<typeof setTimeout> | undefined;
let hiddenIds = $state<string[]>([]);
let overrides = $state<Record<string, boolean>>({});
let deleteError = $state('');
let announcement = $state('');
let lastKey = '';
let previousProcessing = untrack(() => counts.processing);
$effect(() => {
const key = `${query}|${filter}|${tracks.totalItemCount}`;
untrack(() => {
if (key !== lastKey) {
lastKey = key;
list.reset(tracks);
} else {
list.merge(tracks.items);
}
});
search = query;
const ids = new Set(list.items.map((track) => track.id));
});
const items = $derived(
list.items
.filter((track) => !hiddenIds.includes(track.id))
.map((track) =>
track.id in overrides ? { ...track, published: overrides[track.id] } : track
)
);
$effect(() => {
const ids = new Set(items.map((track) => track.id));
if (selectedIds.some((id) => !ids.has(id)))
selectedIds = selectedIds.filter((id) => ids.has(id));
});
const items = $derived(list.items);
const processing = $derived(
counts.processing > 0 ||
list.items.some((track) => track.status === 'Pending' || track.status === 'Processing')
);
$effect(() => {
if (!processing) return;
let delay = REFRESH_MIN_MS;
let stopped = false;
let handle: ReturnType<typeof setTimeout>;
const refresh = async () => {
if (document.visibilityState === 'visible') await invalidateAll();
delay = Math.min(delay * 1.5, REFRESH_MAX_MS);
if (!stopped) handle = setTimeout(refresh, delay);
};
handle = setTimeout(refresh, delay);
return () => {
stopped = true;
clearTimeout(handle);
};
});
$effect(() => {
const now = counts.processing;
if (now < previousProcessing) {
announcement = `${plural(previousProcessing - now, 'canción terminó', 'canciones terminaron')} de procesarse.`;
}
previousProcessing = now;
});
const deleteSubmit: SubmitFunction = () => {
const ids = pendingDelete.map((track) => track.id);
hiddenIds = [...hiddenIds, ...ids];
deleteError = '';
return async ({ result, update }) => {
if (result.type === 'failure') {
hiddenIds = hiddenIds.filter((id) => !ids.includes(id));
deleteError =
(result.data as { message?: string } | undefined)?.message ??
'No se pudieron borrar las canciones.';
await invalidateAll();
return;
}
player.removeTracks(ids);
selectedIds = [];
pendingDelete = [];
await update({ reset: false });
hiddenIds = hiddenIds.filter((id) => !ids.includes(id));
};
};
const publishSubmit: SubmitFunction = ({ formData }) => {
const ids = String(formData.get('trackIds') ?? '').split(',');
const value = formData.get('isPublished') === 'true';
overrides = { ...overrides, ...Object.fromEntries(ids.map((id) => [id, value])) };
return async ({ update }) => {
await update({ reset: false });
overrides = {};
selectedIds = [];
pendingUnpublish = [];
};
};
const hasFilters = $derived(query !== '' || filter !== 'all');
const counters = $derived(filterCounts(counts));
const filterOptions = $derived(
@@ -119,6 +211,8 @@
}
</script>
<p class="sr-only" aria-live="polite">{announcement}</p>
{#if errorMessage}
<Alert tone="danger" class="mt-4">{errorMessage}</Alert>
{:else if notice}
@@ -130,11 +224,7 @@
method="POST"
action="?/setPublished"
class="hidden"
use:enhance={() =>
async ({ update }) => {
await update({ reset: false });
selectedIds = [];
}}
use:enhance={publishSubmit}
>
<input type="hidden" name="trackIds" value={publishIds} />
<input type="hidden" name="isPublished" value={publishValue} />
@@ -218,11 +308,16 @@
<ConfirmDialog
open={pendingDelete.length > 0}
onClose={() => (pendingDelete = [])}
onClose={() => {
pendingDelete = [];
deleteError = '';
}}
title="¿Borrar {pendingTitle(pendingDelete)}?"
description="Esta acción no se puede deshacer: se eliminarán de tu biblioteca, de tus álbumes y de las playlists donde estén."
confirmLabel="Borrar"
action="?/deleteTracks"
submit={deleteSubmit}
error={deleteError}
fields={{ trackIds: pendingDelete.map((track) => track.id).join(',') }}
/>
@@ -233,5 +328,6 @@
description="Dejarán de verse en álbumes, playlists, búsquedas y perfiles. Solo tú podrás verlas y reproducirlas."
confirmLabel="Despublicar"
action="?/setPublished"
submit={publishSubmit}
fields={{ trackIds: pendingUnpublish.map((track) => track.id).join(','), isPublished: 'false' }}
/>
@@ -1,4 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import Dialog from './Dialog.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
@@ -10,19 +13,44 @@
confirmLabel: string;
action: string;
fields?: Record<string, string>;
submit?: SubmitFunction;
error?: string;
}
let { open, onClose, title, description, confirmLabel, action, fields = {} }: Props = $props();
let {
open,
onClose,
title,
description,
confirmLabel,
action,
fields = {},
submit,
error
}: Props = $props();
</script>
<Dialog {open} {onClose} {title} {description}>
{#snippet actions()}
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
<form method="POST" {action}>
{#snippet body()}
{#each Object.entries(fields) as [name, value] (name)}
<input type="hidden" {name} {value} />
{/each}
<Button type="submit" variant="danger">{confirmLabel}</Button>
{/snippet}
<Dialog {open} {onClose} {title} {description}>
{#if error}
<Alert tone="danger" class="mt-4">{error}</Alert>
{/if}
{#snippet actions()}
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
{#if submit}
<form method="POST" {action} use:enhance={submit}>
{@render body()}
</form>
{:else}
<form method="POST" {action}>
{@render body()}
</form>
{/if}
{/snippet}
</Dialog>
+30 -1
View File
@@ -5,7 +5,15 @@ import { readStorage, writeStorage } from '$lib/utils/storage';
import { ListenTracker } from './listenTracker';
import { mediaSession } from './mediaSession';
import { ProgressClock } from './progressClock';
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
import {
append,
insertNext,
isLastIndex,
move,
nextIndex,
previousIndex,
removeTracks
} from './queue';
import { resolveStream } from './stream';
const RECENT_LIMIT = 15;
@@ -281,6 +289,27 @@ class PlayerState {
this.tracks = move(this.tracks, from, insertAt);
}
removeTracks(ids: string[]) {
const gone = new Set(ids);
if (!this.tracks.some((track) => gone.has(track.id))) return;
const result = removeTracks(this.tracks, gone, this.#index());
this.tracks = result.queue;
if (!result.currentRemoved) return;
const next = result.queue[result.nextIndex] ?? result.queue[0];
if (next) {
this.currentId = next.id;
this.#loadCurrent();
return;
}
this.#audio?.pause();
this.#loadToken++;
this.currentId = null;
this.playing = false;
this.loading = false;
this.progress = 0;
mediaSession.setPlaying(false);
}
clearUpcoming() {
this.tracks = this.current ? [this.current] : [];
}
+28 -1
View File
@@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest';
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
import {
append,
insertNext,
isLastIndex,
move,
nextIndex,
previousIndex,
removeTracks
} from './queue';
const t = (id: string) => ({ id });
const ids = (queue: { id: string }[]) => queue.map((item) => item.id);
@@ -83,3 +91,22 @@ describe('isLastIndex', () => {
expect(isLastIndex(3, 1)).toBe(false);
});
});
describe('removeTracks', () => {
it('keeps the queue and current track when others are removed', () => {
const result = removeTracks(['a', 'b', 'c'].map(t), new Set(['a']), 1);
expect(ids(result.queue)).toEqual(['b', 'c']);
expect(result.currentRemoved).toBe(false);
});
it('points at the track that followed the removed current one', () => {
const result = removeTracks(['a', 'b', 'c', 'd'].map(t), new Set(['a', 'c']), 2);
expect(ids(result.queue)).toEqual(['b', 'd']);
expect(result.currentRemoved).toBe(true);
expect(result.nextIndex).toBe(1);
});
it('reports an empty queue when everything is removed', () => {
const result = removeTracks(['a'].map(t), new Set(['a']), 0);
expect(result.queue).toEqual([]);
expect(result.currentRemoved).toBe(true);
});
});
+12
View File
@@ -25,6 +25,18 @@ export function append<T extends Identified>(queue: T[], items: T[]): T[] {
return added.length > 0 ? [...queue, ...added] : queue;
}
export function removeTracks<T extends Identified>(
queue: T[],
ids: ReadonlySet<string>,
currentIndex: number
): { queue: T[]; currentRemoved: boolean; nextIndex: number } {
const remaining = queue.filter((item) => !ids.has(item.id));
const currentRemoved = currentIndex >= 0 && ids.has(queue[currentIndex]?.id ?? '');
const before =
currentIndex < 0 ? 0 : queue.slice(0, currentIndex).filter((i) => !ids.has(i.id)).length;
return { queue: remaining, currentRemoved, nextIndex: before };
}
export function move<T>(queue: T[], from: number, insertAt: number): T[] {
if (from < 0 || from >= queue.length) return queue;
const target = insertAt > from ? insertAt - 1 : insertAt;
@@ -30,6 +30,10 @@ export function createPagedList<T>(
loading = false;
error = false;
},
merge(fresh: T[]) {
const byKey = new Map(fresh.map((item) => [keyOf(item), item]));
items = items.map((item) => byKey.get(keyOf(item)) ?? item);
},
async loadMore() {
if (loading) return;
loading = true;