diff --git a/web-player/src/lib/components/music/GenrePicker.svelte b/web-player/src/lib/components/music/GenrePicker.svelte new file mode 100644 index 0000000..350b2fb --- /dev/null +++ b/web-player/src/lib/components/music/GenrePicker.svelte @@ -0,0 +1,58 @@ + + + +
+ {#each options as option (option.genre)} + toggle(option.genre)} + > + {option.label} + + {/each} +
+ {#if name} + {#each selected as genre (genre)} + + {/each} + {/if} + {#snippet hint()} +

{help}

+ {selected.length} elegidos + {/snippet} +
diff --git a/web-player/src/lib/components/music/ManageTrackList.svelte b/web-player/src/lib/components/music/ManageTrackList.svelte deleted file mode 100644 index b5b72c4..0000000 --- a/web-player/src/lib/components/music/ManageTrackList.svelte +++ /dev/null @@ -1,143 +0,0 @@ - - - - -
-
- allSelected, toggleAll} /> - Título - {#each COLUMNS as column (column.id)} - - {/each} - - -
-
- {#each tracks as track, i (track.id)} - {@const active = player.currentId === track.id} -
- selected.has(track.id), (checked) => toggle(track.id, checked)} - /> -
onPlay(i)} - class="flex min-w-0 flex-1 text-left" - > - - {#snippet overlay()} - {#if active} - - {/if} - {/snippet} - -
- {#each COLUMNS as column (column.id)} - - {/each} - - {fmtTime(track.duration)} - - onDelete(track)}> - - -
- {/each} -
-
diff --git a/web-player/src/lib/components/music/TrackList.svelte b/web-player/src/lib/components/music/TrackList.svelte index 38e7ac7..d892982 100644 --- a/web-player/src/lib/components/music/TrackList.svelte +++ b/web-player/src/lib/components/music/TrackList.svelte @@ -1,20 +1,34 @@ -
-
- {#if index} - - {/if} - Título - {#each columns as column (column)} - - {/each} - - {#if rowAction} - - {/if} -
-
- {#each tracks as track, i (track.id)} - {@const active = player.currentId === track.id} - -
oncontextmenu(event, track, i) : undefined} + track.id} + gap="lg" + rowClass={(track) => (player.currentId === track.id ? 'surface-active' : 'hover:bg-hover')} + oncontextmenu={oncontextmenu ? (event, track, i) => oncontextmenu(event, track, i) : undefined} +> + {#snippet head()} + + {/snippet} + {#snippet cell(track, column, i)} + {@const active = player.currentId === track.id} + {#if column.id === 'index'} + + {#if active} + + {:else} + {i + 1} +
- {/each} -
-
+ + + {/if} + {/if} + {/snippet} + diff --git a/web-player/src/lib/components/music/TrackStatusBadge.svelte b/web-player/src/lib/components/music/TrackStatusBadge.svelte new file mode 100644 index 0000000..1aa83b7 --- /dev/null +++ b/web-player/src/lib/components/music/TrackStatusBadge.svelte @@ -0,0 +1,16 @@ + + + diff --git a/web-player/src/lib/components/music/TrackTitleCell.svelte b/web-player/src/lib/components/music/TrackTitleCell.svelte new file mode 100644 index 0000000..1fd0492 --- /dev/null +++ b/web-player/src/lib/components/music/TrackTitleCell.svelte @@ -0,0 +1,33 @@ + + +
+ + {#snippet overlay()} + {#if eqOverlay && active} + + {/if} + {/snippet} + +
diff --git a/web-player/src/lib/components/music/library/AlbumTrackPicker.svelte b/web-player/src/lib/components/music/library/AlbumTrackPicker.svelte new file mode 100644 index 0000000..f322e65 --- /dev/null +++ b/web-player/src/lib/components/music/library/AlbumTrackPicker.svelte @@ -0,0 +1,60 @@ + + + + + +
+

Canciones

+

+ {inAlbum.length > 0 ? plural(inAlbum.length, 'canción', 'canciones') : 'Sin canciones'} +

+ + {#if available.length > 0} +

Añadir desde tu biblioteca

+ + {/if} + +
+ {#if inAlbum.length > 0} +

En este álbum

+ + {:else} + + {/if} +
+
diff --git a/web-player/src/lib/components/music/library/LibraryAlbumEditor.svelte b/web-player/src/lib/components/music/library/LibraryAlbumEditor.svelte index b3aaaa9..05a8796 100644 --- a/web-player/src/lib/components/music/library/LibraryAlbumEditor.svelte +++ b/web-player/src/lib/components/music/library/LibraryAlbumEditor.svelte @@ -1,184 +1,57 @@ - - -
-
{ - submitting = true; - return async ({ update, result }) => { - await update({ reset: false }); - submitting = false; - if (result.type === 'success') onClose(); - }; - }} - class="mt-6 grid gap-8 lg:grid-cols-[auto_1fr]" - > + + {#snippet aside()} - - - -
- -

Las canciones del álbum usan esta portada.

- - - - - - - - - - - - - - - - -
- -
-
-
-

Canciones

-

- {inAlbum.length > 0 ? plural(inAlbum.length, 'canción', 'canciones') : 'Sin canciones'} -

-
- -
- - {#if available.length > 0} -

Añadir desde tu biblioteca

- - {/if} - -
- {#if inAlbum.length > 0} -

En este álbum

- - {:else} - - {/if} -
- - {#if formMessage} - {formMessage} - {/if} - -
- - {#if !isNew} - - {/if} -
-
- -
+ + {/snippet} + {#snippet footer()} + {#if album} + + {/if} + {/snippet} + {#if album} 0} {#if selectedIds.length > 0} -
- {plural(selectedIds.length, 'seleccionada', 'seleccionadas')} + (selectedIds = [])} + > - -
+ {:else}
- import UploadCloud from '@lucide/svelte/icons/cloud-upload'; - import { genreInfo } from '$lib/data/genres'; + import { genreLabels } from '$lib/data/genres'; import { filesFromDrop, readDuration, @@ -11,12 +10,14 @@ type Visibility } from '$lib/data/uploadQueue'; import { plural } from '$lib/utils/format'; + import type { GenreOption } from '$lib/types'; + import FileDropzone from '$lib/components/ui/forms/FileDropzone.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte'; import UploadQueueList from '$lib/components/music/library/UploadQueueList.svelte'; import UploadSettings from '$lib/components/music/library/UploadSettings.svelte'; interface Props { - genres: { genre: string; incompatibleWith: string[] }[]; + genres: GenreOption[]; } let { genres }: Props = $props(); @@ -27,16 +28,10 @@ let selectedGenres = $state([]); let visibility = $state('private'); let coauthors = $state([]); - let dragging = $state(false); - let input = $state(); const single = $derived(queue.length === 1 ? queue[0] : null); const summary = $derived(summarizeQueue(queue)); - const genresLabel = $derived( - selectedGenres.length > 0 - ? selectedGenres.map((genre) => genreInfo(genre).label).join(', ') - : '-' - ); + const genresLabel = $derived(genreLabels(selectedGenres)); const visibilityLabel = $derived(visibility === 'private' ? 'Privado' : 'Público'); const queueMeta = $derived( [ @@ -60,19 +55,6 @@ } } - function onPick(event: Event) { - const target = event.currentTarget as HTMLInputElement; - enqueue(Array.from(target.files ?? [])); - target.value = ''; - } - - async function onDrop(event: DragEvent) { - event.preventDefault(); - dragging = false; - if (!event.dataTransfer) return; - enqueue(await filesFromDrop(event.dataTransfer)); - } - function setTitle(id: string, title: string) { queue = queue.map((item) => (item.id === id ? { ...item, title } : item)); } @@ -84,39 +66,14 @@
-
{ - event.preventDefault(); - dragging = true; - }} - ondragleave={() => (dragging = false)} - ondrop={onDrop} - class="flex min-h-64 flex-col items-center justify-center rounded-panel border border-dashed p-6 text-center transition sm:p-10 {dragging - ? 'border-accent/60 bg-accent-tint' - : 'border-line-strong bg-surface'}" - > - - - -

- Arrastra tus archivos aquí -

-

- WAV, FLAC, AIFF o MP3 · hasta 500 MB por archivo · puedes soltar carpetas enteras -

- - -
+ + import { COL, type GridColumn } from '$lib/utils/gridColumns'; + + const TEXT = 'truncate text-xs text-fg-2'; + + const COLUMNS: GridColumn[] = [ + { id: 'select', width: COL.check, mobile: COL.check, customHead: true }, + { id: 'title', label: 'Título', width: COL.title, mobile: COL.titleMobile, mobileLabel: true }, + { id: 'album', label: 'Álbum', width: COL.album, class: TEXT }, + { id: 'genres', label: 'Géneros', width: COL.genres, class: TEXT }, + { id: 'status', label: 'Estado', width: COL.status }, + { + id: 'duration', + label: 'Duración', + width: COL.duration, + mobile: COL.durationMobile, + align: 'center', + class: `${TEXT} tabular-nums` + }, + { id: 'delete', width: COL.action, mobile: COL.actionMobile } + ]; + + + + + track.id} + rowClass={(track) => (player.currentId === track.id ? 'surface-active' : 'hover:bg-hover')} +> + {#snippet head()} + allSelected, toggleAll} /> + {/snippet} + {#snippet cell(track, column, i)} + {#if column.id === 'select'} + selected.has(track.id), (checked) => toggle(track.id, checked)} + /> + {:else if column.id === 'title'} + onPlay(i)} /> + {:else if column.id === 'album'} + {track.albumTitle ?? '-'} + {:else if column.id === 'genres'} + {genreLabels(track.genres)} + {:else if column.id === 'status'} + + {:else if column.id === 'duration'} + {fmtTime(track.duration)} + {:else} + onDelete(track)}> + + + {/if} + {/snippet} + diff --git a/web-player/src/lib/components/music/library/UploadQueueList.svelte b/web-player/src/lib/components/music/library/UploadQueueList.svelte index 27f1665..93ccdff 100644 --- a/web-player/src/lib/components/music/library/UploadQueueList.svelte +++ b/web-player/src/lib/components/music/library/UploadQueueList.svelte @@ -1,21 +1,42 @@ -
-
- Título - - - - - Estado - -
-
- {#each items as item (item.id)} -
-
- - {item.format} - -
- onTitle(item.id, event.currentTarget.value)} - aria-label="Título de {item.file.name}" - class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none" - /> -

{item.file.name}

-
+ item.id} + rowClass={() => 'hover:bg-hover'} +> + {#snippet cell(item, column)} + {#if column.id === 'title'} +
+ + {item.format} + +
+ onTitle(item.id, event.currentTarget.value)} + aria-label="Título de {item.file.name}" + class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none" + /> +

{item.file.name}

- - - - - - {#if item.error} - - {:else} - - {/if} - - onRemove(item.id)}> - -
- {/each} -
-
+ {:else if column.id === 'genres'} + {genresLabel} + {:else if column.id === 'access'} + {visibilityLabel} + {:else if column.id === 'size'} + {formatSize(item.file.size) || '-'} + {:else if column.id === 'duration'} + {item.duration === null ? '-' : fmtTime(item.duration)} + {:else if column.id === 'status'} + {#if item.error} + + {:else} + + {/if} + {:else} + onRemove(item.id)}> + + + {/if} + {/snippet} + diff --git a/web-player/src/lib/components/music/library/UploadSettings.svelte b/web-player/src/lib/components/music/library/UploadSettings.svelte index dfefef3..66b8c58 100644 --- a/web-player/src/lib/components/music/library/UploadSettings.svelte +++ b/web-player/src/lib/components/music/library/UploadSettings.svelte @@ -1,11 +1,19 @@ + + + @@ -47,12 +73,16 @@ @@ -71,6 +101,19 @@ /> + {#if artist !== undefined} + + + + {/if} + + {#if showType} + + + + + {/if} + void; onSuccess?: () => void; fields: Snippet; extra?: Snippet; + aside?: Snippet; + footer?: Snippet; } let { @@ -33,13 +37,19 @@ submittingLabel, canSubmit, helperText, + layout = 'inline', + coverHint, onCancel, onSuccess, fields, - extra + extra, + aside, + footer }: Props = $props(); let submitting = $state(false); + + const split = $derived(layout === 'split');
-
- + {#if split} +
- -
+ {#if coverHint} +

{coverHint}

+ {/if} {@render fields()} + {@render extra?.()} +
+
+ {@render aside?.()} +
+ {@render feedback()} + {@render actions()} +
+
+ {:else} +
+ + + +
+ {@render fields()} +
-
- {@render extra?.()} + {@render extra?.()} + {@render feedback()} + {@render actions()} + {/if} + +{#snippet feedback()} {#if formMessage} {formMessage} {/if} +{/snippet} +{#snippet actions()}
{#if helperText}

{helperText}

{/if} + {@render footer?.()}
- +{/snippet} diff --git a/web-player/src/lib/components/ui/forms/FileDropzone.svelte b/web-player/src/lib/components/ui/forms/FileDropzone.svelte new file mode 100644 index 0000000..1c0f6dc --- /dev/null +++ b/web-player/src/lib/components/ui/forms/FileDropzone.svelte @@ -0,0 +1,95 @@ + + + diff --git a/web-player/src/lib/components/ui/media/DataGrid.svelte b/web-player/src/lib/components/ui/media/DataGrid.svelte new file mode 100644 index 0000000..711336a --- /dev/null +++ b/web-player/src/lib/components/ui/media/DataGrid.svelte @@ -0,0 +1,77 @@ + + + + +
+
+ {#each columns as column (column.id)} +
+ {#if head && column.customHead} + {@render head(column)} + {:else if column.label} + {column.label} + {/if} +
+ {/each} +
+
+ {#each rows as row, i (rowKey(row))} +
oncontextmenu(event, row, i) : undefined} + > + {#each columns as column (column.id)} +
+ {@render cell(row, column, i)} +
+ {/each} +
+ {/each} +
+
diff --git a/web-player/src/lib/components/ui/media/MediaCard.svelte b/web-player/src/lib/components/ui/media/MediaCard.svelte index a9fcdc0..7d25726 100644 --- a/web-player/src/lib/components/ui/media/MediaCard.svelte +++ b/web-player/src/lib/components/ui/media/MediaCard.svelte @@ -9,7 +9,6 @@ subtitle?: string | null; src?: string | null; trackIds?: string[]; - onclick?: (event: MouseEvent) => void; onPlay?: (event: MouseEvent) => void; art?: Snippet; oncontextmenu?: (event: MouseEvent) => void; @@ -22,7 +21,6 @@ subtitle, src, trackIds = [], - onclick, onPlay, art, oncontextmenu, @@ -32,7 +30,6 @@ + import type { Snippet } from 'svelte'; + import Button from './Button.svelte'; + + interface Props { + label: string; + onClear: () => void; + class?: string; + children: Snippet; + } + + let { label, onClear, class: klass = '', children }: Props = $props(); + + +
+ {label} + {@render children()} + +
diff --git a/web-player/src/lib/data/genres.test.ts b/web-player/src/lib/data/genres.test.ts index 132f671..dfe29ee 100644 --- a/web-player/src/lib/data/genres.test.ts +++ b/web-player/src/lib/data/genres.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { findConflict, genreHue } from './genres'; +import { findConflict, genreHue, genreLabels } from './genres'; describe('genreHue', () => { it('is stable and within 0-359', () => { @@ -26,3 +26,10 @@ describe('findConflict', () => { expect(findConflict(['x', 'y'], options)).toBeUndefined(); }); }); + +describe('genreLabels', () => { + it('joins the labels and falls back to a dash', () => { + expect(genreLabels(['HipHop', 'Rock'])).toBe('Hip hop, Rock'); + expect(genreLabels([])).toBe('-'); + }); +}); diff --git a/web-player/src/lib/data/genres.ts b/web-player/src/lib/data/genres.ts index a7b1c64..3757fa3 100644 --- a/web-player/src/lib/data/genres.ts +++ b/web-player/src/lib/data/genres.ts @@ -1,3 +1,5 @@ +import type { GenreOption } from '$lib/types'; + const GENRE_INFO: Record = { Pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' }, Rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' }, @@ -45,6 +47,10 @@ export function genreHue(genre: string): number { return hash; } +export function genreLabels(genres: string[]): string { + return genres.map((genre) => genreInfo(genre).label).join(', ') || '-'; +} + export function genreTiles(genres: { genre: string }[]) { return genres.map(({ genre }) => ({ genre, @@ -53,7 +59,7 @@ export function genreTiles(genres: { genre: string }[]) { })); } -export function genreOptions(genres: { genre: string; incompatibleWith: string[] }[]) { +export function genreOptions(genres: GenreOption[]) { return genres.map(({ genre, incompatibleWith }) => ({ genre, incompatibleWith, @@ -61,10 +67,7 @@ export function genreOptions(genres: { genre: string; incompatibleWith: string[] })); } -export function findConflict( - tags: string[], - options: { genre: string; incompatibleWith: string[] }[] -): [string, string] | undefined { +export function findConflict(tags: string[], options: GenreOption[]): [string, string] | undefined { const byGenre = new Map(options.map((option) => [option.genre, option.incompatibleWith])); for (let i = 0; i < tags.length; i++) { for (let j = i + 1; j < tags.length; j++) { diff --git a/web-player/src/lib/types.ts b/web-player/src/lib/types.ts index 35b2e81..a5008a5 100644 --- a/web-player/src/lib/types.ts +++ b/web-player/src/lib/types.ts @@ -17,6 +17,11 @@ export interface Track { listensCount: number; } +export interface GenreOption { + genre: string; + incompatibleWith: string[]; +} + export type TrackStatus = 'Pending' | 'Processing' | 'Completed' | 'Failed'; export interface LibraryTrack extends Track { diff --git a/web-player/src/lib/utils/gridColumns.test.ts b/web-player/src/lib/utils/gridColumns.test.ts new file mode 100644 index 0000000..8ec212a --- /dev/null +++ b/web-player/src/lib/utils/gridColumns.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { gridColumns } from './gridColumns'; + +describe('gridColumns', () => { + it('builds desktop and mobile templates, skipping columns without a mobile width', () => { + const style = gridColumns([ + { id: 'title', width: '1fr', mobile: '2fr' }, + { id: 'album', width: '10rem' }, + { id: 'action', width: '2rem', mobile: '1rem' } + ]); + expect(style).toBe('--mf-track-cols:1fr 10rem 2rem; --mf-track-cols-mobile:2fr 1rem'); + }); +}); diff --git a/web-player/src/lib/utils/gridColumns.ts b/web-player/src/lib/utils/gridColumns.ts new file mode 100644 index 0000000..1345789 --- /dev/null +++ b/web-player/src/lib/utils/gridColumns.ts @@ -0,0 +1,41 @@ +export interface GridColumn { + id: string; + label?: string; + width: string; + mobile?: string; + align?: 'center'; + mobileLabel?: boolean; + customHead?: boolean; + class?: string; +} + +export const COL = { + check: '1.75rem', + index: '2rem', + indexMobile: '1.75rem', + title: 'minmax(11.25rem,1fr)', + titleMobile: 'minmax(7.5rem,1fr)', + titleWide: 'minmax(12.5rem,1fr)', + action: '2.25rem', + actionMobile: '2rem', + duration: '5rem', + durationWide: '6.5rem', + durationMobile: '3.25rem', + date: '7.75rem', + plays: '6.5rem', + album: 'minmax(6rem,10rem)', + genres: 'minmax(6rem,12.5rem)', + genresWide: 'minmax(6rem,13.75rem)', + status: '7rem', + statusQueue: '8.75rem', + statusMobile: '6rem', + access: '6rem', + size: '5.5rem', + durationQueue: '4.5rem' +} as const; + +export function gridColumns(columns: readonly GridColumn[]): string { + const desktop = columns.map((column) => column.width).join(' '); + const mobile = columns.flatMap((column) => (column.mobile ? [column.mobile] : [])).join(' '); + return `--mf-track-cols:${desktop}; --mf-track-cols-mobile:${mobile}`; +} diff --git a/web-player/src/routes/(app)/library/albums/[id]/+page.svelte b/web-player/src/routes/(app)/library/albums/[id]/+page.svelte index dfd7151..5aa21a7 100644 --- a/web-player/src/routes/(app)/library/albums/[id]/+page.svelte +++ b/web-player/src/routes/(app)/library/albums/[id]/+page.svelte @@ -1,5 +1,6 @@