Files

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',
},
});