283 lines
8.5 KiB
TypeScript
283 lines
8.5 KiB
TypeScript
import React from 'react';
|
|
import { View, Text, TouchableOpacity, StyleSheet, PanResponder } from 'react-native';
|
|
import Svg, { Circle, Path, Polyline, Text as SvgText } from 'react-native-svg';
|
|
import { theme } from '../theme';
|
|
import { RepeatMode } from '../types';
|
|
|
|
interface PlayerControlsProps {
|
|
isPlaying: boolean;
|
|
isShuffle: boolean;
|
|
repeatMode: RepeatMode;
|
|
position: number;
|
|
duration: number;
|
|
onPlayPause: () => void;
|
|
onNext: () => void;
|
|
onPrev: () => void;
|
|
onShuffle: () => void;
|
|
onRepeat: () => void;
|
|
onSeek: (position: number) => void;
|
|
}
|
|
|
|
const formatTime = (seconds: number): string => {
|
|
if (isNaN(seconds) || seconds < 0) return '0:00';
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const PlayIcon = ({ size = 24 }: { size?: number }) => (
|
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.background}>
|
|
<Path d="M8 5v14l11-7z" />
|
|
</Svg>
|
|
);
|
|
|
|
const PauseIcon = ({ size = 24 }: { size?: number }) => (
|
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.background}>
|
|
<Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
|
</Svg>
|
|
);
|
|
|
|
const NextIcon = ({ size = 22 }: { size?: number }) => (
|
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.textSecondary}>
|
|
<Path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
|
</Svg>
|
|
);
|
|
|
|
const PrevIcon = ({ size = 22 }: { size?: number }) => (
|
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.textSecondary}>
|
|
<Path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z" />
|
|
</Svg>
|
|
);
|
|
|
|
const ShuffleIcon = ({ active = false }: { active?: boolean }) => (
|
|
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke={active ? theme.colors.primary : theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
<Polyline points="16 1 21 3 21 8" />
|
|
<Path d="M4 4 C8 4 10 8 12 12 C14 16 16 20 20 20" />
|
|
<Polyline points="16 23 21 21 21 18" />
|
|
<Path d="M4 20 C8 20 10 16 12 12 C14 8 16 4 20 4" />
|
|
</Svg>
|
|
);
|
|
|
|
const RepeatIcon = ({ mode }: { mode: RepeatMode }) => {
|
|
const active = mode !== 'none';
|
|
const color = active ? theme.colors.primary : theme.colors.textSecondary;
|
|
const badgeFill = mode === 'none' ? 'transparent' : theme.colors.primary;
|
|
const badgeStroke = mode === 'none' ? 'transparent' : theme.colors.background;
|
|
|
|
return (
|
|
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
<Polyline points="17 1 21 5 17 9" />
|
|
<Path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
|
<Polyline points="7 23 3 19 7 15" />
|
|
<Path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
|
{mode !== 'none' ? <Circle cx="18" cy="18" r="4.4" fill={badgeFill} stroke={badgeStroke} strokeWidth={1.2} /> : null}
|
|
{mode === 'one' ? (
|
|
<SvgText
|
|
x="18"
|
|
y="19.4"
|
|
textAnchor="middle"
|
|
fontSize="6.5"
|
|
fontWeight="700"
|
|
fill={theme.colors.background}
|
|
stroke="none"
|
|
>
|
|
1
|
|
</SvgText>
|
|
) : null}
|
|
{mode === 'all' ? (
|
|
<>
|
|
<Circle cx="16.4" cy="18" r="0.9" fill={theme.colors.background} stroke="none" />
|
|
<Circle cx="19.6" cy="18" r="0.9" fill={theme.colors.background} stroke="none" />
|
|
</>
|
|
) : null}
|
|
</Svg>
|
|
);
|
|
};
|
|
|
|
export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
isPlaying,
|
|
isShuffle,
|
|
repeatMode,
|
|
position,
|
|
duration,
|
|
onPlayPause,
|
|
onNext,
|
|
onPrev,
|
|
onShuffle,
|
|
onRepeat,
|
|
onSeek,
|
|
}) => {
|
|
const progress = duration > 0 ? position / duration : 0;
|
|
const progressBarRef = React.useRef<View>(null);
|
|
const [isDragging, setIsDragging] = React.useState(false);
|
|
const [dragProgress, setDragProgress] = React.useState(progress);
|
|
|
|
const handleSeekTo = (newProgress: number) => {
|
|
if (duration > 0) {
|
|
onSeek(newProgress * duration);
|
|
}
|
|
};
|
|
|
|
const panResponder = React.useMemo(
|
|
() =>
|
|
PanResponder.create({
|
|
onStartShouldSetPanResponder: () => true,
|
|
onStartShouldSetPanResponderCapture: () => true,
|
|
onMoveShouldSetPanResponder: () => true,
|
|
onMoveShouldSetPanResponderCapture: () => true,
|
|
|
|
onPanResponderGrant: () => {
|
|
setIsDragging(true);
|
|
},
|
|
|
|
onPanResponderMove: (evt) => {
|
|
// 测量进度条宽度
|
|
progressBarRef.current?.measure((x, y, width) => {
|
|
if (width > 0) {
|
|
const newProgress = Math.max(0, Math.min(1, evt.nativeEvent.locationX / width));
|
|
setDragProgress(newProgress);
|
|
}
|
|
});
|
|
},
|
|
|
|
onPanResponderRelease: (evt) => {
|
|
setIsDragging(false);
|
|
progressBarRef.current?.measure((x, y, width) => {
|
|
if (width > 0) {
|
|
const newProgress = Math.max(0, Math.min(1, evt.nativeEvent.locationX / width));
|
|
handleSeekTo(newProgress);
|
|
}
|
|
});
|
|
},
|
|
|
|
onPanResponderTerminate: (evt) => {
|
|
setIsDragging(false);
|
|
progressBarRef.current?.measure((x, y, width) => {
|
|
if (width > 0) {
|
|
const newProgress = Math.max(0, Math.min(1, evt.nativeEvent.locationX / width));
|
|
handleSeekTo(newProgress);
|
|
}
|
|
});
|
|
},
|
|
}),
|
|
[duration]
|
|
);
|
|
|
|
const currentProgress = isDragging ? dragProgress : progress;
|
|
const thumbPosition = currentProgress * 100;
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.glassBar}>
|
|
{/* Progress bar */}
|
|
<View style={styles.progressWrap}>
|
|
<View style={styles.timeRow}>
|
|
<Text style={styles.timeText}>{formatTime(position)}</Text>
|
|
<Text style={styles.timeText}>{formatTime(duration)}</Text>
|
|
</View>
|
|
|
|
<View
|
|
ref={progressBarRef}
|
|
style={styles.progressTrack}
|
|
{...panResponder.panHandlers}
|
|
>
|
|
<View style={[styles.progressFill, { width: `${currentProgress * 100}%` }]} />
|
|
<View style={[styles.progressThumb, { left: `${thumbPosition}%`, transform: [{ translateX: -7 }] }]} />
|
|
</View>
|
|
</View>
|
|
|
|
{/* Main controls */}
|
|
<View style={styles.controls}>
|
|
<TouchableOpacity style={styles.ctrlBtn} onPress={onShuffle}>
|
|
<ShuffleIcon active={isShuffle} />
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity style={styles.ctrlBtn} onPress={() => onPrev()}>
|
|
<PrevIcon />
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity style={styles.playBtn} onPress={() => onPlayPause()}>
|
|
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity style={styles.ctrlBtn} onPress={() => onNext()}>
|
|
<NextIcon />
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity style={styles.ctrlBtn} onPress={() => onRepeat()}>
|
|
<RepeatIcon mode={repeatMode} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
paddingHorizontal: theme.spacing.md,
|
|
paddingBottom: theme.spacing.md,
|
|
},
|
|
glassBar: {
|
|
backgroundColor: 'rgba(10,10,30,0.65)',
|
|
borderRadius: theme.borderRadius.xl,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
padding: theme.spacing.md,
|
|
},
|
|
progressWrap: {
|
|
marginBottom: theme.spacing.md,
|
|
},
|
|
progressTrack: {
|
|
height: 4,
|
|
backgroundColor: 'rgba(255,255,255,0.10)',
|
|
borderRadius: 2,
|
|
position: 'relative',
|
|
},
|
|
progressFill: {
|
|
height: '100%',
|
|
backgroundColor: theme.colors.primary,
|
|
borderRadius: 2,
|
|
},
|
|
progressThumb: {
|
|
position: 'absolute',
|
|
top: -5,
|
|
width: 14,
|
|
height: 14,
|
|
borderRadius: 7,
|
|
backgroundColor: theme.colors.primary,
|
|
shadowColor: theme.colors.primary,
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: 0.5,
|
|
shadowRadius: 4,
|
|
elevation: 5,
|
|
},
|
|
timeRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
marginBottom: theme.spacing.xs,
|
|
},
|
|
timeText: {
|
|
fontSize: 11,
|
|
color: theme.colors.textSecondary,
|
|
fontVariant: ['tabular-nums'],
|
|
},
|
|
controls: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: theme.spacing.lg,
|
|
},
|
|
ctrlBtn: {
|
|
padding: theme.spacing.sm,
|
|
},
|
|
playBtn: {
|
|
width: 52,
|
|
height: 52,
|
|
borderRadius: 26,
|
|
backgroundColor: theme.colors.primary,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
});
|