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:
zimplexing
2025-08-14 15:14:37 +08:00
parent 09c3931117
commit 1ef5a6b445
5 changed files with 140 additions and 6 deletions

View File

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