import { unwrap } from '@pipu/core'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link, useNavigate } from '@tanstack/react-router'; import { useState, type MouseEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession } from '../app/session'; import { Cover, TrackList } from '../entities/catalog'; import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists'; import type { components } from '@pipu/api-client'; import { usePlayer } from '../player/context'; import { useErrorText } from '../shared/lib/errors'; import { playlistShareUrl } from '@pipu/core'; import { desktopHandle } from '../desktop'; import { useMusicShare } from '../features/social/Social'; import { formatDuration } from '../shared/lib/format'; import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui'; import { playIcon, playlistAddIcon, searchIcon, shuffleIcon, shareIcon } from '../shared/ui/icons'; import c from './catalog.module.css'; import s from './page.module.css'; import p from './playlists.module.css'; export function PlaylistsPage() { const { t } = useTranslation(); const list = usePlaylists(); const errorText = useErrorText(); const navigate = useNavigate(); const [create, setCreate] = useState(false); const [search, setSearch] = useState(''); const items = list.data?.items.filter(x => x.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? []; return

{t('playlists.title')}

{list.error ? errorText(list.error) : null} {list.isPending && } {list.isSuccess && !items.length && ( setCreate(true)}>{t('playlists.create')} : undefined} > {t(search ? 'playlists.no_results' : 'playlists.empty')} )}
{items.map(item => )}
{create && setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
; } export function PlaylistPage({ id, shared = false }: { id: string; shared?: boolean }) { const { t } = useTranslation(); const query = usePlaylist(id, shared); const errorText = useErrorText(); return
← {t('playlists.title')} {query.error ? errorText(query.error) : null} {query.isPending && } {query.data && !query.isError && }
; } export function ActivityPlaylistPage({ userId }: { userId: string }) { const session = useSession(); const errorText = useErrorText(); const { t } = useTranslation(); const query = useQuery({ queryKey: ['activity-playlist', session.state.status === 'signed_in' ? session.state.user.id : '', userId], queryFn: async () => unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId } } })), staleTime: 0, refetchInterval: 15_000 }); return
← {t('social.title')} {query.error ? errorText(query.error) : null} {query.isPending && } {query.data && !query.isError && }
; } function PlaylistDetail({ playlist, readOnly = false, shareId, activity = false }: { playlist: Playlist; readOnly?: boolean; shareId?: string; activity?: boolean }) { const openShare = useMusicShare(); const { t } = useTranslation(); const session = useSession(); const cache = useQueryClient(); const player = usePlayer(); const navigate = useNavigate(); const errorText = useErrorText(); const [edit, setEdit] = useState(false); const [remove, setRemove] = useState(false); const [manage, setManage] = useState(false); const sharing = useMutation({ mutationFn: async () => { let token = shareId ?? playlist.share_id; if (!readOnly) { token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })).share_id; cache.setQueryData(['playlists', playlist.id], current => current ? { ...current, share_id: token } : current); } if (!token) throw new Error('Share link unavailable'); openShare({ attachment: { kind: 'playlist', id: token, title: playlist.name }, url: playlistShareUrl(desktopHandle()?.server ?? window.location.origin, token) }); } }); const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })), onSuccess: () => { cache.setQueryData(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } }); const context = { type: 'collection' as const, id: playlist.id, label: playlist.name }; const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids }, })), onSuccess: data => { cache.setQueryData(['playlists', playlist.id], data); void cache.invalidateQueries({ queryKey: ['playlists'] }); }, onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } }); const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id: playlist.id } } })), onSuccess: () => { cache.removeQueries({ queryKey: ['playlists', playlist.id] }); void cache.invalidateQueries({ queryKey: ['playlists'] }); void navigate({ to: '/collection/playlists' }); } }); const play = (shuffle: boolean) => { const tracks = playlist.tracks.filter(x => x.available); if (shuffle) for (let i = tracks.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [tracks[i], tracks[j]] = [tracks[j]!, tracks[i]!]; } if (tracks.length) player.playList(tracks, 0, context); }; const move = (index: number, delta: number) => { const ids = playlist.tracks.map(x => x.id); [ids[index], ids[index + delta]] = [ids[index + delta]!, ids[index]!]; save.mutate(ids); }; return <>
{t(readOnly || playlist.share_id ? 'playlists.shared' : 'playlists.private')}

{playlist.name}

{playlist.description}

{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}
{!activity && } {!readOnly && <> {playlist.share_id && } }
{!readOnly &&

{t('playlists.share_hint')}

} {sharing.error ? t('player.share_error') : revoke.error ? errorText(revoke.error) : null} {save.error ? errorText(save.error) : null} {!playlist.tracks.length && ( {t('playlists.find_tracks')}}> {t('playlists.empty_tracks')} )} String(i + 1)} actions={!readOnly && manage ? (track, i) => : undefined} /> {!readOnly && edit && setEdit(false)} onSaved={() => setEdit(false)} />} {!readOnly && remove && ( { if (!deletion.isPending) setRemove(false); }} footer={ <> } >

{t('playlists.delete_confirm', { name: playlist.name })}

{deletion.error ? errorText(deletion.error) : null}
)} ; } function PlaylistCard({ item }: { item: components['schemas']['PlaylistSummary'] }) { const { t } = useTranslation(); const session = useSession(); const player = usePlayer(); const play = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); void (async () => { const full = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id: item.id } } })); const tracks = full.tracks.filter((x) => x.available); if (tracks.length) player.playList(tracks, 0, { type: 'collection', id: item.id, label: item.name }); })(); }; return ( {item.track_count > 0 && ( )} {item.name} {t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)} {item.description && {item.description}} ); }