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')}
)}
{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 && (
)}
>;
}
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}}
);
}