Add likes, follows and playlist visibility; redesign web-player UI
Backend: - New Likes feature (toggle/get liked tracks) and endpoints - User follow/unfollow, listening stats, public user profile - Playlist visibility (public/private) support - Various auth and DTO/response adjustments to support the above Frontend (web-player): - New design system pass: theme tokens/mode (light/dark), glass menu, tabs bar, player dock/extras/queue components, mix tiles, artist link - New routes: Liked, public user profile (/u/[id]), likes API proxy - Search refactor: shared SearchBox component used by TopBar and MobileHeader (search now reachable on mobile), live results as you type instead of requiring Enter, dropped the "Buscando…" quick-result preview - Removed now-unused components (MobileNav, CoverBadge, MixBentoTile, PlayPauseOverlay, SuggestionsTable, userDirectory) - Design handoff reference docs added under web-player/design_handoff_musify_player and web-player/docs/design-system-refactor-plan.md
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { toQueueItems, type ApiTrackLike, type TrackSourceKind } from './player.svelte';
|
||||
|
||||
export interface LikedTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
artist?: string;
|
||||
coverUrl?: string;
|
||||
source?: TrackSourceKind;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
likedAt: number;
|
||||
}
|
||||
|
||||
type LikeToggleInput = {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
coverUrl?: string;
|
||||
source?: TrackSourceKind;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
};
|
||||
|
||||
function toggleRequestBody(track: LikeToggleInput) {
|
||||
return track.source === 'youtube'
|
||||
? { source: 'YouTube' as const, externalId: String(track.id) }
|
||||
: { trackId: String(track.id) };
|
||||
}
|
||||
|
||||
class LikedStore {
|
||||
entries = $state<Record<string, LikedTrack>>({});
|
||||
private hydrated = false;
|
||||
|
||||
count = $derived(Object.keys(this.entries).length);
|
||||
list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt));
|
||||
|
||||
hydrate(tracks: (ApiTrackLike & { createdAt: string })[]) {
|
||||
if (this.hydrated) return;
|
||||
this.hydrated = true;
|
||||
|
||||
const items = toQueueItems(tracks);
|
||||
const next: Record<string, LikedTrack> = {};
|
||||
items.forEach((item, i) => {
|
||||
next[String(item.id)] = {
|
||||
id: String(item.id),
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
coverUrl: item.coverUrl,
|
||||
source: item.source,
|
||||
explicit: item.explicit,
|
||||
ownerUserId: item.ownerUserId,
|
||||
likedAt: new Date(tracks[i].createdAt).getTime()
|
||||
};
|
||||
});
|
||||
this.entries = next;
|
||||
}
|
||||
|
||||
isLiked(id: string | number): boolean {
|
||||
return String(id) in this.entries;
|
||||
}
|
||||
|
||||
async toggle(track: LikeToggleInput) {
|
||||
const id = String(track.id);
|
||||
const before = this.entries;
|
||||
const wasLiked = id in before;
|
||||
const next = { ...before };
|
||||
if (wasLiked) delete next[id];
|
||||
else {
|
||||
next[id] = {
|
||||
id,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
coverUrl: track.coverUrl,
|
||||
source: track.source,
|
||||
explicit: track.explicit,
|
||||
ownerUserId: track.ownerUserId,
|
||||
likedAt: Date.now()
|
||||
};
|
||||
}
|
||||
this.entries = next;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/likes', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(toggleRequestBody(track))
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to toggle like');
|
||||
} catch {
|
||||
this.entries = before;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const liked = new LikedStore();
|
||||
@@ -13,6 +13,7 @@ export interface PlayerTrack {
|
||||
source: TrackSourceKind;
|
||||
coverUrl?: string;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
@@ -28,6 +29,7 @@ export interface QueueItem {
|
||||
source?: TrackSourceKind;
|
||||
coverUrl?: string;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
}
|
||||
|
||||
export interface ApiTrackLike {
|
||||
@@ -38,6 +40,7 @@ export interface ApiTrackLike {
|
||||
externalId?: string | null;
|
||||
audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
||||
isExplicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
}
|
||||
|
||||
function isYouTubeTrack(track: ApiTrackLike): boolean {
|
||||
@@ -69,7 +72,8 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
||||
? youTubeThumbnailUrl(track.externalId)
|
||||
: `/api/tracks/${track.id}/cover?size=small`
|
||||
: undefined,
|
||||
explicit: track.isExplicit
|
||||
explicit: track.isExplicit,
|
||||
ownerUserId: youTube ? undefined : track.ownerUserId
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -101,7 +105,8 @@ function toTrack(item: QueueItem): PlayerTrack {
|
||||
duration: 0,
|
||||
explicit: item.explicit,
|
||||
source: item.source ?? 'local',
|
||||
coverUrl: item.coverUrl
|
||||
coverUrl: item.coverUrl,
|
||||
ownerUserId: item.ownerUserId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,6 +120,8 @@ class PlayerState {
|
||||
error = $state('');
|
||||
recentlyPlayed = $state<PlayerTrack[]>([]);
|
||||
playlists = $state<Playlist[]>([]);
|
||||
shuffle = $state(false);
|
||||
repeat = $state(false);
|
||||
|
||||
accentColor = $state<string | null>(null);
|
||||
|
||||
@@ -154,6 +161,11 @@ class PlayerState {
|
||||
});
|
||||
audio.addEventListener('ended', () => {
|
||||
this.#stopProgressLoop();
|
||||
if (this.repeat) {
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
this.next();
|
||||
});
|
||||
audio.addEventListener('error', () => {
|
||||
@@ -397,10 +409,39 @@ class PlayerState {
|
||||
next() {
|
||||
if (this.tracks.length === 0) return;
|
||||
const idx = this.#index();
|
||||
this.currentId = this.tracks[(idx + 1) % this.tracks.length].id;
|
||||
let nextIdx = (idx + 1) % this.tracks.length;
|
||||
if (this.shuffle && this.tracks.length > 1) {
|
||||
do {
|
||||
nextIdx = Math.floor(Math.random() * this.tracks.length);
|
||||
} while (nextIdx === idx);
|
||||
}
|
||||
this.currentId = this.tracks[nextIdx].id;
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
toggleShuffle() {
|
||||
this.shuffle = !this.shuffle;
|
||||
}
|
||||
|
||||
toggleRepeat() {
|
||||
this.repeat = !this.repeat;
|
||||
}
|
||||
|
||||
playQueueIndex(index: number) {
|
||||
const track = this.tracks[index];
|
||||
if (!track) return;
|
||||
if (this.currentId === track.id) {
|
||||
this.toggle();
|
||||
return;
|
||||
}
|
||||
this.currentId = track.id;
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
clearUpcoming() {
|
||||
this.tracks = this.currentId === null ? [] : [this.current];
|
||||
}
|
||||
|
||||
previous() {
|
||||
if (this.tracks.length === 0) return;
|
||||
const audio = this.#audioEl();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
let open = $state(false);
|
||||
|
||||
export const queuePanel = {
|
||||
get open() {
|
||||
return open;
|
||||
},
|
||||
toggle() {
|
||||
open = !open;
|
||||
},
|
||||
close() {
|
||||
open = false;
|
||||
},
|
||||
show() {
|
||||
open = true;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user