feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user