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 { interface Props {
tracks: LibraryTrack[]; tracks: LibraryTrack[];
memberIds: string[]; memberIds: string[];
onchange?: () => void;
} }
let { tracks, memberIds }: Props = $props(); let { tracks, memberIds, onchange }: Props = $props();
const [send, receive] = transferBetweenLists(); const [send, receive] = transferBetweenLists();
@@ -28,10 +29,12 @@
function add(track: LibraryTrack) { function add(track: LibraryTrack) {
currentIds = [...currentIds, track.id]; currentIds = [...currentIds, track.id];
onchange?.();
} }
function remove(track: LibraryTrack) { function remove(track: LibraryTrack) {
currentIds = currentIds.filter((id) => id !== track.id); currentIds = currentIds.filter((id) => id !== track.id);
onchange?.();
} }
</script> </script>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { beforeNavigate } from '$app/navigation';
import type { Album, LibraryTrack } from '$lib/types'; import type { Album, LibraryTrack } from '$lib/types';
import { albumCover } from '$lib/utils/hrefs'; import { albumCover } from '$lib/utils/hrefs';
import AlbumForm from '$lib/components/ui/forms/AlbumForm.svelte'; import AlbumForm from '$lib/components/ui/forms/AlbumForm.svelte';
@@ -20,39 +21,52 @@
const isNew = $derived(album === null); const isNew = $derived(album === null);
let confirmingDelete = $state(false); 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> </script>
<AlbumForm <div oninput={() => (dirty = true)} role="presentation">
action="?/saveAlbum" <AlbumForm
layout="split" action="?/saveAlbum"
{artist} layout="split"
showType {artist}
initialType={album ? (album.type.toLowerCase() as 'album' | 'ep' | 'single') : 'album'} showType
initialTitle={album?.title} initialType={album ? (album.type.toLowerCase() as 'album' | 'ep' | 'single') : 'album'}
initialDescription={album?.description ?? ''} initialTitle={album?.title}
initialReleaseYear={album?.releaseYear ?? new Date().getFullYear()} initialDescription={album?.description ?? ''}
titlePlaceholder="Sin título" initialReleaseYear={album?.releaseYear ?? new Date().getFullYear()}
coverFallbackUrl={album ? albumCover(album.id, 'large') : undefined} titlePlaceholder="Sin título"
coverHint="Las canciones del álbum usan esta portada." coverFallbackUrl={album ? albumCover(album.id, 'large') : undefined}
requireCover={isNew} coverHint="Las canciones del álbum usan esta portada."
{formMessage} requireCover={isNew}
submitLabel={isNew ? 'Crear álbum' : 'Guardar cambios'} {formMessage}
submittingLabel={isNew ? 'Creando…' : 'Guardando…'} submitLabel={isNew ? 'Crear álbum' : 'Guardar cambios'}
onCancel={onClose} submittingLabel={isNew ? 'Creando…' : 'Guardando…'}
onSuccess={onClose} onCancel={onClose}
> onSuccess={saved}
{#snippet aside()} >
<input type="hidden" name="albumId" value={album?.id ?? ''} /> {#snippet aside()}
<AlbumTrackPicker {tracks} {memberIds} /> <input type="hidden" name="albumId" value={album?.id ?? ''} />
{/snippet} <AlbumTrackPicker {tracks} {memberIds} onchange={() => (dirty = true)} />
{#snippet footer()} {/snippet}
{#if album} {#snippet footer()}
<Button variant="danger" size="sm" onclick={() => (confirmingDelete = true)}> {#if album}
Eliminar álbum <Button variant="danger" size="sm" onclick={() => (confirmingDelete = true)}>
</Button> Eliminar álbum
{/if} </Button>
{/snippet} {/if}
</AlbumForm> {/snippet}
</AlbumForm>
</div>
{#if album} {#if album}
<ConfirmDialog <ConfirmDialog
@@ -1,13 +1,16 @@
<script lang="ts" module> <script lang="ts" module>
const FADE = { duration: 200 }; const FADE = { duration: 200 };
const SEARCH_DEBOUNCE_MS = 300; const SEARCH_DEBOUNCE_MS = 300;
const REFRESH_MIN_MS = 3000;
const REFRESH_MAX_MS = 30000;
</script> </script>
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte'; import { tick, untrack } from 'svelte';
import type { SubmitFunction } from '@sveltejs/kit';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { goto } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import Upload from '@lucide/svelte/icons/upload'; import Upload from '@lucide/svelte/icons/upload';
import Music from '@lucide/svelte/icons/music'; import Music from '@lucide/svelte/icons/music';
import SearchX from '@lucide/svelte/icons/search-x'; import SearchX from '@lucide/svelte/icons/search-x';
@@ -62,14 +65,103 @@
let publishValue = $state('true'); let publishValue = $state('true');
let timer: ReturnType<typeof setTimeout> | undefined; 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(() => { $effect(() => {
list.reset(tracks); const key = `${query}|${filter}|${tracks.totalItemCount}`;
untrack(() => {
if (key !== lastKey) {
lastKey = key;
list.reset(tracks);
} else {
list.merge(tracks.items);
}
});
search = query; search = query;
const ids = new Set(list.items.map((track) => track.id));
selectedIds = selectedIds.filter((id) => ids.has(id));
}); });
const items = $derived(list.items); 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 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 hasFilters = $derived(query !== '' || filter !== 'all');
const counters = $derived(filterCounts(counts)); const counters = $derived(filterCounts(counts));
const filterOptions = $derived( const filterOptions = $derived(
@@ -119,6 +211,8 @@
} }
</script> </script>
<p class="sr-only" aria-live="polite">{announcement}</p>
{#if errorMessage} {#if errorMessage}
<Alert tone="danger" class="mt-4">{errorMessage}</Alert> <Alert tone="danger" class="mt-4">{errorMessage}</Alert>
{:else if notice} {:else if notice}
@@ -130,11 +224,7 @@
method="POST" method="POST"
action="?/setPublished" action="?/setPublished"
class="hidden" class="hidden"
use:enhance={() => use:enhance={publishSubmit}
async ({ update }) => {
await update({ reset: false });
selectedIds = [];
}}
> >
<input type="hidden" name="trackIds" value={publishIds} /> <input type="hidden" name="trackIds" value={publishIds} />
<input type="hidden" name="isPublished" value={publishValue} /> <input type="hidden" name="isPublished" value={publishValue} />
@@ -218,11 +308,16 @@
<ConfirmDialog <ConfirmDialog
open={pendingDelete.length > 0} open={pendingDelete.length > 0}
onClose={() => (pendingDelete = [])} onClose={() => {
pendingDelete = [];
deleteError = '';
}}
title="¿Borrar {pendingTitle(pendingDelete)}?" 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." 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" confirmLabel="Borrar"
action="?/deleteTracks" action="?/deleteTracks"
submit={deleteSubmit}
error={deleteError}
fields={{ trackIds: pendingDelete.map((track) => track.id).join(',') }} 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." description="Dejarán de verse en álbumes, playlists, búsquedas y perfiles. Solo tú podrás verlas y reproducirlas."
confirmLabel="Despublicar" confirmLabel="Despublicar"
action="?/setPublished" action="?/setPublished"
submit={publishSubmit}
fields={{ trackIds: pendingUnpublish.map((track) => track.id).join(','), isPublished: 'false' }} fields={{ trackIds: pendingUnpublish.map((track) => track.id).join(','), isPublished: 'false' }}
/> />
@@ -1,4 +1,7 @@
<script lang="ts"> <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 Dialog from './Dialog.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte';
@@ -10,19 +13,44 @@
confirmLabel: string; confirmLabel: string;
action: string; action: string;
fields?: Record<string, 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> </script>
{#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}> <Dialog {open} {onClose} {title} {description}>
{#if error}
<Alert tone="danger" class="mt-4">{error}</Alert>
{/if}
{#snippet actions()} {#snippet actions()}
<Button variant="secondary" onclick={onClose}>Cancelar</Button> <Button variant="secondary" onclick={onClose}>Cancelar</Button>
<form method="POST" {action}> {#if submit}
{#each Object.entries(fields) as [name, value] (name)} <form method="POST" {action} use:enhance={submit}>
<input type="hidden" {name} {value} /> {@render body()}
{/each} </form>
<Button type="submit" variant="danger">{confirmLabel}</Button> {:else}
</form> <form method="POST" {action}>
{@render body()}
</form>
{/if}
{/snippet} {/snippet}
</Dialog> </Dialog>
+30 -1
View File
@@ -5,7 +5,15 @@ import { readStorage, writeStorage } from '$lib/utils/storage';
import { ListenTracker } from './listenTracker'; import { ListenTracker } from './listenTracker';
import { mediaSession } from './mediaSession'; import { mediaSession } from './mediaSession';
import { ProgressClock } from './progressClock'; 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'; import { resolveStream } from './stream';
const RECENT_LIMIT = 15; const RECENT_LIMIT = 15;
@@ -281,6 +289,27 @@ class PlayerState {
this.tracks = move(this.tracks, from, insertAt); 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() { clearUpcoming() {
this.tracks = this.current ? [this.current] : []; this.tracks = this.current ? [this.current] : [];
} }
+28 -1
View File
@@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest'; 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 t = (id: string) => ({ id });
const ids = (queue: { id: string }[]) => queue.map((item) => item.id); const ids = (queue: { id: string }[]) => queue.map((item) => item.id);
@@ -83,3 +91,22 @@ describe('isLastIndex', () => {
expect(isLastIndex(3, 1)).toBe(false); 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; 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[] { export function move<T>(queue: T[], from: number, insertAt: number): T[] {
if (from < 0 || from >= queue.length) return queue; if (from < 0 || from >= queue.length) return queue;
const target = insertAt > from ? insertAt - 1 : insertAt; const target = insertAt > from ? insertAt - 1 : insertAt;
@@ -30,6 +30,10 @@ export function createPagedList<T>(
loading = false; loading = false;
error = 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() { async loadMore() {
if (loading) return; if (loading) return;
loading = true; loading = true;