feat(social): add listening activity, shared rooms and modern chat
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s
This commit is contained in:
1 parent
0a05bfe2cd
commit
11a16cd023
59 files changed
+5022
-542
No files matched your search
@@ -8,7 +8,8 @@ import { player } from '../../player/controller';
|
||||
import { usePlayerState } from '../../player/hooks';
|
||||
import { useColors } from '../../theme';
|
||||
import { Cover, Icon, type IconName } from '../../ui/kit';
|
||||
import { useSocialUnread } from '../../features/social/Social';
|
||||
import { NativeChatProvider, useSocialUnread } from '../../features/social/Social';
|
||||
import { ListeningBanner } from '../../features/social/Listening';
|
||||
import { UpdateDialog } from '../../ui/UpdateDialog';
|
||||
|
||||
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', friends: 'people-outline', profile: 'person' };
|
||||
@@ -26,12 +27,13 @@ export default function TabsLayout() {
|
||||
};
|
||||
const net = useNetInfo();
|
||||
return (
|
||||
<>
|
||||
<NativeChatProvider>
|
||||
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
|
||||
<Tabs
|
||||
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
|
||||
tabBar={({ state, navigation }) => (
|
||||
<View>
|
||||
<ListeningBanner />
|
||||
<MiniPlayer />
|
||||
<TabBar
|
||||
routes={state.routes.map((r) => r.name)}
|
||||
@@ -50,7 +52,7 @@ export default function TabsLayout() {
|
||||
<Tabs.Screen name="profile" />
|
||||
</Tabs>
|
||||
<UpdateDialog />
|
||||
</>
|
||||
</NativeChatProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||
import { MusicShareSheet } from '../features/social/Social';
|
||||
import { ListeningBanner } from '../features/social/Listening';
|
||||
import { trackShareUrl } from '@pipu/core';
|
||||
|
||||
import { useSessionCtx, useUser } from '../lib/session';
|
||||
@@ -22,6 +23,7 @@ export default function PlayerScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const st = usePlayerState();
|
||||
const locked = st.listening?.locked === true;
|
||||
const prefs = useUser().ui_prefs;
|
||||
const { server } = useSessionCtx();
|
||||
const [sharing, setSharing] = useState(false);
|
||||
@@ -45,6 +47,7 @@ export default function PlayerScreen() {
|
||||
return (
|
||||
<Screen>
|
||||
{sharing && server && <MusicShareSheet attachment={{ kind: 'track', id: track.id, title: track.title }} url={trackShareUrl(server, track.id)} close={() => setSharing(false)} />}
|
||||
<ListeningBanner />
|
||||
<View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
|
||||
@@ -94,7 +97,7 @@ export default function PlayerScreen() {
|
||||
<View style={{ gap: 6 }}>
|
||||
<Pressable
|
||||
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
|
||||
onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
|
||||
disabled={locked} onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
|
||||
accessibilityRole="adjustable"
|
||||
accessibilityLabel={t('player.progress')}
|
||||
accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }}
|
||||
@@ -116,18 +119,18 @@ export default function PlayerScreen() {
|
||||
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
|
||||
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
|
||||
<Pressable disabled={locked} onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
|
||||
<Icon name="skip-previous" size={40} color={colors.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => player.toggle()}
|
||||
disabled={locked} onPress={() => player.toggle()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}
|
||||
style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: colors.text, alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color={colors.bg} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
||||
<Pressable disabled={locked} onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
||||
<Icon name="skip-next" size={40} color={colors.text} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CHAT_REACTIONS, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AccessibilityInfo, Animated, KeyboardAvoidingView, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { eventId } from '../../player/eventId';
|
||||
import { useColors } from '../../theme';
|
||||
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||
import { readDraft, saveDraft } from './drafts';
|
||||
|
||||
function Bubble({ children, mine, animate }: { children: ReactNode; mine: boolean; animate: boolean }) {
|
||||
const colors = useColors(); const opacity = useRef(new Animated.Value(1)).current;
|
||||
useEffect(() => { if (!animate) return; opacity.setValue(0); const animation = Animated.timing(opacity,{toValue:1,duration:180,useNativeDriver:true});animation.start();return()=>animation.stop(); },[animate,opacity]);
|
||||
return <Animated.View style={{ alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '90%', padding: 14, borderRadius: 16, backgroundColor: mine ? colors['accent-soft'] : colors.surface2, gap: 8, opacity, transform:[{translateY:opacity.interpolate({inputRange:[0,1],outputRange:[6,0]})}] }}>{children}</Animated.View>;
|
||||
}
|
||||
export function ChatConversation({ person, api, queryKey, userId, server, active }: { person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; server: string; active: boolean }) {
|
||||
const colors=useColors();const {t,i18n}=useTranslation();const router=useRouter();const cache=useQueryClient();const errorText=useErrorText();
|
||||
const [text,setText]=useState('');const [ready,setReady]=useState(false);const touched=useRef(false);const [search,setSearch]=useState('');const [q,setQ]=useState('');
|
||||
const [reply,setReply]=useState<DirectMessage|null>(null);const [editing,setEditing]=useState<DirectMessage|null>(null);const [menu,setMenu]=useState<DirectMessage|null>(null);const [emojis,setEmojis]=useState(false);const [reduced,setReduced]=useState(true);
|
||||
const beforeEdit=useRef('');const pending=useRef<{id:string;text:string;reply_to?:string}|null>(null);
|
||||
useEffect(()=>{let live=true;void readDraft(server,userId,person.id).then(value=>{if(live){if(!touched.current)setText(value);setReady(true);}});return()=>{live=false;};},[server,userId,person.id]);
|
||||
useEffect(()=>{if(!ready||editing)return;const timer=setTimeout(()=>{void saveDraft(server,userId,person.id,text).catch(()=>undefined);},300);return()=>clearTimeout(timer);},[text,ready,editing,server,userId,person.id]);
|
||||
useEffect(()=>{void AccessibilityInfo.isReduceMotionEnabled().then(setReduced);const sub=AccessibilityInfo.addEventListener('reduceMotionChanged',setReduced);return()=>sub.remove();},[]);
|
||||
useEffect(()=>{const timer=setTimeout(()=>setQ(search.trim()),250);return()=>clearTimeout(timer);},[search]);
|
||||
useEffect(()=>{const timer=setTimeout(()=>{void api.typing(person.id,active&&touched.current&&!!text.trim()&&!editing).catch(()=>undefined);},400);return()=>clearTimeout(timer);},[text,active,editing,api,person.id]);
|
||||
useEffect(()=>()=>{void api.typing(person.id,false).catch(()=>undefined);},[api,person.id]);
|
||||
const meta=useQuery({queryKey:[...queryKey,'typing',person.id],queryFn:()=>api.chatMeta(person.id),enabled:active,refetchInterval:active?2000:false});
|
||||
const messages=useInfiniteQuery({queryKey:[...queryKey,'chat',person.id,q],initialPageParam:undefined as number|undefined,queryFn:({pageParam})=>api.messages(person.id,pageParam,q),getNextPageParam:last=>last.length===50?last[0]?.seq:undefined,enabled:active,refetchInterval:active?3000:false});
|
||||
const list=[...(messages.data?.pages??[])].reverse().flat();const last=list.at(-1)?.seq;const unread=list.some(m=>m.sender_id!==userId&&!m.read);
|
||||
useEffect(()=>{if(last&&unread&&active&&!q)void api.action({action:'read_chat',target_id:person.id,up_to_seq:last}).then(()=>cache.invalidateQueries({queryKey})).catch(()=>undefined);},[last,unread,active,q,api,person.id,messages.dataUpdatedAt]);
|
||||
const refresh=()=>cache.invalidateQueries({queryKey});
|
||||
const update=useMutation({mutationFn:(input:{id:string;body:ChatMessageUpdate})=>api.updateMessage(person.id,input.id,input.body),onSuccess:refresh});
|
||||
const send=useMutation({mutationFn:async()=>{if(editing){await api.updateMessage(person.id,editing.id,{action:'edit',text});return;}if(!pending.current||pending.current.text!==text||pending.current.reply_to!==reply?.id)pending.current={id:eventId(Date.now()),text,...(reply?{reply_to:reply.id}:{})};await api.send(person.id,{...pending.current,attachment_kind:'none'});},onSuccess:async()=>{setText(editing?beforeEdit.current:'');setEditing(null);setReply(null);pending.current=null;await refresh();}});
|
||||
const scroll=useRef<ScrollView>(null);const [bottom,setBottom]=useState(true);useEffect(()=>setBottom(true),[last]);
|
||||
const cancelEdit=()=>{setText(beforeEdit.current);setEditing(null);};
|
||||
const mutate=(m:DirectMessage,body:ChatMessageUpdate)=>{if(!update.isPending)update.mutate({id:m.id,body});setMenu(null);};
|
||||
return <KeyboardAvoidingView behavior="padding" style={{flex:1}}>
|
||||
<View style={{padding:12,gap:8}}><H1>{person.display_name}</H1><Field label={t('social.chat_search')} value={search} onChangeText={setSearch} maxLength={100}/><ErrorBox>{messages.error||send.error||update.error?errorText(messages.error??send.error??update.error):null}</ErrorBox></View>
|
||||
<ScrollView ref={scroll} keyboardShouldPersistTaps="handled" contentContainerStyle={{padding:14,gap:12}} onContentSizeChange={()=>{if(bottom&&!q)scroll.current?.scrollToEnd({animated:!reduced});}}>
|
||||
{messages.hasNextPage&&<Button title={t('social.older')} variant="ghost" busy={messages.isFetchingNextPage} onPress={()=>{setBottom(false);void messages.fetchNextPage();}}/>}
|
||||
{!list.length&&<Muted>{t(messages.isPending?'social.loading':q?'social.chat_no_results':'social.first_message')}</Muted>}
|
||||
{list.map((m,i)=><View key={m.id} style={{gap:8}}>
|
||||
{(i===0||new Date(list[i-1]!.created_at).toDateString()!==new Date(m.created_at).toDateString())&&<Text style={{color:colors.text3,fontSize:11,textAlign:'center'}}>{new Date(m.created_at).toLocaleDateString(i18n.language,{day:'numeric',month:'long'})}</Text>}
|
||||
<Bubble mine={m.sender_id===userId} animate={!reduced}>
|
||||
<Pressable disabled={m.deleted} onLongPress={()=>setMenu(m)} accessibilityLabel={t('social.message_actions')}>
|
||||
{m.reply_to&&<View style={{borderLeftWidth:2,borderLeftColor:colors.accent,paddingLeft:8,marginBottom:8}}><Muted>{m.reply_preview||t('social.message_deleted')}</Muted></View>}
|
||||
<Text selectable style={{color:m.deleted?colors.text3:colors.text,fontSize:15,fontStyle:m.deleted?'italic':'normal'}}>{m.deleted?t('social.message_deleted'):m.text}</Text>
|
||||
</Pressable>
|
||||
{!m.deleted&&m.attachment_id&&m.attachment_kind!=='none'&&<Button title={t(m.attachment_kind==='track'?'social.listen_track':'social.listen_playlist')} variant="secondary" onPress={()=>router.push({pathname:m.attachment_kind==='track'?'/social-track':'/social-playlist',params:{id:m.attachment_id}})}/>}
|
||||
{!m.deleted&&!!m.reactions?.length&&<View style={{flexDirection:'row',gap:6,flexWrap:'wrap'}}>{m.reactions.map(r=><Pressable key={r.emoji} accessibilityRole="button" accessibilityState={{selected:r.mine}} onPress={()=>mutate(m,{action:'react',emoji:r.emoji as typeof CHAT_REACTIONS[number]})} style={{padding:6,borderRadius:12,backgroundColor:r.mine?colors['accent-soft']:colors.surface3}}><Text style={{color:colors.text}}>{r.emoji} {r.count}</Text></Pressable>)}</View>}
|
||||
<View style={{flexDirection:'row',gap:8,alignItems:'center'}}><Text style={{color:colors.text3,fontSize:10,flex:1}}>{new Date(m.created_at).toLocaleTimeString(i18n.language,{hour:'2-digit',minute:'2-digit'})}{m.edited_at&&!m.deleted?` · ${t('social.edited')}`:''}{m.sender_id===userId?` · ${t(m.read?'social.read':'social.sent')}`:''}</Text>{!m.deleted&&<Pressable accessibilityRole="button" accessibilityLabel={t('social.message_actions')} hitSlop={10} onPress={()=>setMenu(m)}><Text style={{color:colors.text3}}>•••</Text></Pressable>}</View>
|
||||
</Bubble>
|
||||
</View>)}
|
||||
</ScrollView>
|
||||
<Text accessibilityLiveRegion="polite" style={{minHeight:18,paddingHorizontal:14,color:colors.text3,fontSize:11}}>{active&&meta.data?.typing?`${person.display_name} ${t('social.typing')}`:''}</Text>
|
||||
{(reply||editing)&&<View style={{padding:10,backgroundColor:colors['accent-soft'],gap:4}}><Muted>{t(editing?'social.editing':'social.replying')}</Muted><Text numberOfLines={2} style={{color:colors.text2}}>{(editing??reply)?.text}</Text><Button title={t(editing?'social.cancel_edit':'social.cancel_reply')} variant="ghost" onPress={()=>editing?cancelEdit():setReply(null)}/></View>}
|
||||
<View style={{padding:12,gap:8,borderTopWidth:1,borderTopColor:colors.line}}><Field label={t('social.message')} value={text} onChangeText={value=>{touched.current=true;setText(value);}} maxLength={4000} multiline editable={!send.isPending}/><View style={{flexDirection:'row',gap:8}}><Button title="☺" variant="ghost" onPress={()=>setEmojis(v=>!v)}/><View style={{flex:1}}><Button title={t(editing?'playlists.save':'social.send')} busy={send.isPending} disabled={!text.trim()} onPress={()=>send.mutate()}/></View></View>{emojis&&<View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>{setText(v=>(v+emoji).slice(0,4000));setEmojis(false);}}/>)}</View>}</View>
|
||||
<Modal transparent visible={!!menu} animationType={reduced?'none':'fade'} onRequestClose={()=>setMenu(null)}><View style={{flex:1,justifyContent:'flex-end',backgroundColor:colors.overlay}}><Screen style={{flex:0,maxHeight:'80%',borderTopLeftRadius:24,borderTopRightRadius:24}}><ScrollView contentContainerStyle={{padding:20,gap:10}}><Button title={t('form.close')} variant="ghost" onPress={()=>setMenu(null)}/>{menu&&<><Button title={t('social.reply')} variant="secondary" onPress={()=>{if(editing)cancelEdit();setReply(menu);setMenu(null);}}/><View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>mutate(menu,{action:'react',emoji})}/>)}</View>{menu.sender_id===userId&&<><Button title={t('social.edit_message')} variant="secondary" onPress={()=>{beforeEdit.current=text;setEditing(menu);setText(menu.text);setReply(null);setMenu(null);}}/><Button title={t('social.delete_message')} variant="danger" onPress={()=>mutate(menu,{action:'delete'})}/></>}</>}</ScrollView></Screen></View></Modal>
|
||||
</KeyboardAvoidingView>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { socialApi, type ListeningPreferences, type SocialPerson } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, ScrollView, Switch, Text, View } from 'react-native';
|
||||
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||
import { player } from '../../player/controller';
|
||||
import { eventId } from '../../player/eventId';
|
||||
import { usePlayerState } from '../../player/hooks';
|
||||
import { useColors } from '../../theme';
|
||||
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||
|
||||
function useListening() {
|
||||
const session = useSession(); const { server } = useSessionCtx(); const user = useUser();
|
||||
const api = useMemo(() => socialApi(session), [session]); const key = ['m-listening', server, user.id] as const;
|
||||
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
|
||||
return { api, key, query, user };
|
||||
}
|
||||
export function ListeningPanel() {
|
||||
const { api, key, query, user } = useListening(); const { t } = useTranslation(); const cache = useQueryClient(); const errorText = useErrorText(); const colors = useColors();
|
||||
const [expanded, setExpanded] = useState(false); const prefs = query.data?.preferences;
|
||||
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const action = useMutation({ mutationFn: (a: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: eventId(Date.now()), action: a.action, target_id: a.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <View style={{ gap: 12 }}><Button title={t('social.together')} variant="secondary" onPress={() => setExpanded(v => !v)} />
|
||||
{expanded && <View style={{ padding: 16, borderRadius: 16, gap: 14, backgroundColor: colors.surface }}><Muted>{t('social.stream_prefs_hint')}</Muted>
|
||||
{(['requests', 'auto_accept'] as const).map(field => <View key={field} style={{ gap: 8 }}><Muted>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</Muted><View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} disabled={!prefs || save.isPending} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onPress={() => prefs && save.mutate({ ...prefs, [field]: v })} />)}</View></View>)}
|
||||
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}><Text style={{ color: colors.text, flex: 1 }}>{t('social.stream_autoplay')}</Text><Switch accessibilityLabel={t('social.stream_autoplay')} value={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onValueChange={autoplay => prefs && save.mutate({ ...prefs, autoplay })} /></View>
|
||||
</View>}
|
||||
{query.data?.requests.map(r => <View key={r.id} style={{ padding: 16, borderRadius: 16, gap: 10, backgroundColor: colors.surface }}><Text style={{ color: colors.text }}>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</Text>{r.recipient_id === user.id ? <><Button title={t('social.accept')} disabled={action.isPending} onPress={() => action.mutate({ action: 'accept', target: r.id })} /><Button title={t('social.decline')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'decline', target: r.id })} /></> : <Button title={t('form.cancel')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'cancel', target: r.id })} />}</View>)}
|
||||
<ErrorBox>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</ErrorBox>
|
||||
</View>;
|
||||
}
|
||||
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
|
||||
const { t } = useTranslation(); const [open, setOpen] = useState(false);
|
||||
return <><Button title={t('social.together')} variant="ghost" onPress={() => setOpen(true)} />{open && <TogetherSheet person={person} close={() => setOpen(false)} />}</>;
|
||||
}
|
||||
function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) {
|
||||
const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText();
|
||||
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now()));
|
||||
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted>
|
||||
<Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} />
|
||||
<Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} />
|
||||
<Muted>{t('social.stream_hint')}</Muted><ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
|
||||
{action.isSuccess ? <Muted>{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</Muted> : <Button title={t('social.send')} busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onPress={() => action.mutate()} />}
|
||||
</ScrollView></Screen></Modal>;
|
||||
}
|
||||
export function ListeningBanner() {
|
||||
const state = usePlayerState(); const session = useSession(); const user = useUser(); const colors = useColors(); const { t } = useTranslation(); const errorText = useErrorText(); const room = state.listening?.room;
|
||||
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: eventId(Date.now()), action: room.owner_id === user.id ? 'end' : 'leave', target_id: room.id }) });
|
||||
if (!room) return null;
|
||||
return <View style={{ padding: 12, gap: 6, backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.ok }}><Text style={{ color: colors.text, fontWeight: '600' }}>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</Text>
|
||||
<Muted>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</Muted>
|
||||
<Muted>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</Muted>
|
||||
{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button title={t('social.stream_start_audio')} variant="secondary" onPress={() => player.beginListeningAudio()} />}
|
||||
<Button title={t(room.owner_id === user.id ? 'social.stream_end' : 'social.stream_leave')} variant="ghost" busy={action.isPending} onPress={() => action.mutate()} />
|
||||
<ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
|
||||
</View>;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { player } from '../../player/controller';
|
||||
import { Button, ErrorBox, H1, Muted, Screen, TrackRow, useErrorText } from '../../ui/kit';
|
||||
|
||||
export function SharedMusic({ kind }: { kind: 'track' | 'playlist' }) {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { id, owner } = useLocalSearchParams<{ id: string; owner?: string }>();
|
||||
const session = useSession();
|
||||
const { server } = useSessionCtx();
|
||||
const user = useUser();
|
||||
@@ -17,20 +17,22 @@ export function SharedMusic({ kind }: { kind: 'track' | 'playlist' }) {
|
||||
const { t } = useTranslation();
|
||||
const errorText = useErrorText();
|
||||
const [playError, setPlayError] = useState<unknown>();
|
||||
const query = useQuery({ queryKey: ['shared-music', server, user.id, kind, id], queryFn: async () => {
|
||||
const query = useQuery({ queryKey: ['shared-music', server, user.id, kind, id, owner], refetchInterval: owner ? 15_000 : false, queryFn: async () => {
|
||||
if (owner) return unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId: owner } } }));
|
||||
if (kind === 'track') {
|
||||
const track = unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } }));
|
||||
return { name: track.title, tracks: [track] };
|
||||
return { id: track.id, name: track.title, tracks: [track] };
|
||||
}
|
||||
return unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }));
|
||||
} });
|
||||
return <Screen><FlatList data={query.data?.tracks ?? []} keyExtractor={x => x.id}
|
||||
const visible = query.isError ? undefined : query.data;
|
||||
return <Screen><FlatList data={visible?.tracks ?? []} keyExtractor={x => x.id}
|
||||
ListHeaderComponent={<View style={{ padding: 20, gap: 16 }}>
|
||||
<Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />
|
||||
<H1>{query.data?.name ?? t(kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')}</H1>
|
||||
<H1>{visible?.name ?? t(kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')}</H1>
|
||||
{query.isPending && <Muted>{t('social.loading')}</Muted>}
|
||||
<ErrorBox>{query.error || playError ? errorText(query.error ?? playError) : null}</ErrorBox>
|
||||
</View>}
|
||||
renderItem={({ item, index }) => <TrackRow track={item} onPress={() => void player.playList(query.data?.tracks ?? [], index, { type: 'collection', id, label: query.data?.name }).catch(setPlayError)} />}
|
||||
renderItem={({ item, index }) => <TrackRow track={item} onPress={() => void player.playList(visible?.tracks ?? [], index, { type: kind === 'track' ? 'queue' : 'collection', id: visible?.id, label: visible?.name }).catch(setPlayError)} />}
|
||||
/></Screen>;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { attachmentPath, socialApi, uuidv7, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||
import { attachmentPath, socialApi, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||
import { eventId } from '../../player/eventId';
|
||||
import { ListeningPanel, ListenTogetherButton } from './Listening';
|
||||
import { ChatConversation } from './ChatConversation';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AppState, Clipboard, KeyboardAvoidingView, Modal, ScrollView, Share, Text, View } from 'react-native';
|
||||
import { AppState, Clipboard, KeyboardAvoidingView, Modal, Pressable, ScrollView, Share, Text, View } from 'react-native';
|
||||
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||
import { useColors } from '../../theme';
|
||||
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||
@@ -20,6 +23,30 @@ function useSocial() {
|
||||
return { api, key, inbox, user, active };
|
||||
}
|
||||
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
|
||||
|
||||
const ChatDockContext = createContext<(id: string) => boolean>(() => false);
|
||||
export function NativeChatProvider({ children }: { children: ReactNode }) {
|
||||
const { inbox } = useSocial(); const colors = useColors(); const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false); const [peer, setPeer] = useState<string | null>(null);
|
||||
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
|
||||
const selected = friends.find(p => p.id === peer); const unread = friends.reduce((sum,p) => sum+p.unread,0);
|
||||
return <ChatDockContext.Provider value={id => { setPeer(id); setOpen(true); return true; }}>{children}
|
||||
<View style={{ position: 'absolute', bottom: 158, right: 16 }}><Button title={`${t('social.messages')}${unread ? ` · ${unread}` : ''}`} variant="secondary" onPress={() => setOpen(true)} /></View>
|
||||
<Modal transparent visible={open} animationType="slide" onRequestClose={() => setOpen(false)}>
|
||||
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: colors.overlay }}><View style={{ height: '82%', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', backgroundColor: colors.bg }}>
|
||||
<Screen><View style={{ padding: 12, flexDirection: 'row', justifyContent: 'space-between' }}><Button title={t('social.people')} variant="ghost" onPress={() => setPeer(null)} /><Button title={t('social.minimize')} variant="ghost" onPress={() => setOpen(false)} /></View>
|
||||
{selected ? <Chat key={selected.id} person={selected} active={open} /> : <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}><H1>{t('social.messages')}</H1>{friends.map(p => <Button key={p.id} title={`${p.display_name}${p.unread ? ` · ${p.unread}` : ''}`} variant="secondary" onPress={() => setPeer(p.id)} />)}{!friends.length && <Muted>{t('social.no_friends_share')}</Muted>}</ScrollView>}
|
||||
</Screen>
|
||||
</View></View>
|
||||
</Modal>
|
||||
</ChatDockContext.Provider>;
|
||||
}
|
||||
|
||||
export function FriendRecommendations() {
|
||||
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
|
||||
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <View style={{ gap: 8 }}><Button title={t('social.friends_recommendations')} variant={(inbox.data?.friends_recommendations ?? true) ? 'primary' : 'secondary'} disabled={!inbox.data || save.isPending} onPress={() => save.mutate(!(inbox.data?.friends_recommendations ?? true))} /><Muted>{t('social.friends_recommendations_hint')}</Muted><ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox></View>;
|
||||
}
|
||||
export function MusicShareSheet({ attachment, url, close }: { attachment: MusicAttachment; url: string; close: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { inbox, api, key } = useSocial();
|
||||
@@ -31,7 +58,7 @@ export function MusicShareSheet({ attachment, url, close }: { attachment: MusicA
|
||||
const send = useMutation({ mutationFn: async (peer: string) => {
|
||||
if (peer === 'copy') { Clipboard.setString(url); setCopied(true); return; }
|
||||
if (peer === 'external') { await Share.share({ title: attachment.title, message: url }); return; }
|
||||
let id = ids.current.get(peer); if (!id) { id = uuidv7(); ids.current.set(peer, id); }
|
||||
let id = ids.current.get(peer); if (!id) { id = eventId(Date.now()); ids.current.set(peer, id); }
|
||||
await api.send(peer, { id, text: attachment.title, attachment_kind: attachment.kind, attachment_id: attachment.id });
|
||||
setSent(v => [...v, peer]); await cache.invalidateQueries({ queryKey: key });
|
||||
} });
|
||||
@@ -49,6 +76,7 @@ export function MusicShareSheet({ attachment, url, close }: { attachment: MusicA
|
||||
}
|
||||
|
||||
export function SocialScreen({ root = false }: { root?: boolean }) {
|
||||
const dock = useContext(ChatDockContext);
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
@@ -62,12 +90,20 @@ export function SocialScreen({ root = false }: { root?: boolean }) {
|
||||
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
|
||||
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: q.length >= 2 });
|
||||
const act = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend');
|
||||
if (selected) return <Screen><Button title={t('social.back')} variant="ghost" onPress={() => setPeer('')} /><Chat key={selected.id} person={selected} /></Screen>;
|
||||
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
|
||||
return <Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||
{!root && <Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />}
|
||||
<H1>{t('social.title')}</H1><Muted>{t('social.subtitle')}</Muted>
|
||||
<View style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
|
||||
<Muted>{t('social.activity_visibility')}</Muted>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} disabled={!inbox.data || visibility.isPending} onPress={() => visibility.mutate(v)} />)}</View>
|
||||
<Muted>{t('social.activity_privacy_hint')}</Muted>
|
||||
<ErrorBox>{visibility.error ? errorText(visibility.error) : null}</ErrorBox>
|
||||
</View>
|
||||
<ListeningPanel />
|
||||
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button title={t('social.people')} variant={notifications ? 'ghost' : 'primary'} onPress={() => setNotifications(false)} />
|
||||
<Button title={`${t('social.notifications')} ${inbox.data?.unread || ''}`} variant={notifications ? 'primary' : 'ghost'} onPress={() => setNotifications(true)} />
|
||||
@@ -87,7 +123,15 @@ export function SocialScreen({ root = false }: { root?: boolean }) {
|
||||
{!people.length && inbox.data && !results.isFetching && <Muted>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</Muted>}
|
||||
{people.map(p => <View key={p.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
|
||||
<Text style={{ color: colors.text, fontWeight: '600', fontSize: 17 }}>{p.display_name}</Text><Muted>@{p.username} · {t(`social.rel_${p.relationship}`)}</Muted>
|
||||
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => setPeer(p.id)} />}
|
||||
{p.activity && <View style={{ borderLeftWidth: 2, borderLeftColor: colors.ok, paddingLeft: 12, gap: 6 }}>
|
||||
<Text style={{ color: colors.ok, fontSize: 12 }}>{t('social.listening')}</Text>
|
||||
<Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-track', params: { id: p.activity!.track_id } })}><Text style={{ color: colors.accent2 }}>{p.activity.track_title}</Text></Pressable>
|
||||
{!!p.activity.artist && <Muted>{p.activity.artist}</Muted>}
|
||||
{p.activity.context === 'wave' && <Muted>{t('wave.title')}</Muted>}
|
||||
{p.activity.context === 'playlist' && p.activity.playlist_id && <Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-playlist', params: { owner: p.id, id: p.activity!.playlist_id } })}><Text style={{ color: colors.accent2 }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Text></Pressable>}
|
||||
</View>}
|
||||
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => { if (!dock(p.id)) setPeer(p.id); }} />}
|
||||
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
|
||||
{p.relationship === 'none' && <Button title={t('social.add')} disabled={act.isPending} onPress={() => act.mutate({ action: 'request', target_id: p.id })} />}
|
||||
{p.relationship === 'incoming' && <Button title={t('social.accept')} disabled={act.isPending} onPress={() => act.mutate({ action: 'accept', target_id: p.id })} />}
|
||||
{['friend', 'incoming', 'outgoing'].includes(p.relationship) && <Button title={t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'remove', target_id: p.id })} />}
|
||||
@@ -96,52 +140,9 @@ export function SocialScreen({ root = false }: { root?: boolean }) {
|
||||
</>}
|
||||
</ScrollView></Screen>;
|
||||
}
|
||||
function Chat({ person }: { person: SocialPerson }) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const { server } = useSessionCtx();
|
||||
const { api, key, user, active } = useSocial();
|
||||
const router = useRouter();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [text, setText] = useState('');
|
||||
const [focused, setFocused] = useState(false);
|
||||
useFocusEffect(useCallback(() => { setFocused(true); return () => setFocused(false); }, []));
|
||||
const pending = useRef<{ id: string; text: string } | null>(null);
|
||||
const messages = useInfiniteQuery({ queryKey: [...key, 'chat', person.id], initialPageParam: undefined as number | undefined,
|
||||
queryFn: ({ pageParam }) => api.messages(person.id, pageParam), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined, enabled: active && focused, refetchInterval: active && focused ? 8_000 : false });
|
||||
const list = [...(messages.data?.pages ?? [])].reverse().flat();
|
||||
const last = list.at(-1)?.seq;
|
||||
const unread = list.some(m => m.sender_id !== user.id && !m.read);
|
||||
useEffect(() => {
|
||||
if (last && unread && active && focused) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey: key })).catch(() => undefined);
|
||||
}, [last, unread, active, focused, api, person.id, messages.dataUpdatedAt]);
|
||||
const send = useMutation({ mutationFn: async () => {
|
||||
if (!pending.current || pending.current.text !== text) pending.current = { id: uuidv7(), text };
|
||||
return api.send(person.id, { ...pending.current, attachment_kind: 'none' });
|
||||
}, onSuccess: async () => { setText(''); pending.current = null; await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const scroll = useRef<ScrollView>(null);
|
||||
const [scrollBottom, setScrollBottom] = useState(true);
|
||||
useEffect(() => { setScrollBottom(true); }, [last]);
|
||||
return <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
|
||||
<View style={{ padding: 16, gap: 8 }}><H1>{person.display_name}</H1><Muted>{t('social.chat_hint')}</Muted><ErrorBox>{messages.error || send.error ? errorText(messages.error ?? send.error) : null}</ErrorBox></View>
|
||||
<ScrollView ref={scroll} keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 16, gap: 12 }} onContentSizeChange={() => { if (scrollBottom) scroll.current?.scrollToEnd({ animated: false }); }}>
|
||||
{messages.hasNextPage && <Button title={t('social.older')} variant="ghost" busy={messages.isFetchingNextPage} onPress={() => { setScrollBottom(false); void messages.fetchNextPage(); }} />}
|
||||
{messages.isPending && <Muted>{t('social.loading')}</Muted>}
|
||||
{messages.isSuccess && !list.length && <Muted>{t('social.first_message')}</Muted>}
|
||||
{list.map(m => <View key={m.id} style={{ alignSelf: m.sender_id === user.id ? 'flex-end' : 'flex-start', maxWidth: '88%', padding: 14, borderRadius: 16, backgroundColor: m.sender_id === user.id ? colors['accent-soft'] : colors.surface2, gap: 8 }}>
|
||||
<Text style={{ color: colors.text, fontSize: 15 }}>{m.text}</Text>
|
||||
{attachmentPath(m) && <Button title={t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} variant="secondary" onPress={() => {
|
||||
if (!server || !m.attachment_id) return;
|
||||
if (m.attachment_kind === 'track') router.push({ pathname: '/social-track', params: { id: m.attachment_id } });
|
||||
else router.push({ pathname: '/social-playlist', params: { id: m.attachment_id } });
|
||||
}} />}
|
||||
<Muted>{new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}{m.sender_id === user.id ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</Muted>
|
||||
</View>)}
|
||||
</ScrollView>
|
||||
<View style={{ padding: 16, gap: 12, borderTopWidth: 1, borderTopColor: colors.line }}>
|
||||
<Field label={t('social.message')} value={text} onChangeText={setText} maxLength={4000} multiline editable={!send.isPending} />
|
||||
<Button title={t('social.send')} disabled={!text.trim()} busy={send.isPending} onPress={() => send.mutate()} />
|
||||
</View>
|
||||
</KeyboardAvoidingView>;
|
||||
function Chat({ person, active: visible = true }: { person: SocialPerson; active?: boolean }) {
|
||||
const {api,key,user,active}=useSocial(); const {server}=useSessionCtx();
|
||||
const [focused,setFocused]=useState(false);
|
||||
useFocusEffect(useCallback(()=>{setFocused(true);return()=>setFocused(false);},[]));
|
||||
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} server={server??''} active={visible&&active&&focused}/>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { sha256 } from 'js-sha256';
|
||||
import { chatDraftKey } from '@pipu/core';
|
||||
|
||||
const directory = `${FileSystem.documentDirectory}chat-drafts/`;
|
||||
const path = (server: string, user: string, peer: string) => `${directory}${sha256(chatDraftKey(server,user,peer))}.txt`;
|
||||
export async function readDraft(server: string, user: string, peer: string) {
|
||||
try { return await FileSystem.readAsStringAsync(path(server,user,peer)); } catch { return ''; }
|
||||
}
|
||||
export async function saveDraft(server: string, user: string, peer: string, text: string) {
|
||||
if (!text) { await FileSystem.deleteAsync(path(server,user,peer), { idempotent: true }); return; }
|
||||
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
|
||||
await FileSystem.writeAsStringAsync(path(server,user,peer),text);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import {
|
||||
EventBuffer,
|
||||
createActivityPublisher,
|
||||
type ActivityPublisher,
|
||||
ListeningCoordinator,
|
||||
type ListeningRoom,
|
||||
WaveCoordinator,
|
||||
EARLY_SKIP_MS,
|
||||
type WaveInfo,
|
||||
@@ -32,6 +36,7 @@ export { offlineWave };
|
||||
export type { WaveInfo } from '@pipu/core';
|
||||
|
||||
export interface PlayerState {
|
||||
listening?: import('@pipu/core').ListeningClientState;
|
||||
ready: boolean;
|
||||
track: Track | null;
|
||||
queue: Track[];
|
||||
@@ -72,6 +77,11 @@ async function quality(): Promise<Quality> {
|
||||
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
|
||||
*/
|
||||
class Controller {
|
||||
private activity: ActivityPublisher | null = null;
|
||||
private listening: ListeningCoordinator | null = null;
|
||||
private interrupted = false;
|
||||
private remoteRoom: string | null = null;
|
||||
private queueVersion = 0;
|
||||
private session: AuthSession | null = null;
|
||||
private server = '';
|
||||
private events: EventBuffer | null = null;
|
||||
@@ -104,12 +114,27 @@ class Controller {
|
||||
|
||||
private update(patch: Partial<PlayerState>) {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.activity?.update(this.state);
|
||||
this.listening?.update();
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
|
||||
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
|
||||
attach(session: AuthSession | null, server: string, userId: string | null) {
|
||||
if (this.session === session) return;
|
||||
this.activity?.dispose();
|
||||
this.listening?.dispose();
|
||||
this.listening = null;
|
||||
this.activity = session && userId ? createActivityPublisher(session, eventId(Date.now())) : null;
|
||||
if (session && this.activity) {
|
||||
this.listening = new ListeningCoordinator(session, this.activity.deviceId, {
|
||||
read: this.getState,
|
||||
pause: () => { this.queueVersion++; this.leaveWave(); void TrackPlayer.pause().catch(() => undefined); },
|
||||
apply: (room, position, current) => this.syncListening(room, position, current),
|
||||
changed: listening => this.update({ listening }),
|
||||
});
|
||||
this.listening.start();
|
||||
}
|
||||
void this.events?.flush();
|
||||
this.session = session;
|
||||
this.server = server;
|
||||
@@ -240,35 +265,49 @@ class Controller {
|
||||
}
|
||||
|
||||
async playList(tracks: Track[], index: number, context: PlayContext) {
|
||||
if (this.listening?.locked) return;
|
||||
const version = ++this.queueVersion;
|
||||
const current = () => version === this.queueVersion && !this.listening?.locked;
|
||||
const list = tracks.filter(playable);
|
||||
const target = tracks[index];
|
||||
if (!target || !list.length) return;
|
||||
await this.setup();
|
||||
if (!current()) return;
|
||||
const native = await this.toRntp(list);
|
||||
if (!current()) return;
|
||||
this.leaveWave();
|
||||
this.finishAttempt(this.lastPositionMs / 1000);
|
||||
const start = Math.max(list.indexOf(target), 0);
|
||||
this.update({ queue: list, context, index: start, track: list[start] ?? null });
|
||||
await TrackPlayer.reset();
|
||||
await TrackPlayer.add(await this.toRntp(list));
|
||||
if (!current()) return;
|
||||
await TrackPlayer.add(native);
|
||||
if (!current()) return;
|
||||
await TrackPlayer.skip(start);
|
||||
if (!current()) return;
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.listening?.locked) return;
|
||||
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
|
||||
}
|
||||
|
||||
next() {
|
||||
if (this.listening?.locked) return;
|
||||
void TrackPlayer.skipToNext().catch(() => undefined);
|
||||
}
|
||||
|
||||
async prev() {
|
||||
if (this.listening?.locked) return;
|
||||
const { position } = await TrackPlayer.getProgress();
|
||||
if (this.listening?.locked) return;
|
||||
if (position > 3) await TrackPlayer.seekTo(0);
|
||||
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
|
||||
}
|
||||
|
||||
seek(ms: number) {
|
||||
if (this.listening?.locked) return;
|
||||
const t = this.state.track;
|
||||
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
|
||||
this.attempt?.seek(ms);
|
||||
@@ -306,6 +345,7 @@ class Controller {
|
||||
// --- «Моя волна» ---
|
||||
|
||||
async startWave(activity: string | null, mood: string | null) {
|
||||
if (this.listening?.locked) return;
|
||||
if (!this.session) return;
|
||||
await this.setup();
|
||||
this.leaveWave();
|
||||
@@ -335,6 +375,7 @@ class Controller {
|
||||
}
|
||||
|
||||
async setWaveContext(activity: string | null, mood: string | null) {
|
||||
if (this.listening?.locked) return;
|
||||
const wave = this.state.wave;
|
||||
if (!wave || !this.session) return this.startWave(activity, mood);
|
||||
const version = this.wavePolicy.changeContext(wave.sessionId);
|
||||
@@ -422,18 +463,28 @@ class Controller {
|
||||
|
||||
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
|
||||
async restore() {
|
||||
if (!this.session || this.state.track) return;
|
||||
if (!this.session || this.state.track || this.listening?.locked) return;
|
||||
const session = this.session;
|
||||
const initial = this.state;
|
||||
const current = () => this.session === session && this.state.queue === initial.queue && !this.state.track && !this.listening?.locked;
|
||||
await this.setup();
|
||||
if (!current()) return;
|
||||
try {
|
||||
const st = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
|
||||
const st = unwrap(await session.client.GET('/api/v1/me/playback-state'));
|
||||
if (!current()) return;
|
||||
if (!st.track_id || !st.queue.length) return;
|
||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
|
||||
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
|
||||
if (!current()) return;
|
||||
const tracks = res.items.filter(playable);
|
||||
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
|
||||
if (!tracks.length) return;
|
||||
const native = await this.toRntp(tracks);
|
||||
if (!current()) return;
|
||||
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
|
||||
await TrackPlayer.reset();
|
||||
await TrackPlayer.add(await this.toRntp(tracks));
|
||||
if (this.state.queue !== tracks || this.listening?.locked) return;
|
||||
await TrackPlayer.add(native);
|
||||
if (this.state.queue !== tracks || this.listening?.locked) return;
|
||||
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
|
||||
} catch {
|
||||
// офлайн или нет сохранённого состояния
|
||||
@@ -441,6 +492,7 @@ class Controller {
|
||||
}
|
||||
|
||||
async reset() {
|
||||
this.queueVersion++;
|
||||
this.wavePolicy.activate(null);
|
||||
this.attempt = null;
|
||||
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
||||
@@ -451,6 +503,35 @@ class Controller {
|
||||
this.save();
|
||||
void this.events?.flush();
|
||||
}
|
||||
|
||||
play() { if (!this.listening?.locked) void TrackPlayer.play(); }
|
||||
pause() { if (!this.listening?.locked) void TrackPlayer.pause(); }
|
||||
setInterrupted(paused: boolean) { this.interrupted = paused; }
|
||||
beginListeningAudio() { this.listening?.beginAudio(); }
|
||||
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
|
||||
if (!room.track_id) { await TrackPlayer.pause(); return; }
|
||||
await this.setup();
|
||||
if (!current()) return;
|
||||
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
|
||||
const track = unwrap(await this.session!.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
|
||||
const native = await this.toRntp([track]);
|
||||
if (!current()) return;
|
||||
this.remoteRoom = room.id;
|
||||
this.leaveWave(); this.finishAttempt(this.lastPositionMs / 1000);
|
||||
await TrackPlayer.pause();
|
||||
if (!current()) return;
|
||||
this.update({ queue: [track], track, index: 0, durationMs: track.duration_ms, positionMs: 0, context: { type: 'queue' }, wave: null });
|
||||
await TrackPlayer.reset();
|
||||
if (!current()) return;
|
||||
await TrackPlayer.add(native);
|
||||
}
|
||||
if (!current()) return;
|
||||
const target = Math.min(position, Math.max(0, this.state.durationMs - 100));
|
||||
if (Math.abs(this.state.positionMs - target) > 1500) await TrackPlayer.seekTo(target / 1000);
|
||||
if (!current()) { await TrackPlayer.pause(); return; }
|
||||
if (room.playing && !this.interrupted) { if (!this.state.playing) await TrackPlayer.play(); }
|
||||
else if (this.state.playing) await TrackPlayer.pause();
|
||||
}
|
||||
}
|
||||
|
||||
export const player = new Controller();
|
||||
@@ -7,14 +7,15 @@ import { player } from './controller';
|
||||
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
|
||||
*/
|
||||
export async function PlaybackService(): Promise<void> {
|
||||
TrackPlayer.addEventListener(Event.RemotePlay, () => void TrackPlayer.play());
|
||||
TrackPlayer.addEventListener(Event.RemotePause, () => void TrackPlayer.pause());
|
||||
TrackPlayer.addEventListener(Event.RemoteStop, () => void TrackPlayer.stop());
|
||||
TrackPlayer.addEventListener(Event.RemotePlay, () => player.play());
|
||||
TrackPlayer.addEventListener(Event.RemotePause, () => player.pause());
|
||||
TrackPlayer.addEventListener(Event.RemoteStop, () => player.pause());
|
||||
TrackPlayer.addEventListener(Event.RemoteNext, () => player.next());
|
||||
TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev());
|
||||
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000));
|
||||
// Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса).
|
||||
TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
|
||||
player.setInterrupted(paused || permanent);
|
||||
if (permanent) void TrackPlayer.pause();
|
||||
else if (paused) void TrackPlayer.pause();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FriendRecommendations } from '../features/social/Social';
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||
@@ -38,6 +39,7 @@ export function RecommendationSettings() {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const errorText = useErrorText();
|
||||
return <View>
|
||||
<FriendRecommendations />
|
||||
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
|
||||
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
|
||||
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
|
||||
|
||||
@@ -70,3 +70,18 @@ it('does not let a late context response replace a manually selected playlist',
|
||||
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
|
||||
expect(player.getState().context?.type).toBe('album');
|
||||
});
|
||||
|
||||
it('does not restore a stale saved queue after manual playback starts', async () => {
|
||||
const { player } = await import('../src/player/controller');
|
||||
const saved = deferred<unknown>();
|
||||
const get = vi.fn().mockReturnValue(saved.promise);
|
||||
const post = vi.fn();
|
||||
player.attach({ client: { GET: get, POST: post } } as unknown as AuthSession, 'http://localhost', null);
|
||||
const restoring = player.restore();
|
||||
await vi.waitFor(() => expect(get).toHaveBeenCalled());
|
||||
await player.playList([track('chosen')], 0, { type: 'queue' });
|
||||
saved.resolve({ data: { track_id: 'old', queue: ['old'], position_ms: 500 }, response: new Response() });
|
||||
await restoring;
|
||||
expect(player.getState().track?.id).toBe('chosen');
|
||||
expect(post).not.toHaveBeenCalledWith('/api/v1/tracks/batch', expect.anything());
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
||||
for (const width of [320, 800, 1280]) {
|
||||
test(`friends, messages and sharing at ${width}px`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
if (width === 800) await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { sessionStorage.setItem('shared-url', data.url ?? ''); } }));
|
||||
await page.goto('/login');
|
||||
@@ -21,6 +22,10 @@ for (const width of [320, 800, 1280]) {
|
||||
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||
await expect(page.getByText('Привет! Нашёл музыку для нашей поездки 🎵', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeEmpty();
|
||||
await page.getByRole('button', { name: 'Действия с сообщением', exact: true }).click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Удалить для всех', exact: true })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: '❤️', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '❤️ 1', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('conversation.png'), fullPage: true });
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AuthSession } from '@pipu/core';
|
||||
import { AuthSession, refreshListening } from '@pipu/core';
|
||||
import { createI18n } from '@pipu/i18n';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { createMemoryHistory } from '@tanstack/react-router';
|
||||
@@ -15,6 +15,74 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('social', () => {
|
||||
it('follows a room with locked playback controls, independent volume and local exit', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, session, user } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
const id = crypto.randomUUID();
|
||||
api.listeningRooms.set(id, { id, owner_id: bob.id, owner_name: 'bob', device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, position_ms: 5000, playing: false, updated_at: new Date().toISOString(), participants: [] });
|
||||
api.listeningMembers.set(alice.id, id); api.listeningMembers.set(bob.id, id);
|
||||
refreshListening(session);
|
||||
await screen.findByText('Слушаете вместе с bob');
|
||||
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await waitFor(() => expect(within(player).getByRole('button', { name: 'Слушать' })).toBeDisabled());
|
||||
expect(within(player).getByRole('slider', { name: 'Громкость' })).toBeEnabled();
|
||||
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
|
||||
await waitFor(() => expect(screen.queryByText('Слушаете вместе с bob')).not.toBeInTheDocument());
|
||||
expect(api.listeningMembers.has(alice.id)).toBe(false);
|
||||
});
|
||||
it('edits, replies, reacts, searches, deletes and preserves a minimized draft', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Написать' }));
|
||||
const composer = () => screen.getByRole('textbox', { name: 'Сообщение' });
|
||||
await user.type(composer(), 'Первый вариант');
|
||||
await user.click(screen.getByRole('button', { name: 'Отправить' }));
|
||||
await screen.findByText('Первый вариант');
|
||||
const actions = async (name: string) => {
|
||||
await user.click((await screen.findAllByRole('button', { name: 'Действия с сообщением' }))[0]!);
|
||||
await user.click(await screen.findByRole('menuitem', { name }));
|
||||
};
|
||||
await actions('Редактировать');
|
||||
await user.clear(composer()); await user.type(composer(), 'Исправленный текст');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await screen.findByText('Исправленный текст', { exact: true });
|
||||
await actions('❤️');
|
||||
await screen.findByRole('button', { name: '❤️ 1' });
|
||||
await actions('Ответить');
|
||||
await user.type(composer(), 'Ответ на музыку');
|
||||
await user.click(screen.getByRole('button', { name: 'Отправить' }));
|
||||
await waitFor(() => expect(api.messages).toHaveLength(2));
|
||||
expect(api.messages[1]?.reply_to).toBe(api.messages[0]?.id);
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Поиск в переписке' }), 'Исправленный');
|
||||
await waitFor(() => expect(screen.queryByText('Ответ на музыку', { exact: true })).not.toBeInTheDocument());
|
||||
await actions('Удалить для всех');
|
||||
await waitFor(() => expect(api.messages[0]?.deleted).toBe(true));
|
||||
await user.clear(screen.getByRole('searchbox', { name: 'Поиск в переписке' }));
|
||||
await user.type(composer(), 'Мой черновик');
|
||||
await user.click(screen.getByRole('button', { name: 'Свернуть чат' }));
|
||||
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'bob' }));
|
||||
expect(composer()).toHaveValue('Мой черновик');
|
||||
});
|
||||
|
||||
it('saves privacy and independent request admission preferences', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { api, user } = await renderApp([alice], '/friends', { signedInAs: alice });
|
||||
const privacy = await screen.findByRole('group', { name: 'Показывать мой статус' });
|
||||
expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(within(privacy).getByRole('button', { name: 'Друзьям' }));
|
||||
await waitFor(() => expect(api.activityVisibility.get(alice.id)).toBe('friends'));
|
||||
await user.click(screen.getByText('Слушать вместе', { exact: true }));
|
||||
const requests = await screen.findByRole('group', { name: 'Принимать запросы и приглашения от' });
|
||||
const auto = screen.getByRole('group', { name: 'Автоматически принимать от' });
|
||||
await user.click(within(requests).getByRole('button', { name: 'Друзьям' }));
|
||||
await waitFor(() => expect(api.listeningPreferences.get(alice.id)?.requests).toBe('friends'));
|
||||
await waitFor(() => expect(within(auto).getByRole('button', { name: 'Всем' })).toBeEnabled());
|
||||
await user.click(within(auto).getByRole('button', { name: 'Всем' }));
|
||||
await waitFor(() => expect(api.listeningPreferences.get(alice.id)).toMatchObject({ requests: 'friends', auto_accept: 'everyone' }));
|
||||
});
|
||||
it('shows persistent notifications and acknowledges only the chosen event', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const bob = makeUser('bob', 'user');
|
||||
|
||||
@@ -26,7 +26,7 @@ import { ConnectPage } from '../pages/ConnectPage';
|
||||
import { TrackPage } from '../pages/TrackPage';
|
||||
import { ArtistPage } from '../pages/ArtistPage';
|
||||
import { CollectionPage } from '../pages/CollectionPage';
|
||||
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
||||
import { ActivityPlaylistPage, PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
||||
import { HomePage } from '../pages/HomePage';
|
||||
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
@@ -148,6 +148,7 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
||||
};
|
||||
|
||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||
const activityPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/activity/playlist/$userId', component: () => <ActivityPlaylistPage key={activityPlaylistRoute.useParams().userId} userId={activityPlaylistRoute.useParams().userId} /> });
|
||||
const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string } => typeof s.peer === 'string' ? { peer: s.peer } : {}, component: () => <SocialPage peer={socialRoute.useSearch().peer} /> });
|
||||
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
|
||||
@@ -295,6 +296,7 @@ const routeTree = rootRoute.addChildren([
|
||||
setupPasswordRoute,
|
||||
appRoute.addChildren([
|
||||
homeRoute,
|
||||
activityPlaylistRoute,
|
||||
socialRoute,
|
||||
trackRoute,
|
||||
playlistsRoute,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { attachmentPath, CHAT_REACTIONS, chatDraftKey, uuidv7, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDesktop } from '../../desktop';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Field, FormError, Menu } from '../../shared/ui/ui';
|
||||
import s from './social.module.css';
|
||||
|
||||
export function ChatConversation({ person, api, queryKey, userId, compact = false, active = true }: { person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; compact?: boolean; active?: boolean }) {
|
||||
const { t, i18n } = useTranslation(); const cache = useQueryClient(); const errorText = useErrorText();
|
||||
const server = useDesktop()?.server ?? window.location.origin;
|
||||
const draftKey = chatDraftKey(server, userId, person.id);
|
||||
const [text, setText] = useState(() => { try { return localStorage.getItem(draftKey) ?? ''; } catch { return ''; } });
|
||||
const [reply, setReply] = useState<DirectMessage | null>(null); const [editing, setEditing] = useState<DirectMessage | null>(null);
|
||||
const [search, setSearch] = useState(''); const [q, setQ] = useState(''); const [emojis, setEmojis] = useState(false);
|
||||
const beforeEdit = useRef(''); const pending = useRef<{ id: string; text: string; reply_to?: string } | null>(null);
|
||||
const typedAt = useRef(0);
|
||||
const [visible, setVisible] = useState(document.visibilityState === 'visible');
|
||||
useEffect(() => { const change = () => setVisible(document.visibilityState === 'visible'); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []);
|
||||
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 250); return () => clearTimeout(timer); }, [search]);
|
||||
useEffect(() => {
|
||||
if (editing) return;
|
||||
const timer = setTimeout(() => { try { if (text) localStorage.setItem(draftKey, text); else localStorage.removeItem(draftKey); } catch { /* Private mode can deny storage. */ } }, 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [text, draftKey, editing]);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { void api.typing(person.id, active && visible && !!text.trim() && !editing && Date.now() - typedAt.current < 6000).catch(() => undefined); }, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [text, active, visible, editing, person.id, api]);
|
||||
useEffect(() => () => { void api.typing(person.id, false).catch(() => undefined); }, [api, person.id]);
|
||||
const meta = useQuery({ queryKey: [...queryKey, 'typing', person.id], queryFn: () => api.chatMeta(person.id), enabled: active && visible, refetchInterval: active && visible ? 2000 : false });
|
||||
const messages = useInfiniteQuery({ queryKey: [...queryKey, 'chat', person.id, q], initialPageParam: undefined as number | undefined,
|
||||
queryFn: ({ pageParam }) => api.messages(person.id, pageParam, q), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined, enabled: active, refetchInterval: active && visible ? 3000 : false });
|
||||
const list = [...(messages.data?.pages ?? [])].reverse().flat(); const last = list.at(-1)?.seq;
|
||||
const firstUnread = list.find(m => m.sender_id !== userId && !m.read)?.id;
|
||||
useEffect(() => {
|
||||
if (last && firstUnread && visible && active && !q) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey })).catch(() => undefined);
|
||||
}, [last, firstUnread, visible, active, q, api, person.id, cache, messages.dataUpdatedAt]);
|
||||
const end = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { if (active && !q) end.current?.scrollIntoView?.({ block: 'nearest' }); }, [last, active, q]);
|
||||
const refresh = () => cache.invalidateQueries({ queryKey });
|
||||
const update = useMutation({ mutationFn: (input: { id: string; body: ChatMessageUpdate }) => api.updateMessage(person.id, input.id, input.body), onSuccess: refresh });
|
||||
const send = useMutation({ mutationFn: async () => {
|
||||
if (editing) { await api.updateMessage(person.id, editing.id, { action: 'edit', text }); return; }
|
||||
if (!pending.current || pending.current.text !== text || pending.current.reply_to !== reply?.id) pending.current = { id: uuidv7(), text, ...(reply ? { reply_to: reply.id } : {}) };
|
||||
await api.send(person.id, { ...pending.current, attachment_kind: 'none' });
|
||||
}, onSuccess: async () => { setText(editing ? beforeEdit.current : ''); setEditing(null); setReply(null); pending.current = null; await refresh(); } });
|
||||
const cancelEdit = () => { setText(beforeEdit.current); setEditing(null); };
|
||||
const mutate = (id: string, body: ChatMessageUpdate) => { if (!update.isPending) update.mutate({ id, body }); };
|
||||
return <section className={s.chat} aria-label={t('social.chat')}>
|
||||
{!compact && <header><strong>{person.display_name}</strong><p className={s.muted}>{t('social.chat_hint')}</p></header>}
|
||||
<div className={s.chatSearch}><Field label={t('social.chat_search')} value={search} onChange={setSearch} type="search" maxLength={100} /></div>
|
||||
<FormError>{messages.error || send.error || update.error ? errorText(messages.error ?? send.error ?? update.error) : null}</FormError>
|
||||
<div className={s.messages}>
|
||||
{messages.hasNextPage && <Button busy={messages.isFetchingNextPage} onClick={() => void messages.fetchNextPage()}>{t('social.older')}</Button>}
|
||||
{messages.isPending && <p>{t('social.loading')}</p>}
|
||||
{!list.length && messages.isSuccess && <p className={s.empty}>{t(q ? 'social.chat_no_results' : 'social.first_message')}</p>}
|
||||
{list.map((m, i) => <Fragment key={m.id}>
|
||||
{(i === 0 || new Date(list[i - 1]!.created_at).toDateString() !== new Date(m.created_at).toDateString()) && <time className={s.date}>{new Date(m.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'long', year: 'numeric' })}</time>}
|
||||
{m.id === firstUnread && !q && <div className={s.date}>{t('social.new_messages')}</div>}
|
||||
<article id={`message-${m.id}`} className={`${s.bubble} ${m.sender_id === userId ? s.mine : ''} ${m.deleted ? s.deleted : ''}`}>
|
||||
{m.reply_to && <blockquote className={s.quote}>{m.reply_preview || t('social.message_deleted')}</blockquote>}
|
||||
<div className={s.messageText}>{m.deleted ? t('social.message_deleted') : m.text}</div>
|
||||
{!m.deleted && attachmentPath(m) && <Link className={s.music} to={m.attachment_kind === 'track' ? '/track/$id' : '/shared/playlist/$shareId'} params={{ id: m.attachment_id!, shareId: m.attachment_id! }}>{t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} ↗</Link>}
|
||||
{!m.deleted && !!m.reactions?.length && <div className={s.reactions}>{m.reactions.map(r => <button key={r.emoji} type="button" aria-pressed={r.mine} disabled={update.isPending} onClick={() => mutate(m.id, { action: 'react', emoji: r.emoji as typeof CHAT_REACTIONS[number] })}>{r.emoji} {r.count}</button>)}</div>}
|
||||
<div className={s.messageFoot}><small className={s.muted}>{new Date(m.created_at).toLocaleTimeString(i18n.language, { hour: '2-digit', minute: '2-digit' })}{m.edited_at && !m.deleted ? ` · ${t('social.edited')}` : ''}{m.sender_id === userId ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</small>
|
||||
{!m.deleted && <Menu portal label={t('social.message_actions')} trigger="•••" items={[
|
||||
{ label: t('social.reply'), onSelect: () => { if (editing) cancelEdit(); setReply(m); } },
|
||||
...CHAT_REACTIONS.map(emoji => ({ label: emoji, onSelect: () => mutate(m.id, { action: 'react', emoji }) })),
|
||||
...(m.sender_id === userId ? [{ label: t('social.edit_message'), onSelect: () => { beforeEdit.current = text; setEditing(m); setReply(null); setText(m.text); } }, { label: t('social.delete_message'), danger: true, onSelect: () => mutate(m.id, { action: 'delete' }) }] : []),
|
||||
]} />}
|
||||
</div>
|
||||
</article>
|
||||
</Fragment>)}
|
||||
{send.isPending && !editing && !list.some(m => m.id === pending.current?.id) && <article className={`${s.bubble} ${s.mine} ${s.sending}`} role="status">{text} · …</article>}
|
||||
<div ref={end} />
|
||||
</div>
|
||||
<div className={s.typing} role="status" aria-live="polite">{active && meta.data?.typing ? `${person.display_name} ${t('social.typing')}` : ''}</div>
|
||||
{(reply || editing) && <div className={s.replyBar}><div><small>{t(editing ? 'social.editing' : 'social.replying')}</small><p>{(editing ?? reply)?.text}</p></div><Button variant="ghost" onClick={() => editing ? cancelEdit() : setReply(null)} aria-label={t(editing ? 'social.cancel_edit' : 'social.cancel_reply')}>×</Button></div>}
|
||||
<form className={s.composer} onSubmit={e => { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}>
|
||||
<textarea aria-label={t('social.message')} placeholder={t('social.message')} maxLength={4000} rows={2} value={text} disabled={send.isPending} onChange={e => { typedAt.current = Date.now(); setText(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); } }} />
|
||||
<Button variant="ghost" aria-label={t('social.emoji')} aria-expanded={emojis} onClick={() => setEmojis(v => !v)}>☺</Button>
|
||||
{emojis && <div className={s.reactions}>{CHAT_REACTIONS.map(emoji => <button key={emoji} type="button" onClick={() => { setText(v => (v + emoji).slice(0, 4000)); setEmojis(false); }}>{emoji}</button>)}</div>}
|
||||
<Button type="submit" variant="primary" busy={send.isPending} disabled={!text.trim()}>{t(editing ? 'playlists.save' : 'social.send')}</Button>
|
||||
</form>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { socialApi, uuidv7, type ListeningPreferences, type SocialPerson } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
import { useDesktop } from '../../desktop';
|
||||
import { usePlayer, usePlayerState } from '../../player/context';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, FormError } from '../../shared/ui/ui';
|
||||
import s from './social.module.css';
|
||||
|
||||
function useListening() {
|
||||
const session = useSession(); const user = useUser(); const server = useDesktop()?.server ?? '';
|
||||
const api = useMemo(() => socialApi(session), [session]);
|
||||
const key = ['listening', server, user.id] as const;
|
||||
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
|
||||
return { api, key, query, user };
|
||||
}
|
||||
export function ListeningPanel() {
|
||||
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
|
||||
const { api, key, query, user } = useListening();
|
||||
const prefs = query.data?.preferences;
|
||||
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const action = useMutation({ mutationFn: (input: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: uuidv7(), action: input.action, target_id: input.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <div className={s.stack}>
|
||||
<details className={s.privacy}><summary>{t('social.together')}</summary><p className={s.muted}>{t('social.stream_prefs_hint')}</p>
|
||||
{(['requests', 'auto_accept'] as const).map(field => <fieldset key={field} className={s.privacy} disabled={!prefs || save.isPending}><legend>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={(prefs?.[field] ?? 'nobody') === v} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onClick={() => prefs && save.mutate({ ...prefs, [field]: v })}>{t(`social.visibility_${v}`)}</Button>)}</div></fieldset>)}
|
||||
<label><input type="checkbox" checked={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onChange={e => prefs && save.mutate({ ...prefs, autoplay: e.target.checked })} /> {t('social.stream_autoplay')}</label>
|
||||
</details>
|
||||
{query.data?.requests.map(r => <article key={r.id} className={s.person}><strong>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</strong><div className={s.actions}>{r.recipient_id === user.id ? <><Button disabled={action.isPending} onClick={() => action.mutate({ action: 'accept', target: r.id })}>{t('social.accept')}</Button><Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'decline', target: r.id })}>{t('social.decline')}</Button></> : <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'cancel', target: r.id })}>{t('form.cancel')}</Button>}</div></article>)}
|
||||
<FormError>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</FormError>
|
||||
</div>;
|
||||
}
|
||||
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
|
||||
const { t } = useTranslation(); const [open, setOpen] = useState(false);
|
||||
return <><Button variant="ghost" onClick={() => setOpen(true)}>{t('social.together')}</Button>{open && <ListenTogetherDialog person={person} close={() => setOpen(false)} />}</>;
|
||||
}
|
||||
function ListenTogetherDialog({ person, close }: { person: SocialPerson; close: () => void }) {
|
||||
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
|
||||
const { api, key } = useListening(); const state = usePlayerState();
|
||||
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite');
|
||||
const [id] = useState(uuidv7);
|
||||
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <Dialog title={t('social.together')} onClose={close}><strong>{person.display_name}</strong><fieldset className={s.privacy} disabled={action.isPending || action.isSuccess}><legend>{t('social.stream_choice')}</legend>
|
||||
<label className={s.choice}><input type="radio" checked={kind === 'join'} disabled={!person.activity} onChange={() => setKind('join')} />{t('social.join_stream')}</label>
|
||||
<label className={s.choice}><input type="radio" checked={kind === 'invite'} disabled={!state.track || state.listening?.locked} onChange={() => setKind('invite')} />{t('social.invite_stream')}</label>
|
||||
</fieldset><p className={s.muted}>{t('social.stream_hint')}</p>
|
||||
<FormError>{action.error ? errorText(action.error) : null}</FormError>
|
||||
{action.isSuccess ? <p role="status">{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</p> : <Button variant="primary" busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onClick={() => action.mutate()}>{t('social.send')}</Button>}
|
||||
</Dialog>;
|
||||
}
|
||||
export function ListeningBanner() {
|
||||
const { t } = useTranslation(); const state = usePlayerState(); const player = usePlayer();
|
||||
const session = useSession(); const errorText = useErrorText(); const room = state.listening?.room;
|
||||
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: uuidv7(), action: room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'end' : 'leave', target_id: room.id }) });
|
||||
if (!room) return null;
|
||||
return <section className={s.stream} aria-label={t('social.together')}><div><strong>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</strong><p className={s.muted}>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</p><details><summary>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</summary>{room.participants.map(p => <div key={p.id}>{p.name}</div>)}</details></div>
|
||||
<div className={s.actions}>{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button onClick={() => player.beginListeningAudio()}>{t('social.stream_start_audio')}</Button>}<Button busy={action.isPending} variant="ghost" onClick={() => action.mutate()}>{t(room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'social.stream_end' : 'social.stream_leave')}</Button></div>
|
||||
<FormError>{action.error ? errorText(action.error) : null}</FormError>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { attachmentPath, socialApi, uuidv7, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||
import { attachmentPath, socialApi, uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { Link, useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
@@ -8,6 +8,8 @@ import { useDesktop } from '../../desktop';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
|
||||
import s from './social.module.css';
|
||||
import { ListeningPanel, ListenTogetherButton } from './Listening';
|
||||
import { ChatConversation } from './ChatConversation';
|
||||
|
||||
const scopes = new WeakMap<object, number>();
|
||||
let nextScope = 0;
|
||||
@@ -25,12 +27,35 @@ function useSocial() {
|
||||
|
||||
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
|
||||
|
||||
export function FriendRecommendations() {
|
||||
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
|
||||
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <div><label><input type="checkbox" checked={inbox.data?.friends_recommendations ?? true} disabled={!inbox.data || save.isPending} onChange={e => save.mutate(e.target.checked)} /> {t('social.friends_recommendations')}</label><p className={s.muted}>{t('social.friends_recommendations_hint')}</p><FormError>{save.error ? errorText(save.error) : null}</FormError></div>;
|
||||
}
|
||||
|
||||
type ShareValue = { attachment: MusicAttachment; url: string };
|
||||
const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); });
|
||||
const ChatContext = createContext<(id: string) => void>(() => {});
|
||||
export const useMusicShare = () => useContext(ShareContext);
|
||||
export function SocialProvider({ children }: { children: ReactNode }) {
|
||||
const [share, setShare] = useState<ShareValue | null>(null);
|
||||
return <ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider>;
|
||||
const [chat, setChat] = useState<{ peer: string | null; open: boolean }>({ peer: null, open: false });
|
||||
return <ShareContext.Provider value={setShare}><ChatContext.Provider value={id => setChat({ peer: id, open: true })}>{children}<ChatDock chat={chat} change={setChat} />{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ChatContext.Provider></ShareContext.Provider>;
|
||||
}
|
||||
|
||||
function ChatDock({ chat, change }: { chat: { peer: string | null; open: boolean }; change: (value: { peer: string | null; open: boolean }) => void }) {
|
||||
const { inbox } = useSocial(); const { t } = useTranslation();
|
||||
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
|
||||
const selected = friends.find(p => p.id === chat.peer);
|
||||
const unread = friends.reduce((sum, p) => sum + p.unread, 0);
|
||||
const pathname = useRouterState({ select: state => state.location.pathname });
|
||||
useEffect(() => { if (window.matchMedia?.('(max-width: 1023px)').matches) change({ ...chat, open: false }); }, [pathname]);
|
||||
return <aside className={`${s.dock} ${chat.open ? s.dockOpen : ''}`} aria-label={t('social.messages')}>
|
||||
<div className={s.dockHeader}><button type="button" aria-expanded={chat.open} onClick={() => change({ ...chat, open: !chat.open })}>{selected?.display_name ?? t('social.messages')}{unread ? ` · ${unread}` : ''}</button>{chat.open && <><button type="button" onClick={() => change({ peer: null, open: true })} aria-label={t('social.people')}>☰</button><button type="button" onClick={() => change({ ...chat, open: false })} aria-label={t('social.minimize')}>−</button></>}</div>
|
||||
<div hidden={!chat.open}>
|
||||
{selected ? <Chat key={selected.id} person={selected} compact active={chat.open} /> : <div className={s.dockList}>{friends.map(p => <button key={p.id} type="button" onClick={() => change({ peer: p.id, open: true })}><span className={s.avatar} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><span>{p.display_name}</span>{p.unread ? <small>{p.unread}</small> : null}</button>)}{!friends.length && <p className={s.empty}>{t('social.no_friends_share')}</p>}</div>}
|
||||
</div>
|
||||
</aside>;
|
||||
}
|
||||
function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -70,6 +95,7 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
const { api, inbox, key } = useSocial();
|
||||
const cache = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const showChat = useContext(ChatContext);
|
||||
const errorText = useErrorText();
|
||||
const [tab, setTab] = useState<'people' | 'notifications'>('people');
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -77,11 +103,18 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
|
||||
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: q.length >= 2 });
|
||||
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend');
|
||||
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
|
||||
const open = (id: string) => { setTab('people'); setSearch(''); setQ(''); void navigate({ to: '/friends', search: { peer: id } }); };
|
||||
const open = (id: string) => { setTab('people'); setSearch(''); setQ(''); if (inbox.data?.people.some(p => p.id === id && p.relationship === 'friend')) showChat(id); else void navigate({ to: '/friends', search: { peer: id } }); };
|
||||
return <div className={s.page}>
|
||||
<header><h1>{t('social.title')}</h1><p className={s.muted}>{t('social.subtitle')}</p></header>
|
||||
<fieldset className={s.privacy} disabled={!inbox.data || visibility.isPending}>
|
||||
<legend>{t('social.activity_visibility')}</legend>
|
||||
<div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={(inbox.data?.activity_visibility ?? 'nobody') === v} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} onClick={() => visibility.mutate(v)}>{t(`social.visibility_${v}`)}</Button>)}</div>
|
||||
<p className={s.muted}>{t('social.activity_privacy_hint')}</p>
|
||||
<FormError>{visibility.error ? errorText(visibility.error) : null}</FormError>
|
||||
</fieldset>
|
||||
<ListeningPanel />
|
||||
<nav className={s.tabs} aria-label={t('social.title')}>
|
||||
<Button variant={tab === 'people' ? 'primary' : 'ghost'} onClick={() => setTab('people')}>{t('social.people')}</Button>
|
||||
<Button variant={tab === 'notifications' ? 'primary' : 'ghost'} onClick={() => setTab('notifications')}>{t('social.notifications')} {inbox.data?.unread || ''}</Button>
|
||||
@@ -94,14 +127,13 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
<div><strong>{n.actor_name}</strong><p>{t(`social.kind_${n.kind}`)}</p><time className={s.muted}>{new Date(n.created_at).toLocaleString()}</time></div>
|
||||
<div className={s.actions}><Button onClick={() => open(n.actor_id)}>{t('social.open')}</Button>{!n.read && <Button disabled={action.isPending} onClick={() => action.mutate({ action: 'read_notification', target_id: n.id })}>{t('social.mark_read')}</Button>}</div>
|
||||
</article>)}
|
||||
</div> : <div className={s.layout}>
|
||||
</div> : <div>
|
||||
<aside className={s.stack}>
|
||||
<Field label={t('social.search')} value={search} onChange={setSearch} type="search" maxLength={80} />
|
||||
{q.length >= 2 && results.isFetching && <p role="status">{t('social.loading')}</p>}
|
||||
{!people.length && inbox.data && !results.isFetching && <p className={s.empty}>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</p>}
|
||||
{people.map(p => <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={a => action.mutate({ action: a, target_id: p.id })} />)}
|
||||
</aside>
|
||||
{selected ? <Chat key={selected.id} person={selected} /> : <div className={s.empty}>{t('social.choose_chat')}</div>}
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -109,8 +141,16 @@ function Person({ person: p, busy, open, act, selected }: { person: SocialPerson
|
||||
const { t } = useTranslation();
|
||||
return <article className={`${s.person} ${selected ? s.selected : ''}`}>
|
||||
<div className={s.identity}><span className={s.avatar} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><div><strong>{p.display_name}</strong><div className={s.muted}>@{p.username} · {t(`social.rel_${p.relationship}`)}</div></div></div>
|
||||
{p.activity && <div className={s.activity}>
|
||||
<span className={s.listening}>{t('social.listening')}</span>
|
||||
<Link to="/track/$id" params={{ id: p.activity.track_id }}>{p.activity.track_title}</Link>
|
||||
{p.activity.artist && <span className={s.muted}>{p.activity.artist}</span>}
|
||||
{p.activity.context === 'wave' && <span className={s.muted}>{t('wave.title')}</span>}
|
||||
{p.activity.context === 'playlist' && p.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: p.id }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Link>}
|
||||
</div>}
|
||||
<div className={s.actions}>
|
||||
{p.relationship === 'friend' && <Button onClick={open}>{t('social.chat')}{p.unread ? ` · ${p.unread}` : ''}</Button>}
|
||||
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
|
||||
{p.relationship === 'none' && <Button disabled={busy} onClick={() => act('request')}>{t('social.add')}</Button>}
|
||||
{p.relationship === 'incoming' && <Button disabled={busy} variant="primary" onClick={() => act('accept')}>{t('social.accept')}</Button>}
|
||||
{['incoming', 'outgoing', 'friend'].includes(p.relationship) && <Button disabled={busy} variant="ghost" onClick={() => act('remove')}>{t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')}</Button>}
|
||||
@@ -118,48 +158,7 @@ function Person({ person: p, busy, open, act, selected }: { person: SocialPerson
|
||||
</div>
|
||||
</article>;
|
||||
}
|
||||
function Chat({ person }: { person: SocialPerson }) {
|
||||
const { t } = useTranslation();
|
||||
const { api, key, user } = useSocial();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [text, setText] = useState('');
|
||||
const pending = useRef<{ id: string; text: string } | null>(null);
|
||||
const messages = useInfiniteQuery({ queryKey: [...key, 'chat', person.id], initialPageParam: undefined as number | undefined,
|
||||
queryFn: ({ pageParam }) => api.messages(person.id, pageParam), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined, refetchInterval: 8_000 });
|
||||
const list = [...(messages.data?.pages ?? [])].reverse().flat();
|
||||
const last = list.at(-1)?.seq;
|
||||
const unread = list.some(m => m.sender_id !== user.id && !m.read);
|
||||
const [visible, setVisible] = useState(document.visibilityState === 'visible');
|
||||
useEffect(() => { const change = () => setVisible(document.visibilityState === 'visible'); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []);
|
||||
useEffect(() => {
|
||||
if (!last || !unread || !visible) return;
|
||||
let cancelled = false;
|
||||
void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => { if (!cancelled) void cache.invalidateQueries({ queryKey: key }); }).catch(() => undefined);
|
||||
return () => { cancelled = true; };
|
||||
}, [last, unread, visible, api, person.id, cache, user.id, messages.dataUpdatedAt]);
|
||||
const end = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { end.current?.scrollIntoView?.({ block: 'nearest' }); }, [last]);
|
||||
const send = useMutation({ mutationFn: async () => {
|
||||
if (!pending.current || pending.current.text !== text) pending.current = { id: uuidv7(), text };
|
||||
return api.send(person.id, { ...pending.current, attachment_kind: 'none' });
|
||||
}, onSuccess: async () => { setText(''); pending.current = null; await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <section className={s.chat} aria-label={t('social.chat')}>
|
||||
<header><strong>{person.display_name}</strong><p className={s.muted}>{t('social.chat_hint')}</p></header>
|
||||
<FormError>{messages.error || send.error ? errorText(messages.error ?? send.error) : null}</FormError>
|
||||
<div className={s.messages}>
|
||||
{messages.hasNextPage && <Button busy={messages.isFetchingNextPage} onClick={() => void messages.fetchNextPage()}>{t('social.older')}</Button>}
|
||||
{messages.isPending && <p>{t('social.loading')}</p>}
|
||||
{!list.length && messages.isSuccess && <p className={s.empty}>{t('social.first_message')}</p>}
|
||||
{list.map(m => <article key={m.id} className={`${s.bubble} ${m.sender_id === user.id ? s.mine : ''}`}>
|
||||
<div>{m.text}</div>
|
||||
{attachmentPath(m) && <Link className={s.music} to={m.attachment_kind === 'track' ? '/track/$id' : '/shared/playlist/$shareId'} params={{ id: m.attachment_id!, shareId: m.attachment_id! }}>{t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} ↗</Link>}
|
||||
<small className={s.muted}>{new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}{m.sender_id === user.id ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</small>
|
||||
</article>)}<div ref={end} />
|
||||
</div>
|
||||
<form className={s.composer} onSubmit={e => { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}>
|
||||
<textarea aria-label={t('social.message')} placeholder={t('social.message')} maxLength={4000} rows={2} value={text} disabled={send.isPending} onChange={e => setText(e.target.value)} />
|
||||
<Button type="submit" variant="primary" busy={send.isPending} disabled={!text.trim()}>{t('social.send')}</Button>
|
||||
</form>
|
||||
</section>;
|
||||
function Chat({ person, compact = false, active = true }: { person: SocialPerson; compact?: boolean; active?: boolean }) {
|
||||
const { api,key,user }=useSocial();
|
||||
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} compact={compact} active={active} />;
|
||||
}
|
||||
@@ -4,6 +4,53 @@
|
||||
.tabs, .actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.layout { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(280px, 1fr); gap: 24px; align-items: start; }
|
||||
.stack { display: grid; gap: 12px; }
|
||||
.privacy { border: 1px solid var(--line); border-radius: 16px; padding: 16px; margin: 0; min-width: 0; }
|
||||
.privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; }
|
||||
.privacy p { margin-bottom: 0; }
|
||||
.activity { display: grid; gap: 5px; border-left: 2px solid var(--ok); padding-left: 12px; overflow-wrap: anywhere; }
|
||||
.activity a { color: var(--accent2); text-decoration: none; }
|
||||
.activity a:hover { text-decoration: underline; }
|
||||
.listening { font-size: 12px; color: var(--ok); }
|
||||
.stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; }
|
||||
.choice { display: flex; gap: 8px; padding: 10px 0; align-items: center; }
|
||||
.dock { position: fixed; right: 24px; bottom: 104px; z-index: 45; width: 220px; border-radius: 16px; border: 1px solid var(--line2); background: var(--elev); box-shadow: var(--shadow); overflow: hidden; }
|
||||
.dockOpen { width: min(390px, calc(100vw - 32px)); max-height: calc(100dvh - 124px); }
|
||||
.dockHeader { display: flex; align-items: center; min-height: 48px; padding: 0 8px; background: var(--surface2); gap: 4px; }
|
||||
.dockHeader button { border: 0; background: transparent; color: var(--text); font: inherit; padding: 12px; cursor: pointer; }
|
||||
.dockHeader button:first-child { flex: 1; text-align: left; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dock .chat { border: 0; border-radius: 0; display: flex; flex-direction: column; max-height: calc(100dvh - 174px); }
|
||||
.dock .messages { flex: 1; min-height: 60px; max-height: min(40vh, 360px); padding: 14px; }
|
||||
.dock .composer { padding: 12px; flex-wrap: wrap; }
|
||||
.dock .composer textarea { flex-basis: 100%; }
|
||||
.dockList { max-height: 50vh; overflow-y: auto; padding: 8px; }
|
||||
.dockList button { display: flex; gap: 12px; align-items: center; border: 0; background: transparent; color: var(--text); padding: 12px; width: 100%; cursor: pointer; border-radius: 10px; text-align: left; font: inherit; }
|
||||
.dockList button:hover { background: var(--hover); }
|
||||
.dockList small { margin-left: auto; color: var(--accent2); }
|
||||
.dockOpen { animation: chatReveal .22s cubic-bezier(.2,.8,.2,1); transform-origin: bottom right; }
|
||||
.bubble { animation: messageIn .18s ease-out; }
|
||||
.chatSearch { padding: 10px 14px; border-bottom: 1px solid var(--line); }
|
||||
.chatSearch label { font-size: 11px; }
|
||||
.date { align-self: center; color: var(--text3); font-size: 11px; margin: 6px 0; }
|
||||
.quote { margin: 0; padding-left: 10px; border-left: 2px solid var(--accent2); color: var(--text2); font-size: 12px; max-height: 60px; overflow: hidden; }
|
||||
.messageText { user-select: text; }
|
||||
.messageFoot { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
|
||||
.messageFoot button { font-size: 12px; min-width: 24px; }
|
||||
.deleted { font-style: italic; opacity: .7; }
|
||||
.sending { opacity: .6; }
|
||||
.reactions { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.reactions button { border: 1px solid var(--line); border-radius: 14px; background: var(--surface2); color: var(--text); padding: 5px 9px; cursor: pointer; transition: transform .15s, background .15s; }
|
||||
.reactions button:hover { transform: translateY(-2px); }
|
||||
.reactions button[aria-pressed='true'] { background: var(--accent-soft); border-color: var(--accent2); }
|
||||
.typing { min-height: 19px; padding: 0 14px; color: var(--text3); font-size: 11px; }
|
||||
.replyBar { display: flex; gap: 10px; align-items: center; padding: 8px 14px; background: var(--accent-soft); }
|
||||
.replyBar > div { flex: 1; min-width: 0; }
|
||||
.replyBar small { color: var(--accent2); }
|
||||
.replyBar p { margin: 3px 0; font-size: 12px; max-height: 35px; overflow: hidden; }
|
||||
@keyframes chatReveal { from { opacity: 0; transform: translateY(16px) scale(.97); } to { opacity: 1; transform: none; } }
|
||||
@keyframes messageIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
@media(prefers-reduced-motion: reduce) { .dockOpen, .bubble { animation: none; } .reactions button { transition: none; } .reactions button:hover { transform: none; } }
|
||||
@media(max-width: 760px) { .dock { bottom: 162px; right: 12px; width: 160px; } .dockOpen { width: calc(100vw - 24px); } .dock .messages { max-height: 32vh; } }
|
||||
@media(max-width: 760px) { .dockOpen { max-height: calc(100dvh - 182px); } .dock .chat { max-height: calc(100dvh - 232px); } }
|
||||
.person { padding: 16px; border: 1px solid var(--line); background: var(--surface); border-radius: 16px; display: grid; gap: 16px; overflow-wrap: anywhere; }
|
||||
.identity { display: flex; gap: 12px; align-items: center; }
|
||||
.avatar { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; display: grid; place-items: center; color: var(--accent2); background: var(--accent-soft); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
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';
|
||||
@@ -57,7 +57,20 @@ export function PlaylistPage({ id, shared = false }: { id: string; shared?: bool
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Playlist; readOnly?: boolean; shareId?: string }) {
|
||||
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 <div className={s.page}>
|
||||
<Link to="/friends">← {t('social.title')}</Link>
|
||||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||
{query.isPending && <PageSkeleton />}
|
||||
{query.data && !query.isError && <PlaylistDetail key={query.data.id} playlist={query.data} readOnly activity />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -108,7 +121,7 @@ function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Pla
|
||||
<div className={s.toolbar}>
|
||||
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
|
||||
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
|
||||
<Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>
|
||||
{!activity && <Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>}
|
||||
{!readOnly && <>
|
||||
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
|
||||
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||
import s from './page.module.css';
|
||||
import { Onboarding } from './Onboarding';
|
||||
import { FriendRecommendations } from '../features/social/Social';
|
||||
import { DesktopOfflineSection, DesktopUpdateButton } from '../features/desktop/DesktopFeatures';
|
||||
|
||||
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
|
||||
@@ -371,6 +372,7 @@ function RecommendationSettings() {
|
||||
const errorText = useErrorText();
|
||||
const options = useQuery({ queryKey: ['onboarding'], queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: 0 });
|
||||
return <section className={s.section}>
|
||||
<FriendRecommendations />
|
||||
{editing && options.data ? <Onboarding key={JSON.stringify(options.data.choice)} options={options.data} editing onDone={() => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ['onboarding'] }); }} />
|
||||
: <Button busy={options.isFetching} onClick={() => { void options.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }}>{t('onboarding.edit')}</Button>}
|
||||
<FormError>{options.error ? errorText(options.error) : null}</FormError>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import {
|
||||
EventBuffer,
|
||||
createActivityPublisher,
|
||||
type ActivityPublisher,
|
||||
ListeningCoordinator,
|
||||
type ListeningRoom,
|
||||
WaveCoordinator,
|
||||
EARLY_SKIP_MS,
|
||||
type WaveOutcome,
|
||||
@@ -35,6 +39,10 @@ const PRELOAD_BEFORE_MS = 20_000;
|
||||
* Media Session и сохранение «трек и момент» (локально и на сервере).
|
||||
*/
|
||||
export class Player {
|
||||
private activity: ActivityPublisher | null = null;
|
||||
private listening: ListeningCoordinator | null = null;
|
||||
private applyingSync = false;
|
||||
private remoteRoom: string | null = null;
|
||||
readonly offline: DesktopOffline | null;
|
||||
private retryEvents: (() => Promise<void>) | null = null;
|
||||
private readonly engine: AudioEngine;
|
||||
@@ -93,6 +101,17 @@ export class Player {
|
||||
|
||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||
start() {
|
||||
this.activity ??= createActivityPublisher(this.session);
|
||||
this.activity.update(this.state);
|
||||
if (!this.listening) {
|
||||
this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, {
|
||||
read: this.getState,
|
||||
pause: () => { this.restoreVersion++; this.leaveWave(); this.audio.pause(); },
|
||||
apply: (room, position, current) => this.syncListening(room, position, current),
|
||||
changed: listening => this.update({ listening }),
|
||||
});
|
||||
this.listening.start();
|
||||
}
|
||||
this.offline?.activate();
|
||||
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
|
||||
if (this.retryEvents) {
|
||||
@@ -116,6 +135,8 @@ export class Player {
|
||||
|
||||
private update(patch: Partial<PlayerState>) {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.activity?.update(this.state);
|
||||
this.listening?.update();
|
||||
for (const fn of this.listeners) fn();
|
||||
this.media.syncDesktopMedia();
|
||||
}
|
||||
@@ -135,6 +156,7 @@ export class Player {
|
||||
|
||||
/** Запустить список с позиции index в контексте (альбом, поиск, коллекция...). */
|
||||
playList(tracks: Track[], index: number, context: PlayContext) {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
const playable = tracks.filter((t) => t.available && t.stream_url);
|
||||
const target = tracks[index];
|
||||
if (!target || !playable.length) return;
|
||||
@@ -151,6 +173,7 @@ export class Player {
|
||||
|
||||
/** Запустить волну (или перезапустить с новым контекстом). */
|
||||
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
|
||||
if (this.listening?.locked) return;
|
||||
this.restoreVersion++;
|
||||
this.finishAttempt('skip');
|
||||
this.leaveWave();
|
||||
@@ -190,6 +213,7 @@ export class Player {
|
||||
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
|
||||
*/
|
||||
async setWaveContext(activity: string | null, mood: string | null) {
|
||||
if (this.listening?.locked) return;
|
||||
const wave = this.state.wave;
|
||||
if (!wave) return this.startWave(activity, mood);
|
||||
this.update({ wave: { ...wave, activity, mood } });
|
||||
@@ -215,6 +239,7 @@ export class Player {
|
||||
}
|
||||
|
||||
stopWave() {
|
||||
if (this.listening?.locked) return;
|
||||
this.leaveWave();
|
||||
this.pause();
|
||||
}
|
||||
@@ -247,11 +272,13 @@ export class Player {
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
if (this.state.playing) this.pause();
|
||||
else this.play();
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
if (!this.queue.current) return;
|
||||
if (!this.audio.src || !this.attempt) {
|
||||
void this.load(true, this.state.positionMs);
|
||||
@@ -263,10 +290,12 @@ export class Player {
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
this.audio.pause();
|
||||
}
|
||||
|
||||
next() {
|
||||
if (this.listening?.locked) return;
|
||||
this.finishAttempt('skip');
|
||||
if (this.queue.next()) {
|
||||
this.syncQueue();
|
||||
@@ -278,6 +307,7 @@ export class Player {
|
||||
|
||||
/** «Назад»: первые 3 с — к предыдущему треку, дальше — в начало текущего. */
|
||||
prev() {
|
||||
if (this.listening?.locked) return;
|
||||
if (this.audio.currentTime > 3) {
|
||||
this.seek(0);
|
||||
return;
|
||||
@@ -290,6 +320,7 @@ export class Player {
|
||||
}
|
||||
|
||||
jump(index: number) {
|
||||
if (this.listening?.locked) return;
|
||||
this.finishAttempt('skip');
|
||||
this.queue.jump(index);
|
||||
this.syncQueue();
|
||||
@@ -297,6 +328,7 @@ export class Player {
|
||||
}
|
||||
|
||||
seek(ms: number) {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
const track = this.queue.current;
|
||||
if (!track) return;
|
||||
const to = Math.min(Math.max(ms, 0), track.duration_ms);
|
||||
@@ -323,12 +355,14 @@ export class Player {
|
||||
}
|
||||
|
||||
toggleShuffle() {
|
||||
if (this.listening?.locked) return;
|
||||
this.queue.setShuffle(!this.queue.shuffle);
|
||||
this.syncQueue();
|
||||
this.saveLocal();
|
||||
}
|
||||
|
||||
cycleRepeat() {
|
||||
if (this.listening?.locked) return;
|
||||
const order: RepeatMode[] = ['off', 'all', 'one'];
|
||||
this.queue.repeat = order[(order.indexOf(this.queue.repeat) + 1) % order.length]!;
|
||||
this.syncQueue();
|
||||
@@ -363,13 +397,13 @@ export class Player {
|
||||
|
||||
// --- загрузка трека ---
|
||||
|
||||
private async load(autoplay: boolean, startMs = 0) {
|
||||
private async load(autoplay: boolean, startMs = 0, current = () => true) {
|
||||
const track = this.queue.current;
|
||||
if (!track?.stream_url) return;
|
||||
const version = this.restoreVersion;
|
||||
if (this.offline) {
|
||||
await this.offline.ready.catch(() => undefined);
|
||||
if (this.queue.current !== track || version !== this.restoreVersion) return;
|
||||
if (!current() || this.queue.current !== track || version !== this.restoreVersion) return;
|
||||
}
|
||||
this.offline?.keep([track.id]);
|
||||
const source = this.offline?.local(track).stream_url ?? track.stream_url;
|
||||
@@ -396,6 +430,7 @@ export class Player {
|
||||
try {
|
||||
const resumed = this.engine.resume();
|
||||
if (resumed) await resumed;
|
||||
if (!current() || version !== this.restoreVersion) return;
|
||||
await this.audio.play();
|
||||
} catch {
|
||||
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
|
||||
@@ -649,6 +684,10 @@ export class Player {
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.listening?.dispose();
|
||||
this.listening = null;
|
||||
this.activity?.dispose();
|
||||
this.activity = null;
|
||||
this.restoreVersion++;
|
||||
this.wavePolicy.activate(null);
|
||||
this.fading = false;
|
||||
@@ -661,4 +700,30 @@ export class Player {
|
||||
this.media.dispose();
|
||||
void this.events.flush();
|
||||
}
|
||||
|
||||
beginListeningAudio() {
|
||||
if (this.state.listening?.room?.playing) {
|
||||
this.applyingSync = true; try { this.play(); } finally { this.applyingSync = false; }
|
||||
}
|
||||
this.listening?.beginAudio();
|
||||
}
|
||||
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
|
||||
if (!room.track_id) { this.audio.pause(); return; }
|
||||
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
|
||||
const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
|
||||
if (!current()) return;
|
||||
this.remoteRoom = room.id;
|
||||
this.restoreVersion++; this.finishAttempt('skip'); this.leaveWave(); this.context = { type: 'queue' };
|
||||
this.queue.set([track], 0); this.queue.repeat = 'off'; this.queue.shuffle = false; this.syncQueue();
|
||||
await this.load(room.playing, Math.min(position, Math.max(0, track.duration_ms - 100)), current);
|
||||
return;
|
||||
}
|
||||
if (!current()) return;
|
||||
this.applyingSync = true;
|
||||
try {
|
||||
if (Math.abs(this.state.positionMs - position) > 1500) this.seek(Math.min(position, Math.max(0, this.state.durationMs - 100)));
|
||||
if (room.playing && !this.state.playing) this.play();
|
||||
if (!room.playing && this.state.playing) this.pause();
|
||||
} finally { this.applyingSync = false; }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WaveInfo, Track, PlayContext, RepeatMode } from '@pipu/core';
|
||||
|
||||
export interface PlayerState {
|
||||
listening?: import('@pipu/core').ListeningClientState;
|
||||
wave: WaveInfo | null;
|
||||
track: Track | null;
|
||||
queue: Track[];
|
||||
|
||||
@@ -210,6 +210,7 @@ export function Menu({
|
||||
header,
|
||||
align = 'end',
|
||||
triggerClassName,
|
||||
portal = false,
|
||||
}: {
|
||||
label: string;
|
||||
trigger: ReactNode;
|
||||
@@ -217,8 +218,10 @@ export function Menu({
|
||||
header?: ReactNode;
|
||||
align?: 'start' | 'end';
|
||||
triggerClassName?: string;
|
||||
portal?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState<CSSProperties>();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -246,7 +249,7 @@ export function Menu({
|
||||
els[next]?.focus();
|
||||
};
|
||||
const menu = (
|
||||
<div ref={menuRef} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
|
||||
<div ref={menuRef} style={portal && !window.matchMedia?.('(max-width: 767px)').matches ? position : undefined} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
|
||||
{header}
|
||||
{items.map((it) => (
|
||||
<button
|
||||
@@ -273,11 +276,18 @@ export function Menu({
|
||||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={e => {
|
||||
if (portal) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const bottom = Math.max(12, window.innerHeight - rect.top + 6);
|
||||
setPosition({ position: 'fixed', zIndex: 60, top: 'auto', bottom, right: Math.max(12, window.innerWidth - rect.right), maxHeight: Math.max(120, window.innerHeight - bottom - 12), overflowY: 'auto' });
|
||||
}
|
||||
setOpen(v => !v);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</button>
|
||||
{open && (window.matchMedia?.('(max-width: 767px)').matches
|
||||
{open && (portal || window.matchMedia?.('(max-width: 767px)').matches
|
||||
? createPortal(menu, document.body)
|
||||
: menu)}
|
||||
</div>
|
||||
|
||||
@@ -82,11 +82,21 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
const blocks = new Set<string>();
|
||||
const messages: (components['schemas']['DirectMessage'] & { recipient: string })[] = [];
|
||||
const notifications: (components['schemas']['SocialNotification'] & { recipient: string })[] = [];
|
||||
const activityVisibility = new Map<string, components['schemas']['ActivityVisibility']>();
|
||||
const friendRecommendations = new Map<string, boolean>();
|
||||
const activity = new Map<string, components['schemas']['ListeningActivity'] & { device_id: string; position_ms: number }>();
|
||||
const listeningPreferences = new Map<string, components['schemas']['ListeningPreferences']>();
|
||||
const listeningRooms = new Map<string, components['schemas']['ListeningRoom']>();
|
||||
const listeningMembers = new Map<string, string>();
|
||||
const listeningRequests = new Map<string, components['schemas']['ListeningRequest'] & { guest_id: string }>();
|
||||
const typing = new Map<string, number>();
|
||||
const defaultListening = { requests: 'nobody', auto_accept: 'nobody', autoplay: true } as const;
|
||||
const pair = (a: string, b: string) => [a, b].sort().join(':');
|
||||
const isBlocked = (a: string, b: string) => blocks.has(`${a}:${b}`) || blocks.has(`${b}:${a}`);
|
||||
const socialPerson = (me: string, p: FakeUser): components['schemas']['SocialPerson'] => {
|
||||
const f = friendships.get(pair(me, p.id));
|
||||
return { id: p.id, username: p.username, display_name: p.display_name ?? p.username,
|
||||
const visible = activityVisibility.get(p.id) === 'everyone' || activityVisibility.get(p.id) === 'friends' && f?.accepted;
|
||||
return { id: p.id, username: p.username, display_name: p.display_name ?? p.username, ...(visible && activity.has(p.id) && !isBlocked(me,p.id) ? { activity: activity.get(p.id) } : {}),
|
||||
relationship: blocks.has(`${me}:${p.id}`) ? 'blocked' : f?.accepted ? 'friend' : f ? f.sender === me ? 'outgoing' : 'incoming' : 'none',
|
||||
unread: messages.filter(m => m.recipient === me && m.sender_id === p.id && !m.read).length };
|
||||
};
|
||||
@@ -216,7 +226,63 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
|
||||
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
||||
|
||||
if (path === '/api/v1/me/social/activity/visibility' && body) { activityVisibility.set(user.id, body.visibility as components['schemas']['ActivityVisibility']); if (body.visibility === 'nobody') activity.delete(user.id); return new Response(null,{status:204}); }
|
||||
if (path === '/api/v1/me/social/recommendations' && body) { friendRecommendations.set(user.id,!!body.enabled);return new Response(null,{status:204}); }
|
||||
if (path === '/api/v1/me/social/activity' && body) {
|
||||
if (activityVisibility.get(user.id) !== undefined && activityVisibility.get(user.id) !== 'nobody' && body.playing) {
|
||||
const track = albumTracks.find(t => t.id === body.track_id); const playlist = playlists.get(String(body.playlist_id));
|
||||
if (track) activity.set(user.id,{track_id:track.id,track_title:track.title,artist:'Кино',context: body.context as 'wave'|'playlist'|'other',device_id:String(body.device_id),position_ms:Number(body.position_ms??0),...(playlist ? {playlist_id:playlist.id,playlist_name:playlist.name} : {})});
|
||||
} else activity.delete(user.id);
|
||||
return new Response(null,{status:204});
|
||||
}
|
||||
const activityPlaylistPath=/^\/api\/v1\/social\/activity\/([^/]+)\/playlist$/.exec(path);
|
||||
if (activityPlaylistPath) {
|
||||
const owner=users.get(activityPlaylistPath[1]!); const a=owner ? socialPerson(user.id,owner).activity : undefined; const p=a?.playlist_id ? playlists.get(a.playlist_id) : undefined;
|
||||
return p ? json({...p,share_id:null}) : problem(404,'social.activity_unavailable');
|
||||
}
|
||||
if (path === '/api/v1/me/social/listening/preferences' && body) { listeningPreferences.set(user.id,body as components['schemas']['ListeningPreferences']);return new Response(null,{status:204}); }
|
||||
if (path === '/api/v1/me/social/listening') {
|
||||
const room=listeningRooms.get(listeningMembers.get(user.id) ?? '');
|
||||
if(room)room.participants=[...listeningMembers].filter(([,id])=>id===room.id).map(([id])=>({id,name:users.get(id)?.display_name??users.get(id)?.username??id}));
|
||||
return json({preferences:listeningPreferences.get(user.id)??defaultListening,room:room??null,requests:[...listeningRequests.values()].filter(r=>r.recipient_id===user.id||r.sender_id===user.id),server_time:new Date().toISOString()});
|
||||
}
|
||||
if (path === '/api/v1/me/social/listening/state' && body) {
|
||||
const room=listeningRooms.get(String(body.room_id));if(!room||room.owner_id!==user.id||room.device_id!==body.device_id)return problem(403,'social.unavailable');
|
||||
Object.assign(room,{track_id:body.track_id??null,position_ms:body.position_ms,playing:body.playing,updated_at:new Date().toISOString()});return new Response(null,{status:204});
|
||||
}
|
||||
if (path === '/api/v1/me/social/listening/actions' && body) {
|
||||
const target=String(body.target_id);const op=String(body.action);
|
||||
if(op==='join'||op==='invite'){
|
||||
const host=op==='join'?target:user.id; const guest=op==='join'?user.id:target; const prefs=listeningPreferences.get(target)??defaultListening;
|
||||
const friend=!!friendships.get(pair(user.id,target))?.accepted;
|
||||
if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.unavailable');
|
||||
let room=[...listeningRooms.values()].find(r=>r.owner_id===host);
|
||||
const source=activity.get(host);if(!room){room={id:crypto.randomUUID(),owner_id:host,owner_name:users.get(host)?.username??host,device_id:String(op==='join'?source?.device_id:body.device_id),track_id:String(op==='join'?source?.track_id:body.track_id),position_ms:Number(op==='join'?source?.position_ms:body.position_ms??0),playing:true,updated_at:new Date().toISOString(),participants:[]};listeningRooms.set(room.id,room);listeningMembers.set(host,room.id);}
|
||||
if(prefs.auto_accept==='everyone'||prefs.auto_accept==='friends'&&friend)listeningMembers.set(guest,room.id);
|
||||
else listeningRequests.set(String(body.id),{id:String(body.id),owner_id:host,guest_id:guest,sender_id:user.id,sender_name:user.username,recipient_id:target,kind:op,expires_at:new Date(Date.now()+120000).toISOString()});
|
||||
}else if(op==='accept'||op==='decline'||op==='cancel'){
|
||||
const r=listeningRequests.get(target);if(r){if(op==='accept'&&r.recipient_id===user.id){const room=[...listeningRooms.values()].find(room=>room.owner_id===r.owner_id);if(room)listeningMembers.set(r.guest_id,room.id);}listeningRequests.delete(target);}
|
||||
}else if(op==='leave'||op==='end'){
|
||||
const room=listeningRooms.get(target);if(room?.owner_id===user.id){listeningRooms.delete(target);for(const [u,r]of listeningMembers)if(r===target)listeningMembers.delete(u);}else listeningMembers.delete(user.id);
|
||||
}
|
||||
return new Response(null,{status:204});
|
||||
}
|
||||
const chatMeta=/^\/api\/v1\/me\/social\/chats\/([^/]+)\/meta$/.exec(path);
|
||||
if(chatMeta){const peer=chatMeta[1]!;if(!friendships.get(pair(user.id,peer))?.accepted||isBlocked(user.id,peer))return problem(403,'social.unavailable');if(req.method==='PUT'){typing.set(`${user.id}:${peer}`,body?.typing?Date.now()+6000:0);return new Response(null,{status:204});}return json({typing:(typing.get(`${peer}:${user.id}`)??0)>Date.now()});}
|
||||
const updateMessage=/^\/api\/v1\/me\/social\/chats\/([^/]+)\/messages\/([^/]+)$/.exec(path);
|
||||
if(updateMessage&&body){
|
||||
const peer=updateMessage[1]!;const m=messages.find(m=>m.id===updateMessage[2]&&pair(m.sender_id,m.recipient)===pair(user.id,peer));
|
||||
if(!m||!friendships.get(pair(user.id,peer))?.accepted||isBlocked(user.id,peer))return problem(403,'social.unavailable');
|
||||
if(body.action!=='react'&&m.sender_id!==user.id)return problem(403,'social.unavailable');
|
||||
if(body.action==='edit'){m.text=String(body.text);m.edited_at=new Date().toISOString();}
|
||||
if(body.action==='delete'){m.deleted=true;m.text='';m.attachment_id=undefined;m.attachment_kind='none';m.reactions=[];}
|
||||
if(body.action==='react'){const reactions=m.reactions??[];const old=reactions.find(r=>r.emoji===body.emoji);m.reactions=old?reactions.filter(r=>r!==old):[...reactions,{emoji:String(body.emoji),count:1,mine:true}];}
|
||||
return new Response(null,{status:204});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/me/social') return json({
|
||||
activity_visibility: activityVisibility.get(user.id) ?? 'nobody',
|
||||
friends_recommendations: friendRecommendations.get(user.id) ?? true,
|
||||
people: [...users.values()].filter(p => p.id !== user.id && !p.is_blocked && !blocks.has(`${p.id}:${user.id}`)).map(p => socialPerson(user.id, p)).filter(p => p.relationship !== 'none'),
|
||||
notifications: notifications.filter(n => n.recipient === user.id && !isBlocked(user.id, n.actor_id)),
|
||||
unread: notifications.filter(n => n.recipient === user.id && !n.read && !isBlocked(user.id, n.actor_id)).length,
|
||||
@@ -239,7 +305,7 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
const chatPath = /^\/api\/v1\/me\/social\/chats\/([^/]+)$/.exec(path);
|
||||
if (chatPath) {
|
||||
const peer=chatPath[1]!;if (!friendships.get(pair(user.id,peer))?.accepted || isBlocked(user.id,peer)) return problem(403,'social.unavailable');
|
||||
if (req.method === 'GET') return json(messages.filter(m => pair(m.sender_id,m.recipient)===pair(user.id,peer) && (!url.searchParams.has('before') || m.seq<Number(url.searchParams.get('before')))).slice(-50));
|
||||
if (req.method === 'GET') {const q=url.searchParams.get('q')?.toLowerCase()??'';return json(messages.filter(m => pair(m.sender_id,m.recipient)===pair(user.id,peer) && (!url.searchParams.has('before') || m.seq<Number(url.searchParams.get('before'))) && (!q||!m.deleted&&m.text.toLowerCase().includes(q))).slice(-50).map(m=>({...m,reply_preview:m.reply_to?messages.find(r=>r.id===m.reply_to)?.text:undefined})));}
|
||||
if (req.method === 'POST' && body) {
|
||||
const old=messages.find(m => m.id===body.id);if(old)return json(old);
|
||||
const message={...body,recipient:peer,sender_id:user.id,seq:messages.length+1,created_at:new Date().toISOString(),read:false} as typeof messages[number];
|
||||
@@ -442,5 +508,5 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
return problem(404, 'common.not_found');
|
||||
};
|
||||
|
||||
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, friendships, notifications, messages, signOut: () => (current = null) };
|
||||
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, friendships, notifications, messages, activityVisibility, activity, friendRecommendations, listeningPreferences, listeningRooms, listeningMembers, listeningRequests, signOut: () => (current = null) };
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { SearchBox } from './SearchBox';
|
||||
import s from './AppShell.module.css';
|
||||
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
|
||||
import { useSocialUnread, SocialProvider } from '../features/social/Social';
|
||||
import { ListeningBanner } from '../features/social/Listening';
|
||||
|
||||
type NavPath =
|
||||
| '/friends'
|
||||
@@ -312,6 +313,7 @@ function ShellMain() {
|
||||
return (
|
||||
<main id="content" className={s.content} tabIndex={-1}>
|
||||
<DesktopOfflineBanner />
|
||||
<ListeningBanner />
|
||||
<div key={key} className={s.routeIn}>
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<Outlet />
|
||||
|
||||
@@ -40,6 +40,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
const player = usePlayer();
|
||||
const openShare = useMusicShare();
|
||||
const st = usePlayerState();
|
||||
const locked = st.listening?.locked === true;
|
||||
const narrow = useNarrow();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
@@ -132,7 +133,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
>
|
||||
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
|
||||
<Icon src={prevIcon} size={26} />
|
||||
</button>
|
||||
<button
|
||||
@@ -140,11 +141,11 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
className={s.play}
|
||||
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
|
||||
aria-busy={st.loading || undefined}
|
||||
onClick={() => player.toggle()}
|
||||
disabled={locked} onClick={() => player.toggle()}
|
||||
>
|
||||
<Icon src={st.playing ? pauseIcon : playIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
|
||||
<Icon src={nextIcon} size={26} />
|
||||
</button>
|
||||
<button
|
||||
@@ -168,7 +169,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||||
aria-label={t('player.progress')}
|
||||
aria-valuetext={formatDuration(st.positionMs)}
|
||||
onChange={(e) => player.seek(Number(e.target.value))}
|
||||
disabled={locked} onChange={(e) => player.seek(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||||
@@ -190,7 +191,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
className={cx(s.icon, st.shuffle && s.accent)}
|
||||
aria-label={t('player.shuffle')}
|
||||
aria-pressed={st.shuffle}
|
||||
onClick={() => player.toggleShuffle()}
|
||||
disabled={locked} onClick={() => player.toggleShuffle()}
|
||||
>
|
||||
<Icon src={shuffleIcon} size={20} />
|
||||
</button>
|
||||
@@ -199,7 +200,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
|
||||
aria-label={repeatLabel}
|
||||
title={repeatLabel}
|
||||
onClick={() => player.cycleRepeat()}
|
||||
disabled={locked} onClick={() => player.cycleRepeat()}
|
||||
>
|
||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
||||
</button>
|
||||
@@ -271,6 +272,7 @@ function NowPlayingSheet({
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const locked = st.listening?.locked === true;
|
||||
const track = st.track;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -321,7 +323,7 @@ function NowPlayingSheet({
|
||||
step={1000}
|
||||
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||||
aria-label={t('player.progress')}
|
||||
onChange={(e) => player.seek(Number(e.target.value))}
|
||||
disabled={locked} onChange={(e) => player.seek(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||||
@@ -330,13 +332,13 @@ function NowPlayingSheet({
|
||||
<button type="button" className={cx(s.icon, track.reaction === 'dislike' && s.danger)} aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')} aria-pressed={track.reaction === 'dislike'} onClick={() => react('dislike')}>
|
||||
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
|
||||
<Icon src={prevIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
|
||||
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} disabled={locked} onClick={() => player.toggle()}>
|
||||
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
|
||||
<Icon src={nextIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={cx(s.icon, track.reaction === 'like' && s.accent)} aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')} aria-pressed={track.reaction === 'like'} onClick={() => react('like')}>
|
||||
@@ -344,10 +346,10 @@ function NowPlayingSheet({
|
||||
</button>
|
||||
</div>
|
||||
<div className={s.sheetExtra}>
|
||||
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} onClick={() => player.toggleShuffle()}>
|
||||
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} disabled={locked} onClick={() => player.toggleShuffle()}>
|
||||
<Icon src={shuffleIcon} size={22} />
|
||||
</button>
|
||||
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
|
||||
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} disabled={locked} onClick={() => player.cycleRepeat()}>
|
||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.share')} onClick={() => void share()}>
|
||||
@@ -379,6 +381,7 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const locked = st.listening?.locked === true;
|
||||
const upcoming = st.queue.slice(st.index, st.index + 40);
|
||||
const close = useRef<HTMLButtonElement>(null);
|
||||
// Клавиатура: при открытии фокус в панель, Escape закрывает.
|
||||
@@ -408,7 +411,7 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
||||
type="button"
|
||||
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
|
||||
aria-current={k === 0 || undefined}
|
||||
onClick={() => player.jump(st.index + k)}
|
||||
disabled={st.listening?.locked} onClick={() => player.jump(st.index + k)}
|
||||
>
|
||||
<Cover album={tr.album} size={40} />
|
||||
<span className={s.meta}>
|
||||
|
||||
Reference in new issue
Block a user