Refresh processing tracks live, apply publish and delete optimistically and guard unsaved album edits
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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] : [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user