feat: add personal playlists and shareable track links
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
3a097c15c8
commit
fa79394869
46 files changed
+2307
-292
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { unwrap, type Track } from '@pipu/core';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -20,6 +21,7 @@ export default function CollectionScreen() {
|
||||
const session = useSession();
|
||||
const current = usePlayerState().track;
|
||||
const [tab, setTab] = useState<Tab>('likes');
|
||||
const router = useRouter();
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ['m-collection', tab],
|
||||
@@ -54,6 +56,8 @@ export default function CollectionScreen() {
|
||||
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
|
||||
|
||||
const tabs = (
|
||||
<View style={{ gap: 12 }}>
|
||||
<Button title={t('playlists.title')} icon="queue-music" variant="secondary" onPress={() => router.push('/playlists')} />
|
||||
<Segmented<Tab>
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
@@ -64,6 +68,7 @@ export default function CollectionScreen() {
|
||||
{ value: 'dislikes', label: t('collection.dislikes') },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ function Screens() {
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="album/[id]" />
|
||||
<Stack.Screen name="artist/[id]" />
|
||||
<Stack.Screen name="playlists" />
|
||||
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||
import { trackShareUrl } from '@pipu/core';
|
||||
|
||||
import { useUser } from '../lib/session';
|
||||
import { useSessionCtx, useUser } from '../lib/session';
|
||||
import { player } from '../player/controller';
|
||||
import { usePlayerState } from '../player/hooks';
|
||||
import { useColors } from '../theme';
|
||||
@@ -21,6 +22,8 @@ export default function PlayerScreen() {
|
||||
const router = useRouter();
|
||||
const st = usePlayerState();
|
||||
const prefs = useUser().ui_prefs;
|
||||
const { server } = useSessionCtx();
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [barWidth, setBarWidth] = useState(1);
|
||||
const track = st.track;
|
||||
|
||||
@@ -36,6 +39,17 @@ export default function PlayerScreen() {
|
||||
const duration = st.durationMs || track.duration_ms;
|
||||
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const share = async () => {
|
||||
if (!server || sharing) return;
|
||||
setSharing(true);
|
||||
try {
|
||||
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
|
||||
} catch {
|
||||
Alert.alert(t('player.share'), t('player.share_error'));
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
@@ -45,7 +59,12 @@ export default function PlayerScreen() {
|
||||
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
|
||||
<DownloadButton track={track} />
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||
<Pressable onPress={() => void share()} disabled={!server || sharing} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.share')} accessibilityState={{ disabled: !server || sharing }}>
|
||||
<Icon name="share" size={24} color={colors.text} />
|
||||
</Pressable>
|
||||
<DownloadButton track={track} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ApiError, type Track } from '@pipu/core';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { useState, useSyncExternalStore } from 'react';
|
||||
@@ -199,6 +200,7 @@ export function TrackRow({
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const liked = track.reaction === 'like';
|
||||
const router = useRouter();
|
||||
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
|
||||
return (
|
||||
<Pressable
|
||||
@@ -233,6 +235,10 @@ export function TrackRow({
|
||||
</Pressable>
|
||||
)}
|
||||
{trailing}
|
||||
<Pressable accessibilityRole="button" accessibilityLabel={t('playlists.add')} style={{ padding: 8 }}
|
||||
onPress={(e) => { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}>
|
||||
<Icon name="playlist-add" size={22} />
|
||||
</Pressable>
|
||||
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
|
||||
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
|
||||
</Pressable>
|
||||
|
||||
Reference in new issue
Block a user