diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index 9cc2c28..9399932 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx @@ -7,6 +7,7 @@ import { useGroups } from './hooks/useGroups'; import { usePlayback } from './hooks/usePlayback'; import { useQueue } from './hooks/useQueue'; import { trackQueryOptions } from './hooks/useTrackDetails'; +import { useEnsureFavourites } from './hooks/usePlaylists'; import { albumQueryOptions, type AlbumTrack } from './hooks/useAlbumBrowse'; import { playlistQueryOptions } from './hooks/usePlaylistBrowse'; import { api } from './lib/sonosApi'; @@ -16,7 +17,6 @@ import { isProgram, isAlbum, isPlaylist, - extractItems, resolveAlbumParams, getItemArt, sonosItemToNormalizedQueueItem, @@ -38,6 +38,10 @@ import { DisplayNameModal } from './components/DisplayNameModal'; import { FeedbackDialog } from './components/FeedbackDialog'; import { ChangelogDialog } from './components/ChangelogDialog'; import { LyricsPanel } from './components/LyricsPanel'; +import { ProfilePanel } from './components/ProfilePanel'; +import { PlaylistPanel } from './components/PlaylistPanel'; +import { ContextMenuProvider } from './components/common/ContextMenu'; +import { ToastProvider } from './components/common/Toast'; import { usePrefetchNextLyrics } from './hooks/usePrefetchNextLyrics'; import { ErrorBoundary } from './components/ErrorBoundary'; import { Splash } from './components/Splash'; @@ -85,6 +89,7 @@ function MainApp() { const [feedbackOpen, setFeedbackOpen] = useState(false); const [changelogOpen, setChangelogOpen] = useState(false); const [displayName, setDisplayName] = useState(undefined); // undefined = not yet loaded + useEnsureFavourites(displayName); const [queueDockedWidth, setQueueDockedWidth] = useState(380); const queueSidebarRef = useRef(null); const shellRef = useRef(null); @@ -410,19 +415,11 @@ useEffect(() => { staleTime: 5 * 60 * 1000, }); - const { data: history = [], isLoading: histLoading } = useQuery({ - queryKey: ['history'], - queryFn: async () => { - const r = await api.content.history({ count: 20 }); - return r.error ? [] : extractItems(r.data); - }, - enabled: homeEnabled, - staleTime: 60 * 1000, - }); - - const splashReady = isAuthed && groups.length > 0 && !ytmLoading && !histLoading; + const splashReady = isAuthed && groups.length > 0 && !ytmLoading; return ( + +
{ onResync={() => window.sonos.resync()} displayName={displayName} onSaveName={(name) => { - window.sonos.setDisplayName(name).catch(() => {}); - setDisplayName(name); + const stored = name.trim(); + window.sonos.setDisplayName(stored).catch(() => {}); + setDisplayName(stored || null); }} onChangelogOpen={() => setChangelogOpen(true)} /> @@ -452,8 +450,7 @@ useEffect(() => { onAddToQueue={handleAddToQueue} ytm={ytm} ytmLoading={ytmLoading} - history={history} - histLoading={histLoading} + displayName={displayName} /> } /> @@ -465,8 +462,7 @@ useEffect(() => { onAddToQueue={handleAddToQueue} ytm={ytm} ytmLoading={ytmLoading} - history={history} - histLoading={histLoading} + displayName={displayName} /> } /> @@ -476,6 +472,8 @@ useEffect(() => { } /> } /> } /> + { window.sonos.setDisplayName('').catch(() => {}); setDisplayName(null); }} />} /> + } /> { isAuthed={isAuthed} playback={playback} onShuffle={reloadQueue} + displayName={displayName} /> {toastMsg &&
{toastMsg}
} {displayName === null && ( @@ -515,6 +514,8 @@ useEffect(() => { {feedbackOpen && setFeedbackOpen(false)} />} {changelogOpen && setChangelogOpen(false)} />}
+
+
); } diff --git a/renderer/src/components/CardRow.tsx b/renderer/src/components/CardRow.tsx index 4c396f6..677cf9b 100644 --- a/renderer/src/components/CardRow.tsx +++ b/renderer/src/components/CardRow.tsx @@ -1,4 +1,4 @@ -import { getName, browseSub, getItemArt, isAlbum, isPlaylist, isContainer, isProgram } from '../lib/itemHelpers'; +import { getName, browseSub, getItemArt, isAlbum, isArtist, isPlaylist, isContainer, isProgram } from '../lib/itemHelpers'; import { MediaCard } from './common/MediaCard'; import type { SonosItem } from '../types/sonos'; import styles from '../styles/CardRow.module.css'; @@ -8,15 +8,18 @@ export function CardRow({ isLoading, onAdd, onOpen, + cardSize, }: { items: SonosItem[]; isLoading: boolean; onAdd: (item: SonosItem) => void; onOpen: (item: SonosItem) => void; + cardSize?: string; }) { + const sizeStyle = cardSize ? { '--card-size': cardSize } as React.CSSProperties : undefined; if (isLoading) { return ( -
+
{Array.from({ length: 6 }).map((_, i) => (
))} @@ -25,15 +28,16 @@ export function CardRow({ } if (!items.length) return null; return ( -
+
{items.map((item, i) => ( onAdd(item)} - onOpen={(isAlbum(item) || isPlaylist(item) || isContainer(item) || isProgram(item)) ? () => onOpen(item) : undefined} + circular={isArtist(item)} + onAdd={(isContainer(item) || isArtist(item)) ? undefined : () => onAdd(item)} + onOpen={(isAlbum(item) || isPlaylist(item) || isContainer(item) || isProgram(item) || isArtist(item)) ? () => onOpen(item) : undefined} /> ))}
diff --git a/renderer/src/components/HomePanel.tsx b/renderer/src/components/HomePanel.tsx index e515249..a62ca56 100644 --- a/renderer/src/components/HomePanel.tsx +++ b/renderer/src/components/HomePanel.tsx @@ -1,6 +1,7 @@ -import { useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useLocation, useSearchParams } from 'react-router-dom'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import { User } from 'lucide-react'; import { api } from '../lib/sonosApi'; import { extractItems, @@ -9,23 +10,93 @@ import { resolveArtistParams, isAlbum, isArtist, + getName, + browseSub, } from '../lib/itemHelpers'; import { useOpenItem } from '../hooks/useOpenItem'; +import { useRecentlyPlayed } from '../hooks/useRecentlyPlayed'; +import { useMyPlaylists } from '../hooks/usePlaylists'; +import { useUsers } from '../hooks/useUsers'; +import { useDailyGame, useMyScore } from '../hooks/useDailyGame'; +import { useImage } from '../hooks/useImage'; +import { createDragGhost } from '../lib/dragHelpers'; +import { PlaylistCard } from './common/PlaylistCard'; import type { ServiceSearch } from '../types/ServiceSearch'; import { albumQueryOptions } from '../hooks/useAlbumBrowse'; import { artistQueryOptions } from '../hooks/useArtistBrowse'; import { CardRow } from './CardRow'; import { SearchResults } from './search/SearchResults'; +import { CreatePlaylistDialog } from './common/ContextMenu'; import type { SonosItem } from '../types/sonos'; import styles from '../styles/HomePanel.module.css'; +function avatarGradient(name: string) { + let h = 0; + for (let i = 0; i < name.length; i++) { h = name.charCodeAt(i) + ((h << 5) - h); h |= 0; } + const hue = Math.abs(h) % 360; + return `linear-gradient(135deg, hsl(${hue},55%,38%), hsl(${(hue + 40) % 360},60%,28%))`; +} + +function UserAvatarChip({ user, onClick }: { user: UserSummary; onClick: () => void }) { + const art = useImage(user.imageUrl ?? null); + return ( + + ); +} + +function AlbumListItem({ item, onOpen, onAdd }: { item: SonosItem; onOpen: () => void; onAdd: () => void }) { + const art = useImage(item.imageUrl); + + function handleDragStart(e: React.DragEvent) { + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify([item])); + createDragGhost(getName(item), e.dataTransfer); + } + + return ( +
+
+ {art ? :
} +
+
+
{getName(item)}
+
{browseSub(item)}
+
+ +
+ ); +} + +function ArtistGridCell({ item, onOpen }: { item: SonosItem; onOpen: () => void }) { + const art = useImage(item.imageUrl); + return ( +
+
+ {art + ? + :
โ™ช
+ } +
+
{getName(item)}
+
+ ); +} + interface Props { isAuthed: boolean; onAddToQueue: (item: SonosItem) => void; ytm: YtmSections | undefined; ytmLoading: boolean; - history: SonosItem[]; - histLoading: boolean; + displayName?: string | null; } export interface YtmSections { @@ -79,15 +150,45 @@ export async function fetchYtmSections(): Promise { }; } -export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, history, histLoading }: Props) { +export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName }: Props) { const queryClient = useQueryClient(); const location = useLocation(); + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const openItem = useOpenItem(); const view = location.pathname === '/search' ? 'search' : 'home'; const activeSearch = searchParams.get('q') ?? ''; + const { owned: ownedPlaylists, joined: joinedPlaylists } = useMyPlaylists(displayName); + const { data: colleagues = [], isLoading: colleaguesLoading } = useUsers(isAuthed); + + const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(undefined); + const [pickerOpen, setPickerOpen] = useState(false); + const pickerRef = useRef(null); + const { artistItems, albumItems, availableUsers, currentUserId, isLoading: recentLoading } = useRecentlyPlayed(selectedUser); + + // Default to current user once we know who they are + useEffect(() => { + if (currentUserId && !selectedUser) setSelectedUser(currentUserId); + }, [currentUserId, selectedUser]); + + const { data: todayGame } = useDailyGame(); + const todayGameId = todayGame && 'id' in todayGame ? todayGame.id : null; + const { data: myScore } = useMyScore(todayGameId, currentUserId); + const hasCompletedToday = !!myScore?.score; + + // Close picker on outside click + useEffect(() => { + if (!pickerOpen) return; + const handler = (e: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) setPickerOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [pickerOpen]); + const { data: searchResults = [], isFetching: searchLoading } = useQuery({ queryKey: ['search', activeSearch], queryFn: async () => { @@ -128,6 +229,11 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, history, hi ); } + const hasRecent = !!selectedUser || recentLoading || artistItems.length > 0 || albumItems.length > 0; + const recentEmpty = !!selectedUser && !recentLoading && artistItems.length === 0 && albumItems.length === 0; + const users = availableUsers.length > 0 ? availableUsers : (currentUserId ? [currentUserId] : []); + const isPickerLocked = users.length > 1 && !hasCompletedToday; + return (
{!isAuthed ? ( @@ -141,12 +247,175 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, history, hi -
-
-

Recently Played

-
- -
+ {(colleaguesLoading || colleagues.length > 0) && ( +
+

People

+
+ {colleaguesLoading + ? Array.from({ length: 5 }).map((_, i) => ( +
+ )) + : colleagues.map((u) => ( + navigate(`/profile/${encodeURIComponent(u.userId)}`)} + /> + )) + } +
+
+ )} + + {displayName && (ownedPlaylists.length > 0 || joinedPlaylists.length > 0) && ( +
+
+

Playlists

+ +
+
+ {[...ownedPlaylists, ...joinedPlaylists] + .sort((a, b) => { + if (!!b.isFavourites !== !!a.isFavourites) return b.isFavourites ? 1 : -1; + return b.updatedAt - a.updatedAt; + }) + .map((pl) => ( + navigate(`/playlist/${pl.id}`)} + /> + ))} +
+
+ )} + + {hasRecent && ( + <> +
+

+ Recently Played โ€” Last 7 Days + {selectedUser && ( + โ€” + )} + {selectedUser && ( + + + {pickerOpen && !isPickerLocked && users.length > 1 && ( +
    + {users.map((u) => ( +
  • + + +
  • + ))} +
+ )} +
+ )} +

+
+ + {recentEmpty && ( +
Nothing queued in the last 7 days
+ )} + + {recentLoading && ( +
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+
+
+
+ {Array.from({ length: 9 }).map((_, i) => ( +
+
+
+ ))} +
+
+
+ )} + + {!recentEmpty && !recentLoading && ( +
+ {albumItems.length > 0 && ( +
+

Albums

+ {albumItems.map((item, i) => ( + openItem(item)} onAdd={() => onAddToQueue(item)} /> + ))} +
+ )} + {artistItems.length > 0 && (() => { + const noAlbums = albumItems.length === 0; + const numCols = Math.min(noAlbums ? 5 : 3, artistItems.length); + const numRows = Math.ceil(artistItems.length / numCols); + const gridHeight = noAlbums + ? numRows === 1 ? 200 : numRows * 80 + : albumItems.length * 62 - 2; + return ( +
+

Artists

+
+ {artistItems.map((item, i) => ( + openItem(item)} /> + ))} +
+
+ ); + })()} +
+ )} + + )}
@@ -163,6 +432,13 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, history, hi
)} + {createPlaylistOpen && ( + setCreatePlaylistOpen(false)} + /> + )}
); } diff --git a/renderer/src/components/LeaderboardPanel.tsx b/renderer/src/components/LeaderboardPanel.tsx index af0bb3d..337a30e 100644 --- a/renderer/src/components/LeaderboardPanel.tsx +++ b/renderer/src/components/LeaderboardPanel.tsx @@ -48,13 +48,13 @@ function makeDragItem(t: StatsTrack) { } export function LeaderboardPanel() { + const navigate = useNavigate(); const [period, setPeriod] = useState('week'); const [view, setView] = useState<'stats' | 'queuedle'>('stats'); const [selectedUser, setSelectedUser] = useState(null); const [rankInfoOpen, setRankInfoOpen] = useState(false); const { data, isLoading, error, refetch } = useStats(period, selectedUser ?? undefined); const rankings = useGameRankings(null, view === 'queuedle'); - const navigate = useNavigate(); const { resolveAndOpen } = useResolveAndOpen(); const maxUserCount = data?.topUsers?.[0]?.count ?? 1; @@ -132,7 +132,7 @@ export function LeaderboardPanel() { {i < 3 ? MEDALS[i] : {i + 1}} - {r.userName} +
{i < 3 ? MEDALS[i] : {i + 1}} - {u.userId} +
diff --git a/renderer/src/components/PlayerBar.tsx b/renderer/src/components/PlayerBar.tsx index e16eb61..3f345df 100644 --- a/renderer/src/components/PlayerBar.tsx +++ b/renderer/src/components/PlayerBar.tsx @@ -3,6 +3,7 @@ import { useNavigate, useLocation } from "react-router-dom"; import { getActiveProvider } from "../providers"; import { useNowPlaying } from "../hooks/useNowPlaying"; import { useOpenItem } from "../hooks/useOpenItem"; +import { useFavourite } from "../hooks/usePlaylists"; import { ExplicitBadge } from "./common/ExplicitBadge"; import type React from "react"; import { @@ -19,6 +20,7 @@ import { Music, PictureInPicture2, MicVocal, + Heart, } from "lucide-react"; import type { PlaybackState } from "../hooks/usePlayback"; import styles from "../styles/PlayerBar.module.css"; @@ -27,6 +29,7 @@ interface Props { isAuthed: boolean; playback: PlaybackState; onShuffle: () => void; + displayName?: string | null; } function ScrollingText({ @@ -139,7 +142,7 @@ function VolumeButton({ volume }: { volume: number }) { ); } -export function PlayerBar({ isAuthed, playback, onShuffle }: Props) { +export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) { const navigate = useNavigate(); const { pathname } = useLocation(); const lyricsActive = pathname === '/lyrics'; @@ -149,8 +152,18 @@ export function PlayerBar({ isAuthed, playback, onShuffle }: Props) { elapsedLabel, durationLabel, progressPct, durationMs, isPlaying, isVisible, shuffle, repeat, volume, isExplicit, albumItem, prefetchAlbum, + currentObjectId, currentServiceId, currentAccountId, artUrlRaw, } = useNowPlaying(playback); + const { isFavourited, toggle: toggleFavourite } = useFavourite(displayName, { + uri: currentObjectId, + trackName: displayTrack, + artist: displayArtist, + imageUrl: artUrlRaw, + serviceId: currentServiceId, + accountId: currentAccountId, + }); + const { shuffle: rawShuffle, repeat: rawRepeat } = playback; const handleSeek = (e: React.MouseEvent) => { @@ -294,6 +307,15 @@ export function PlayerBar({ isAuthed, playback, onShuffle }: Props) { {/* Right โ€” volume + lyrics + queue + mini player */}
+ {displayName && currentObjectId && ( + + )} + )} +
+
+ + {isOwner && ( + + )} +
+
+ ); +} + +export function PlaylistPanel({ displayName, onAddToQueue }: Props) { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { showTrackMenu } = useTrackContextMenu(); + const { showToast } = useToast(); + const { data: playlist, isLoading } = usePlaylist(id); + const fileInputRef = useRef(null); + const renameInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(''); + const [confirmDelete, setConfirmDelete] = useState(false); + const coverArtSrc = useImage(playlist?.imageUrl ?? null); + const dragSrcIndex = useRef(-1); + const cancelRename = useRef(false); + const [dragOverIndex, setDragOverIndex] = useState(null); + + const handleDragStart = useCallback((index: number, e: React.DragEvent) => { + dragSrcIndex.current = index; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('application/playlist-track-index', String(index)); + }, []); + + const handleDragOver = useCallback((index: number, e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('application/playlist-track-index')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (index !== dragSrcIndex.current) setDragOverIndex(index); + }, []); + + const handleDrop = useCallback(async (index: number, e: React.DragEvent) => { + e.preventDefault(); + const raw = e.dataTransfer.getData('application/playlist-track-index'); + setDragOverIndex(null); + if (!raw || !playlist || !id) return; + const from = parseInt(raw, 10); + if (from === index) return; + const snapshot = playlist; + const newTracks = [...playlist.tracks]; + const [moved] = newTracks.splice(from, 1); + newTracks.splice(index, 0, moved); + queryClient.setQueryData(['playlist', id], { ...playlist, tracks: newTracks }); + try { + await window.sonos.reorderPlaylistTracks(id, from, index); + queryClient.invalidateQueries({ queryKey: ['playlist', id] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + } catch { + queryClient.setQueryData(['playlist', id], snapshot); + showToast('Failed to reorder tracks'); + } + }, [playlist, id, queryClient, displayName]); + + const handleDragEnd = useCallback(() => { + setDragOverIndex(null); + dragSrcIndex.current = -1; + }, []); + + if (!id) return null; + + const isOwner = playlist?.owner === displayName; + const isMember = playlist?.members.includes(displayName ?? '') ?? false; + const canReorder = isOwner || isMember; + + async function handlePlayAll() { + if (!playlist?.tracks.length) return; + for (const t of playlist.tracks) { + onAddToQueue(trackToSonosItem(t), -1); + } + } + + async function handleJoin() { + if (!id || !displayName) return; + try { + await window.sonos.joinPlaylist(id, isMember ? 'leave' : 'join'); + queryClient.invalidateQueries({ queryKey: ['playlist', id] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'joined', displayName] }); + } catch { + showToast(isMember ? 'Failed to leave playlist' : 'Failed to join playlist'); + } + } + + async function handleImageUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file || !id) return; + if (file.size > 5 * 1024 * 1024) { alert('Image must be under 5 MB'); return; } + setUploading(true); + try { + const buffer = await file.arrayBuffer(); + const result = await window.sonos.uploadPlaylistImage(id, buffer, file.type, displayName ?? ''); + if (result && 'imageUrl' in result) { + queryClient.invalidateQueries({ queryKey: ['playlist', id] }); + } else if (result && 'error' in result) { + showToast((result as { error: string }).error); + } + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + } + + async function handleRemoveTrack(uri: string, index: number) { + if (!playlist || !id) return; + const snapshot = playlist; + queryClient.setQueryData(['playlist', id], { + ...playlist, + tracks: playlist.tracks.filter((_, i) => i !== index), + }); + try { + await window.sonos.removeTrackFromPlaylist(id, uri); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + } catch { + queryClient.setQueryData(['playlist', id], snapshot); + showToast('Failed to remove track'); + } + } + + function startRename() { + setRenameValue(playlist?.name ?? ''); + setRenaming(true); + setTimeout(() => renameInputRef.current?.select(), 0); + } + + async function commitRename() { + const trimmed = renameValue.trim(); + if (!trimmed || !id || !playlist || trimmed === playlist.name) { setRenaming(false); return; } + setRenaming(false); + const snapshot = playlist; + queryClient.setQueryData(['playlist', id], { ...playlist, name: trimmed }); + try { + await window.sonos.updatePlaylist(id, { name: trimmed }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + } catch { + queryClient.setQueryData(['playlist', id], snapshot); + showToast('Failed to rename playlist'); + } + } + + async function handleDelete() { + if (!id || !playlist) return; + const owner = playlist.owner; + try { + await window.sonos.deletePlaylist(id); + queryClient.removeQueries({ queryKey: ['playlist', id] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', owner] }); + navigate(`/profile/${encodeURIComponent(owner)}`); + } catch { + setConfirmDelete(false); + showToast('Failed to delete playlist'); + } + } + + return ( +
+ {/* โ”€โ”€ Header โ”€โ”€ */} +
+
+
isOwner && fileInputRef.current?.click()} + title={isOwner ? 'Change image' : undefined} + > + {coverArtSrc + ? + : (!isLoading && playlist ? playlist.name[0].toUpperCase() : '') + } + {isOwner && ( +
+ {uploading ? 'โ€ฆ' : '๐Ÿ“ท'} +
+ )} +
+ +
+ {playlist?.isFavourites ? 'โค๏ธ Favourites' : 'Playlist'} + {isLoading ? ( +
+ ) : playlist ? ( + <> + {renaming ? ( + setRenameValue(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') { cancelRename.current = true; setRenaming(false); } }} + onBlur={() => { if (cancelRename.current) { cancelRename.current = false; return; } commitRename(); }} + autoFocus + /> + ) : ( +

+ {playlist.name} +

+ )} +
+ + ยท + {playlist.isPublic ? '๐ŸŒ Public' : '๐Ÿ”’ Private'} + {playlist.isPublic && ( + <> + ยท + {playlist.members.length} member{playlist.members.length !== 1 ? 's' : ''} + + )} + ยท + {playlist.tracks.length} track{playlist.tracks.length !== 1 ? 's' : ''} + {playlist.createdAt && ( + <> + ยท + Created {new Date(playlist.createdAt).toLocaleDateString()} + + )} +
+
+ {playlist.tracks.length > 0 && ( + + )} + {playlist.isPublic && !isOwner && ( + + )} + {isOwner && !playlist.isFavourites && ( + confirmDelete ? ( + <> + + + + ) : ( + + ) + )} +
+ + ) : ( +
Playlist not found
+ )} +
+
+
+ + {/* โ”€โ”€ Track table โ”€โ”€ */} +
+ {isLoading ? ( + <> +
+ #TitleAdded by +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} + + ) : playlist && playlist.tracks.length === 0 ? ( +

No tracks yet โ€” right-click any track and choose "Add to playlist"

+ ) : playlist ? ( + <> +
+ # + + Title + Added by + +
+ {playlist.tracks.map((t, i) => { + return ( +
+ {dragOverIndex === i &&
} + onAddToQueue(trackToSonosItem(t), -1)} + onRemove={() => handleRemoveTrack(t.uri, i)} + onContextMenu={e => showTrackMenu({ track: t, sonosItem: trackToSonosItem(t) }, e)} + onDragStart={e => handleDragStart(i, e)} + onDragOver={e => handleDragOver(i, e)} + onDrop={e => handleDrop(i, e)} + onDragEnd={handleDragEnd} + /> +
+ ); + })} + + ) : null} +
+ + {/* โ”€โ”€ Members section โ”€โ”€ */} + {playlist?.isPublic && isOwner && playlist.members.length > 1 && ( +
+

Members

+
+ {playlist.members.map((m) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/renderer/src/components/ProfilePanel.tsx b/renderer/src/components/ProfilePanel.tsx new file mode 100644 index 0000000..e3bd378 --- /dev/null +++ b/renderer/src/components/ProfilePanel.tsx @@ -0,0 +1,300 @@ +import { useCallback, useRef, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useUserStats } from '../hooks/useUserStats'; +import { useGameRankings } from '../hooks/useDailyGame'; +import { useMyPlaylists } from '../hooks/usePlaylists'; +import { useUserProfile, useInvalidateUserProfile } from '../hooks/useUserProfile'; +import { useImage } from '../hooks/useImage'; +import { getGameRankIcon } from '../lib/gameRankAssets'; +import { useOpenItem } from '../hooks/useOpenItem'; +import { createDragGhost } from '../lib/dragHelpers'; +import { CardRow } from './CardRow'; +import { MediaRow } from './common/MediaRow'; +import { CreatePlaylistDialog } from './common/ContextMenu'; +import { PlaylistCard } from './common/PlaylistCard'; +import type { SonosItem } from '../types/sonos'; +import styles from '../styles/ProfilePanel.module.css'; + +function TrackSubtitle({ t, openItem, linkClass }: { t: StatsTrack; openItem: (item: SonosItem) => void; linkClass: string }) { + const artistItem: SonosItem | null = t.artistId + ? { type: 'ARTIST', name: t.artist, resource: { type: 'ARTIST', id: { objectId: t.artistId, serviceId: t.serviceId, accountId: t.accountId } } } + : null; + const albumItem: SonosItem | null = t.albumId && t.album + ? { type: 'ALBUM', name: t.album, resource: { type: 'ALBUM', id: { objectId: t.albumId, serviceId: t.serviceId, accountId: t.accountId } } } + : null; + return ( + <> + {artistItem + ? + : t.artist} + {t.album && <>{' ยท '}{albumItem + ? + : t.album}} + + ); +} + +function statsTrackToSonosItem(t: StatsTrack): SonosItem { + return { + type: 'TRACK', + title: t.trackName, + resource: { + type: 'TRACK', + id: { objectId: t.uri ?? '', serviceId: t.serviceId ?? '', accountId: t.accountId ?? '' }, + }, + }; +} + +function getAvatarStyle(name: string): React.CSSProperties { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + hash |= 0; + } + const hue = Math.abs(hash) % 360; + return { + background: `linear-gradient(135deg, hsl(${hue},55%,38%), hsl(${(hue + 40) % 360},60%,28%))`, + }; +} + +interface Props { + onAddToQueue: (item: SonosItem) => void; + displayName?: string | null; + onSignOut?: () => void; +} + +export function ProfilePanel({ onAddToQueue, displayName, onSignOut }: Props) { + const { userName } = useParams<{ userName: string }>(); + const navigate = useNavigate(); + const openItem = useOpenItem(); + const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + +const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, isLoading: statsLoading } = + useUserStats(userName, 25); + + const { data: rankings } = useGameRankings(userName, !!userName); + const ranking = rankings?.find(r => r.userName === userName) ?? null; + + const isOwnProfile = !!displayName && displayName === userName; + const { data: profile } = useUserProfile(userName); + const profileArt = useImage(profile?.imageUrl ?? null); + const invalidateProfile = useInvalidateUserProfile(); + const fileInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [trackSelected, setTrackSelected] = useState>(new Set()); + const lastTrackSelected = useRef(null); + + const handleTrackClick = useCallback((idx: number, e: React.MouseEvent) => { + e.stopPropagation(); + if (e.shiftKey && lastTrackSelected.current !== null) { + const lo = Math.min(lastTrackSelected.current, idx); + const hi = Math.max(lastTrackSelected.current, idx); + setTrackSelected(prev => { const next = new Set(prev); for (let j = lo; j <= hi; j++) next.add(j); return next; }); + } else if (e.ctrlKey || e.metaKey) { + setTrackSelected(prev => { const next = new Set(prev); if (next.has(idx)) next.delete(idx); else next.add(idx); return next; }); + lastTrackSelected.current = idx; + } else { + if (trackSelected.size === 1 && trackSelected.has(idx)) { setTrackSelected(new Set()); lastTrackSelected.current = null; } + else { setTrackSelected(new Set([idx])); lastTrackSelected.current = idx; } + } + }, [trackSelected]); + + const handleTrackDragStart = useCallback((idx: number, e: React.DragEvent) => { + const indices = trackSelected.has(idx) ? [...trackSelected].sort((a, b) => a - b) : [idx]; + if (!trackSelected.has(idx)) { setTrackSelected(new Set([idx])); lastTrackSelected.current = idx; } + const items = indices.map(i => statsTrackToSonosItem(topTracks[i])); + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(items)); + createDragGhost(items.length > 1 ? `${items.length} tracks` : topTracks[idx].trackName, e.dataTransfer); + }, [trackSelected, topTracks]); + + async function handleAvatarUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file || !userName) return; + setUploading(true); + try { + const buf = await file.arrayBuffer(); + await window.sonos.uploadProfileImage(userName, buf, file.type); + invalidateProfile(userName); + } finally { + setUploading(false); + e.target.value = ''; + } + } + + const { owned: ownedPlaylists, joined: joinedPlaylists } = useMyPlaylists(userName); + const allPlaylists = isOwnProfile + ? [...ownedPlaylists, ...joinedPlaylists] + : ownedPlaylists.filter(p => p.isPublic); + const visiblePlaylists = [...allPlaylists].sort((a, b) => { + if (!!b.isFavourites !== !!a.isFavourites) return b.isFavourites ? 1 : -1; + return b.updatedAt - a.updatedAt; + }); + + if (!userName) return null; + + const tierIcon = ranking ? getGameRankIcon(ranking.tierKey) : null; + + return ( +
+ {/* โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} +
+
fileInputRef.current?.click() : undefined} + title={isOwnProfile ? 'Change photo' : undefined} + > + {profileArt + ? + : userName[0].toUpperCase() + } + {isOwnProfile && ( +
+ {uploading ? 'โ€ฆ' : '๐Ÿ“ท'} +
+ )} + +
+
+
+

{userName}

+
+ {(totalEvents > 0 || isOwnProfile) && ( + + {totalEvents > 0 && ( + <>{totalEvents.toLocaleString()} plays + )} + {isOwnProfile && onSignOut && ( + <> + {totalEvents > 0 && |} + + + )} + + )} +
+ {ranking && ( +
+ {tierIcon && } +
+ + {ranking.isProvisional ? 'Provisional' : ranking.tierName} + + + {ranking.isProvisional + ? <>{ranking.gamesPlayed} {ranking.gamesPlayed === 1 ? 'game' : 'games'} played + : <>{ranking.averagePercent.toFixed(0)}% avg ยท best {ranking.bestTotal} ยท {ranking.gamesPlayed} {ranking.gamesPlayed === 1 ? 'game' : 'games'} + } + +
+
+ )} +
+ + {/* โ”€โ”€ Top Tracks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + {(statsLoading || topTracks.length > 0) && ( +
+

Top Tracks

+
{ setTrackSelected(new Set()); lastTrackSelected.current = null; }} + > +
+ {statsLoading + ? Array.from({ length: 5 }).map((_, i) => ( +
+ )) + : topTracks.slice(0, 5).map((t, i) => ( + } + artUrl={t.imageUrl} + trailing={{t.count}ร—} + isSelected={trackSelected.has(i)} + onClick={e => handleTrackClick(i, e)} + draggable={!!t.uri} + onDragStart={t.uri ? e => handleTrackDragStart(i, e) : undefined} + /> + )) + } +
+
+ {statsLoading + ? Array.from({ length: 5 }).map((_, i) => ( +
+ )) + : topTracks.slice(5, 10).map((t, i) => ( + } + artUrl={t.imageUrl} + trailing={{t.count}ร—} + isSelected={trackSelected.has(i + 5)} + onClick={e => handleTrackClick(i + 5, e)} + draggable={!!t.uri} + onDragStart={t.uri ? e => handleTrackDragStart(i + 5, e) : undefined} + /> + )) + } +
+
+
+ )} + + {/* โ”€โ”€ Top Artists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + {(statsLoading || topArtists.length > 0) && ( +
+

Top Artists

+ +
+ )} + + {/* โ”€โ”€ Top Albums โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + {(statsLoading || topAlbums.length > 0) && ( +
+

Top Albums

+ +
+ )} + + {/* โ”€โ”€ Playlists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + {(visiblePlaylists.length > 0 || isOwnProfile) && ( +
+
+

Playlists

+ {isOwnProfile && ( + + )} +
+
+ {visiblePlaylists.map((pl) => ( + navigate(`/playlist/${pl.id}`)} + /> + ))} +
+
+ )} + + {createPlaylistOpen && ( + setCreatePlaylistOpen(false)} + /> + )} +
+ ); +} diff --git a/renderer/src/components/TopNav.tsx b/renderer/src/components/TopNav.tsx index 6082d27..d39c780 100644 --- a/renderer/src/components/TopNav.tsx +++ b/renderer/src/components/TopNav.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useRef, KeyboardEvent } from 'react'; import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; +import { useUserProfile } from '../hooks/useUserProfile'; +import { useImage } from '../hooks/useImage'; import { ChevronLeft, ChevronRight, @@ -7,7 +9,6 @@ import { Trophy, Search, X, - User, RefreshCw, Gamepad2, Lightbulb, @@ -42,14 +43,17 @@ export function TopNav({ const location = useLocation(); const [searchParams] = useSearchParams(); const [searchText, setSearchText] = useState(searchParams.get('q') ?? ''); - const [nameOpen, setNameOpen] = useState(false); + const [profileOpen, setProfileOpen] = useState(false); const [nameValue, setNameValue] = useState(''); const [groupOpen, setGroupOpen] = useState(false); const [appVersion, setAppVersion] = useState(null); - const popoverRef = useRef(null); + const profileRef = useRef(null); const groupRef = useRef(null); // React Router sets window.history.state = { idx, key } on every navigation + const { data: profile } = useUserProfile(displayName ?? undefined); + const profileArt = useImage(profile?.imageUrl ?? null); + const histIdx = (window.history.state as { idx?: number } | null)?.idx ?? 0; const canGoBack = histIdx > 0; const canGoForward = histIdx < window.history.length - 1; @@ -62,19 +66,19 @@ export function TopNav({ }, []); useEffect(() => { - if (nameOpen) setNameValue(displayName ?? ''); - }, [nameOpen, displayName]); + if (profileOpen) setNameValue(''); + }, [profileOpen]); useEffect(() => { - if (!nameOpen) return; + if (!profileOpen) return; function onDown(e: MouseEvent) { - if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { - setNameOpen(false); + if (profileRef.current && !profileRef.current.contains(e.target as Node)) { + setProfileOpen(false); } } document.addEventListener('mousedown', onDown); return () => document.removeEventListener('mousedown', onDown); - }, [nameOpen]); + }, [profileOpen]); useEffect(() => { if (!groupOpen) return; @@ -91,7 +95,7 @@ export function TopNav({ const trimmed = nameValue.trim(); if (trimmed) { onSaveName(trimmed); - setNameOpen(false); + setProfileOpen(false); } } @@ -190,6 +194,65 @@ export function TopNav({
+ {/* Profile chip โ€” right of search */} + {displayName !== undefined && ( +
+ {displayName ? ( + + ) : ( + <> + + {profileOpen && ( +
+
Enter your name to get started
+ setNameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') submitName(); + if (e.key === 'Escape') setProfileOpen(false); + }} + maxLength={32} + spellCheck={false} + autoFocus + /> + +
+ {appVersion &&
v{appVersion}
} +
+
+ )} + + )} +
+ )} + {isAuthed && groups.length === 0 ? (
)} - {displayName !== undefined && ( -
- - {nameOpen && ( -
-
Display name
- setNameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') submitName(); - if (e.key === 'Escape') setNameOpen(false); - }} - maxLength={32} - spellCheck={false} - autoFocus - /> - -
- {appVersion &&
v{appVersion}
} -
-
- )} -
- )} - diff --git a/renderer/src/components/__tests__/HomePanel.test.tsx b/renderer/src/components/__tests__/HomePanel.test.tsx index 659a76b..7268ac7 100644 --- a/renderer/src/components/__tests__/HomePanel.test.tsx +++ b/renderer/src/components/__tests__/HomePanel.test.tsx @@ -10,9 +10,11 @@ const mockBrowseContainer = vi.fn(); const mockUseLocation = vi.fn(); const mockUseSearchParams = vi.fn(); +const mockNavigate = vi.fn(); vi.mock('react-router-dom', () => ({ useLocation: () => mockUseLocation(), useSearchParams: () => mockUseSearchParams(), + useNavigate: () => mockNavigate, })); vi.mock('../../hooks/useOpenItem', () => ({ useOpenItem: () => vi.fn() })); @@ -37,6 +39,11 @@ vi.mock('../search/SearchResults', () => ({ SearchResults: ({ results }: { results: SonosItem[] }) =>
Search results ({results.length})
, })); +const mockUseRecentlyPlayed = vi.fn(); +vi.mock('../../hooks/useRecentlyPlayed', () => ({ + useRecentlyPlayed: (...args: unknown[]) => mockUseRecentlyPlayed(...args), +})); + function makeWrapper() { return ({ children }: { children: React.ReactNode }) => createElement(QueryClientProvider, { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, children); @@ -54,13 +61,19 @@ function makeRootItem(title: string, objectId: string): SonosItem { } as unknown as SonosItem; } +const defaultRecent = { + artistItems: [], + albumItems: [], + availableUsers: [], + currentUserId: 'testuser', + isLoading: false, +}; + const defaultProps = { isAuthed: true, onAddToQueue: vi.fn(), ytm: { forYou: [], newReleases: [], charts: [] }, ytmLoading: false, - history: [], - histLoading: false, }; beforeEach(() => { @@ -69,6 +82,7 @@ beforeEach(() => { mockUseSearchParams.mockReturnValue([new URLSearchParams()]); mockServiceQuery.mockResolvedValue({ error: null, data: { items: [] } }); mockBrowseContainer.mockResolvedValue({ error: null, data: { items: [] } }); + mockUseRecentlyPlayed.mockReturnValue(defaultRecent); }); // โ”€โ”€ fetchYtmSections โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -106,8 +120,7 @@ describe('fetchYtmSections', () => { const track: SonosItem = { name: 'Track A', type: 'TRACK' } as SonosItem; mockBrowseContainer .mockResolvedValueOnce({ error: null, data: makeRootData([homeItem]) }) - .mockResolvedValueOnce({ error: null, data: { items: [track] } }); // home browse - // nr and charts return empty (default mock) + .mockResolvedValueOnce({ error: null, data: { items: [track] } }); const result = await fetchYtmSections(); expect(result.forYou).toHaveLength(1); @@ -120,7 +133,7 @@ describe('fetchYtmSections', () => { const track: SonosItem = { name: 'Home Track', type: 'TRACK' } as SonosItem; mockBrowseContainer .mockResolvedValueOnce({ error: null, data: makeRootData([supermixItem, homeItem]) }) - .mockResolvedValueOnce({ error: null, data: { items: [track] } }); // home browse + .mockResolvedValueOnce({ error: null, data: { items: [track] } }); const result = await fetchYtmSections(); expect(result.forYou[0].title).toBe('My Supermix'); @@ -139,7 +152,7 @@ describe('fetchYtmSections', () => { const homeItem = makeRootItem('Home', 'home-id'); mockBrowseContainer .mockResolvedValueOnce({ error: null, data: makeRootData([homeItem]) }) - .mockResolvedValueOnce({ error: 'fail', data: null }); // home browse fails + .mockResolvedValueOnce({ error: 'fail', data: null }); const result = await fetchYtmSections(); expect(result.forYou).toEqual([]); @@ -152,7 +165,6 @@ describe('HomePanel', () => { it('shows sections when authed on home view', () => { render(, { wrapper: makeWrapper() }); expect(screen.getByText('For You')).toBeInTheDocument(); - expect(screen.getByText('Recently Played')).toBeInTheDocument(); expect(screen.getByText('New Releases')).toBeInTheDocument(); expect(screen.getByText('Charts')).toBeInTheDocument(); }); @@ -174,15 +186,39 @@ describe('HomePanel', () => { expect(screen.getAllByText('Loading cards').length).toBeGreaterThan(0); }); - it('shows loading card row when histLoading is true', () => { - render(, { wrapper: makeWrapper() }); - expect(screen.getAllByText('Loading cards').length).toBeGreaterThan(0); + it('shows loading skeleton when recentLoading is true', () => { + const artist = { type: 'ARTIST', title: 'The Beatles' } as SonosItem; + mockUseRecentlyPlayed.mockReturnValue({ ...defaultRecent, artistItems: [artist], isLoading: true }); + const { container } = render(, { wrapper: makeWrapper() }); + expect(container.querySelector('.recentGrid')).toBeInTheDocument(); }); - it('passes history items to recently played row', () => { - const history = [{ name: 'Track 1', type: 'TRACK' } as SonosItem, { name: 'Track 2', type: 'TRACK' } as SonosItem]; - render(, { wrapper: makeWrapper() }); - expect(screen.getByText('Card row (2)')).toBeInTheDocument(); + it('renders recently played section when data is present', () => { + const artist = { type: 'ARTIST', title: 'The Beatles' } as SonosItem; + mockUseRecentlyPlayed.mockReturnValue({ ...defaultRecent, artistItems: [artist] }); + render(, { wrapper: makeWrapper() }); + expect(screen.getByText(/Recently Played/)).toBeInTheDocument(); + expect(screen.getByText('Artists')).toBeInTheDocument(); + expect(screen.getByText('The Beatles')).toBeInTheDocument(); + }); + + it('hides recently played sections when all are empty and not loading', () => { + render(, { wrapper: makeWrapper() }); + expect(screen.queryByText('Recently Played')).not.toBeInTheDocument(); + expect(screen.queryByText('Artists')).not.toBeInTheDocument(); + expect(screen.queryByText('Albums')).not.toBeInTheDocument(); + }); + + it('shows user picker when availableUsers are present', () => { + const artist = { type: 'ARTIST', title: 'The Beatles' } as SonosItem; + mockUseRecentlyPlayed.mockReturnValue({ + ...defaultRecent, + artistItems: [artist], + availableUsers: ['joe', 'jane'], + currentUserId: 'joe', + }); + render(, { wrapper: makeWrapper() }); + expect(screen.getByRole('button', { name: /joe/i })).toBeInTheDocument(); }); it('shows SearchResults on /search path after query resolves', async () => { diff --git a/renderer/src/components/__tests__/LeaderboardPanel.test.tsx b/renderer/src/components/__tests__/LeaderboardPanel.test.tsx index a636df1..9c73318 100644 --- a/renderer/src/components/__tests__/LeaderboardPanel.test.tsx +++ b/renderer/src/components/__tests__/LeaderboardPanel.test.tsx @@ -296,33 +296,39 @@ describe('LeaderboardPanel', () => { }); describe('user drill-down', () => { + function clickAliceRow() { + // The username button navigates to profile (stopPropagation). + // Clicking the row div (via the medal/rank span) drills into that user's stats. + fireEvent.click(screen.getByRole('button', { name: 'alice' }).closest('div[class]')!); + } + it('navigates to user stats when a user row is clicked', () => { render(); - fireEvent.click(screen.getByText('alice')); + clickAliceRow(); expect(mockUseStats).toHaveBeenCalledWith('week', 'alice'); }); it('shows the selected username as the title', () => { render(); - fireEvent.click(screen.getByText('alice')); + clickAliceRow(); expect(screen.getByRole('heading', { name: 'alice' })).toBeInTheDocument(); }); it('hides the top queuers section when a user is selected', () => { render(); - fireEvent.click(screen.getByText('alice')); + clickAliceRow(); expect(screen.queryByText('Top queuers')).toBeNull(); }); it('shows a back button when a user is selected', () => { render(); - fireEvent.click(screen.getByText('alice')); + clickAliceRow(); expect(screen.getByTitle('Back')).toBeInTheDocument(); }); it('returns to leaderboard when back button is clicked', () => { render(); - fireEvent.click(screen.getByText('alice')); + clickAliceRow(); fireEvent.click(screen.getByTitle('Back')); expect(screen.getByText('Leaderboard')).toBeInTheDocument(); diff --git a/renderer/src/components/__tests__/PlaylistPanel.test.tsx b/renderer/src/components/__tests__/PlaylistPanel.test.tsx new file mode 100644 index 0000000..ef2f0a5 --- /dev/null +++ b/renderer/src/components/__tests__/PlaylistPanel.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createElement } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { PlaylistPanel } from '../PlaylistPanel'; +import type { SonosItem } from '../../types/sonos'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', () => ({ + useParams: () => ({ id: PLAYLIST_ID }), + useNavigate: () => mockNavigate, +})); + +vi.mock('../../hooks/useImage', () => ({ + useImage: () => null, +})); + +vi.mock('../../hooks/useTrackDetails', () => ({ + useTrackDetails: () => ({ data: null }), +})); + +const mockShowTrackMenu = vi.fn(); +vi.mock('../common/ContextMenu', () => ({ + useTrackContextMenu: () => ({ showTrackMenu: mockShowTrackMenu }), +})); + +const mockShowToast = vi.fn(); +vi.mock('../common/Toast', () => ({ + useToast: () => ({ showToast: mockShowToast }), +})); + +const PLAYLIST_ID = 'playlist-1'; +const OWNER = 'Alice'; +const TRACK_URI = 'x-sonos-spotify:spotify:track:abc123'; + +function makeTrack(): PlaylistTrack { + return { + uri: TRACK_URI, + trackName: 'Test Track', + artist: 'Test Artist', + imageUrl: null, + serviceId: 'gm', + accountId: 'acc1', + addedBy: OWNER, + addedAt: 0, + }; +} + +function makePlaylist(tracks: PlaylistTrack[] = []): PlaylistDoc { + return { + id: PLAYLIST_ID, + name: 'My Playlist', + owner: OWNER, + isPublic: false, + isFavourites: false, + members: [OWNER], + memberCount: 1, + trackCount: tracks.length, + tracks, + createdAt: 1000, + updatedAt: 1000, + }; +} + +function renderPanel(qc: QueryClient) { + return render( + createElement( + QueryClientProvider, + { client: qc }, + createElement(PlaylistPanel, { + displayName: OWNER, + onAddToQueue: vi.fn() as (item: SonosItem, position?: number) => void, + }), + ), + ); +} + +function makeQc() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('handleRemoveTrack', () => { + it('calls removeTrackFromPlaylist with correct args and removes track from cache', async () => { + const qc = makeQc(); + qc.setQueryData(['playlist', PLAYLIST_ID], makePlaylist([makeTrack()])); + vi.mocked(window.sonos.removeTrackFromPlaylist).mockResolvedValueOnce({} as PlaylistDoc); + + renderPanel(qc); + + const removeBtn = await screen.findByTitle('Remove'); + fireEvent.click(removeBtn); + + await waitFor(() => { + expect(vi.mocked(window.sonos.removeTrackFromPlaylist)).toHaveBeenCalledWith( + PLAYLIST_ID, + TRACK_URI, + ); + }); + + const updated = qc.getQueryData(['playlist', PLAYLIST_ID]); + expect(updated?.tracks).toHaveLength(0); + }); + + it('restores cache and shows toast when server rejects', async () => { + const qc = makeQc(); + qc.setQueryData(['playlist', PLAYLIST_ID], makePlaylist([makeTrack()])); + vi.mocked(window.sonos.removeTrackFromPlaylist).mockRejectedValueOnce(new Error('network')); + + renderPanel(qc); + + const removeBtn = await screen.findByTitle('Remove'); + fireEvent.click(removeBtn); + + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith('Failed to remove track'); + }); + + const restored = qc.getQueryData(['playlist', PLAYLIST_ID]); + expect(restored?.tracks).toHaveLength(1); + }); +}); diff --git a/renderer/src/components/__tests__/TopNav.test.tsx b/renderer/src/components/__tests__/TopNav.test.tsx index de72b9e..ef9cb57 100644 --- a/renderer/src/components/__tests__/TopNav.test.tsx +++ b/renderer/src/components/__tests__/TopNav.test.tsx @@ -13,6 +13,14 @@ vi.mock('react-router-dom', () => ({ useSearchParams: () => mockUseSearchParams(), })); +vi.mock('../../hooks/useUserProfile', () => ({ + useUserProfile: () => ({ data: null, isLoading: false }), +})); + +vi.mock('../../hooks/useImage', () => ({ + useImage: () => null, +})); + const defaultProps = { isAuthed: true, groups: [{ id: 'g1', name: 'Living Room', coordinatorId: 'g1', providerId: 'sonos' as const }], @@ -90,22 +98,20 @@ describe('TopNav', () => { expect(onChangelogOpen).toHaveBeenCalled(); }); - it('shows user name popover when User icon is clicked', async () => { + it('navigates to profile page when profile chip is clicked (signed in)', async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByTitle('Alice')); - expect(screen.getByText('Display name')).toBeInTheDocument(); + await user.click(screen.getByTitle('My profile')); + expect(mockNavigate).toHaveBeenCalledWith('/profile/Alice'); }); - it('calls onSaveName with trimmed name when Save is clicked', async () => { + it('calls onSaveName with trimmed name when Sign in is submitted (unauthenticated)', async () => { const onSaveName = vi.fn(); const user = userEvent.setup(); - render(); - await user.click(screen.getByTitle('Alice')); - const input = screen.getByDisplayValue('Alice'); - await user.clear(input); - await user.type(input, 'Bob'); - await user.click(screen.getByText('Save')); + render(); + await user.click(screen.getByTitle('Sign in')); + await user.type(screen.getByPlaceholderText('Your name'), 'Bob'); + await user.click(screen.getAllByText('Sign in')[1]); expect(onSaveName).toHaveBeenCalledWith('Bob'); }); @@ -127,10 +133,10 @@ describe('TopNav', () => { expect(onResync).toHaveBeenCalled(); }); - it('shows app version in name popover after load', async () => { + it('shows app version in sign-in popover after load (unauthenticated)', async () => { const user = userEvent.setup(); - render(); - await user.click(screen.getByTitle('Alice')); + render(); + await user.click(screen.getByTitle('Sign in')); await waitFor(() => expect(screen.getByText('v1.0.0')).toBeInTheDocument()); }); diff --git a/renderer/src/components/album/AlbumPanel.tsx b/renderer/src/components/album/AlbumPanel.tsx index 3a5b21f..f7725cc 100644 --- a/renderer/src/components/album/AlbumPanel.tsx +++ b/renderer/src/components/album/AlbumPanel.tsx @@ -7,6 +7,7 @@ import { usePlaylistBrowse } from '../../hooks/usePlaylistBrowse'; import { useDominantColor } from '../../hooks/useDominantColor'; import { artistQueryOptions } from '../../hooks/useArtistBrowse'; import { resolveAlbumParams, isPlaylist, isProgram, getItemArt } from '../../lib/itemHelpers'; +import { createDragGhost } from '../../lib/dragHelpers'; import { AlbumTrackRow } from './AlbumTrackRow'; import type { SonosItem, SonosItemId } from '../../types/sonos'; import styles from '../../styles/AlbumPanel.module.css'; @@ -84,17 +85,7 @@ export function AlbumPanel({ onAddToQueue }: Props) { if (!selected.has(index)) { setSelected(new Set([index])); lastSelected.current = index; } e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map(idx => data.tracks[idx].raw))); - const ghost = document.createElement('div'); - Object.assign(ghost.style, { - position: 'fixed', top: '-100px', left: '0', - background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', - color: '#fff', padding: '5px 12px', borderRadius: '6px', - fontSize: '12px', fontWeight: '600', pointerEvents: 'none', whiteSpace: 'nowrap', - }); - ghost.textContent = toMove.length > 1 ? `${toMove.length} tracks` : data.tracks[index].title; - document.body.appendChild(ghost); - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, 20); - setTimeout(() => ghost.remove(), 0); + createDragGhost(toMove.length > 1 ? `${toMove.length} tracks` : data.tracks[index].title, e.dataTransfer); } const totalSecs = data?.tracks.reduce((s, t) => s + t.durationSeconds, 0) ?? 0; diff --git a/renderer/src/components/album/AlbumTrackRow.tsx b/renderer/src/components/album/AlbumTrackRow.tsx index 0a555d5..c28e687 100644 --- a/renderer/src/components/album/AlbumTrackRow.tsx +++ b/renderer/src/components/album/AlbumTrackRow.tsx @@ -1,6 +1,7 @@ import { useNavigate } from 'react-router-dom'; import { useImage } from '../../hooks/useImage'; import { ExplicitBadge } from '../common/ExplicitBadge'; +import { useTrackContextMenu } from '../common/ContextMenu'; import { fmtDuration } from '../../lib/itemHelpers'; import type { AlbumTrack } from '../../hooks/useAlbumBrowse'; import type { SonosItem } from '../../types/sonos'; @@ -27,12 +28,26 @@ export function AlbumTrackRow({ onClick, onDragStart, onAdd, }: Props) { const navigate = useNavigate(); + const { showTrackMenu } = useTrackContextMenu(); + + const trackPayload: PlaylistTrack = { + uri: track.id.objectId ?? '', + trackName: track.title, + artist: track.artists.join(', '), + albumName: track.albumName ?? undefined, + imageUrl: track.artUrl, + serviceId: track.id.serviceId ?? serviceId, + accountId: track.id.accountId ?? accountId, + addedBy: '', + addedAt: 0, + }; return (
showTrackMenu({ track: trackPayload }, e)} onDragStart={onDragStart} > {track.ordinal} diff --git a/renderer/src/components/artist/ArtistHero.tsx b/renderer/src/components/artist/ArtistHero.tsx index 7bc36af..4b6e8b0 100644 --- a/renderer/src/components/artist/ArtistHero.tsx +++ b/renderer/src/components/artist/ArtistHero.tsx @@ -4,6 +4,7 @@ import { useImage } from '../../hooks/useImage'; import { useDominantColor } from '../../hooks/useDominantColor'; import { artistQueryOptions } from '../../hooks/useArtistBrowse'; import { resolveArtistParams, getItemArt, getName } from '../../lib/itemHelpers'; +import { createDragGhost } from '../../lib/dragHelpers'; import { HeroTrackRow } from './HeroTrackRow'; import type { SonosItem } from '../../types/sonos'; import styles from './ArtistHero.module.css'; @@ -64,17 +65,7 @@ export function ArtistHero({ if (!selected.has(index)) { setSelected(new Set([index])); lastSelected.current = index; } e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map(i => tracks[i].raw))); - const ghost = document.createElement('div'); - Object.assign(ghost.style, { - position: 'fixed', top: '-100px', left: '0', - background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', - color: '#fff', padding: '5px 12px', borderRadius: '6px', - fontSize: '12px', fontWeight: '600', pointerEvents: 'none', whiteSpace: 'nowrap', - }); - ghost.textContent = toMove.length > 1 ? `${toMove.length} tracks` : getName(tracks[index].raw); - document.body.appendChild(ghost); - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, 20); - setTimeout(() => ghost.remove(), 0); + createDragGhost(toMove.length > 1 ? `${toMove.length} tracks` : getName(tracks[index].raw), e.dataTransfer); } return ( diff --git a/renderer/src/components/artist/HeroTrackRow.tsx b/renderer/src/components/artist/HeroTrackRow.tsx index f023f6e..0b75efd 100644 --- a/renderer/src/components/artist/HeroTrackRow.tsx +++ b/renderer/src/components/artist/HeroTrackRow.tsx @@ -1,5 +1,6 @@ import { useImage } from '../../hooks/useImage'; import { ExplicitBadge } from '../common/ExplicitBadge'; +import { useTrackContextMenu } from '../common/ContextMenu'; import { fmtDuration } from '../../lib/itemHelpers'; import styles from './ArtistHero.module.css'; @@ -23,11 +24,26 @@ interface Props { export function HeroTrackRow({ track, index, isSelected, onClick, onDragStart, onAdd }: Props) { const art = useImage(track.artUrl); + const { showTrackMenu } = useTrackContextMenu(); + + const rid = track.raw.resource?.id; + const trackPayload: PlaylistTrack = { + uri: track.id?.objectId ?? rid?.objectId ?? '', + trackName: track.title, + artist: typeof track.raw.artist === 'string' ? track.raw.artist : (track.raw.artist?.name ?? ''), + imageUrl: track.artUrl, + serviceId: rid?.serviceId ?? '', + accountId: rid?.accountId ?? '', + addedBy: '', + addedAt: 0, + }; + return (
onClick(index, e)} + onContextMenu={e => showTrackMenu({ track: trackPayload }, e)} onDragStart={e => onDragStart(index, e)} >
diff --git a/renderer/src/components/artist/TopSongRow.tsx b/renderer/src/components/artist/TopSongRow.tsx index ecd92c8..ac838bc 100644 --- a/renderer/src/components/artist/TopSongRow.tsx +++ b/renderer/src/components/artist/TopSongRow.tsx @@ -1,5 +1,6 @@ import { useImage } from '../../hooks/useImage'; import { ExplicitBadge } from '../common/ExplicitBadge'; +import { useTrackContextMenu } from '../common/ContextMenu'; import { fmtDuration } from '../../lib/itemHelpers'; import type { AlbumTrack } from '../../hooks/useAlbumBrowse'; import type { SonosItem } from '../../types/sonos'; @@ -18,12 +19,27 @@ interface Props { export function TopSongRow({ track, index, isSelected, isCurrentTrack, isPlaybackActive, onAdd, onClick, onDragStart }: Props) { const art = useImage(track.artUrl); + const { showTrackMenu } = useTrackContextMenu(); const subtitle = (track.raw as Record)?.['subtitle'] as string | undefined; + + const trackPayload: PlaylistTrack = { + uri: track.id.objectId ?? '', + trackName: track.title, + artist: track.artists.join(', '), + albumName: track.albumName ?? undefined, + imageUrl: track.artUrl, + serviceId: track.id.serviceId ?? '', + accountId: track.id.accountId ?? '', + addedBy: '', + addedAt: 0, + }; + return (
onClick(index, e)} + onContextMenu={e => showTrackMenu({ track: trackPayload }, e)} onDragStart={e => onDragStart(index, e)} > {isCurrentTrack ? ( diff --git a/renderer/src/components/common/ContextMenu.tsx b/renderer/src/components/common/ContextMenu.tsx new file mode 100644 index 0000000..2d693d1 --- /dev/null +++ b/renderer/src/components/common/ContextMenu.tsx @@ -0,0 +1,385 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import { useMyPlaylists, favouritesId } from '../../hooks/usePlaylists'; +import { useToast } from './Toast'; +import type { SonosItem } from '../../types/sonos'; +import styles from '../../styles/ContextMenu.module.css'; + +interface TrackMenuPayload { + track: PlaylistTrack; + sonosItem?: SonosItem; +} + +interface ContextMenuCtx { + showTrackMenu: (payload: TrackMenuPayload, e: React.MouseEvent) => void; +} + +const ContextMenuContext = createContext({ showTrackMenu: () => {} }); + +export function useTrackContextMenu() { + return useContext(ContextMenuContext); +} + +interface ProviderProps { + children: React.ReactNode; + displayName: string | null | undefined; + onAddToQueue: (item: SonosItem, position?: number) => void; +} + +interface MenuState { + open: boolean; + x: number; + y: number; + payload: TrackMenuPayload | null; + submenuOpen: boolean; + submenuAnchor: { x: number; y: number } | null; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function PlaylistSubmenu({ + anchor, + displayName, + track, + onClose, + onOpenCreate, +}: { + anchor: { x: number; y: number }; + displayName: string | null | undefined; + track: PlaylistTrack; + onClose: () => void; + onOpenCreate: () => void; +}) { + const { owned, joined, isLoading } = useMyPlaylists(displayName); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const ref = useRef(null); + + const allPlaylists = [...owned, ...joined]; + + async function handleAdd(playlist: PlaylistMeta) { + try { + await window.sonos.addTrackToPlaylist(playlist.id, track); + queryClient.invalidateQueries({ queryKey: ['playlist', playlist.id] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', playlist.owner] }); + if (displayName && playlist.owner !== displayName) { + queryClient.invalidateQueries({ queryKey: ['playlists', 'joined', displayName] }); + } + } catch { + showToast(`Failed to add to ${playlist.name}`); + } + onClose(); + } + + // Position submenu to right; flip left if near edge + const menuW = 200; + const left = anchor.x + menuW > window.innerWidth - 8 ? anchor.x - menuW : anchor.x; + const top = clamp(anchor.y, 8, window.innerHeight - 8); + + return createPortal( +
e.stopPropagation()} + > + {isLoading &&
Loadingโ€ฆ
} + {!isLoading && allPlaylists.length === 0 && ( +
No playlists yet
+ )} + {allPlaylists.map((pl) => ( +
handleAdd(pl)}> + โ™ช + {pl.name} + {!pl.isPublic && ๐Ÿ”’} +
+ ))} + {allPlaylists.length > 0 &&
} +
{ onClose(); onOpenCreate(); }} + > + + + Create new playlistโ€ฆ +
+
, + document.body, + ); +} + +export function ContextMenuProvider({ children, displayName, onAddToQueue }: ProviderProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const [menuState, setMenuState] = useState({ + open: false, x: 0, y: 0, payload: null, submenuOpen: false, submenuAnchor: null, + }); + const [createOpen, setCreateOpen] = useState(false); + const [pendingTrack, setPendingTrack] = useState(null); + const menuRef = useRef(null); + + const close = useCallback(() => { + setMenuState((s) => ({ ...s, open: false, submenuOpen: false, submenuAnchor: null })); + }, []); + + const showTrackMenu = useCallback((payload: TrackMenuPayload, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const menuH = 120; + const menuW = 200; + const x = clamp(e.clientX, 8, window.innerWidth - menuW - 8); + const y = clamp(e.clientY, 8, window.innerHeight - menuH - 8); + setMenuState({ open: true, x, y, payload, submenuOpen: false, submenuAnchor: null }); + }, []); + + useEffect(() => { + if (!menuState.open) return; + function onMouseDown(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + close(); + } + } + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') close(); + } + document.addEventListener('mousedown', onMouseDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [menuState.open, close]); + + function handlePlayNext() { + if (menuState.payload?.sonosItem) onAddToQueue(menuState.payload.sonosItem, 0); + close(); + } + + function handleAddToQueue() { + if (menuState.payload?.sonosItem) onAddToQueue(menuState.payload.sonosItem, -1); + close(); + } + + async function handleAddToFavourites() { + if (!menuState.payload || !displayName) return; + const favId = favouritesId(displayName); + const track = menuState.payload.track; + const snapshot = queryClient.getQueryData(['playlist', favId]); + if (snapshot) { + queryClient.setQueryData(['playlist', favId], { + ...snapshot, + tracks: [...snapshot.tracks, track], + }); + } + try { + await window.sonos.addTrackToPlaylist(favId, track); + queryClient.invalidateQueries({ queryKey: ['playlist', favId] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + } catch { + if (snapshot) queryClient.setQueryData(['playlist', favId], snapshot); + showToast('Failed to add to Favourites'); + } + close(); + } + + function handleOpenPlaylistSubmenu(e: React.MouseEvent) { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setMenuState((s) => ({ + ...s, + submenuOpen: !s.submenuOpen, + submenuAnchor: { x: rect.right + 4, y: rect.top }, + })); + } + + function handleOpenProfile() { + if (menuState.payload?.track.addedBy) { + navigate(`/profile/${encodeURIComponent(menuState.payload.track.addedBy)}`); + } + close(); + } + + return ( + + {children} + {menuState.open && menuState.payload && createPortal( +
e.stopPropagation()} + > + {menuState.payload.sonosItem && ( + <> +
+ โ–ถ + Play next +
+
+ + + Add to queue +
+
+ + )} + {displayName && ( +
+ โค๏ธ + Add to Favourites +
+ )} +
+ โ™ช + Add to playlist + โ–ถ +
+ {menuState.payload.track.addedBy && ( + <> +
+
+ ๐Ÿ‘ค + View {menuState.payload.track.addedBy}'s profile +
+ + )} +
, + document.body, + )} + {menuState.open && menuState.submenuOpen && menuState.submenuAnchor && menuState.payload && ( + { + setPendingTrack(menuState.payload!.track); + setCreateOpen(true); + }} + /> + )} + {createOpen && ( + { setCreateOpen(false); setPendingTrack(null); }} + /> + )} + + ); +} + +// โ”€โ”€ CreatePlaylistDialog โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function CreatePlaylistDialog({ + displayName, + pendingTrack, + onClose, +}: { + displayName: string | null | undefined; + pendingTrack: PlaylistTrack | null; + onClose: () => void; +}) { + const [name, setName] = useState(''); + const [isPublic, setIsPublic] = useState(false); + const [busy, setBusy] = useState(false); + const [errorMsg, setErrorMsg] = useState(''); + const queryClient = useQueryClient(); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + async function handleCreate() { + if (!name.trim() || !displayName) return; + setBusy(true); + setErrorMsg(''); + try { + const playlist = await window.sonos.createPlaylist(name.trim(), isPublic); + if (pendingTrack && playlist.id) { + await window.sonos.addTrackToPlaylist(playlist.id, pendingTrack); + } + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + onClose(); + } catch (err) { + setErrorMsg(err instanceof Error ? err.message : String(err)); + setBusy(false); + } + } + + return createPortal( +
+
e.stopPropagation()} + > +
New playlist
+ setName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') onClose(); }} + placeholder="Playlist name" + style={{ + background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 8, + padding: '8px 12px', color: 'var(--text)', fontSize: 14, outline: 'none', + fontFamily: 'inherit', + }} + /> + + {errorMsg && ( +
+ {errorMsg} +
+ )} +
+ + +
+
+
, + document.body, + ); +} diff --git a/renderer/src/components/common/MediaCard.module.css b/renderer/src/components/common/MediaCard.module.css index 2219a06..22ace37 100644 --- a/renderer/src/components/common/MediaCard.module.css +++ b/renderer/src/components/common/MediaCard.module.css @@ -18,6 +18,22 @@ border-radius: 0; } +.artWrapCircular { + width: var(--card-size); + height: var(--card-size); + border-radius: 50%; + overflow: hidden; + position: relative; + background: var(--bg-1); + transition: box-shadow 0.3s ease; +} +.card:hover .artWrapCircular { + box-shadow: + 0 0 0 2px rgba(255, 255, 255, 0.5), + 0 0 10px 3px rgba(255, 255, 255, 0.2), + 0 0 20px 4px rgba(255, 255, 255, 0.08); +} + .art { width: 100%; height: 100%; diff --git a/renderer/src/components/common/MediaCard.tsx b/renderer/src/components/common/MediaCard.tsx index 9f8ba7a..de4b7c6 100644 --- a/renderer/src/components/common/MediaCard.tsx +++ b/renderer/src/components/common/MediaCard.tsx @@ -7,15 +7,16 @@ interface Props { sub?: string | null; artUrl?: string | null; explicit?: boolean; + circular?: boolean; onAdd?: () => void; onOpen?: () => void; } -export function MediaCard({ name, sub, artUrl, explicit, onAdd, onOpen }: Props) { +export function MediaCard({ name, sub, artUrl, explicit, circular, onAdd, onOpen }: Props) { const cachedArt = useImage(artUrl); return (
-
+
{cachedArt ? :
โ™ช
diff --git a/renderer/src/components/common/MediaRow.tsx b/renderer/src/components/common/MediaRow.tsx index 807fee9..1c74724 100644 --- a/renderer/src/components/common/MediaRow.tsx +++ b/renderer/src/components/common/MediaRow.tsx @@ -15,13 +15,14 @@ interface Props { onClick?: (e: React.MouseEvent) => void; onDoubleClick?: () => void; onHover?: () => void; + onContextMenu?: (e: React.MouseEvent) => void; draggable?: boolean; onDragStart?: (e: React.DragEvent) => void; } export function MediaRow({ name, artUrl, explicit, isPlaying, isSelected, leading, subtitle, trailing, - onAdd, onClick, onDoubleClick, onHover, draggable, onDragStart, + onAdd, onClick, onDoubleClick, onHover, onContextMenu, draggable, onDragStart, }: Props) { const cachedArt = useImage(artUrl); const classes = [ @@ -39,6 +40,7 @@ export function MediaRow({ onClick={onClick} onDoubleClick={onDoubleClick} onMouseEnter={onHover} + onContextMenu={onContextMenu} onDragStart={onDragStart} > {leading !== undefined &&
{leading}
} diff --git a/renderer/src/components/common/PlaylistCard.module.css b/renderer/src/components/common/PlaylistCard.module.css new file mode 100644 index 0000000..b6342c2 --- /dev/null +++ b/renderer/src/components/common/PlaylistCard.module.css @@ -0,0 +1,46 @@ +.playlistCard { + background: none; + border: none; + padding: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + cursor: pointer; + width: 120px; + text-align: left; +} +.playlistCard:hover .playlistCardArt { opacity: 0.85; } + +.playlistCardArt { + width: 120px; + height: 120px; + border-radius: var(--r-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + font-weight: 700; + color: rgba(255, 255, 255, 0.6); + transition: opacity 0.15s; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} + +.playlistCardName { + font-size: 12px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} + +.playlistCardMeta { + font-size: 11px; + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} diff --git a/renderer/src/components/common/PlaylistCard.tsx b/renderer/src/components/common/PlaylistCard.tsx new file mode 100644 index 0000000..f612fde --- /dev/null +++ b/renderer/src/components/common/PlaylistCard.tsx @@ -0,0 +1,32 @@ +import { useImage } from '../../hooks/useImage'; +import { getPlaylistColor } from '../../lib/playlistColor'; +import styles from './PlaylistCard.module.css'; + +interface Props { + pl: PlaylistMeta; + displayName?: string | null; + onClick: () => void; +} + +export function PlaylistCard({ pl, displayName, onClick }: Props) { + const art = useImage(pl.imageUrl ?? null); + return ( + + ); +} diff --git a/renderer/src/components/common/Toast.tsx b/renderer/src/components/common/Toast.tsx new file mode 100644 index 0000000..53e486b --- /dev/null +++ b/renderer/src/components/common/Toast.tsx @@ -0,0 +1,50 @@ +import { createContext, useCallback, useContext, useState } from 'react'; +import { createPortal } from 'react-dom'; +import styles from '../../styles/Toast.module.css'; + +type ToastType = 'error' | 'success' | 'info'; + +interface ToastItem { + id: number; + message: string; + type: ToastType; +} + +interface ToastCtx { + showToast: (message: string, type?: ToastType) => void; +} + +const ToastContext = createContext({ showToast: () => {} }); + +export function useToast() { + return useContext(ToastContext); +} + +let nextId = 0; +const DISMISS_MS = 4000; + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((message: string, type: ToastType = 'error') => { + const id = nextId++; + setToasts(prev => [...prev, { id, message, type }]); + setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), DISMISS_MS); + }, []); + + return ( + + {children} + {createPortal( +
+ {toasts.map(t => ( +
+ {t.message} +
+ ))} +
, + document.body, + )} +
+ ); +} diff --git a/renderer/src/components/common/__tests__/CreatePlaylistDialog.test.tsx b/renderer/src/components/common/__tests__/CreatePlaylistDialog.test.tsx new file mode 100644 index 0000000..5de8325 --- /dev/null +++ b/renderer/src/components/common/__tests__/CreatePlaylistDialog.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createElement } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CreatePlaylistDialog } from '../ContextMenu'; + +function makeQc() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +const DISPLAY_NAME = 'Alice'; +const NEW_PLAYLIST: PlaylistDoc = { + id: 'new-playlist-id', + name: 'Road Trip', + owner: DISPLAY_NAME, + isPublic: false, + isFavourites: false, + members: [DISPLAY_NAME], + memberCount: 1, + trackCount: 0, + tracks: [], + createdAt: 0, + updatedAt: 0, +}; + +const PENDING_TRACK: PlaylistTrack = { + uri: 'x-sonos-spotify:spotify:track:xyz', + trackName: 'Desert Rose', + artist: 'Sting', + imageUrl: null, + serviceId: 'gm', + accountId: 'acc1', + addedBy: DISPLAY_NAME, + addedAt: 0, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function renderDialog(pendingTrack: PlaylistTrack | null, onClose = vi.fn()) { + const qc = makeQc(); + render( + createElement( + QueryClientProvider, + { client: qc }, + createElement(CreatePlaylistDialog, { displayName: DISPLAY_NAME, pendingTrack, onClose }), + ), + ); + return { qc, onClose }; +} + +describe('CreatePlaylistDialog', () => { + it('creates playlist without adding a track when pendingTrack is null', async () => { + const user = userEvent.setup(); + vi.mocked(window.sonos.createPlaylist).mockResolvedValueOnce(NEW_PLAYLIST); + vi.mocked(window.sonos.addTrackToPlaylist).mockResolvedValueOnce({} as PlaylistDoc); + + const { onClose } = renderDialog(null); + + await user.type(screen.getByPlaceholderText('Playlist name'), 'Road Trip'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(vi.mocked(window.sonos.createPlaylist)).toHaveBeenCalledWith('Road Trip', false); + expect(vi.mocked(window.sonos.addTrackToPlaylist)).not.toHaveBeenCalled(); + }); + + it('adds pendingTrack to the new playlist after creation', async () => { + const user = userEvent.setup(); + vi.mocked(window.sonos.createPlaylist).mockResolvedValueOnce(NEW_PLAYLIST); + vi.mocked(window.sonos.addTrackToPlaylist).mockResolvedValueOnce({} as PlaylistDoc); + + renderDialog(PENDING_TRACK); + + await user.type(screen.getByPlaceholderText('Playlist name'), 'Road Trip'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(vi.mocked(window.sonos.addTrackToPlaylist)).toHaveBeenCalledWith( + NEW_PLAYLIST.id, + PENDING_TRACK, + ); + }); + }); + + it('shows inline error and stays open when server rejects', async () => { + const user = userEvent.setup(); + vi.mocked(window.sonos.createPlaylist).mockRejectedValueOnce(new Error('Name too long')); + + const { onClose } = renderDialog(null); + + await user.type(screen.getByPlaceholderText('Playlist name'), 'Road Trip'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => screen.getByText('Name too long')); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/renderer/src/components/queue/DraggableQueueRow.tsx b/renderer/src/components/queue/DraggableQueueRow.tsx index 317400f..69cc2d4 100644 --- a/renderer/src/components/queue/DraggableQueueRow.tsx +++ b/renderer/src/components/queue/DraggableQueueRow.tsx @@ -1,8 +1,10 @@ +import { useNavigate } from 'react-router-dom'; import { useImage } from '../../hooks/useImage'; import { useOpenItem } from '../../hooks/useOpenItem'; import { useQueueTrack } from '../../hooks/useQueueTrack'; import { getActiveProvider } from '../../providers'; import { ExplicitBadge } from '../common/ExplicitBadge'; +import { useTrackContextMenu } from '../common/ContextMenu'; import type { NormalizedQueueItem } from '../../types/provider'; import styles from '../../styles/QueueSidebar.module.css'; @@ -40,8 +42,22 @@ export function DraggableQueueRow({ : isPlayingByObjectId; const cachedArt = useImage(artUrl); const openItem = useOpenItem(); + const navigate = useNavigate(); + const { showTrackMenu } = useTrackContextMenu(); const name = item.track.title; + const trackPayload: PlaylistTrack = { + uri: item.track.id, + trackName: item.track.title, + artist: item.track.artist, + albumName: item.track.albumName ?? undefined, + imageUrl: item.track.imageUrl, + serviceId: item.track.serviceId ?? '', + accountId: item.track.accountId ?? '', + addedBy: '', + addedAt: 0, + }; + return (
onRowClick(index, e)} onDoubleClick={() => getActiveProvider().skipToTrack(index + 1)} + onContextMenu={e => showTrackMenu({ track: trackPayload }, e)} onDragStart={e => onDragStart(index, e)} onDragOver={e => onDragOver(index, e)} onDrop={onDrop} @@ -95,7 +112,15 @@ export function DraggableQueueRow({ )} {attribution && ( -
by {attribution.user}
+
+ by{' '} + +
)}
{timeToPlay !== undefined && ( diff --git a/renderer/src/components/queue/QueueSidebar.tsx b/renderer/src/components/queue/QueueSidebar.tsx index 1973d73..fa481a2 100644 --- a/renderer/src/components/queue/QueueSidebar.tsx +++ b/renderer/src/components/queue/QueueSidebar.tsx @@ -2,6 +2,7 @@ import { useEffect, useImperativeHandle, useMemo, useRef, useState, Fragment, fo import { useQueries } from '@tanstack/react-query'; import { Loader2 } from 'lucide-react'; import { applyReorderLocally } from '../../lib/queueHelpers'; +import { createDragGhost } from '../../lib/dragHelpers'; import { getActiveProvider } from '../../providers'; import { useAttribution } from '../../hooks/useAttribution'; import { trackQueryOptions } from '../../hooks/useTrackDetails'; @@ -207,19 +208,7 @@ export const QueueSidebar = forwardRef(function Queue draggingSet.current = toMove; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('application/queue-indices', JSON.stringify([...toMove])); - const ghost = document.createElement('div'); - Object.assign(ghost.style, { - position: 'fixed', top: '-100px', left: '0', - background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', - color: '#fff', padding: '5px 12px', borderRadius: '6px', - fontSize: '12px', fontWeight: '600', pointerEvents: 'none', - whiteSpace: 'nowrap', zIndex: '9999', - }); - const label = toMove.size > 1 ? `${toMove.size} tracks` : (items[index]?.track.title ?? ''); - ghost.textContent = label; - document.body.appendChild(ghost); - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, 20); - setTimeout(() => ghost.remove(), 0); + createDragGhost(toMove.size > 1 ? `${toMove.size} tracks` : (items[index]?.track.title ?? ''), e.dataTransfer); } function handleDragOver(index: number, e: React.DragEvent) { diff --git a/renderer/src/components/queue/__tests__/DraggableQueueRow.test.tsx b/renderer/src/components/queue/__tests__/DraggableQueueRow.test.tsx index 8e6c75b..af08c88 100644 --- a/renderer/src/components/queue/__tests__/DraggableQueueRow.test.tsx +++ b/renderer/src/components/queue/__tests__/DraggableQueueRow.test.tsx @@ -10,6 +10,7 @@ const { mockSkipToTrack } = vi.hoisted(() => ({ vi.mock('../../../hooks/useImage', () => ({ useImage: () => null })); vi.mock('../../../hooks/useOpenItem', () => ({ useOpenItem: () => vi.fn() })); vi.mock('../../../providers', () => ({ getActiveProvider: () => ({ skipToTrack: mockSkipToTrack }) })); +vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() })); const mockUseQueueTrack = vi.fn(); vi.mock('../../../hooks/useQueueTrack', () => ({ @@ -123,7 +124,7 @@ describe('DraggableQueueRow', () => { it('shows attribution when provided', () => { const attribution: AttributionEntry = { user: 'alice', timestamp: 0, trackName: 'T', artist: 'A' }; render(); - expect(screen.getByText('by alice')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'alice' })).toBeInTheDocument(); }); it('shows art placeholder when no image is cached', () => { diff --git a/renderer/src/components/queuedle/QueuedlePanel.tsx b/renderer/src/components/queuedle/QueuedlePanel.tsx index de84cb0..1c730b1 100644 --- a/renderer/src/components/queuedle/QueuedlePanel.tsx +++ b/renderer/src/components/queuedle/QueuedlePanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useDailyGame, useSubmitGameScore, @@ -36,6 +37,7 @@ function londonDateToday(): string { } export function QueuedlePanel() { + const navigate = useNavigate(); const [selectedDate, setSelectedDate] = useState(null); const [leaderboardTab, setLeaderboardTab] = useState('today'); const todayId = useMemo(() => londonDateToday(), []); @@ -280,7 +282,7 @@ export function QueuedlePanel() { {scores.slice(0, 10).map((s, i) => (
{i < 3 ? ['๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰'][i] : i + 1} - {s.userName} + {s.mainScore}/{currentGame.questions.length} ยท {s.bonusScore}/{currentGame.questions.length} @@ -312,7 +314,7 @@ export function QueuedlePanel() { rankedRows.slice(0, 10).map((ranked, i) => (
{i + 1} - {ranked.userName} + {ranked.gamesPlayed} {ranked.gamesPlayed === 1 ? 'game' : 'games'} ยท{' '} diff --git a/renderer/src/components/search/SearchResults.tsx b/renderer/src/components/search/SearchResults.tsx index 5ed07b8..41dd0f7 100644 --- a/renderer/src/components/search/SearchResults.tsx +++ b/renderer/src/components/search/SearchResults.tsx @@ -2,10 +2,12 @@ import { useRef, useState } from 'react'; import { useImage } from '../../hooks/useImage'; import { useOpenItem } from '../../hooks/useOpenItem'; import { getName, browseSub, getItemArt, isAlbum, isArtist, isTrack } from '../../lib/itemHelpers'; +import { createDragGhost } from '../../lib/dragHelpers'; import { ArtistHero } from '../artist/ArtistHero'; import { ArtistCircle } from './ArtistCircle'; import { MediaRow } from '../common/MediaRow'; -import type { SonosItem, SonosArtist } from '../../types/sonos'; +import { useTrackContextMenu } from '../common/ContextMenu'; +import type { SonosItem, SonosArtist, SonosItemId } from '../../types/sonos'; import styles from './SearchResults.module.css'; function SearchAlbumCard({ album, onOpen, onAdd }: { album: SonosItem; onOpen: (item: SonosItem) => void; onAdd: () => void }) { @@ -32,9 +34,29 @@ export function SearchResults({ onAddToQueue: (item: SonosItem) => void; }) { const openItem = useOpenItem(); + const { showTrackMenu } = useTrackContextMenu(); const [selected, setSelected] = useState>(new Set()); const lastSelected = useRef(null); + function buildTrackPayload(item: SonosItem): PlaylistTrack { + const rid = (typeof item.id === 'object' ? item.id : item.resource?.id) as SonosItemId | undefined; + const artist = typeof item.artist === 'string' ? item.artist + : (item.artist as SonosArtist | undefined)?.name + ?? item.artists?.[0]?.name ?? ''; + const albumName = typeof item.album === 'string' ? item.album : item.album?.name; + return { + uri: rid?.objectId ?? item.objectId ?? '', + trackName: (item.title ?? item.name ?? '') as string, + artist, + albumName, + imageUrl: getItemArt(item), + serviceId: rid?.serviceId ?? item.serviceId ?? '', + accountId: rid?.accountId ?? item.accountId ?? '', + addedBy: '', + addedAt: 0, + }; + } + const topArtist = results.length > 0 && isArtist(results[0]) ? results[0] : null; const artists = results.filter(isArtist); const remainingArtists = topArtist ? artists.slice(1) : artists; @@ -61,17 +83,7 @@ export function SearchResults({ if (!selected.has(i)) { setSelected(new Set([i])); lastSelected.current = i; } e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map(idx => tracks[idx]))); - const ghost = document.createElement('div'); - Object.assign(ghost.style, { - position: 'fixed', top: '-100px', left: '0', - background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', - color: '#fff', padding: '5px 12px', borderRadius: '6px', - fontSize: '12px', fontWeight: '600', pointerEvents: 'none', whiteSpace: 'nowrap', - }); - ghost.textContent = toMove.length > 1 ? `${toMove.length} tracks` : getName(tracks[i]); - document.body.appendChild(ghost); - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, 20); - setTimeout(() => ghost.remove(), 0); + createDragGhost(toMove.length > 1 ? `${toMove.length} tracks` : getName(tracks[i]), e.dataTransfer); } if (results.length === 0) return
No results.
; @@ -148,6 +160,7 @@ export function SearchResults({ subtitle={subtitle} onAdd={() => onAddToQueue(item)} onClick={e => { e.stopPropagation(); handleTrackClick(i, e); }} + onContextMenu={e => showTrackMenu({ track: buildTrackPayload(item) }, e)} draggable onDragStart={e => handleDragStart(e, i)} /> diff --git a/renderer/src/hooks/__tests__/usePlaylists.test.ts b/renderer/src/hooks/__tests__/usePlaylists.test.ts new file mode 100644 index 0000000..90ef984 --- /dev/null +++ b/renderer/src/hooks/__tests__/usePlaylists.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { createElement } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useFavourite, useEnsureFavourites, favouritesId } from '../usePlaylists'; + +const mockShowToast = vi.fn(); +vi.mock('../../components/common/Toast', () => ({ + useToast: () => ({ showToast: mockShowToast }), +})); + +function makeWrapper(qc: QueryClient) { + return ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: qc }, children); +} + +function makeQc() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +const DISPLAY_NAME = 'Alice'; +const TRACK_URI = 'x-sonos-spotify:spotify:track:abc123'; + +const sampleTrack = { + uri: TRACK_URI, + trackName: 'Test Track', + artist: 'Test Artist', + albumName: 'Test Album', + imageUrl: null as null, + serviceId: 'gm', + accountId: 'acc1', +}; + +function makePlaylistTrack(): PlaylistTrack { + return { + uri: TRACK_URI, + trackName: 'Test Track', + artist: 'Test Artist', + imageUrl: null, + serviceId: 'gm', + accountId: 'acc1', + addedBy: DISPLAY_NAME, + addedAt: 0, + }; +} + +function makeFavPlaylist(tracks: PlaylistTrack[] = []): PlaylistDoc { + const id = favouritesId(DISPLAY_NAME); + return { + id, + name: 'Favourites', + owner: DISPLAY_NAME, + isPublic: false, + isFavourites: true, + members: [DISPLAY_NAME], + memberCount: 1, + trackCount: tracks.length, + tracks, + createdAt: 0, + updatedAt: 0, + }; +} + +describe('useFavourite', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns isFavourited=false when playlist is not in cache', () => { + const qc = makeQc(); + const { result } = renderHook( + () => useFavourite(DISPLAY_NAME, sampleTrack), + { wrapper: makeWrapper(qc) }, + ); + expect(result.current.isFavourited).toBe(false); + }); + + it('returns isFavourited=true when track uri matches cached playlist', () => { + const qc = makeQc(); + const favId = favouritesId(DISPLAY_NAME); + qc.setQueryData(['playlist', favId], makeFavPlaylist([makePlaylistTrack()])); + + const { result } = renderHook( + () => useFavourite(DISPLAY_NAME, sampleTrack), + { wrapper: makeWrapper(qc) }, + ); + expect(result.current.isFavourited).toBe(true); + }); + + it('toggle add: calls addTrackToPlaylist and updates cache optimistically', async () => { + const qc = makeQc(); + const favId = favouritesId(DISPLAY_NAME); + qc.setQueryData(['playlist', favId], makeFavPlaylist()); + vi.mocked(window.sonos.addTrackToPlaylist).mockResolvedValueOnce({} as PlaylistDoc); + + const { result } = renderHook( + () => useFavourite(DISPLAY_NAME, sampleTrack), + { wrapper: makeWrapper(qc) }, + ); + + await act(() => result.current.toggle()); + + expect(vi.mocked(window.sonos.addTrackToPlaylist)).toHaveBeenCalledWith( + favId, + expect.objectContaining({ uri: TRACK_URI, addedBy: DISPLAY_NAME }), + ); + const updated = qc.getQueryData(['playlist', favId]); + expect(updated?.tracks).toHaveLength(1); + expect(updated?.tracks[0].uri).toBe(TRACK_URI); + }); + + it('toggle add rollback: restores cache and shows toast when server fails', async () => { + const qc = makeQc(); + const favId = favouritesId(DISPLAY_NAME); + qc.setQueryData(['playlist', favId], makeFavPlaylist()); + vi.mocked(window.sonos.addTrackToPlaylist).mockRejectedValueOnce(new Error('network')); + + const { result } = renderHook( + () => useFavourite(DISPLAY_NAME, sampleTrack), + { wrapper: makeWrapper(qc) }, + ); + + await act(() => result.current.toggle()); + + const restored = qc.getQueryData(['playlist', favId]); + expect(restored?.tracks).toHaveLength(0); + expect(mockShowToast).toHaveBeenCalledWith('Failed to add to Favourites'); + }); + + it('toggle remove: calls removeTrackFromPlaylist and removes from cache', async () => { + const qc = makeQc(); + const favId = favouritesId(DISPLAY_NAME); + qc.setQueryData(['playlist', favId], makeFavPlaylist([makePlaylistTrack()])); + vi.mocked(window.sonos.removeTrackFromPlaylist).mockResolvedValueOnce({} as PlaylistDoc); + + const { result } = renderHook( + () => useFavourite(DISPLAY_NAME, sampleTrack), + { wrapper: makeWrapper(qc) }, + ); + + expect(result.current.isFavourited).toBe(true); + await act(() => result.current.toggle()); + + expect(vi.mocked(window.sonos.removeTrackFromPlaylist)).toHaveBeenCalledWith(favId, TRACK_URI); + const updated = qc.getQueryData(['playlist', favId]); + expect(updated?.tracks).toHaveLength(0); + }); + + it('toggle remove rollback: restores cache and shows toast when server fails', async () => { + const qc = makeQc(); + const favId = favouritesId(DISPLAY_NAME); + qc.setQueryData(['playlist', favId], makeFavPlaylist([makePlaylistTrack()])); + vi.mocked(window.sonos.removeTrackFromPlaylist).mockRejectedValueOnce(new Error('network')); + + const { result } = renderHook( + () => useFavourite(DISPLAY_NAME, sampleTrack), + { wrapper: makeWrapper(qc) }, + ); + + expect(result.current.isFavourited).toBe(true); + await act(() => result.current.toggle()); + + const restored = qc.getQueryData(['playlist', favId]); + expect(restored?.tracks).toHaveLength(1); + expect(mockShowToast).toHaveBeenCalledWith('Failed to remove from Favourites'); + }); +}); + +describe('useEnsureFavourites', () => { + it('registers query with staleTime=5 minutes and retry=2', () => { + const qc = new QueryClient(); + renderHook(() => useEnsureFavourites(DISPLAY_NAME), { wrapper: makeWrapper(qc) }); + const query = qc.getQueryCache().find({ queryKey: ['favourites-ensure', DISPLAY_NAME] }); + const opts = query?.options as { staleTime?: number; retry?: number | boolean | null } | undefined; + expect(opts?.staleTime).toBe(5 * 60_000); + expect(opts?.retry).toBe(2); + }); +}); diff --git a/renderer/src/hooks/useNowPlaying.ts b/renderer/src/hooks/useNowPlaying.ts index df9cb5f..af21ad2 100644 --- a/renderer/src/hooks/useNowPlaying.ts +++ b/renderer/src/hooks/useNowPlaying.ts @@ -54,6 +54,11 @@ export function useNowPlaying(playback: PlaybackState) { // Passthrough from playback state progressPct, durationMs, isPlaying, isVisible, shuffle, repeat, volume, isExplicit, + // Current track identity (for favouriting etc.) + currentObjectId: currentObjectId ?? null, + currentServiceId: svcId ?? null, + currentAccountId: accId ?? null, + artUrlRaw: td?.artUrl ?? artUrl ?? null, // Derived albumItem, prefetchAlbum, diff --git a/renderer/src/hooks/usePlaylists.ts b/renderer/src/hooks/usePlaylists.ts new file mode 100644 index 0000000..ebc2e86 --- /dev/null +++ b/renderer/src/hooks/usePlaylists.ts @@ -0,0 +1,134 @@ +import { useCallback } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useToast } from '../components/common/Toast'; + +export function useEnsureFavourites(userName: string | null | undefined) { + return useQuery({ + queryKey: ['favourites-ensure', userName], + queryFn: () => window.sonos.ensureFavourites(), + enabled: !!userName, + staleTime: 5 * 60_000, + gcTime: Infinity, + retry: 2, + }); +} + +export function favouritesId(userName: string) { + return `fav-${userName}`; +} + +async function safeFetchPlaylists(filter: { owner?: string; member?: string }): Promise { + const result = await window.sonos.fetchPlaylists(filter); + return Array.isArray(result) ? result : []; +} + +export function useMyPlaylists(userName: string | null | undefined) { + const owned = useQuery({ + queryKey: ['playlists', 'owned', userName], + queryFn: () => safeFetchPlaylists({ owner: userName! }), + enabled: !!userName, + staleTime: 2 * 60_000, + }); + const joined = useQuery({ + queryKey: ['playlists', 'joined', userName], + queryFn: () => safeFetchPlaylists({ member: userName! }), + enabled: !!userName, + staleTime: 2 * 60_000, + }); + return { + owned: owned.data ?? [], + joined: joined.data ?? [], + isLoading: owned.isLoading || joined.isLoading, + refetch: () => { owned.refetch(); joined.refetch(); }, + }; +} + +export function usePlaylist(id: string | undefined) { + return useQuery({ + queryKey: ['playlist', id], + queryFn: async () => { + const result = await window.sonos.fetchPlaylist(id!); + if (!result || typeof result !== 'object' || 'error' in result) throw new Error('playlist not found'); + return result as PlaylistDoc; + }, + enabled: !!id, + staleTime: 60_000, + }); +} + +interface FavTrack { + uri: string | null | undefined; + trackName: string | undefined; + artist: string | undefined; + albumName?: string | undefined; + imageUrl?: string | null; + serviceId: string | null | undefined; + accountId: string | null | undefined; +} + +export function useFavourite(displayName: string | null | undefined, track: FavTrack | null) { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const favId = displayName ? favouritesId(displayName) : null; + + const { data: playlist } = usePlaylist(favId ?? undefined); + + const isFavourited = !!(track?.uri && playlist?.tracks.some(t => t.uri === track.uri)); + + const toggle = useCallback(async () => { + if (!favId || !track?.uri || !displayName) return; + const snapshot = queryClient.getQueryData(['playlist', favId]); + if (isFavourited) { + if (snapshot) { + queryClient.setQueryData(['playlist', favId], { + ...snapshot, + tracks: snapshot.tracks.filter(t => t.uri !== track.uri), + }); + } + try { + await window.sonos.removeTrackFromPlaylist(favId, track.uri); + queryClient.invalidateQueries({ queryKey: ['playlist', favId] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + } catch { + if (snapshot) queryClient.setQueryData(['playlist', favId], snapshot); + showToast('Failed to remove from Favourites'); + } + } else { + const playlistTrack: PlaylistTrack = { + uri: track.uri, + trackName: track.trackName ?? '', + artist: track.artist ?? '', + albumName: track.albumName, + imageUrl: track.imageUrl ?? null, + serviceId: track.serviceId ?? '', + accountId: track.accountId ?? '', + addedBy: displayName, + addedAt: Date.now(), + }; + if (snapshot) { + queryClient.setQueryData(['playlist', favId], { + ...snapshot, + tracks: [...snapshot.tracks, playlistTrack], + }); + } + try { + await window.sonos.addTrackToPlaylist(favId, playlistTrack); + queryClient.invalidateQueries({ queryKey: ['playlist', favId] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + } catch { + if (snapshot) queryClient.setQueryData(['playlist', favId], snapshot); + showToast('Failed to add to Favourites'); + } + } + }, [favId, track, isFavourited, displayName, queryClient, showToast]); + + return { isFavourited, toggle }; +} + +export function useInvalidatePlaylists() { + const queryClient = useQueryClient(); + return (userName: string) => { + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', userName] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'joined', userName] }); + }; +} diff --git a/renderer/src/hooks/useRecentlyPlayed.ts b/renderer/src/hooks/useRecentlyPlayed.ts new file mode 100644 index 0000000..07a54d4 --- /dev/null +++ b/renderer/src/hooks/useRecentlyPlayed.ts @@ -0,0 +1,91 @@ +import { useQuery, useQueries } from '@tanstack/react-query'; +import { api } from '../lib/sonosApi'; +import type { SonosItem } from '../types/sonos'; +import type { ArtistResponse } from '../types/ArtistResponse'; + +function relativeDate(ts: number): string { + const diffMs = Date.now() - ts; + const days = Math.floor(diffMs / 86_400_000); + if (days === 0) return 'Today'; + if (days === 1) return 'Yesterday'; + if (days < 7) return `${days} days ago`; + if (days < 14) return '1 week ago'; + return `${Math.floor(days / 7)} weeks ago`; +} + +function toArtistItem(a: RecentArtist, imageUrl?: string | null): SonosItem { + return { + type: 'ARTIST', + title: a.artist, + name: a.artist, + imageUrl: imageUrl ?? a.imageUrl, + resource: { + type: 'ARTIST', + id: { objectId: a.artistId, serviceId: a.serviceId, accountId: a.accountId }, + }, + }; +} + +function toAlbumItem(a: RecentAlbum): SonosItem { + return { + type: 'ITEM_ALBUM', + title: a.album, + name: a.album, + artist: a.artist, + imageUrl: a.imageUrl, + id: { objectId: a.albumId, serviceId: a.serviceId, accountId: a.accountId }, + summary: { content: `${a.artist} โ€ข ${relativeDate(a.lastPlayed)}` }, + }; +} + +export function useRecentlyPlayed(selectedUserId?: string) { + const { data: currentUserId } = useQuery({ + queryKey: ['displayName'], + queryFn: () => window.sonos.getDisplayName(), + staleTime: Infinity, + }); + + const userId = selectedUserId ?? currentUserId ?? null; + + const { data, isLoading } = useQuery({ + queryKey: ['recentlyPlayed', userId], + queryFn: async () => { + if (!userId) return null; + return window.sonos.fetchRecentlyPlayed(userId); + }, + enabled: !!userId, + staleTime: 5 * 60 * 1000, + }); + + const rawArtists = data?.artists ?? []; + + const artistImageQueries = useQueries({ + queries: rawArtists.slice(0, 9).map((a) => ({ + queryKey: ['artist-image', a.artistId], + queryFn: async (): Promise => { + if (!a.artistId || !a.serviceId || !a.accountId) return null; + const r = await api.browse.artist(a.artistId, { + serviceId: a.serviceId, + accountId: a.accountId, + muse2: true, + }); + if (r.error) return null; + return (r.data as ArtistResponse).images?.tile1x1 ?? null; + }, + staleTime: Infinity, + gcTime: 60 * 60 * 1000, + enabled: !!a.artistId && !!a.serviceId && !!a.accountId, + })), + }); + + return { + artistItems: rawArtists.slice(0, 9).map((a, i) => { + const q = artistImageQueries[i]; + return toArtistItem(a, q?.isSuccess ? (q.data ?? undefined) : undefined); + }), + albumItems: (data?.albums ?? []).map(toAlbumItem), + availableUsers: data?.availableUsers ?? [], + currentUserId: currentUserId ?? null, + isLoading: !!userId && isLoading, + }; +} diff --git a/renderer/src/hooks/useUserProfile.ts b/renderer/src/hooks/useUserProfile.ts new file mode 100644 index 0000000..0427284 --- /dev/null +++ b/renderer/src/hooks/useUserProfile.ts @@ -0,0 +1,19 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +export function useUserProfile(userName: string | undefined) { + return useQuery({ + queryKey: ['user-profile', userName], + queryFn: async () => { + const r = await window.sonos.fetchUserProfile(userName!); + return r ?? null; + }, + enabled: !!userName, + staleTime: 5 * 60_000, + }); +} + +export function useInvalidateUserProfile() { + const queryClient = useQueryClient(); + return (userName: string) => + queryClient.invalidateQueries({ queryKey: ['user-profile', userName] }); +} diff --git a/renderer/src/hooks/useUserStats.ts b/renderer/src/hooks/useUserStats.ts new file mode 100644 index 0000000..c020658 --- /dev/null +++ b/renderer/src/hooks/useUserStats.ts @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query'; +import type { SonosItem } from '../types/sonos'; + +function toArtistItem(a: StatsArtist): SonosItem { + return { + type: 'ARTIST', + title: a.artist, + name: a.artist, + imageUrl: a.imageUrl, + resource: { + type: 'ARTIST', + id: { objectId: a.artistId, serviceId: a.serviceId, accountId: a.accountId }, + }, + }; +} + +function toAlbumItem(a: StatsAlbum): SonosItem { + return { + type: 'ITEM_ALBUM', + title: a.album, + name: a.album, + artist: a.artist, + imageUrl: a.imageUrl, + id: { objectId: a.albumId, serviceId: a.serviceId, accountId: a.accountId }, + summary: { content: a.artist }, + }; +} + +export function useUserStats(userName: string | undefined, count?: number) { + const { data, isLoading } = useQuery({ + queryKey: ['stats', 'alltime', userName ?? null, count ?? 10], + queryFn: () => window.sonos.fetchStats('alltime', userName, count), + enabled: !!userName, + staleTime: 5 * 60_000, + }); + + return { + topTracks: data?.topTracks ?? [], + artistItems: (data?.topArtists ?? []).map(toArtistItem), + albumItems: (data?.topAlbums ?? []).map(toAlbumItem), + totalEvents: data?.totalEvents ?? 0, + isLoading: !!userName && isLoading, + }; +} diff --git a/renderer/src/hooks/useUsers.ts b/renderer/src/hooks/useUsers.ts new file mode 100644 index 0000000..2bd58b0 --- /dev/null +++ b/renderer/src/hooks/useUsers.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; + +export function useUsers(enabled = true) { + return useQuery({ + queryKey: ['users'], + queryFn: () => window.sonos.fetchUsers(), + enabled, + staleTime: 5 * 60_000, + }); +} diff --git a/renderer/src/lib/dragHelpers.ts b/renderer/src/lib/dragHelpers.ts new file mode 100644 index 0000000..e13238c --- /dev/null +++ b/renderer/src/lib/dragHelpers.ts @@ -0,0 +1,13 @@ +export function createDragGhost(label: string, dataTransfer: DataTransfer): void { + const ghost = document.createElement('div'); + Object.assign(ghost.style, { + position: 'fixed', top: '-100px', left: '0', + background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', + color: '#fff', padding: '5px 12px', borderRadius: '6px', + fontSize: '12px', fontWeight: '600', pointerEvents: 'none', whiteSpace: 'nowrap', + }); + ghost.textContent = label; + document.body.appendChild(ghost); + dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, 20); + setTimeout(() => ghost.remove(), 0); +} diff --git a/renderer/src/lib/playlistColor.ts b/renderer/src/lib/playlistColor.ts new file mode 100644 index 0000000..4d9069a --- /dev/null +++ b/renderer/src/lib/playlistColor.ts @@ -0,0 +1,9 @@ +export function getPlaylistColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + hash |= 0; + } + const hue = Math.abs(hash) % 360; + return `linear-gradient(135deg, hsl(${hue},45%,28%), hsl(${(hue + 50) % 360},50%,20%))`; +} diff --git a/renderer/src/styles/CardRow.module.css b/renderer/src/styles/CardRow.module.css index 189fd97..25ec5fb 100644 --- a/renderer/src/styles/CardRow.module.css +++ b/renderer/src/styles/CardRow.module.css @@ -2,7 +2,8 @@ display: flex; gap: 14px; overflow-x: auto; - padding-bottom: 4px; + padding: 16px 2px 4px; + margin-top: -10px; } .cardRow::-webkit-scrollbar { height: 6px; diff --git a/renderer/src/styles/ContextMenu.module.css b/renderer/src/styles/ContextMenu.module.css new file mode 100644 index 0000000..0a04e92 --- /dev/null +++ b/renderer/src/styles/ContextMenu.module.css @@ -0,0 +1,90 @@ +.menu { + position: fixed; + z-index: 9999; + background: var(--bg-glass, rgba(22, 22, 24, 0.92)); + backdrop-filter: blur(24px) saturate(160%); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 4px; + min-width: 190px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.3); + animation: menuIn 0.1s ease-out; + user-select: none; +} + +@keyframes menuIn { + from { opacity: 0; transform: scale(0.97); } + to { opacity: 1; transform: scale(1); } +} + +.item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: var(--r-sm); + cursor: pointer; + font-size: 13px; + color: var(--text-2); + position: relative; + white-space: nowrap; + transition: background 0.08s, color 0.08s; +} +.item:hover { + background: var(--bg-2); + color: var(--text); +} + +.itemIcon { + font-size: 12px; + width: 16px; + text-align: center; + flex-shrink: 0; + color: var(--text-3); +} + +.itemArrow { + margin-left: auto; + font-size: 10px; + color: var(--text-3); + padding-left: 8px; +} + +.separator { + height: 1px; + background: var(--border); + margin: 4px 2px; +} + +/* Submenu */ +.submenuWrap { + position: relative; +} + +.submenu { + position: fixed; + z-index: 10000; + background: var(--bg-glass, rgba(22, 22, 24, 0.92)); + backdrop-filter: blur(24px) saturate(160%); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 4px; + min-width: 190px; + max-height: 280px; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55); + animation: menuIn 0.1s ease-out; +} + +.submenu::-webkit-scrollbar { width: 6px; } +.submenu::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.15); + border-radius: 3px; +} + +.emptyHint { + font-size: 12px; + color: var(--text-3); + padding: 6px 10px; + pointer-events: none; +} diff --git a/renderer/src/styles/HomePanel.module.css b/renderer/src/styles/HomePanel.module.css index 9bc059d..b09aa58 100644 --- a/renderer/src/styles/HomePanel.module.css +++ b/renderer/src/styles/HomePanel.module.css @@ -15,6 +15,94 @@ margin-bottom: 18px; } +.sectionAction { + margin-left: auto; + background: none; + border: 1px solid var(--border); + border-radius: 12px; + padding: 4px 12px; + font-size: 12px; + font-family: inherit; + color: var(--text-3); + cursor: pointer; + transition: all 0.12s; +} +.sectionAction:hover { color: var(--text); border-color: var(--border-2); background: rgba(255,255,255,0.05); } + +.playlistRow { + display: flex; + gap: 12px; + overflow-x: auto; + padding-bottom: 8px; +} +.playlistRow::-webkit-scrollbar { height: 6px; } +.playlistRow::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } + +.usersRow { + display: flex; + gap: 20px; + overflow-x: auto; + /* vertical padding absorbs the scale(1.07) + ring shadow without clipping */ + padding: 6px 4px 10px; + margin: -6px -4px -10px; +} +.usersRow::-webkit-scrollbar { height: 0; } + +.userChip { + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + background: none; + border: none; + cursor: pointer; + padding: 0; + flex-shrink: 0; + font-family: inherit; +} +.userChip:hover .userAvatar { transform: scale(1.07); box-shadow: 0 0 0 2px rgba(255,255,255,0.3); } + +.userAvatar { + width: 72px; + height: 72px; + border-radius: 50%; + background: var(--bg-2); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + font-weight: 700; + color: var(--text); + overflow: hidden; + transition: transform 0.15s, box-shadow 0.15s; + flex-shrink: 0; +} +.userAvatar img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.userAvatarSkel { + width: 72px; + height: 72px; + border-radius: 50%; + background: var(--bg-2); + animation: skelPulse 1.4s ease-in-out infinite; + flex-shrink: 0; +} + +.userName { + font-size: 11px; + color: var(--text-2); + max-width: 64px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; +} + .sectionTitle { font-size: 22px; font-weight: 700; @@ -28,3 +116,345 @@ text-align: center; padding: 56px 0; } + +.recentHeader { + margin-bottom: 20px; +} + +.userSep { + color: var(--text-3); +} + +.subSectionTitle { + font-size: 16px; + font-weight: 600; + color: var(--text-2); + letter-spacing: -0.2px; +} + +.recentEmpty { + color: var(--text-3); + font-size: 13px; + padding: 12px 0 32px; +} + +/* โ”€โ”€ Skeletons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.skelTitle { + height: 18px; + width: 56px; + border-radius: 4px; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; + margin-bottom: 2px; +} + +.skelAlbumArt { + width: 46px; + height: 46px; + border-radius: 5px; + flex-shrink: 0; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; +} + +.skelLine { + height: 12px; + width: 78%; + border-radius: 3px; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; +} + +.skelLineShort { + height: 10px; + width: 50%; + border-radius: 3px; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; + margin-top: 5px; +} + +.skelCircle { + width: min(100cqi, calc(100cqb - 18px)); + height: min(100cqi, calc(100cqb - 18px)); + border-radius: 50%; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; +} + +/* โ”€โ”€ Recently Played grid layout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.recentGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + align-items: stretch; + margin-bottom: 40px; +} + +/* Album list */ + +.albumList { + display: flex; + flex-direction: column; + gap: 2px; +} + +.albumRow { + display: flex; + gap: 12px; + align-items: center; + padding: 7px 8px; + border-radius: 8px; + cursor: grab; + transition: background 0.15s; +} +.albumRow:active { cursor: grabbing; } +.albumRow:hover { background: rgba(255, 255, 255, 0.06); } +.albumRow:hover .albumAddBtn { opacity: 1; } + +.albumAddBtn { + margin-left: auto; + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.12); + border: none; + cursor: pointer; + color: var(--text); + font-size: 18px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s, background 0.15s; +} +.albumAddBtn:hover { background: rgba(255, 255, 255, 0.22); } + +.albumArtWrap { + width: 46px; + height: 46px; + border-radius: 5px; + overflow: hidden; + flex-shrink: 0; + background: var(--bg-1); +} +.albumArt { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.albumArtPh { width: 100%; height: 100%; } + +.albumInfo { overflow: hidden; } +.albumName { + font-size: 13px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.albumSub { + font-size: 11px; + color: var(--text-2); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Artist grid */ + +.artistGrid { + display: flex; + flex-direction: column; + gap: 12px; +} + +.artistGridInner { + display: grid; + grid-template-columns: repeat(var(--grid-cols, 3), 1fr); + grid-template-rows: repeat(var(--grid-rows, 3), 1fr); + row-gap: 4px; + column-gap: 0; +} + +.artistCell { + container-type: size; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + cursor: pointer; +} + +.artistCircle { + position: relative; + width: min(100cqi, calc(100cqb - 18px)); + height: min(100cqi, calc(100cqb - 18px)); + flex-shrink: 0; + border-radius: 50%; + overflow: hidden; + background: var(--bg-1); + transition: box-shadow 0.3s ease; +} +.artistCell:hover .artistCircle { + box-shadow: + 0 0 0 2px rgba(255, 255, 255, 0.5), + 0 0 10px 3px rgba(255, 255, 255, 0.2), + 0 0 20px 4px rgba(255, 255, 255, 0.08); +} + +.artistImg { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.artistPh { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + color: var(--text-3); +} +.artistCellName { + font-size: 10px; + font-weight: 500; + color: var(--text-2); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + line-height: 1; +} + +.userDropdown { + position: relative; + display: inline-block; +} + +.userDropdownTrigger { + background: none; + border: none; + padding: 0; + margin: 0; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; + color: var(--text); + cursor: pointer; + display: inline-flex; + align-items: baseline; + gap: 4px; +} + +.userDropdownTrigger:disabled { + cursor: default; +} + +.chevron { + font-size: 15px; + opacity: 0.75; + line-height: 1; +} + +.chevronLocked { + font-size: 13px; + opacity: 0.6; + line-height: 1; +} + +.userDropdownList { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 50; + background: #1e1e1e; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 4px; + min-width: 160px; + list-style: none; + margin: 0; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); +} + +.userDropdownLi { + display: flex; + align-items: center; +} + +.userDropdownItem, +.userDropdownItemActive { + display: block; + flex: 1; + min-width: 0; + background: none; + border: none; + text-align: left; + padding: 7px 12px; + border-radius: 5px; + font-size: 13px; + cursor: pointer; + color: var(--text-2); + transition: background 0.1s; +} + +.profileLinkBtn { + background: none; + border: none; + color: var(--text-3); + cursor: pointer; + padding: 4px 6px; + border-radius: 4px; + display: flex; + align-items: center; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.12s, color 0.12s, background 0.12s; +} +.userDropdownLi:hover .profileLinkBtn { opacity: 1; } +.profileLinkBtn:hover { color: var(--text); background: rgba(255, 255, 255, 0.07); } + +.userDropdownItem:hover { + background: rgba(255, 255, 255, 0.07); + color: var(--text); +} + +.userDropdownItemActive { + color: var(--text); + font-weight: 600; +} + +.userDropdown[data-tooltip] { + cursor: default; +} + +.userDropdown[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + top: calc(100% + 8px); + left: 0; + background: #242424; + color: var(--text-2); + font-size: 12px; + font-weight: 400; + letter-spacing: 0; + padding: 7px 11px; + border-radius: 7px; + white-space: nowrap; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45); + pointer-events: none; + z-index: 100; +} diff --git a/renderer/src/styles/LeaderboardPanel.module.css b/renderer/src/styles/LeaderboardPanel.module.css index 1c21c97..421893c 100644 --- a/renderer/src/styles/LeaderboardPanel.module.css +++ b/renderer/src/styles/LeaderboardPanel.module.css @@ -298,6 +298,24 @@ white-space: nowrap; } +.userNameBtn { + font-size: 14px; + font-weight: 500; + color: var(--text); + width: 120px; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background: none; + border: none; + padding: 0; + text-align: left; + font-family: inherit; + cursor: pointer; +} +.userNameBtn:hover { text-decoration: underline; } + .barWrap { flex: 1; height: 6px; diff --git a/renderer/src/styles/PlayerBar.module.css b/renderer/src/styles/PlayerBar.module.css index e139994..47f105c 100644 --- a/renderer/src/styles/PlayerBar.module.css +++ b/renderer/src/styles/PlayerBar.module.css @@ -200,6 +200,13 @@ color: #1ed760; background: rgba(255, 255, 255, 0.08); } +.ctrl.favourited { + color: #e85d7a; +} +.ctrl.favourited:hover:not(:disabled) { + color: #ff6b8a; + background: rgba(232, 93, 122, 0.1); +} .playBtn { width: 36px; diff --git a/renderer/src/styles/PlaylistPanel.module.css b/renderer/src/styles/PlaylistPanel.module.css new file mode 100644 index 0000000..3d804f8 --- /dev/null +++ b/renderer/src/styles/PlaylistPanel.module.css @@ -0,0 +1,427 @@ +.panel { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding-bottom: calc(var(--player-h) + 24px); +} + +/* โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.header { + padding: calc(var(--nav-h) + 24px) 28px 24px; + background: linear-gradient(to bottom, rgba(255,255,255,0.04), transparent); +} + +.headerContent { + display: flex; + gap: 24px; + align-items: flex-end; +} + +.coverArt { + width: 180px; + height: 180px; + border-radius: var(--r-md); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 64px; + font-weight: 700; + color: rgba(255, 255, 255, 0.6); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + position: relative; + overflow: hidden; +} + +.coverArtOwner { cursor: pointer; } + +.coverArtImg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.coverArtOverlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + opacity: 0; + transition: opacity 0.15s; +} +.coverArtOwner:hover .coverArtOverlay { opacity: 1; } +.coverArtUploading { opacity: 1 !important; } + +.headerInfo { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; + padding-bottom: 4px; +} + +.playlistLabel { + font-size: 11px; + font-weight: 600; + color: var(--text-3); + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.playlistName { + font-size: 32px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.6px; + line-height: 1.1; + margin: 0; +} + +.playlistNameEditable { + cursor: text; +} +.playlistNameEditable:hover { opacity: 0.8; } + +.renameInput { + font-size: 32px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.6px; + line-height: 1.1; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + padding: 2px 8px; + font-family: inherit; + outline: none; + width: 100%; +} + +.metaRow { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: 13px; + color: var(--text-3); +} + +.ownerLink { + background: none; + border: none; + padding: 0; + font-size: inherit; + font-family: inherit; + color: var(--text-2); + cursor: pointer; + font-weight: 500; +} +.ownerLink:hover { color: var(--text); text-decoration: underline; } + +.metaDot { color: var(--text-3); } + +.badge { + font-size: 12px; + color: var(--text-3); +} + +.headerActions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.playAllBtn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 20px; + padding: 8px 20px; + font-size: 13px; + font-weight: 600; + font-family: inherit; + color: var(--text); + cursor: pointer; + transition: background 0.15s; +} +.playAllBtn:hover { background: rgba(255, 255, 255, 0.18); } + +.joinBtn { + background: none; + border: 1px solid var(--border); + border-radius: 20px; + padding: 8px 20px; + font-size: 13px; + font-weight: 500; + font-family: inherit; + color: var(--text-2); + cursor: pointer; + transition: all 0.15s; +} +.joinBtn:hover { border-color: var(--border-2); color: var(--text); background: rgba(255,255,255,0.05); } +.joined { color: var(--text-3); } + +.deleteBtn { + background: none; + border: 1px solid rgba(255, 80, 80, 0.3); + border-radius: 20px; + padding: 8px 20px; + font-size: 13px; + font-weight: 500; + font-family: inherit; + color: rgba(255, 80, 80, 0.6); + cursor: pointer; + transition: all 0.15s; +} +.deleteBtn:hover { border-color: rgba(255, 80, 80, 0.7); color: rgba(255, 80, 80, 1); background: rgba(255,80,80,0.08); } + +.deleteConfirmBtn { + background: rgba(255, 80, 80, 0.15); + border: 1px solid rgba(255, 80, 80, 0.5); + border-radius: 20px; + padding: 8px 20px; + font-size: 13px; + font-weight: 600; + font-family: inherit; + color: rgba(255, 100, 100, 1); + cursor: pointer; + transition: all 0.15s; +} +.deleteConfirmBtn:hover { background: rgba(255, 80, 80, 0.25); } + +.cancelBtn { + background: none; + border: 1px solid var(--border); + border-radius: 20px; + padding: 8px 20px; + font-size: 13px; + font-weight: 500; + font-family: inherit; + color: var(--text-3); + cursor: pointer; + transition: all 0.15s; +} +.cancelBtn:hover { border-color: var(--border-2); color: var(--text-2); } + +/* โ”€โ”€ Track table โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.tracks { + padding: 8px 28px 8px; + --playlist-cols: 32px 40px 3fr 1.4fr 56px; +} + +.tableHeader { + display: grid; + grid-template-columns: var(--playlist-cols); + align-items: center; + gap: 12px; + padding: 6px 8px 8px; + border-bottom: 1px solid var(--border); + margin-bottom: 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + color: var(--text-3); + text-transform: uppercase; +} + +.trackRow { + display: grid; + grid-template-columns: var(--playlist-cols); + align-items: center; + gap: 12px; + padding: 5px 8px; + border-radius: var(--r-sm); + transition: background 0.12s; + cursor: grab; +} +.trackRow:active { cursor: grabbing; } +.trackRow:hover { background: var(--bg-2); } +.trackRow:hover .addBtn { opacity: 1; } +.trackRow:hover .removeBtn { opacity: 1; } + +.ordinalCell { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.ordinal { + font-size: 13px; + color: var(--text-3); +} + +.dragHandle { + display: none; + font-size: 14px; + color: var(--text-3); + cursor: grab; + line-height: 1; +} +.trackRow:hover .dragHandle { display: block; } +.trackRow:hover .ordinal { display: none; } + +.dropLine { + height: 2px; + background: rgba(255, 255, 255, 0.5); + margin: 1px 8px; + border-radius: 1px; +} + +.trackArtWrap { + width: 40px; + height: 40px; + border-radius: 4px; + overflow: hidden; + background: var(--bg-1); + flex-shrink: 0; +} +.trackArt { width: 100%; height: 100%; object-fit: cover; display: block; } +.trackArtPh { width: 100%; height: 100%; background: var(--bg-2); } + +.trackMain { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.trackName { + font-size: 13px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.trackArtist { + font-size: 11px; + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.trackAlbum { + font-size: 12px; + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.trackAddedBy { + font-size: 12px; + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.addedByLink { + background: none; + border: none; + padding: 0; + font-size: inherit; + font-family: inherit; + color: inherit; + cursor: pointer; +} +.addedByLink:hover { color: var(--text); text-decoration: underline; } + +.trackControls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; +} + +.addBtn { + width: 26px; + height: 26px; + background: none; + border: 1px solid var(--border); + border-radius: 50%; + color: var(--text-3); + cursor: pointer; + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; + opacity: 0; + flex-shrink: 0; +} +.addBtn:hover { border-color: var(--border-2); color: var(--text-2); background: var(--bg-2); } + +.removeBtn { + background: none; + border: none; + color: var(--text-3); + font-size: 16px; + cursor: pointer; + padding: 0 2px; + line-height: 1; + opacity: 0; + transition: color 0.12s, opacity 0.12s; + flex-shrink: 0; +} +.removeBtn:hover { color: rgba(255, 80, 80, 0.9); } + +.emptyMsg { + font-size: 13px; + color: var(--text-3); + padding: 32px 8px; +} + +/* โ”€โ”€ Members section โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.section { + padding: 0 28px; + margin-top: 32px; +} + +.sectionTitle { + font-size: 16px; + font-weight: 700; + color: var(--text-2); + margin-bottom: 12px; + letter-spacing: -0.2px; +} + +.memberList { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.memberChip { + background: none; + border: 1px solid var(--border); + border-radius: 20px; + padding: 5px 14px; + font-size: 12px; + font-family: inherit; + color: var(--text-2); + cursor: pointer; + transition: all 0.12s; +} +.memberChip:hover { border-color: var(--border-2); color: var(--text); background: rgba(255,255,255,0.05); } + +/* โ”€โ”€ Skeleton โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.skelBlock { + border-radius: 4px; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; +} diff --git a/renderer/src/styles/ProfilePanel.module.css b/renderer/src/styles/ProfilePanel.module.css new file mode 100644 index 0000000..109fe83 --- /dev/null +++ b/renderer/src/styles/ProfilePanel.module.css @@ -0,0 +1,337 @@ +.panel { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: calc(var(--nav-h) + 24px) 28px calc(var(--player-h) + 24px); +} + +/* โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.header { + display: flex; + align-items: stretch; + gap: 20px; + margin-bottom: 40px; + padding-bottom: 28px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.avatar { + width: 140px; + height: 140px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 60px; + font-weight: 700; + color: #fff; + flex-shrink: 0; + letter-spacing: -1px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); + position: relative; + overflow: hidden; +} + +.avatarEditable { + cursor: pointer; +} +.avatarEditable:hover .avatarOverlay { opacity: 1; } + +.avatarImg { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + border-radius: 50%; +} + +.avatarOverlay { + position: absolute; + inset: 0; + border-radius: 50%; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + font-weight: 300; + color: rgba(255, 255, 255, 0.85); + opacity: 0; + transition: opacity 0.15s; +} +.avatarOverlayActive { opacity: 1 !important; } + +.headerInfo { + display: flex; + flex-direction: column; + min-width: 0; +} + +.nameWrap { + flex: 1; + display: flex; + align-items: flex-end; + padding-bottom: 20px; +} + +.userName { + font-size: 48px; + font-weight: 700; + color: var(--text); + letter-spacing: -1.5px; + line-height: 1; +} + +.headerMeta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.tierBadge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 500; + color: var(--text-2); + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 20px; + padding: 4px 10px 4px 5px; +} + +.tierIcon { + width: 18px; + height: 18px; + object-fit: contain; +} + +.statChip { + font-size: 12px; + color: var(--text-3); + display: flex; + align-items: center; + gap: 6px; +} + +.statChipValue { + font-weight: 600; + color: var(--text-2); + font-variant-numeric: tabular-nums; +} + +.statChipSep { + color: rgba(255, 255, 255, 0.2); +} + +.signOutInline { + background: none; + border: none; + color: rgba(255, 80, 80, 0.6); + font-size: 12px; + font-weight: 500; + font-family: inherit; + padding: 0; + cursor: pointer; + transition: color 0.12s; +} +.signOutInline:hover { color: rgba(255, 80, 80, 1); } + +/* โ”€โ”€ Sections โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.section { + margin-bottom: 44px; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} + +.sectionTitle { + font-size: 22px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.4px; + margin-bottom: 8px; +} + +.sectionAction { + background: none; + border: 1px solid var(--border); + border-radius: 20px; + padding: 5px 14px; + font-size: 12px; + font-weight: 500; + font-family: inherit; + color: var(--text-2); + cursor: pointer; + transition: all 0.12s; +} +.sectionAction:hover { border-color: var(--border-2); color: var(--text); background: rgba(255,255,255,0.05); } + +.subSectionTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-2); + letter-spacing: -0.1px; + margin-top: 20px; + margin-bottom: 0; +} + +.emptyMsg { + font-size: 13px; + color: var(--text-3); + padding: 16px 0 8px; +} + +/* โ”€โ”€ Top Tracks grid โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.topTracksGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 12px; + margin-top: 4px; +} + +.topTracksCol { + display: flex; + flex-direction: column; + min-width: 0; +} + +@media (max-width: 720px) { + .topTracksGrid { + grid-template-columns: 1fr; + } +} + +.trackLink { + background: none; + border: none; + color: inherit; + font: inherit; + padding: 0; + cursor: pointer; + opacity: 0.75; + transition: opacity 0.12s; +} +.trackLink:hover { opacity: 1; text-decoration: underline; } + +.countBadge { + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--text-3); + white-space: nowrap; +} + +/* โ”€โ”€ Queuedle stats grid โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.rankHero { + display: flex; + align-items: center; + gap: 16px; + margin-left: auto; + padding: 14px 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 14px; + flex-shrink: 0; +} + +.rankHeroIcon { + width: 80px; + height: 80px; + object-fit: contain; + flex-shrink: 0; + filter: drop-shadow(0 4px 16px rgba(0, 0, 0, 0.5)); +} + +.rankHeroText { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.rankHeroName { + font-size: 24px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.5px; + line-height: 1.1; +} + +.rankHeroPercent { + font-size: 13px; + color: var(--text-3); + letter-spacing: 0; +} + +.sc { + font-variant-caps: small-caps; + letter-spacing: 0.04em; +} + +.queuedleStats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} + +.statCell { + background: var(--bg-2, rgba(255, 255, 255, 0.04)); + border-radius: 10px; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.statCellValue { + font-size: 22px; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; + letter-spacing: -0.5px; + line-height: 1; +} + +.statCellLabel { + font-size: 10px; + color: var(--text-3); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.6px; +} + +/* โ”€โ”€ Skeletons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.skelAvatar { + width: 72px; + height: 72px; + border-radius: 50%; + background: var(--bg-1); + flex-shrink: 0; + animation: skelPulse 1.8s ease-in-out infinite; +} + +.skelBlock { + border-radius: 4px; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; +} + +/* โ”€โ”€ Playlists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.playlistRow { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 16px; +} + diff --git a/renderer/src/styles/QueueSidebar.module.css b/renderer/src/styles/QueueSidebar.module.css index f63ead6..3fc0bfa 100644 --- a/renderer/src/styles/QueueSidebar.module.css +++ b/renderer/src/styles/QueueSidebar.module.css @@ -296,6 +296,17 @@ text-overflow: ellipsis; } +.attributionUser { + background: none; + border: none; + padding: 0; + font-size: inherit; + font-family: inherit; + color: inherit; + cursor: pointer; +} +.attributionUser:hover { color: var(--text); text-decoration: underline; } + .timeToPlay { font-size: 10px; color: var(--text-3); diff --git a/renderer/src/styles/Queuedle.module.css b/renderer/src/styles/Queuedle.module.css index 8d0aa99..eb74128 100644 --- a/renderer/src/styles/Queuedle.module.css +++ b/renderer/src/styles/Queuedle.module.css @@ -966,6 +966,21 @@ text-overflow: ellipsis; white-space: nowrap; } +.scoreNameBtn { + background: none; + border: none; + padding: 0; + font-size: 14px; + font-family: inherit; + color: var(--text); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + text-align: left; +} +.scoreNameBtn:hover { text-decoration: underline; } .scoreBreakdown { font-size: 11px; color: var(--text-3); diff --git a/renderer/src/styles/Toast.module.css b/renderer/src/styles/Toast.module.css new file mode 100644 index 0000000..31ec093 --- /dev/null +++ b/renderer/src/styles/Toast.module.css @@ -0,0 +1,52 @@ +.container { + position: fixed; + bottom: 108px; /* above the player bar (24px bottom + 68px bar + 16px gap) */ + right: 20px; + z-index: 500; + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-end; + pointer-events: none; +} + +.toast { + padding: 9px 16px; + border-radius: 10px; + font-size: 13px; + font-weight: 500; + max-width: 320px; + backdrop-filter: blur(16px); + border: 1px solid transparent; + animation: toastIn 0.2s ease; + pointer-events: auto; +} + +@keyframes toastIn { + from { + opacity: 0; + transform: translateX(12px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.error { + background: rgba(220, 38, 38, 0.85); + border-color: rgba(248, 113, 113, 0.3); + color: #fff; +} + +.success { + background: rgba(22, 163, 74, 0.85); + border-color: rgba(74, 222, 128, 0.3); + color: #fff; +} + +.info { + background: rgba(30, 30, 40, 0.92); + border-color: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); +} diff --git a/renderer/src/styles/TopNav.module.css b/renderer/src/styles/TopNav.module.css index 1614dff..15f1e47 100644 --- a/renderer/src/styles/TopNav.module.css +++ b/renderer/src/styles/TopNav.module.css @@ -208,18 +208,65 @@ .groupOption:hover { background: var(--bg-2); color: var(--text); } .groupOptionActive { color: var(--text) !important; font-weight: 600; } -/* โ”€โ”€ Name popover โ”€โ”€ */ -.nameWrap { position: relative; } +/* โ”€โ”€ Profile chip + popover โ”€โ”€ */ +.profileWrap { position: relative; } -.namePopover { +.profileChip { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + padding: 4px 8px 4px 4px; + border-radius: 20px; + cursor: pointer; + transition: background 0.12s; + flex-shrink: 0; + max-width: 140px; +} +.profileChip:hover { background: rgba(255, 255, 255, 0.08); } +.profileChipOpen { background: rgba(255, 255, 255, 0.10); } +.profileChipActive { background: rgba(255, 255, 255, 0.08); } + +.profileAvatar { + width: 22px; + height: 22px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.18); + color: #fff; + font-size: 11px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.profileName { + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.75); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.profileSignIn { + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.5); + padding: 0 4px; +} + +.profilePopover { position: absolute; - top: calc(100% + 20px); - right: 0; + top: calc(100% + 16px); + left: 0; background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: 12px; - width: 200px; + width: 220px; display: flex; flex-direction: column; gap: 8px; @@ -227,12 +274,85 @@ z-index: 300; } -.namePopoverLabel { +.profileHeader { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 4px; +} + +.profileAvatarLg { + width: 34px; + height: 34px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.15); + color: #fff; + font-size: 15px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.profileHeaderName { + font-size: 14px; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profileViewBtn { + background: rgba(255, 255, 255, 0.07); + border: 1px solid var(--border); + border-radius: var(--r-sm); + color: var(--text-2); + font-size: 12px; + font-weight: 500; + font-family: inherit; + padding: 7px 10px; + cursor: pointer; + text-align: left; + transition: background 0.12s, color 0.12s; +} +.profileViewBtn:hover { background: rgba(255, 255, 255, 0.12); color: var(--text); } + +.profileDivider { + height: 1px; + background: var(--border); + margin: 0 -12px; +} + +.profileChangeSection { + display: flex; + flex-direction: column; + gap: 6px; +} + +.profileChangeLabel { font-size: 11px; color: var(--text-3); font-weight: 500; } +.profileSignOutBtn { + background: none; + border: none; + color: rgba(255, 80, 80, 0.75); + font-size: 12px; + font-weight: 500; + font-family: inherit; + padding: 4px 0; + cursor: pointer; + text-align: left; + transition: color 0.12s; +} +.profileSignOutBtn:hover { color: rgba(255, 80, 80, 1); } + +/* โ”€โ”€ Name input (shared by profile popover) โ”€โ”€ */ + .nameInput { background: var(--bg-2); border: 1px solid var(--border); diff --git a/renderer/src/styles/global.css b/renderer/src/styles/global.css index faf5f3c..92e3f87 100644 --- a/renderer/src/styles/global.css +++ b/renderer/src/styles/global.css @@ -94,3 +94,8 @@ html[data-mini] #root { ::-webkit-scrollbar-thumb:hover { background-color: rgba(255, 255, 255, 0.22); } + +@keyframes skelPulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 0.55; } +} diff --git a/renderer/src/test/setup.ts b/renderer/src/test/setup.ts index 4bd396b..09eaf46 100644 --- a/renderer/src/test/setup.ts +++ b/renderer/src/test/setup.ts @@ -61,6 +61,7 @@ Object.defineProperty(window, 'sonos', { getVersion: vi.fn(pending), isNewVersion: vi.fn(() => Promise.resolve(false)), openExternal: vi.fn(() => Promise.resolve()), + fetchRecentlyPlayed: vi.fn(() => Promise.resolve(null)), geniusDescription: vi.fn(() => Promise.resolve(null)), geniusArtist: vi.fn(() => Promise.resolve(null)), trackEvent: vi.fn(() => Promise.resolve()), @@ -71,5 +72,19 @@ Object.defineProperty(window, 'sonos', { onWindowMaximized: vi.fn(() => () => {}), onUpdateDownloaded: vi.fn(noop), installUpdate: vi.fn(pending), + fetchPlaylists: vi.fn(() => Promise.resolve([])), + fetchPlaylist: vi.fn(pending), + createPlaylist: vi.fn(pending), + addTrackToPlaylist: vi.fn(pending), + removeTrackFromPlaylist: vi.fn(pending), + reorderPlaylistTracks: vi.fn(pending), + ensureFavourites: vi.fn(pending), + updatePlaylist: vi.fn(pending), + deletePlaylist: vi.fn(pending), + joinPlaylist: vi.fn(pending), + uploadPlaylistImage: vi.fn(pending), + fetchUsers: vi.fn(() => Promise.resolve([])), + fetchUserProfile: vi.fn(() => Promise.resolve(null)), + uploadProfileImage: vi.fn(pending), }, }); diff --git a/renderer/src/types/globals.d.ts b/renderer/src/types/globals.d.ts index 9395a5a..c3106f5 100644 --- a/renderer/src/types/globals.d.ts +++ b/renderer/src/types/globals.d.ts @@ -189,6 +189,89 @@ interface GameStatsResult { error?: string; } +interface RecentTrack { + trackName: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + album?: string; + albumId?: string; + imageUrl?: string; + uri?: string; + lastPlayed: number; +} + +interface RecentArtist { + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + imageUrl?: string; + lastPlayed: number; +} + +interface RecentAlbum { + album: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + albumId?: string; + imageUrl?: string; + lastPlayed: number; +} + +interface RecentlyPlayedData { + tracks: RecentTrack[]; + artists: RecentArtist[]; + albums: RecentAlbum[]; + availableUsers?: string[]; +} + +interface UserProfile { + id: string; + imageUrl?: string | null; + updatedAt?: number; +} + +interface UserSummary { + userId: string; + lastQueued: number; + imageUrl?: string | null; +} + +interface PlaylistTrack { + uri: string; + trackName: string; + artist: string; + albumName?: string; + imageUrl?: string | null; + serviceId: string; + accountId: string; + addedBy: string; + addedAt: number; +} + +interface PlaylistMeta { + id: string; + name: string; + owner: string; + isPublic: boolean; + isFavourites?: boolean; + memberCount: number; + trackCount: number; + updatedAt: number; + imageUrl?: string | null; +} + +interface PlaylistDoc extends PlaylistMeta { + members: string[]; + tracks: PlaylistTrack[]; + createdAt: number; + imageUrl?: string | null; +} + interface SonosPreload { getVersion: () => Promise; isNewVersion: () => Promise; @@ -239,7 +322,8 @@ interface SonosPreload { albumId?: string; imageUrl?: string; }) => Promise; - fetchStats: (period: string, userId?: string) => Promise; + fetchRecentlyPlayed: (userId: string) => Promise; + fetchStats: (period: string, userId?: string, count?: number) => Promise; fetchDailyGame: (date?: string) => Promise; submitGameScore: (input: { gameId: string; @@ -264,6 +348,20 @@ interface SonosPreload { onWindowMaximized: (cb: (maximized: boolean) => void) => Unsubscribe; onUpdateDownloaded: (cb: (version: string) => void) => Unsubscribe; installUpdate: () => Promise; + ensureFavourites: () => Promise; + fetchPlaylists: (filter: { owner?: string; member?: string }) => Promise; + fetchPlaylist: (id: string) => Promise; + createPlaylist: (name: string, isPublic: boolean) => Promise; + updatePlaylist: (playlistId: string, patch: { name?: string; isPublic?: boolean }) => Promise; + deletePlaylist: (playlistId: string) => Promise<{ ok?: boolean; error?: string }>; + addTrackToPlaylist: (playlistId: string, track: PlaylistTrack) => Promise; + removeTrackFromPlaylist: (playlistId: string, uri: string) => Promise; + reorderPlaylistTracks: (playlistId: string, fromIndex: number, toIndex: number) => Promise; + joinPlaylist: (playlistId: string, action: 'join' | 'leave') => Promise; + uploadPlaylistImage: (playlistId: string, data: ArrayBuffer, mimeType: string, userName: string) => Promise<{ imageUrl: string } | { error: string }>; + fetchUsers: () => Promise; + fetchUserProfile: (userName: string) => Promise; + uploadProfileImage: (userName: string, data: ArrayBuffer, mimeType: string) => Promise<{ imageUrl: string } | { error: string }>; } interface Window { diff --git a/server/azuredeploy.json b/server/azuredeploy.json index c21dabd..cb55a48 100644 --- a/server/azuredeploy.json +++ b/server/azuredeploy.json @@ -83,6 +83,15 @@ "publicAccess": "None" } }, + { + "type": "Microsoft.Storage/storageAccounts/blobServices/containers", + "apiVersion": "2023-01-01", + "name": "[concat(variables('storageName'), '/default/playlist-images')]", + "dependsOn": ["[resourceId('Microsoft.Storage/storageAccounts', variables('storageName'))]"], + "properties": { + "publicAccess": "None" + } + }, { "type": "Microsoft.SignalRService/webPubSub", "apiVersion": "2023-02-01", @@ -168,6 +177,34 @@ } } }, + { + "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", + "apiVersion": "2024-02-15-preview", + "name": "[concat(variables('cosmosName'), '/truetunes/playlists')]", + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', variables('cosmosName'), 'truetunes')]" + ], + "properties": { + "resource": { + "id": "playlists", + "partitionKey": { "paths": ["/id"], "kind": "Hash" } + } + } + }, + { + "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", + "apiVersion": "2024-02-15-preview", + "name": "[concat(variables('cosmosName'), '/truetunes/profiles')]", + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', variables('cosmosName'), 'truetunes')]" + ], + "properties": { + "resource": { + "id": "profiles", + "partitionKey": { "paths": ["/id"], "kind": "Hash" } + } + } + }, { "type": "Microsoft.Web/serverfarms", "apiVersion": "2023-01-01", diff --git a/server/src/functions/favourites-ensure.ts b/server/src/functions/favourites-ensure.ts new file mode 100644 index 0000000..30ffc1f --- /dev/null +++ b/server/src/functions/favourites-ensure.ts @@ -0,0 +1,61 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { getPlaylistContainer } from '../lib/getContainer'; + +export async function favouritesEnsureHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const userName = request.params['userName']; + if (!userName) { + return { status: 400, jsonBody: { error: 'userName param required' } }; + } + + const id = `fav-${userName}`; + + try { + const container = getPlaylistContainer(); + + // Return existing + const { resource } = await container.item(id, id).read(); + if (resource) { + return { jsonBody: resource, headers: { 'Access-Control-Allow-Origin': '*' } }; + } + + // Create + const now = Date.now(); + const doc = { + id, + name: 'Favourites', + owner: userName, + isPublic: false, + isFavourites: true, + members: [userName], + tracks: [], + createdAt: now, + updatedAt: now, + }; + + try { + await container.items.create(doc); + context.log(`[favourites-ensure] created for ${userName}`); + return { status: 201, jsonBody: doc, headers: { 'Access-Control-Allow-Origin': '*' } }; + } catch (createErr) { + // 409 = concurrent create โ€” another request beat us; read and return the existing doc + if ((createErr as { code?: number }).code === 409) { + const { resource: existing } = await container.item(id, id).read(); + if (existing) return { jsonBody: existing, headers: { 'Access-Control-Allow-Origin': '*' } }; + } + throw createErr; + } + } catch (err) { + context.error('[favourites-ensure] failed:', err); + return { status: 500, jsonBody: { error: 'Internal server error' } }; + } +} + +app.http('favourites-ensure', { + methods: ['POST'], + route: 'profile/{userName}/favourites', + authLevel: 'anonymous', + handler: favouritesEnsureHandler, +}); diff --git a/server/src/functions/playlist-add-track.ts b/server/src/functions/playlist-add-track.ts new file mode 100644 index 0000000..d041264 --- /dev/null +++ b/server/src/functions/playlist-add-track.ts @@ -0,0 +1,75 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { withOCC } from '../lib/withOCC'; +import { getPlaylistContainer } from '../lib/getContainer'; + +interface PlaylistTrack { + uri: string; + trackName: string; + artist: string; + albumName?: string; + imageUrl?: string | null; + serviceId: string; + accountId: string; + addedBy: string; + addedAt: number; +} + +export async function playlistAddTrackHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const id = request.params['id']; + if (!id) { + return { status: 400, jsonBody: { error: 'id param required' } }; + } + + let body: { track?: PlaylistTrack; userName?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return { status: 400, jsonBody: { error: 'Invalid JSON body' } }; + } + + const { track, userName } = body; + if (!track || !userName) { + return { status: 400, jsonBody: { error: 'track and userName required' } }; + } + + try { + const container = getPlaylistContainer(); + + const updatedTrack: PlaylistTrack = { ...track, addedBy: userName, addedAt: Date.now() }; + + const resource = await withOCC( + () => container.item(id, id).read(), + (doc) => { + if (!doc.members.includes(userName) && doc.owner !== userName) { + throw Object.assign(new Error('Not a member of this playlist'), { statusCode: 403 }); + } + doc.tracks = [...(doc.tracks ?? []), updatedTrack]; + doc.updatedAt = Date.now(); + }, + (doc, etag) => container.item(id, id).replace(doc, { accessCondition: { type: 'IfMatch', condition: etag } }), + ); + + context.log(`[playlist-add-track] id=${id} userName=${userName} track=${track.trackName}`); + + return { + jsonBody: resource, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + const code = (err as { statusCode?: number }).statusCode; + if (code === 404) return { status: 404, jsonBody: { error: 'Playlist not found' } }; + if (code === 403) return { status: 403, jsonBody: { error: 'Not a member of this playlist' } }; + context.error('[playlist-add-track] failed:', err); + return { status: 500, jsonBody: { error: 'Internal server error' } }; + } +} + +app.http('playlist-add-track', { + methods: ['POST'], + route: 'playlist/{id}/tracks', + authLevel: 'anonymous', + handler: playlistAddTrackHandler, +}); diff --git a/server/src/functions/playlist-create.ts b/server/src/functions/playlist-create.ts new file mode 100644 index 0000000..c01fda2 --- /dev/null +++ b/server/src/functions/playlist-create.ts @@ -0,0 +1,60 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { getPlaylistContainer } from '../lib/getContainer'; + +export async function playlistCreateHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + let body: { name?: string; isPublic?: boolean; owner?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return { status: 400, jsonBody: { error: 'Invalid JSON body' } }; + } + + const { name, isPublic = false, owner } = body; + if (!name || !owner) { + return { status: 400, jsonBody: { error: 'name and owner required' } }; + } + const trimmedName = name.trim(); + if (!trimmedName) return { status: 400, jsonBody: { error: 'name cannot be empty' } }; + if (trimmedName.length > 200) return { status: 400, jsonBody: { error: 'name too long (max 200 chars)' } }; + + const id = crypto.randomUUID(); + const now = Date.now(); + + const doc = { + id, + name: trimmedName, + owner, + isPublic, + members: [owner], + tracks: [], + createdAt: now, + updatedAt: now, + }; + + try { + const container = getPlaylistContainer(); + + await container.items.create(doc); + + context.log(`[playlist-create] id=${id} owner=${owner} isPublic=${isPublic}`); + + return { + status: 201, + jsonBody: doc, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[playlist-create] create failed:', err); + return { status: 500, jsonBody: { error: 'Internal server error' } }; + } +} + +app.http('playlist-create', { + methods: ['POST'], + route: 'playlists', + authLevel: 'anonymous', + handler: playlistCreateHandler, +}); diff --git a/server/src/functions/playlist-delete.ts b/server/src/functions/playlist-delete.ts new file mode 100644 index 0000000..4fbca64 --- /dev/null +++ b/server/src/functions/playlist-delete.ts @@ -0,0 +1,59 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { getPlaylistContainer } from '../lib/getContainer'; + +export async function playlistDeleteHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const id = request.params['id']; + if (!id) { + return { status: 400, jsonBody: { error: 'id param required' } }; + } + + let body: { userName?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return { status: 400, jsonBody: { error: 'Invalid JSON body' } }; + } + + const { userName } = body; + if (!userName) { + return { status: 400, jsonBody: { error: 'userName required' } }; + } + + try { + const container = getPlaylistContainer(); + + const { resource } = await container.item(id, id).read(); + if (!resource) { + return { status: 404, jsonBody: { error: 'Playlist not found' } }; + } + if (resource.owner !== userName) { + return { status: 403, jsonBody: { error: 'Only the owner can delete a playlist' } }; + } + if (resource.isFavourites) { + return { status: 403, jsonBody: { error: 'Favourites playlist cannot be deleted' } }; + } + + await container.item(id, id).delete(); + + context.log(`[playlist-delete] id=${id} owner=${userName}`); + + return { + status: 200, + jsonBody: { ok: true }, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[playlist-delete] failed:', err); + return { status: 500, jsonBody: { error: 'Internal server error' } }; + } +} + +app.http('playlist-delete', { + methods: ['DELETE'], + route: 'playlist/{id}', + authLevel: 'anonymous', + handler: playlistDeleteHandler, +}); diff --git a/server/src/functions/playlist-get.ts b/server/src/functions/playlist-get.ts new file mode 100644 index 0000000..1805364 --- /dev/null +++ b/server/src/functions/playlist-get.ts @@ -0,0 +1,39 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { getPlaylistContainer } from '../lib/getContainer'; + +export async function playlistGetHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const id = request.params['id']; + if (!id) { + return { status: 400, jsonBody: { error: 'id param required' } }; + } + + try { + const container = getPlaylistContainer(); + + const { resource } = await container.item(id, id).read(); + + if (!resource) { + return { status: 404, jsonBody: { error: 'Playlist not found' } }; + } + + context.log(`[playlist-get] id=${id} tracks=${resource.tracks?.length ?? 0}`); + + return { + jsonBody: resource, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[playlist-get] read failed:', err); + return { status: 500, jsonBody: { error: 'Internal server error' } }; + } +} + +app.http('playlist-get', { + methods: ['GET'], + route: 'playlist/{id}', + authLevel: 'anonymous', + handler: playlistGetHandler, +}); diff --git a/server/src/functions/playlist-join.ts b/server/src/functions/playlist-join.ts new file mode 100644 index 0000000..2d528fe --- /dev/null +++ b/server/src/functions/playlist-join.ts @@ -0,0 +1,80 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { withOCC } from '../lib/withOCC'; +import { getPlaylistContainer } from '../lib/getContainer'; + +export async function playlistJoinHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const id = request.params['id']; + if (!id) { + return { status: 400, jsonBody: { error: 'id param required' } }; + } + + let body: { userName?: string; action?: 'join' | 'leave' }; + try { + body = (await request.json()) as typeof body; + } catch { + return { status: 400, jsonBody: { error: 'Invalid JSON body' } }; + } + + const { userName, action = 'join' } = body; + if (!userName) { + return { status: 400, jsonBody: { error: 'userName required' } }; + } + + try { + const container = getPlaylistContainer(); + + const resource = await withOCC( + () => container.item(id, id).read(), + (doc) => { + if (action === 'join') { + if (!doc.isPublic) { + throw Object.assign(new Error('Playlist is private'), { statusCode: 403 }); + } + if (!doc.members.includes(userName)) { + doc.members = [...doc.members, userName]; + doc.updatedAt = Date.now(); + } + } else { + if (doc.owner === userName) { + throw Object.assign(new Error('Owner cannot leave their own playlist'), { statusCode: 400 }); + } + doc.members = doc.members.filter((m: string) => m !== userName); + doc.updatedAt = Date.now(); + } + }, + (doc, etag) => container.item(id, id).replace(doc, { accessCondition: { type: 'IfMatch', condition: etag } }), + ); + + context.log(`[playlist-join] id=${id} userName=${userName} action=${action}`); + + return { + jsonBody: { + id: resource.id, + name: resource.name, + owner: resource.owner, + isPublic: resource.isPublic, + memberCount: resource.members.length, + trackCount: (resource.tracks ?? []).length, + updatedAt: resource.updatedAt, + }, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + const code = (err as { statusCode?: number }).statusCode; + if (code === 404) return { status: 404, jsonBody: { error: 'Playlist not found' } }; + if (code === 403) return { status: 403, jsonBody: { error: (err as Error).message } }; + if (code === 400) return { status: 400, jsonBody: { error: (err as Error).message } }; + context.error('[playlist-join] failed:', err); + return { status: 500, jsonBody: { error: 'Internal server error' } }; + } +} + +app.http('playlist-join', { + methods: ['POST'], + route: 'playlist/{id}/members', + authLevel: 'anonymous', + handler: playlistJoinHandler, +}); diff --git a/server/src/functions/playlist-list.ts b/server/src/functions/playlist-list.ts new file mode 100644 index 0000000..467ee12 --- /dev/null +++ b/server/src/functions/playlist-list.ts @@ -0,0 +1,76 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { getPlaylistContainer } from '../lib/getContainer'; + +interface PlaylistDoc { + id: string; + name: string; + owner: string; + isPublic: boolean; + isFavourites?: boolean; + members: string[]; + tracks: unknown[]; + imageUrl?: string | null; + createdAt: number; + updatedAt: number; +} + +export async function playlistListHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const owner = request.query.get('owner'); + const member = request.query.get('member'); + + if (!owner && !member) { + return { status: 400, jsonBody: { error: 'owner or member query param required' } }; + } + + try { + const container = getPlaylistContainer(); + + let query: { query: string; parameters: { name: string; value: string }[] }; + + if (owner) { + query = { + query: 'SELECT c.id, c.name, c.owner, c.isPublic, c.isFavourites, c.members, c.imageUrl, c.createdAt, c.updatedAt, ARRAY_LENGTH(c.tracks) AS trackCount FROM c WHERE c.owner = @owner ORDER BY c.updatedAt DESC', + parameters: [{ name: '@owner', value: owner }], + }; + } else { + query = { + query: 'SELECT c.id, c.name, c.owner, c.isPublic, c.isFavourites, c.members, c.imageUrl, c.createdAt, c.updatedAt, ARRAY_LENGTH(c.tracks) AS trackCount FROM c WHERE ARRAY_CONTAINS(c.members, @member) AND c.owner != @member ORDER BY c.updatedAt DESC', + parameters: [{ name: '@member', value: member! }], + }; + } + + const { resources } = await container.items.query(query).fetchAll(); + + const result = resources.map((r) => ({ + id: r.id, + name: r.name, + owner: r.owner, + isPublic: r.isPublic, + isFavourites: r.isFavourites ?? false, + memberCount: (r.members ?? []).length, + trackCount: r.trackCount ?? 0, + updatedAt: r.updatedAt, + imageUrl: r.imageUrl ?? null, + })); + + context.log(`[playlist-list] owner=${owner ?? ''} member=${member ?? ''} count=${result.length}`); + + return { + jsonBody: result, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[playlist-list] query failed:', err); + return { status: 500, jsonBody: { error: 'Internal server error' } }; + } +} + +app.http('playlist-list', { + methods: ['GET'], + route: 'playlists', + authLevel: 'anonymous', + handler: playlistListHandler, +}); diff --git a/server/src/functions/playlist-remove-track.ts b/server/src/functions/playlist-remove-track.ts new file mode 100644 index 0000000..cb8d783 --- /dev/null +++ b/server/src/functions/playlist-remove-track.ts @@ -0,0 +1,67 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { withOCC } from '../lib/withOCC'; +import { getPlaylistContainer } from '../lib/getContainer'; + +export async function playlistRemoveTrackHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const id = request.params['id']; + if (!id) { + return { status: 400, jsonBody: { error: 'id param required' } }; + } + + let body: { userName?: string; uri?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return { status: 400, jsonBody: { error: 'Invalid JSON body' } }; + } + + const { userName, uri } = body; + if (!userName || !uri) { + return { status: 400, jsonBody: { error: 'userName and uri required' } }; + } + + try { + const container = getPlaylistContainer(); + + const resource = await withOCC( + () => container.item(id, id).read(), + (doc) => { + if (doc.owner !== userName) { + throw Object.assign(new Error('Only the owner can remove tracks'), { statusCode: 403 }); + } + const tracks: { uri: string }[] = doc.tracks ?? []; + const firstIdx = tracks.findIndex((t) => t.uri === uri); + if (firstIdx === -1) { + throw Object.assign(new Error('Track not found in playlist'), { statusCode: 404 }); + } + tracks.splice(firstIdx, 1); + doc.tracks = tracks; + doc.updatedAt = Date.now(); + }, + (doc, etag) => container.item(id, id).replace(doc, { accessCondition: { type: 'IfMatch', condition: etag } }), + ); + + context.log(`[playlist-remove-track] id=${id} uri=${uri}`); + + return { + jsonBody: resource, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + const code = (err as { statusCode?: number }).statusCode; + if (code === 404) return { status: 404, jsonBody: { error: (err as Error).message } }; + if (code === 403) return { status: 403, jsonBody: { error: 'Not a member of this playlist' } }; + context.error('[playlist-remove-track] failed:', err); + return { status: 500, jsonBody: { error: 'Internal server error' } }; + } +} + +app.http('playlist-remove-track', { + methods: ['DELETE'], + route: 'playlist/{id}/tracks', + authLevel: 'anonymous', + handler: playlistRemoveTrackHandler, +}); diff --git a/server/src/functions/playlist-reorder.ts b/server/src/functions/playlist-reorder.ts new file mode 100644 index 0000000..a6db32c --- /dev/null +++ b/server/src/functions/playlist-reorder.ts @@ -0,0 +1,71 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { withOCC } from '../lib/withOCC'; +import { getPlaylistContainer } from '../lib/getContainer'; + +export async function playlistReorderHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const id = request.params['id']; + if (!id) { + return { status: 400, jsonBody: { error: 'id param required' } }; + } + + let body: { userName?: string; fromIndex?: number; toIndex?: number }; + try { + body = (await request.json()) as typeof body; + } catch { + return { status: 400, jsonBody: { error: 'Invalid JSON body' } }; + } + + const { userName, fromIndex, toIndex } = body; + if (!userName || fromIndex === undefined || toIndex === undefined) { + return { status: 400, jsonBody: { error: 'userName, fromIndex, toIndex required' } }; + } + if (!Number.isInteger(fromIndex) || !Number.isInteger(toIndex)) { + return { status: 400, jsonBody: { error: 'fromIndex and toIndex must be integers' } }; + } + + try { + const container = getPlaylistContainer(); + + const resource = await withOCC( + () => container.item(id, id).read(), + (doc) => { + if (doc.owner !== userName && !doc.members.includes(userName)) { + throw Object.assign(new Error('Not a member of this playlist'), { statusCode: 403 }); + } + const tracks: unknown[] = [...(doc.tracks ?? [])]; + if (fromIndex < 0 || fromIndex >= tracks.length || toIndex < 0 || toIndex >= tracks.length) { + throw Object.assign(new Error('Index out of range'), { statusCode: 400 }); + } + const [moved] = tracks.splice(fromIndex, 1); + tracks.splice(toIndex, 0, moved); + doc.tracks = tracks; + doc.updatedAt = Date.now(); + }, + (doc, etag) => container.item(id, id).replace(doc, { accessCondition: { type: 'IfMatch', condition: etag } }), + ); + + context.log(`[playlist-reorder] id=${id} userName=${userName} from=${fromIndex} to=${toIndex}`); + + return { + jsonBody: resource, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + const code = (err as { statusCode?: number }).statusCode; + if (code === 404) return { status: 404, jsonBody: { error: 'Playlist not found' } }; + if (code === 403) return { status: 403, jsonBody: { error: 'Not a member of this playlist' } }; + if (code === 400) return { status: 400, jsonBody: { error: 'Index out of range' } }; + context.error('[playlist-reorder] failed:', err); + return { status: 500, jsonBody: { error: 'Internal server error' } }; + } +} + +app.http('playlist-reorder', { + methods: ['PATCH'], + route: 'playlist/{id}/tracks', + authLevel: 'anonymous', + handler: playlistReorderHandler, +}); diff --git a/server/src/functions/playlist-update.ts b/server/src/functions/playlist-update.ts new file mode 100644 index 0000000..97fb06d --- /dev/null +++ b/server/src/functions/playlist-update.ts @@ -0,0 +1,73 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { withOCC } from '../lib/withOCC'; +import { getPlaylistContainer } from '../lib/getContainer'; + +export async function playlistUpdateHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const id = request.params['id']; + if (!id) { + return { status: 400, jsonBody: { error: 'id param required' } }; + } + + let body: { userName?: string; name?: string; isPublic?: boolean }; + try { + body = (await request.json()) as typeof body; + } catch { + return { status: 400, jsonBody: { error: 'Invalid JSON body' } }; + } + + const { userName, name, isPublic } = body; + if (!userName) { + return { status: 400, jsonBody: { error: 'userName required' } }; + } + if (name === undefined && isPublic === undefined) { + return { status: 400, jsonBody: { error: 'At least one of name or isPublic required' } }; + } + if (name !== undefined) { + const trimmed = name.trim(); + if (!trimmed) return { status: 400, jsonBody: { error: 'name cannot be empty' } }; + if (trimmed.length > 200) return { status: 400, jsonBody: { error: 'name too long (max 200 chars)' } }; + } + + try { + const container = getPlaylistContainer(); + + const resource = await withOCC( + () => container.item(id, id).read(), + (doc) => { + if (doc.owner !== userName) { + throw Object.assign(new Error('Only the owner can update a playlist'), { statusCode: 403 }); + } + if (doc.isFavourites && isPublic === true) { + throw Object.assign(new Error('Favourites playlist cannot be made public'), { statusCode: 403 }); + } + if (name !== undefined) doc.name = name.trim(); + if (isPublic !== undefined) doc.isPublic = isPublic; + doc.updatedAt = Date.now(); + }, + (doc, etag) => container.item(id, id).replace(doc, { accessCondition: { type: 'IfMatch', condition: etag } }), + ); + + context.log(`[playlist-update] id=${id} owner=${userName} name=${name} isPublic=${isPublic}`); + + return { + jsonBody: resource, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + const code = (err as { statusCode?: number }).statusCode; + if (code === 404) return { status: 404, jsonBody: { error: 'Playlist not found' } }; + if (code === 403) return { status: 403, jsonBody: { error: (err as Error).message } }; + context.error('[playlist-update] failed:', err); + return { status: 500, jsonBody: { error: 'Internal server error' } }; + } +} + +app.http('playlist-update', { + methods: ['PUT'], + route: 'playlist/{id}', + authLevel: 'anonymous', + handler: playlistUpdateHandler, +}); diff --git a/server/src/functions/playlist-upload-image.ts b/server/src/functions/playlist-upload-image.ts new file mode 100644 index 0000000..16b61de --- /dev/null +++ b/server/src/functions/playlist-upload-image.ts @@ -0,0 +1,116 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { + BlobServiceClient, + StorageSharedKeyCredential, + BlobSASPermissions, + generateBlobSASQueryParameters, +} from '@azure/storage-blob'; +import { CosmosClient } from '@azure/cosmos'; + +const ALLOWED_TYPES: Record = { + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', +}; + +function parseConnStr(s: string): { accountName: string; accountKey: string } { + const parts = Object.fromEntries( + s.split(';').filter(Boolean).map(p => { + const idx = p.indexOf('='); + return [p.slice(0, idx), p.slice(idx + 1)] as [string, string]; + }) + ); + return { accountName: parts['AccountName'] ?? '', accountKey: parts['AccountKey'] ?? '' }; +} + +export async function playlistUploadImageHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const id = request.params['id']; + if (!id) return { status: 400, jsonBody: { error: 'id required' } }; + + const userName = request.query.get('userName'); + if (!userName) return { status: 400, jsonBody: { error: 'userName required' } }; + + const storageConn = process.env['STORAGE_CONNECTION_STRING']; + const cosmosConn = process.env['COSMOS_CONNECTION_STRING']; + const dbName = process.env['COSMOS_DATABASE'] ?? 'truetunes'; + + if (!storageConn || !cosmosConn) { + return { status: 500, jsonBody: { error: 'Storage or Cosmos not configured' } }; + } + + const mimeType = request.headers.get('content-type')?.split(';')[0]?.trim() ?? ''; + const ext = ALLOWED_TYPES[mimeType]; + if (!ext) { + return { status: 400, jsonBody: { error: `Unsupported image type: ${mimeType}` } }; + } + + const body = await request.arrayBuffer(); + if (!body || body.byteLength === 0) return { status: 400, jsonBody: { error: 'Empty body' } }; + if (body.byteLength > 5 * 1024 * 1024) return { status: 413, jsonBody: { error: 'Image must be under 5 MB' } }; + + try { + // Verify ownership before uploading to storage + const cosmos = new CosmosClient(cosmosConn); + const { resource: playlistDoc } = await cosmos.database(dbName).container('playlists').item(id, id).read<{ owner: string }>(); + if (!playlistDoc) return { status: 404, jsonBody: { error: 'Playlist not found' } }; + if (playlistDoc.owner !== userName) return { status: 403, jsonBody: { error: 'Only the owner can change the playlist image' } }; + const { accountName, accountKey } = parseConnStr(storageConn); + const sharedKeyCredential = new StorageSharedKeyCredential(accountName, accountKey); + const blobServiceClient = new BlobServiceClient( + `https://${accountName}.blob.core.windows.net`, + sharedKeyCredential, + ); + + const containerClient = blobServiceClient.getContainerClient('playlist-images'); + await containerClient.createIfNotExists(); + + const blobName = `${id}.${ext}`; + const blockBlob = containerClient.getBlockBlobClient(blobName); + await blockBlob.upload(body, body.byteLength, { + blobHTTPHeaders: { blobContentType: mimeType }, + }); + + // SAS URL valid for 20 years โ€” stored in Cosmos as the imageUrl + const expiresOn = new Date(); + expiresOn.setFullYear(expiresOn.getFullYear() + 20); + const sas = generateBlobSASQueryParameters( + { + containerName: 'playlist-images', + blobName, + permissions: BlobSASPermissions.parse('r'), + startsOn: new Date(), + expiresOn, + }, + sharedKeyCredential, + ); + const imageUrl = `${blockBlob.url}?${sas}`; + + // Patch Cosmos doc + await cosmos.database(dbName).container('playlists').item(id, id).patch([ + { op: 'set', path: '/imageUrl', value: imageUrl }, + { op: 'set', path: '/updatedAt', value: Date.now() }, + ]); + + context.log(`[playlist-upload-image] id=${id} ext=${ext} size=${body.byteLength}`); + + return { + jsonBody: { imageUrl }, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[playlist-upload-image] failed:', err); + return { status: 500, jsonBody: { error: 'Internal server error' } }; + } +} + +app.http('playlist-upload-image', { + methods: ['POST'], + route: 'playlist/{id}/image', + authLevel: 'anonymous', + handler: playlistUploadImageHandler, +}); diff --git a/server/src/functions/profile-get.ts b/server/src/functions/profile-get.ts new file mode 100644 index 0000000..e46605b --- /dev/null +++ b/server/src/functions/profile-get.ts @@ -0,0 +1,41 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { CosmosClient } from '@azure/cosmos'; + +export async function profileGetHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const userName = request.params['userName']; + if (!userName) return { status: 400, jsonBody: { error: 'userName required' } }; + + const connStr = process.env['COSMOS_CONNECTION_STRING']; + const dbName = process.env['COSMOS_DATABASE'] ?? 'truetunes'; + + if (!connStr) return { status: 500, jsonBody: { error: 'Cosmos not configured' } }; + + try { + const { resource } = await new CosmosClient(connStr) + .database(dbName) + .container('profiles') + .item(userName, userName) + .read(); + + return { + jsonBody: resource ?? { id: userName }, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err: unknown) { + if ((err as { code?: number }).code === 404) { + return { jsonBody: { id: userName }, headers: { 'Access-Control-Allow-Origin': '*' } }; + } + context.error('[profile-get] failed:', err); + return { status: 500, jsonBody: { error: String(err) } }; + } +} + +app.http('profile-get', { + methods: ['GET'], + route: 'profile/{userName}', + authLevel: 'anonymous', + handler: profileGetHandler, +}); diff --git a/server/src/functions/profile-upload-image.ts b/server/src/functions/profile-upload-image.ts new file mode 100644 index 0000000..6554145 --- /dev/null +++ b/server/src/functions/profile-upload-image.ts @@ -0,0 +1,106 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { + BlobServiceClient, + StorageSharedKeyCredential, + BlobSASPermissions, + generateBlobSASQueryParameters, +} from '@azure/storage-blob'; +import { CosmosClient } from '@azure/cosmos'; + +const ALLOWED_TYPES: Record = { + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', +}; + +function parseConnStr(s: string): { accountName: string; accountKey: string } { + const parts = Object.fromEntries( + s.split(';').filter(Boolean).map(p => { + const idx = p.indexOf('='); + return [p.slice(0, idx), p.slice(idx + 1)] as [string, string]; + }) + ); + return { accountName: parts['AccountName'] ?? '', accountKey: parts['AccountKey'] ?? '' }; +} + +export async function profileUploadImageHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const userName = request.params['userName']; + if (!userName) return { status: 400, jsonBody: { error: 'userName required' } }; + + const storageConn = process.env['STORAGE_CONNECTION_STRING']; + const cosmosConn = process.env['COSMOS_CONNECTION_STRING']; + const dbName = process.env['COSMOS_DATABASE'] ?? 'truetunes'; + + if (!storageConn || !cosmosConn) { + return { status: 500, jsonBody: { error: 'Storage or Cosmos not configured' } }; + } + + const mimeType = request.headers.get('content-type')?.split(';')[0]?.trim() ?? ''; + const ext = ALLOWED_TYPES[mimeType]; + if (!ext) return { status: 400, jsonBody: { error: `Unsupported image type: ${mimeType}` } }; + + const body = await request.arrayBuffer(); + if (!body || body.byteLength === 0) return { status: 400, jsonBody: { error: 'Empty body' } }; + if (body.byteLength > 5 * 1024 * 1024) return { status: 413, jsonBody: { error: 'Image must be under 5 MB' } }; + + try { + const { accountName, accountKey } = parseConnStr(storageConn); + const sharedKeyCredential = new StorageSharedKeyCredential(accountName, accountKey); + const blobServiceClient = new BlobServiceClient( + `https://${accountName}.blob.core.windows.net`, + sharedKeyCredential, + ); + + const containerClient = blobServiceClient.getContainerClient('profile-images'); + await containerClient.createIfNotExists(); + + const blobName = `${userName}.${ext}`; + const blockBlob = containerClient.getBlockBlobClient(blobName); + await blockBlob.upload(body, body.byteLength, { + blobHTTPHeaders: { blobContentType: mimeType }, + }); + + const expiresOn = new Date(); + expiresOn.setFullYear(expiresOn.getFullYear() + 20); + const sas = generateBlobSASQueryParameters( + { + containerName: 'profile-images', + blobName, + permissions: BlobSASPermissions.parse('r'), + startsOn: new Date(), + expiresOn, + }, + sharedKeyCredential, + ); + const imageUrl = `${blockBlob.url}?${sas}`; + + const cosmos = new CosmosClient(cosmosConn); + await cosmos.database(dbName).container('profiles').items.upsert({ + id: userName, + imageUrl, + updatedAt: Date.now(), + }); + + context.log(`[profile-upload-image] userName=${userName} ext=${ext} size=${body.byteLength}`); + + return { + jsonBody: { imageUrl }, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[profile-upload-image] failed:', err); + return { status: 500, jsonBody: { error: String(err) } }; + } +} + +app.http('profile-upload-image', { + methods: ['POST'], + route: 'profile/{userName}/image', + authLevel: 'anonymous', + handler: profileUploadImageHandler, +}); diff --git a/server/src/functions/recently-played.ts b/server/src/functions/recently-played.ts new file mode 100644 index 0000000..1d927b4 --- /dev/null +++ b/server/src/functions/recently-played.ts @@ -0,0 +1,176 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { CosmosClient } from '@azure/cosmos'; + +interface RawEvent { + eventType?: 'track' | 'album' | null; + trackName: string; + artist: string; + serviceId?: string | null; + accountId?: string | null; + artistId?: string | null; + album?: string | null; + albumId?: string | null; + imageUrl?: string | null; + uri?: string | null; + timestamp: number; +} + +interface RecentTrack { + trackName: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + album?: string; + albumId?: string; + imageUrl?: string; + uri?: string; + lastPlayed: number; +} + +interface RecentArtist { + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + imageUrl?: string; + lastPlayed: number; +} + +interface RecentAlbum { + album: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + albumId?: string; + imageUrl?: string; + lastPlayed: number; +} + +export async function recentlyPlayedHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const connStr = process.env['COSMOS_CONNECTION_STRING']; + const dbName = process.env['COSMOS_DATABASE'] ?? 'truetunes'; + const ctrName = process.env['COSMOS_CONTAINER'] ?? 'events'; + + if (!connStr) return { status: 500, jsonBody: { error: 'Cosmos not configured' } }; + + const userId = request.query.get('userId'); + if (!userId) return { status: 400, jsonBody: { error: 'userId required' } }; + + const daysRaw = parseInt(request.query.get('days') ?? '7', 10); + if (!Number.isInteger(daysRaw) || daysRaw < 1) { + return { status: 400, jsonBody: { error: 'days must be a positive integer' } }; + } + const days = Math.min(daysRaw, 30); + const startMs = Date.now() - days * 24 * 60 * 60 * 1000; + + try { + const client = new CosmosClient(connStr); + const container = client.database(dbName).container(ctrName); + + const usersStart = Date.now() - 30 * 24 * 60 * 60 * 1000; + const [eventsResult, usersResult] = await Promise.all([ + container.items.query({ + query: `SELECT c.eventType, c.trackName, c.artist, c.serviceId, c.accountId, + c.artistId, c.album, c.albumId, c.imageUrl, c.uri, c.timestamp + FROM c + WHERE c.userId = @userId AND c.timestamp >= @start`, + parameters: [ + { name: '@userId', value: userId }, + { name: '@start', value: startMs }, + ], + }).fetchAll(), + container.items.query({ + query: 'SELECT DISTINCT VALUE c.userId FROM c WHERE c.timestamp >= @usersStart', + parameters: [{ name: '@usersStart', value: usersStart }], + }).fetchAll(), + ]); + + const { resources } = eventsResult; + const availableUsers: string[] = usersResult.resources.filter(Boolean).sort(); + + // Sort most-recent first, then deduplicate โ€” keeping the first (most recent) occurrence of each key. + resources.sort((a, b) => b.timestamp - a.timestamp); + + const trackMap = new Map(); + const artistMap = new Map(); + const albumMap = new Map(); + + for (const e of resources) { + const isTrack = e.eventType === 'track' || e.eventType == null; + const isAlbumEvt = e.eventType === 'album' || e.eventType == null; + + if (isTrack && e.trackName && e.artist) { + const key = `${e.trackName}||${e.artist}`; + if (!trackMap.has(key)) { + trackMap.set(key, { + trackName: e.trackName, + artist: e.artist, + serviceId: e.serviceId ?? undefined, + accountId: e.accountId ?? undefined, + artistId: e.artistId ?? undefined, + album: e.album ?? undefined, + albumId: e.albumId ?? undefined, + imageUrl: e.imageUrl ?? undefined, + uri: e.uri ?? undefined, + lastPlayed: e.timestamp, + }); + } + + if (e.artist) { + const aKey = e.artist; + if (!artistMap.has(aKey)) { + artistMap.set(aKey, { + artist: e.artist, + serviceId: e.serviceId ?? undefined, + accountId: e.accountId ?? undefined, + artistId: e.artistId ?? undefined, + imageUrl: e.imageUrl ?? undefined, + lastPlayed: e.timestamp, + }); + } + } + } + + if (isAlbumEvt && e.album) { + const aKey = e.albumId ?? e.album; + if (!albumMap.has(aKey)) { + albumMap.set(aKey, { + album: e.album, + artist: e.artist, + serviceId: e.serviceId ?? undefined, + accountId: e.accountId ?? undefined, + artistId: e.artistId ?? undefined, + albumId: e.albumId ?? undefined, + imageUrl: e.imageUrl ?? undefined, + lastPlayed: e.timestamp, + }); + } + } + } + + const tracks = [...trackMap.values()].slice(0, 20); + const artists = [...artistMap.values()].slice(0, 10); + const albums = [...albumMap.values()].slice(0, 10); + + context.log(`[recently-played] userId=${userId} tracks=${tracks.length} artists=${artists.length} albums=${albums.length} users=${availableUsers.length}`); + + return { + jsonBody: { tracks, artists, albums, availableUsers }, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[recently-played] query failed:', err); + return { status: 500, jsonBody: { error: String(err) } }; + } +} + +app.http('recently-played', { + methods: ['GET'], + authLevel: 'anonymous', + handler: recentlyPlayedHandler, +}); diff --git a/server/src/functions/stats.ts b/server/src/functions/stats.ts index 5d3fb72..244bfe4 100644 --- a/server/src/functions/stats.ts +++ b/server/src/functions/stats.ts @@ -28,6 +28,7 @@ export async function statsHandler( const period = (request.query.get('period') ?? 'alltime') as Period; const userId = request.query.get('userId') ?? undefined; + const count = Math.min(100, Math.max(1, parseInt(request.query.get('count') ?? '10', 10) || 10)); const startMs = periodStartMs(period); try { @@ -55,16 +56,16 @@ export async function statsHandler( const topTracks = Object.values(trackMap) .sort((a, b) => b.count - a.count) - .slice(0, 10) + .slice(0, count) .map(({ key: _key, ...rest }) => rest); const topArtists = Object.values(artistMap) .sort((a, b) => b.count - a.count) - .slice(0, 10); + .slice(0, count); const topAlbums = Object.values(albumMap) .sort((a, b) => b.count - a.count) - .slice(0, 10) + .slice(0, count) .map(({ key: _key, ...rest }) => rest); context.log(`[stats] period=${period} events=${resources.length}`); diff --git a/server/src/functions/users-list.ts b/server/src/functions/users-list.ts new file mode 100644 index 0000000..841244e --- /dev/null +++ b/server/src/functions/users-list.ts @@ -0,0 +1,60 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { CosmosClient } from '@azure/cosmos'; + +export async function usersListHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const connStr = process.env['COSMOS_CONNECTION_STRING']; + const dbName = process.env['COSMOS_DATABASE'] ?? 'truetunes'; + + if (!connStr) return { status: 500, jsonBody: { error: 'Cosmos not configured' } }; + + const exclude = request.query.get('exclude') ?? ''; + + try { + const client = new CosmosClient(connStr); + const eventsContainer = client.database(dbName).container('events'); + const profilesContainer = client.database(dbName).container('profiles'); + + const { resources } = await eventsContainer.items + .query<{ userId: string; lastQueued: number }>({ + query: 'SELECT c.userId, MAX(c.timestamp) AS lastQueued FROM c GROUP BY c.userId', + }) + .fetchAll(); + + const sorted = resources + .filter(r => r.userId && r.userId !== exclude) + .sort((a, b) => b.lastQueued - a.lastQueued); + + const result = await Promise.all( + sorted.map(async (r) => { + try { + const { resource } = await profilesContainer + .item(r.userId, r.userId) + .read<{ id: string; imageUrl?: string | null }>(); + return { userId: r.userId, lastQueued: r.lastQueued, imageUrl: resource?.imageUrl ?? null }; + } catch { + return { userId: r.userId, lastQueued: r.lastQueued, imageUrl: null }; + } + }), + ); + + context.log(`[users-list] count=${result.length} exclude=${exclude}`); + + return { + jsonBody: result, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[users-list] failed:', err); + return { status: 500, jsonBody: { error: 'Internal server error' } }; + } +} + +app.http('users-list', { + methods: ['GET'], + route: 'users', + authLevel: 'anonymous', + handler: usersListHandler, +}); diff --git a/server/src/lib/getContainer.ts b/server/src/lib/getContainer.ts new file mode 100644 index 0000000..541fb1b --- /dev/null +++ b/server/src/lib/getContainer.ts @@ -0,0 +1,14 @@ +import { CosmosClient, Container } from '@azure/cosmos'; + +let _container: Container | undefined; + +export function getPlaylistContainer(): Container { + if (!_container) { + const connStr = process.env['COSMOS_CONNECTION_STRING']; + if (!connStr) throw new Error('Cosmos not configured'); + _container = new CosmosClient(connStr) + .database(process.env['COSMOS_DATABASE'] ?? 'truetunes') + .container('playlists'); + } + return _container; +} diff --git a/server/src/lib/withOCC.ts b/server/src/lib/withOCC.ts new file mode 100644 index 0000000..ba2488d --- /dev/null +++ b/server/src/lib/withOCC.ts @@ -0,0 +1,33 @@ +interface WithEtag { + _etag?: string; +} + +/** + * Wraps a Cosmos read-modify-write with optimistic concurrency control. + * On 412 PreconditionFailed (ETag mismatch) it re-reads and retries up to + * `maxRetries` times before giving up. + */ +export async function withOCC( + readFn: () => Promise<{ resource?: T }>, + mutateFn: (doc: T) => void, + replaceFn: (doc: T, etag: string) => Promise, + maxRetries = 3, +): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + const { resource } = await readFn(); + if (!resource) throw Object.assign(new Error('Document not found'), { statusCode: 404 }); + const etag = resource._etag ?? ''; + mutateFn(resource); + try { + await replaceFn(resource, etag); + return resource; + } catch (err) { + const code = + (err as { statusCode?: number }).statusCode ?? + (err as { code?: number }).code; + if (code === 412 && attempt < maxRetries - 1) continue; + throw err; + } + } + throw new Error('OCC max retries exceeded'); +} diff --git a/src/main.ts b/src/main.ts index 32794ce..a3079e9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1236,6 +1236,146 @@ ipcMain.handle('telemetry:event', (_: IpcMainInvokeEvent, name: string, props?: telemetry.event(name, props); }); +ipcMain.handle('playlist:list', async (_: IpcMainInvokeEvent, filter: { owner?: string; member?: string }) => { + try { + const params = new URLSearchParams(); + if (filter.owner) params.set('owner', filter.owner); + if (filter.member) params.set('member', filter.member); + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/playlists?${params}`); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } +}); + +ipcMain.handle('playlist:get', async (_: IpcMainInvokeEvent, id: string) => { + try { + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(id)}`); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } +}); + +ipcMain.handle('playlist:create', (_: IpcMainInvokeEvent, name: string, isPublic: boolean) => + playlistFetch(`${PUBSUB_FUNCTION_URL}/api/playlists`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, isPublic, owner: config.displayName }), + }), +); + +async function playlistFetch(url: string, init: RequestInit): Promise { + const res = await fetch(url, init); + const body = await res.json().catch(() => ({})) as { error?: string }; + if (!res.ok) throw new Error(body.error ?? `Server error ${res.status}`); + return body; +} + +ipcMain.handle('playlist:addTrack', (_: IpcMainInvokeEvent, playlistId: string, track: unknown) => + playlistFetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}/tracks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track, userName: config.displayName }), + }), +); + +ipcMain.handle('playlist:join', (_: IpcMainInvokeEvent, playlistId: string, action: 'join' | 'leave') => + playlistFetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userName: config.displayName, action }), + }), +); + +ipcMain.handle('profile:ensureFavourites', async () => { + const userName = config.displayName; + if (!userName) throw new Error('No display name set'); + return playlistFetch(`${PUBSUB_FUNCTION_URL}/api/profile/${encodeURIComponent(userName)}/favourites`, { + method: 'POST', + }); +}); + +ipcMain.handle('playlist:delete', (_: IpcMainInvokeEvent, playlistId: string) => + playlistFetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userName: config.displayName ?? '' }), + }), +); + +ipcMain.handle('playlist:update', (_: IpcMainInvokeEvent, playlistId: string, patch: { name?: string; isPublic?: boolean }) => + playlistFetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userName: config.displayName, ...patch }), + }), +); + +ipcMain.handle('playlist:removeTrack', (_: IpcMainInvokeEvent, playlistId: string, uri: string) => + playlistFetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}/tracks`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userName: config.displayName, uri }), + }), +); + +ipcMain.handle('playlist:reorderTracks', (_: IpcMainInvokeEvent, playlistId: string, fromIndex: number, toIndex: number) => + playlistFetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}/tracks`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userName: config.displayName, fromIndex, toIndex }), + }), +); + +ipcMain.handle('playlist:uploadImage', async (_: IpcMainInvokeEvent, playlistId: string, data: ArrayBuffer, mimeType: string, userName: string) => { + try { + const url = `${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}/image?userName=${encodeURIComponent(userName)}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': mimeType }, + body: Buffer.from(data), + }); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } +}); + +ipcMain.handle('users:list', async () => { + const exclude = config.displayName ?? ''; + const params = exclude ? `?exclude=${encodeURIComponent(exclude)}` : ''; + try { + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/users${params}`); + const body = await res.json() as unknown; + return Array.isArray(body) ? body : []; + } catch { + return []; + } +}); + +ipcMain.handle('profile:get', async (_: IpcMainInvokeEvent, userName: string) => { + try { + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/profile/${encodeURIComponent(userName)}`); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } +}); + +ipcMain.handle('profile:uploadImage', async (_: IpcMainInvokeEvent, userName: string, data: ArrayBuffer, mimeType: string) => { + try { + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/profile/${encodeURIComponent(userName)}/image`, { + method: 'POST', + headers: { 'Content-Type': mimeType }, + body: Buffer.from(data), + }); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } +}); + ipcMain.handle( 'pubsub:publishQueued', async (_: IpcMainInvokeEvent, item: { eventType: 'track' | 'album'; uri: string; trackName: string; artist: string; artistId?: string; album?: string; albumId?: string; imageUrl?: string }) => { @@ -1261,10 +1401,21 @@ ipcMain.handle( } ); -ipcMain.handle('stats:fetch', async (_: IpcMainInvokeEvent, period: string, userId?: string) => { +ipcMain.handle('stats:fetch', async (_: IpcMainInvokeEvent, period: string, userId?: string, count?: number) => { try { let url = `${PUBSUB_FUNCTION_URL}/api/stats?period=${encodeURIComponent(period)}`; if (userId) url += `&userId=${encodeURIComponent(userId)}`; + if (count) url += `&count=${count}`; + const res = await fetch(url); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } +}); + +ipcMain.handle('history:recent', async (_: IpcMainInvokeEvent, userId: string) => { + try { + const url = `${PUBSUB_FUNCTION_URL}/api/recently-played?userId=${encodeURIComponent(userId)}`; const res = await fetch(url); return await res.json(); } catch (err) { diff --git a/src/preload.ts b/src/preload.ts index f102e61..007ea24 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -46,7 +46,7 @@ export interface SonosAPI { onAttributionMap: (cb: (map: AttributionMap) => void) => Unsubscribe; onAttributionEvent: (cb: (event: AttributionEvent) => void) => Unsubscribe; refreshAttribution: () => Promise; - fetchStats: (period: string, userId?: string) => Promise; + fetchStats: (period: string, userId?: string, count?: number) => Promise; fetchDailyGame: (date?: string) => Promise; submitGameScore: (input: { gameId: string; @@ -57,9 +57,24 @@ export interface SonosAPI { fetchGameDates: (userName: string) => Promise; fetchMyScore: (gameId: string, userName: string) => Promise; fetchGameStats: (date?: string) => Promise; + fetchRecentlyPlayed: (userId: string) => Promise; geniusDescription: (trackName: string, artistName: string) => Promise; geniusArtist: (artistName: string, trackHint?: string) => Promise; trackEvent: (name: string, properties?: Record) => Promise; + fetchPlaylists: (filter: { owner?: string; member?: string }) => Promise; + fetchPlaylist: (id: string) => Promise; + createPlaylist: (name: string, isPublic: boolean) => Promise; + updatePlaylist: (playlistId: string, patch: { name?: string; isPublic?: boolean }) => Promise; + deletePlaylist: (playlistId: string) => Promise; + addTrackToPlaylist: (playlistId: string, track: unknown) => Promise; + removeTrackFromPlaylist: (playlistId: string, uri: string) => Promise; + reorderPlaylistTracks: (playlistId: string, fromIndex: number, toIndex: number) => Promise; + joinPlaylist: (playlistId: string, action: 'join' | 'leave') => Promise; + uploadPlaylistImage: (playlistId: string, data: ArrayBuffer, mimeType: string, userName: string) => Promise; + ensureFavourites: () => Promise; + fetchUsers: () => Promise; + fetchUserProfile: (userName: string) => Promise; + uploadProfileImage: (userName: string, data: ArrayBuffer, mimeType: string) => Promise; minimizeWindow: () => Promise; maximizeWindow: () => Promise; closeWindow: () => Promise; @@ -161,7 +176,7 @@ contextBridge.exposeInMainWorld('sonos', { return () => ipcRenderer.removeListener('attribution:map', listener); }, refreshAttribution: () => ipcRenderer.invoke('attribution:refresh'), - fetchStats: (period: string, userId?: string) => ipcRenderer.invoke('stats:fetch', period, userId), + fetchStats: (period: string, userId?: string, count?: number) => ipcRenderer.invoke('stats:fetch', period, userId, count), fetchDailyGame: (date?: string) => ipcRenderer.invoke('game:fetch', date), submitGameScore: (input) => ipcRenderer.invoke('game:submit', input), fetchGameLeaderboard: (date?: string) => ipcRenderer.invoke('game:leaderboard', date), @@ -173,6 +188,8 @@ contextBridge.exposeInMainWorld('sonos', { ipcRenderer.on('attribution:event', listener); return () => ipcRenderer.removeListener('attribution:event', listener); }, + fetchRecentlyPlayed: (userId: string) => + ipcRenderer.invoke('history:recent', userId), geniusDescription: (trackName: string, artistName: string) => ipcRenderer.invoke('genius:description', trackName, artistName), geniusArtist: (artistName: string, trackHint?: string) => @@ -194,4 +211,18 @@ contextBridge.exposeInMainWorld('sonos', { return () => ipcRenderer.removeListener('update:downloaded', listener); }, installUpdate: () => ipcRenderer.invoke('update:install'), + fetchPlaylists: (filter) => ipcRenderer.invoke('playlist:list', filter), + fetchPlaylist: (id) => ipcRenderer.invoke('playlist:get', id), + createPlaylist: (name, isPublic) => ipcRenderer.invoke('playlist:create', name, isPublic), + updatePlaylist: (playlistId, patch) => ipcRenderer.invoke('playlist:update', playlistId, patch), + deletePlaylist: (playlistId) => ipcRenderer.invoke('playlist:delete', playlistId), + addTrackToPlaylist: (playlistId, track) => ipcRenderer.invoke('playlist:addTrack', playlistId, track), + removeTrackFromPlaylist: (playlistId, uri) => ipcRenderer.invoke('playlist:removeTrack', playlistId, uri), + reorderPlaylistTracks: (playlistId, fromIndex, toIndex) => ipcRenderer.invoke('playlist:reorderTracks', playlistId, fromIndex, toIndex), + joinPlaylist: (playlistId, action) => ipcRenderer.invoke('playlist:join', playlistId, action), + uploadPlaylistImage: (playlistId, data, mimeType, userName) => ipcRenderer.invoke('playlist:uploadImage', playlistId, data, mimeType, userName), + ensureFavourites: () => ipcRenderer.invoke('profile:ensureFavourites'), + fetchUsers: () => ipcRenderer.invoke('users:list'), + fetchUserProfile: (userName) => ipcRenderer.invoke('profile:get', userName), + uploadProfileImage: (userName, data, mimeType) => ipcRenderer.invoke('profile:uploadImage', userName, data, mimeType), } satisfies SonosAPI);