mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-15 04:14:42 +08:00
feat(player): implement playback speed control with persistent settings
- Add playback rate state and actions to player store - Create SpeedSelectionModal with 7 speed options (0.5x - 2x) - Add speed control button with Gauge icon to PlayerControls - Integrate rate prop with Expo AV Video component - Extend PlayerSettings storage to persist playback rate per video - Support speed control across TV, mobile, and tablet platforms
This commit is contained in:
@@ -7,6 +7,7 @@ import { ThemedView } from "@/components/ThemedView";
|
||||
import { PlayerControls } from "@/components/PlayerControls";
|
||||
import { EpisodeSelectionModal } from "@/components/EpisodeSelectionModal";
|
||||
import { SourceSelectionModal } from "@/components/SourceSelectionModal";
|
||||
import { SpeedSelectionModal } from "@/components/SpeedSelectionModal";
|
||||
import { SeekingBar } from "@/components/SeekingBar";
|
||||
// import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
|
||||
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
|
||||
@@ -50,6 +51,7 @@ export default function PlayScreen() {
|
||||
// showNextEpisodeOverlay,
|
||||
initialPosition,
|
||||
introEndTime,
|
||||
playbackRate,
|
||||
setVideoRef,
|
||||
handlePlaybackStatusUpdate,
|
||||
setShowControls,
|
||||
@@ -147,6 +149,7 @@ export default function PlayScreen() {
|
||||
source={{ uri: currentEpisode?.url || "" }}
|
||||
posterSource={{ uri: detail?.poster ?? "" }}
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
rate={playbackRate}
|
||||
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
|
||||
onLoad={() => {
|
||||
const jumpPosition = initialPosition || introEndTime || 0;
|
||||
@@ -175,6 +178,7 @@ export default function PlayScreen() {
|
||||
|
||||
<EpisodeSelectionModal />
|
||||
<SourceSelectionModal />
|
||||
<SpeedSelectionModal />
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, Pressable } from "react-native";
|
||||
import { Pause, Play, SkipForward, List, Tv, ArrowDownToDot, ArrowUpFromDot } from "lucide-react-native";
|
||||
import { Pause, Play, SkipForward, List, Tv, ArrowDownToDot, ArrowUpFromDot, Gauge } from "lucide-react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { MediaButton } from "@/components/MediaButton";
|
||||
|
||||
@@ -21,10 +21,12 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
|
||||
isSeeking,
|
||||
seekPosition,
|
||||
progressPosition,
|
||||
playbackRate,
|
||||
togglePlayPause,
|
||||
playEpisode,
|
||||
setShowEpisodeModal,
|
||||
setShowSourceModal,
|
||||
setShowSpeedModal,
|
||||
setIntroEndTime,
|
||||
setOutroStartTime,
|
||||
introEndTime,
|
||||
@@ -109,6 +111,10 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
|
||||
<List color="white" size={24} />
|
||||
</MediaButton>
|
||||
|
||||
<MediaButton onPress={() => setShowSpeedModal(true)} timeLabel={playbackRate !== 1.0 ? `${playbackRate}x` : undefined}>
|
||||
<Gauge color="white" size={24} />
|
||||
</MediaButton>
|
||||
|
||||
<MediaButton onPress={() => setShowSourceModal(true)}>
|
||||
<Tv color="white" size={24} />
|
||||
</MediaButton>
|
||||
|
||||
93
components/SpeedSelectionModal.tsx
Normal file
93
components/SpeedSelectionModal.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, Modal, FlatList } from "react-native";
|
||||
import { StyledButton } from "./StyledButton";
|
||||
import usePlayerStore from "@/stores/playerStore";
|
||||
|
||||
interface SpeedOption {
|
||||
rate: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const SPEED_OPTIONS: SpeedOption[] = [
|
||||
{ rate: 0.5, label: "0.5x" },
|
||||
{ rate: 0.75, label: "0.75x" },
|
||||
{ rate: 1.0, label: "1x" },
|
||||
{ rate: 1.25, label: "1.25x" },
|
||||
{ rate: 1.5, label: "1.5x" },
|
||||
{ rate: 1.75, label: "1.75x" },
|
||||
{ rate: 2.0, label: "2x" },
|
||||
];
|
||||
|
||||
export const SpeedSelectionModal: React.FC = () => {
|
||||
const { showSpeedModal, setShowSpeedModal, playbackRate, setPlaybackRate } = usePlayerStore();
|
||||
|
||||
const onSelectSpeed = (rate: number) => {
|
||||
setPlaybackRate(rate);
|
||||
setShowSpeedModal(false);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
setShowSpeedModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={showSpeedModal} transparent={true} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.modalContainer}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>播放速度</Text>
|
||||
<FlatList
|
||||
data={SPEED_OPTIONS}
|
||||
numColumns={3}
|
||||
contentContainerStyle={styles.speedList}
|
||||
keyExtractor={(item) => `speed-${item.rate}`}
|
||||
renderItem={({ item }) => (
|
||||
<StyledButton
|
||||
text={item.label}
|
||||
onPress={() => onSelectSpeed(item.rate)}
|
||||
isSelected={playbackRate === item.rate}
|
||||
hasTVPreferredFocus={playbackRate === item.rate}
|
||||
style={styles.speedItem}
|
||||
textStyle={styles.speedItemText}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
modalContent: {
|
||||
width: 500,
|
||||
height: "100%",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
padding: 20,
|
||||
},
|
||||
modalTitle: {
|
||||
color: "white",
|
||||
marginBottom: 12,
|
||||
textAlign: "center",
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
speedList: {
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
speedItem: {
|
||||
paddingVertical: 10,
|
||||
margin: 4,
|
||||
marginLeft: 10,
|
||||
marginRight: 8,
|
||||
width: "30%",
|
||||
},
|
||||
speedItemText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
@@ -22,6 +22,7 @@ export type Favorite = ApiFavorite;
|
||||
export interface PlayerSettings {
|
||||
introEndTime?: number;
|
||||
outroStartTime?: number;
|
||||
playbackRate?: number;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
@@ -60,10 +61,10 @@ export class PlayerSettingsManager {
|
||||
const allSettings = await this.getAll();
|
||||
const key = generateKey(source, id);
|
||||
// Only save if there are actual values to save
|
||||
if (settings.introEndTime !== undefined || settings.outroStartTime !== undefined) {
|
||||
if (settings.introEndTime !== undefined || settings.outroStartTime !== undefined || settings.playbackRate !== undefined) {
|
||||
allSettings[key] = { ...allSettings[key], ...settings };
|
||||
} else {
|
||||
// If both are undefined, remove the key
|
||||
// If all are undefined, remove the key
|
||||
delete allSettings[key];
|
||||
}
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.PLAYER_SETTINGS, JSON.stringify(allSettings));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from "zustand";
|
||||
import Toast from "react-native-toast-message";
|
||||
import { AVPlaybackStatus, Video } from "expo-av";
|
||||
import { RefObject } from "react";
|
||||
import { PlayRecord, PlayRecordManager } from "@/services/storage";
|
||||
import { PlayRecord, PlayRecordManager, PlayerSettingsManager } from "@/services/storage";
|
||||
import useDetailStore, { episodesSelectorBySource } from "./detailStore";
|
||||
|
||||
interface Episode {
|
||||
@@ -19,11 +19,13 @@ interface PlayerState {
|
||||
showControls: boolean;
|
||||
showEpisodeModal: boolean;
|
||||
showSourceModal: boolean;
|
||||
showSpeedModal: boolean;
|
||||
showNextEpisodeOverlay: boolean;
|
||||
isSeeking: boolean;
|
||||
seekPosition: number;
|
||||
progressPosition: number;
|
||||
initialPosition: number;
|
||||
playbackRate: number;
|
||||
introEndTime?: number;
|
||||
outroStartTime?: number;
|
||||
setVideoRef: (ref: RefObject<Video>) => void;
|
||||
@@ -42,7 +44,9 @@ interface PlayerState {
|
||||
setShowControls: (show: boolean) => void;
|
||||
setShowEpisodeModal: (show: boolean) => void;
|
||||
setShowSourceModal: (show: boolean) => void;
|
||||
setShowSpeedModal: (show: boolean) => void;
|
||||
setShowNextEpisodeOverlay: (show: boolean) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setIntroEndTime: () => void;
|
||||
setOutroStartTime: () => void;
|
||||
reset: () => void;
|
||||
@@ -61,11 +65,13 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
showControls: false,
|
||||
showEpisodeModal: false,
|
||||
showSourceModal: false,
|
||||
showSpeedModal: false,
|
||||
showNextEpisodeOverlay: false,
|
||||
isSeeking: false,
|
||||
seekPosition: 0,
|
||||
progressPosition: 0,
|
||||
initialPosition: 0,
|
||||
playbackRate: 1.0,
|
||||
introEndTime: undefined,
|
||||
outroStartTime: undefined,
|
||||
_seekTimeout: undefined,
|
||||
@@ -93,17 +99,21 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
|
||||
try {
|
||||
const playRecord = await PlayRecordManager.get(detail.source, detail.id.toString());
|
||||
const playerSettings = await PlayerSettingsManager.get(detail.source, detail.id.toString());
|
||||
const initialPositionFromRecord = playRecord?.play_time ? playRecord.play_time * 1000 : 0;
|
||||
const savedPlaybackRate = playerSettings?.playbackRate || 1.0;
|
||||
|
||||
set({
|
||||
isLoading: false,
|
||||
currentEpisodeIndex: episodeIndex,
|
||||
initialPosition: position || initialPositionFromRecord,
|
||||
playbackRate: savedPlaybackRate,
|
||||
episodes: episodes.map((ep, index) => ({
|
||||
url: ep,
|
||||
title: `第 ${index + 1} 集`,
|
||||
})),
|
||||
introEndTime: playRecord?.introEndTime,
|
||||
outroStartTime: playRecord?.outroStartTime,
|
||||
introEndTime: playRecord?.introEndTime || playerSettings?.introEndTime,
|
||||
outroStartTime: playRecord?.outroStartTime || playerSettings?.outroStartTime,
|
||||
});
|
||||
} catch (error) {
|
||||
console.info("Failed to load play record", error);
|
||||
@@ -305,8 +315,26 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
setShowControls: (show) => set({ showControls: show }),
|
||||
setShowEpisodeModal: (show) => set({ showEpisodeModal: show }),
|
||||
setShowSourceModal: (show) => set({ showSourceModal: show }),
|
||||
setShowSpeedModal: (show) => set({ showSpeedModal: show }),
|
||||
setShowNextEpisodeOverlay: (show) => set({ showNextEpisodeOverlay: show }),
|
||||
|
||||
setPlaybackRate: async (rate) => {
|
||||
const { videoRef } = get();
|
||||
const detail = useDetailStore.getState().detail;
|
||||
|
||||
try {
|
||||
await videoRef?.current?.setRateAsync(rate, true);
|
||||
set({ playbackRate: rate });
|
||||
|
||||
// Save the playback rate preference
|
||||
if (detail) {
|
||||
await PlayerSettingsManager.save(detail.source, detail.id.toString(), { playbackRate: rate });
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("Failed to set playback rate:", error);
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
episodes: [],
|
||||
@@ -316,8 +344,10 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
showControls: false,
|
||||
showEpisodeModal: false,
|
||||
showSourceModal: false,
|
||||
showSpeedModal: false,
|
||||
showNextEpisodeOverlay: false,
|
||||
initialPosition: 0,
|
||||
playbackRate: 1.0,
|
||||
introEndTime: undefined,
|
||||
outroStartTime: undefined,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user