ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m30s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m57s
ci / ts / ts (packages + web) (push) Successful in 1m16s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 33s
- My Wave is rendered with react-native-skia on the UI thread; physics and colour helpers moved to @pipu/core (wave-anim) and shared with web. - Full-screen player honours ui_prefs.mobile_player (cover / wave). - Theme switching (dark / light / system) via a theme context and useColors() across all screens. - Profile: theme, wave variant, player view, sound quality sheet (Wi-Fi / mobile / downloads), recent-tracks cache clearing on next cold start, and a change-password sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
6.2 KiB
TypeScript
124 lines
6.2 KiB
TypeScript
import { useRouter } from 'expo-router';
|
|
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
|
|
|
|
import { useUser } from '../lib/session';
|
|
import { player } from '../player/controller';
|
|
import { usePlayerState } from '../player/hooks';
|
|
import { useColors } from '../theme';
|
|
import { DownloadButton } from '../ui/downloads';
|
|
import { Cover, Icon, Screen, formatDuration } from '../ui/kit';
|
|
import { WaveOrb } from '../ui/wave/WaveOrb';
|
|
|
|
/**
|
|
* Полноэкранный плеер: вид `cover` (крупная обложка) или `wave` (визуализация волны в цветах обложки) — по профилю;
|
|
* прогресс с перемоткой, лайк / дизлайк, скачать, управление.
|
|
*/
|
|
export default function PlayerScreen() {
|
|
const colors = useColors();
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const st = usePlayerState();
|
|
const prefs = useUser().ui_prefs;
|
|
const [barWidth, setBarWidth] = useState(1);
|
|
const track = st.track;
|
|
|
|
if (!track) {
|
|
return (
|
|
<Screen>
|
|
<Pressable onPress={() => router.back()} style={{ padding: 16 }} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
|
|
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
|
</Pressable>
|
|
</Screen>
|
|
);
|
|
}
|
|
const duration = st.durationMs || track.duration_ms;
|
|
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
|
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
|
|
|
return (
|
|
<Screen>
|
|
<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')}>
|
|
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
|
</Pressable>
|
|
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
|
|
<DownloadButton track={track} />
|
|
</View>
|
|
|
|
<View style={{ alignItems: 'center' }}>
|
|
{prefs.mobile_player === 'wave' ? (
|
|
<WaveOrb
|
|
variant={prefs.wave_variant ?? 'orb'}
|
|
size={280}
|
|
playing={st.playing}
|
|
bpm={track.features?.bpm ?? 100}
|
|
energy={track.features?.energy ?? 0.6}
|
|
colors={track.album.cover_colors}
|
|
>
|
|
<Cover album={track.album} size={96} round />
|
|
</WaveOrb>
|
|
) : (
|
|
<Cover album={track.album} size={300} />
|
|
)}
|
|
</View>
|
|
|
|
<View style={{ gap: 4 }}>
|
|
<Text numberOfLines={2} style={{ color: colors.text, fontSize: 22, fontWeight: '700' }}>
|
|
{track.title}
|
|
</Text>
|
|
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
|
|
{track.artists.map((a) => a.name).join(', ')}
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={{ gap: 6 }}>
|
|
<Pressable
|
|
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
|
|
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) }}
|
|
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
|
|
onAccessibilityAction={(e) => player.seek(st.positionMs + (e.nativeEvent.actionName === 'increment' ? 10_000 : -10_000))}
|
|
style={{ height: 24, justifyContent: 'center' }}
|
|
>
|
|
<View style={{ height: 4, borderRadius: 2, backgroundColor: colors.surface3 }}>
|
|
<View style={{ height: 4, borderRadius: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
|
|
</View>
|
|
</Pressable>
|
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{formatDuration(st.positionMs)}</Text>
|
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{formatDuration(duration)}</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<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')}>
|
|
<Icon name="skip-previous" size={40} color={colors.text} />
|
|
</Pressable>
|
|
<Pressable
|
|
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')}>
|
|
<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')}>
|
|
<Icon name={track.reaction === 'like' ? 'favorite' : 'favorite-border'} size={28} color={track.reaction === 'like' ? colors.accent2 : colors.text2} />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
</Screen>
|
|
);
|
|
}
|