Files
pipu-music/apps/mobile/src/app/playlists.tsx
T
mrleo1nidandCursor fa79394869
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m8s
feat: add personal playlists and shareable track links
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:37:53 +03:00

130 lines
9.5 KiB
TypeScript

import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
import { useSession, useUser } from '../lib/session';
import { player } from '../player/controller';
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
type Playlist = components['schemas']['Playlist'];
const input = (p: Playlist) => ({ name: p.name, description: p.description, revision: p.revision, track_ids: p.tracks.map(t => t.id) });
export default function PlaylistsScreen() {
const { id, track } = useLocalSearchParams<{ id?: string; track?: string }>();
return id ? <Detail key={id} id={id} /> : <List key={track ?? 'list'} track={track} />;
}
function List({ track }: { track?: string }) {
const session = useSession();
const user = useUser();
const router = useRouter();
const { t } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const [create, setCreate] = useState(false);
const [search, setSearch] = useState('');
const list = useQuery({ queryKey: ['m-playlists', user.id], queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
const add = useMutation({ mutationFn: async (id: string) => {
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
const body = input(p); body.track_ids = [...new Set([...body.track_ids, track!])];
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
return <Screen><FlatList
data={list.data?.items.filter(p => p.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? []}
keyExtractor={p => p.id} onRefresh={() => void list.refetch()} refreshing={list.isRefetching}
contentContainerStyle={{ padding: 16, gap: 12 }}
ListHeaderComponent={<View style={{ gap: 12 }}>
<Button title={t('form.close')} variant="ghost" onPress={() => router.back()} />
<H1>{t(track ? 'playlists.add' : 'playlists.title')}</H1>
<Button title={t('playlists.create')} onPress={() => setCreate(true)} disabled={add.isPending} />
<Field label={t('playlists.search')} value={search} onChangeText={setSearch} />
<ErrorBox>{list.error || add.error ? errorText(list.error ?? add.error) : null}</ErrorBox>
{list.isPending && <Muted>{t('playlists.loading')}</Muted>}
</View>}
ListEmptyComponent={list.isSuccess ? <Muted>{t(search ? 'playlists.no_results' : 'playlists.empty')}</Muted> : null}
renderItem={({ item }) => <Button title={`${item.name} · ${item.track_count}`} variant="secondary" disabled={add.isPending}
onPress={() => track ? add.mutate(item.id) : router.push({ pathname: '/playlists', params: { id: item.id } })} />}
/>{create && <Editor track={track} onClose={() => setCreate(false)} onSaved={p => {
setCreate(false); if (track) router.back(); else router.push({ pathname: '/playlists', params: { id: p.id } });
}} />}</Screen>;
}
function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; track?: string; onClose: () => void; onSaved: (p: Playlist) => void }) {
const session = useSession();
const { t } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const [original] = useState(playlist);
const [name, setName] = useState(playlist?.name ?? '');
const [description, setDescription] = useState(playlist?.description ?? '');
const save = useMutation({ mutationFn: async () => {
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? (track ? [track] : []) };
return original ? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
}, onSuccess: p => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); onSaved(p); },
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
return <Modal animationType="slide" onRequestClose={() => { if (!save.isPending) onClose(); }}><Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
<H1>{t(original ? 'playlists.edit' : 'playlists.create')}</H1>
<Field label={t('playlists.name')} value={name} onChangeText={setName} maxLength={120} autoFocus />
<Field label={t('playlists.description')} value={description} onChangeText={setDescription} maxLength={2000} multiline />
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
<Button title={t('playlists.save')} disabled={!name.trim()} busy={save.isPending} onPress={() => save.mutate()} />
<Button title={t('form.cancel')} variant="ghost" disabled={save.isPending} onPress={onClose} />
</ScrollView></Screen></Modal>;
}
function Detail({ id }: { id: string }) {
const session = useSession();
const user = useUser();
const router = useRouter();
const { t } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const [edit, setEdit] = useState(false);
const [manage, setManage] = useState(false);
const [playError, setPlayError] = useState<unknown>();
const key = ['m-playlists', user.id, id];
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
const p = query.data;
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id } } })),
onSuccess: () => { cache.removeQueries({ queryKey: key }); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
const play = (shuffle: boolean) => {
const tracks = p?.tracks.filter(t => t.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) void player.playList(tracks,0,{ type:'collection', id, label:p?.name }).catch(setPlayError);
};
const move = (index: number, delta: number) => {
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
};
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
<ErrorBox>{query.error || save.error || deletion.error || playError ? errorText(query.error ?? save.error ?? deletion.error ?? playError) : null}</ErrorBox>
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
{text:t('form.cancel'),style:'cancel'}, {text:t('playlists.delete'),style:'destructive',onPress:()=>deletion.mutate()},
])} />
</>}
</View>}
ListEmptyComponent={p ? <View style={{padding:16}}><Muted>{t('playlists.empty_tracks')}</Muted></View> : null}
renderItem={({item,index}) => <View><TrackRow track={item} onPress={() => void player.playList(p!.tracks,index,{type:'collection',id,label:p!.name}).catch(setPlayError)} />
{manage && <View style={{padding:12,gap:6}}>
<Button title={t('playlists.move_up',{name:item.title})} variant="ghost" disabled={!index || save.isPending} onPress={() => move(index,-1)} />
<Button title={t('playlists.move_down',{name:item.title})} variant="ghost" disabled={index===p!.tracks.length-1 || save.isPending} onPress={() => move(index,1)} />
<Button title={t('playlists.remove_track',{name:item.title})} variant="danger" disabled={save.isPending} onPress={() => save.mutate(p!.tracks.filter(t => t.id!==item.id).map(t => t.id))} />
</View>}
</View>}
/>{edit && p && <Editor playlist={p} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}</Screen>;
}