Enhance video playback features by adding playTime and initialPosition handling, and update PlayerControls for better focus management

This commit is contained in:
zimplexing
2025-07-07 22:14:56 +08:00
parent 08e24dd748
commit bd22fa2996
8 changed files with 94 additions and 55 deletions

View File

@@ -76,6 +76,7 @@ export default function HomeScreen() {
year={item.year}
rate={item.rate}
progress={item.progress}
playTime={item.play_time}
episodeIndex={item.episodeIndex}
sourceName={item.sourceName}
totalEpisodes={item.totalEpisodes}

View File

@@ -14,7 +14,12 @@ import { useTVRemoteHandler } from '@/hooks/useTVRemoteHandler';
export default function PlayScreen() {
const videoRef = useRef<Video>(null);
useKeepAwake();
const { source, id, episodeIndex } = useLocalSearchParams<{ source: string; id: string; episodeIndex: string }>();
const { source, id, episodeIndex, position } = useLocalSearchParams<{
source: string;
id: string;
episodeIndex: string;
position: string;
}>();
const {
detail,
@@ -24,6 +29,7 @@ export default function PlayScreen() {
showControls,
showEpisodeModal,
showNextEpisodeOverlay,
initialPosition,
setVideoRef,
loadVideo,
playEpisode,
@@ -39,14 +45,14 @@ export default function PlayScreen() {
useEffect(() => {
setVideoRef(videoRef);
if (source && id) {
loadVideo(source, id, parseInt(episodeIndex || '0', 10));
loadVideo(source, id, parseInt(episodeIndex || '0', 10), parseInt(position || '0', 10));
}
return () => {
reset(); // Reset state when component unmounts
};
}, [source, id, episodeIndex, setVideoRef, loadVideo, reset]);
}, [source, id, episodeIndex, position, setVideoRef, loadVideo, reset]);
const { setCurrentFocus } = useTVRemoteHandler({
const { currentFocus, setCurrentFocus } = useTVRemoteHandler({
showControls,
setShowControls,
showEpisodeModal,
@@ -56,6 +62,7 @@ export default function PlayScreen() {
onPlayNextEpisode: () => {
if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
setShowControls(false);
}
},
});
@@ -71,7 +78,7 @@ export default function PlayScreen() {
const currentEpisode = episodes[currentEpisodeIndex];
return (
<ThemedView style={styles.container}>
<ThemedView focusable style={styles.container}>
<TouchableOpacity
activeOpacity={1}
style={styles.videoContainer}
@@ -86,13 +93,18 @@ export default function PlayScreen() {
source={{ uri: currentEpisode?.url }}
resizeMode={ResizeMode.CONTAIN}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
onLoad={() => usePlayerStore.setState({ isLoading: false })}
onLoad={() => {
if (initialPosition > 0) {
videoRef.current?.setPositionAsync(initialPosition);
}
usePlayerStore.setState({ isLoading: false });
}}
onLoadStart={() => usePlayerStore.setState({ isLoading: true })}
useNativeControls={false}
shouldPlay
/>
{showControls && <PlayerControls />}
{showControls && <PlayerControls currentFocus={currentFocus} setShowControls={setShowControls} />}
<LoadingOverlay visible={isLoading} />

View File

@@ -8,9 +8,12 @@ import { MediaButton } from '@/components/MediaButton';
import usePlayerStore from '@/stores/playerStore';
interface PlayerControlsProps {}
interface PlayerControlsProps {
currentFocus: string | null;
setShowControls: (show: boolean) => void;
}
export const PlayerControls: React.FC<PlayerControlsProps> = () => {
export const PlayerControls: React.FC<PlayerControlsProps> = ({ currentFocus, setShowControls }) => {
const router = useRouter();
const {
detail,
@@ -41,6 +44,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = () => {
const onPlayNextEpisode = () => {
if (hasNextEpisode) {
playEpisode(currentEpisodeIndex + 1);
setShowControls(false);
}
};
@@ -77,11 +81,11 @@ export const PlayerControls: React.FC<PlayerControlsProps> = () => {
</ThemedText>
<View style={styles.bottomControls}>
<MediaButton onPress={() => seek(false)}>
<MediaButton onPress={() => seek(false)} isFocused={currentFocus === 'skipBack'}>
<ChevronsLeft color="white" size={24} />
</MediaButton>
<MediaButton onPress={togglePlayPause}>
<MediaButton onPress={togglePlayPause} isFocused={currentFocus === 'playPause'}>
{status?.isLoaded && status.isPlaying ? (
<Pause color="white" size={24} />
) : (
@@ -89,15 +93,19 @@ export const PlayerControls: React.FC<PlayerControlsProps> = () => {
)}
</MediaButton>
<MediaButton onPress={onPlayNextEpisode} isDisabled={!hasNextEpisode}>
<MediaButton
onPress={onPlayNextEpisode}
isDisabled={!hasNextEpisode}
isFocused={currentFocus === 'nextEpisode'}
>
<SkipForward color={hasNextEpisode ? 'white' : '#666'} size={24} />
</MediaButton>
<MediaButton onPress={() => seek(true)}>
<MediaButton onPress={() => seek(true)} isFocused={currentFocus === 'skipForward'}>
<ChevronsRight color="white" size={24} />
</MediaButton>
<MediaButton onPress={() => setShowEpisodeModal(true)}>
<MediaButton onPress={() => setShowEpisodeModal(true)} isFocused={currentFocus === 'episodes'}>
<List color="white" size={24} />
</MediaButton>
</View>

View File

@@ -16,6 +16,7 @@ interface VideoCardProps {
rate?: string;
sourceName?: string;
progress?: number; // 播放进度0-1之间的小数
playTime?: number; // 播放时间 in ms
episodeIndex?: number; // 剧集索引
totalEpisodes?: number; // 总集数
onFocus?: () => void;
@@ -37,6 +38,7 @@ export default function VideoCard({
onFocus,
onRecordDeleted,
api,
playTime,
}: VideoCardProps) {
const router = useRouter();
const [isFocused, setIsFocused] = useState(false);
@@ -60,7 +62,7 @@ export default function VideoCard({
if (progress !== undefined && episodeIndex !== undefined) {
router.push({
pathname: '/play',
params: { source, id, episodeIndex },
params: { source, id, episodeIndex, position: playTime },
});
} else {
router.push({

View File

@@ -46,7 +46,7 @@ export const useTVRemoteHandler = ({
}
// Only set a timer to hide controls if they are shown AND no element is focused.
if (showControls && currentFocus === null) {
if (showControls) {
controlsTimer.current = setTimeout(() => {
setShowControls(false);
}, 5000);
@@ -64,9 +64,13 @@ export const useTVRemoteHandler = ({
return;
}
// If controls are hidden, the first interaction will just show them.
// If controls are hidden, 'select' should toggle play/pause immediately
// and other interactions will just show the controls.
if (!showControls) {
if (["up", "down", "left", "right", "select"].includes(event.eventType)) {
if (event.eventType === "select") {
onPlayPause();
setShowControls(true);
} else if (["up", "down", "left", "right"].includes(event.eventType)) {
setShowControls(true);
}
return;

View File

@@ -8,6 +8,7 @@ export type RowItem = (SearchResult | PlayRecord) & {
title: string;
poster: string;
progress?: number;
play_time?: number;
lastPlayed?: number;
episodeIndex?: number;
sourceName?: string;
@@ -61,48 +62,48 @@ const useHomeStore = create<HomeState>((set, get) => ({
error: null,
fetchInitialData: async () => {
const { selectedCategory } = get();
set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null });
await get().loadMoreData();
set({ loading: false });
},
loadMoreData: async () => {
const { selectedCategory, pageStart, loading, loadingMore, hasMore } = get();
if (loading || loadingMore || !hasMore) return;
const { selectedCategory, pageStart, loadingMore, hasMore } = get();
if (loadingMore || !hasMore) return;
if (selectedCategory.type === 'record') {
const records = await PlayRecordManager.getAll();
const rowItems = Object.entries(records)
.map(([key, record]) => {
const [source, id] = key.split('+');
return { ...record, id, source, progress: record.play_time / record.total_time, poster: record.cover, sourceName: record.source_name, episodeIndex: record.index, totalEpisodes: record.total_episodes, lastPlayed: record.save_time };
})
.filter(record => record.progress !== undefined && record.progress > 0 && record.progress < 1)
.sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0));
set({ contentData: rowItems, hasMore: false });
return;
if (pageStart > 0) {
set({ loadingMore: true });
}
if (!selectedCategory.type || !selectedCategory.tag) return;
set({ loadingMore: true });
try {
const result = await api.getDoubanData(selectedCategory.type, selectedCategory.tag, 20, pageStart);
if (result.list.length === 0) {
set({ hasMore: false });
if (selectedCategory.type === 'record') {
const records = await PlayRecordManager.getAll();
const rowItems = Object.entries(records)
.map(([key, record]) => {
const [source, id] = key.split('+');
return { ...record, id, source, progress: record.play_time / record.total_time, poster: record.cover, sourceName: record.source_name, episodeIndex: record.index, totalEpisodes: record.total_episodes, lastPlayed: record.save_time, play_time: record.play_time };
})
.filter(record => record.progress !== undefined && record.progress > 0 && record.progress < 1)
.sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0));
set({ contentData: rowItems, hasMore: false });
} else if (selectedCategory.type && selectedCategory.tag) {
const result = await api.getDoubanData(selectedCategory.type, selectedCategory.tag, 20, pageStart);
if (result.list.length === 0) {
set({ hasMore: false });
} else {
const newItems = result.list.map(item => ({
...item,
id: item.title,
source: 'douban',
})) as RowItem[];
set(state => ({
contentData: pageStart === 0 ? newItems : [...state.contentData, ...newItems],
pageStart: state.pageStart + result.list.length,
hasMore: true,
}));
}
} else {
const newItems = result.list.map(item => ({
...item,
id: item.title,
source: 'douban',
})) as RowItem[];
set(state => ({
contentData: pageStart === 0 ? newItems : [...state.contentData, ...newItems],
pageStart: state.pageStart + result.list.length,
hasMore: true,
}));
set({ hasMore: false });
}
} catch (err: any) {
if (err.message === 'API_URL_NOT_SET') {
@@ -111,7 +112,7 @@ const useHomeStore = create<HomeState>((set, get) => ({
set({ error: '加载失败,请重试' });
}
} finally {
set({ loadingMore: false });
set({ loading: false, loadingMore: false });
}
},

View File

@@ -27,8 +27,9 @@ interface PlayerState {
isSeeking: boolean;
seekPosition: number;
progressPosition: number;
initialPosition: number;
setVideoRef: (ref: RefObject<Video>) => void;
loadVideo: (source: string, id: string, episodeIndex: number) => Promise<void>;
loadVideo: (source: string, id: string, episodeIndex: number, position?: number) => Promise<void>;
playEpisode: (index: number) => void;
togglePlayPause: () => void;
seek: (forward: boolean) => void;
@@ -53,11 +54,18 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
isSeeking: false,
seekPosition: 0,
progressPosition: 0,
initialPosition: 0,
setVideoRef: (ref) => set({ videoRef: ref }),
loadVideo: async (source, id, episodeIndex) => {
set({ isLoading: true, detail: null, episodes: [], currentEpisodeIndex: 0 });
loadVideo: async (source, id, episodeIndex, position) => {
set({
isLoading: true,
detail: null,
episodes: [],
currentEpisodeIndex: 0,
initialPosition: position || 0,
});
try {
const videoDetail = await api.getVideoDetail(source, id);
const episodes = videoDetail.episodes.map((ep, index) => ({ url: ep, title: `${index + 1}` }));
@@ -76,7 +84,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
playEpisode: (index) => {
const { episodes, videoRef } = get();
if (index >= 0 && index < episodes.length) {
set({ currentEpisodeIndex: index, showNextEpisodeOverlay: false });
set({ currentEpisodeIndex: index, showNextEpisodeOverlay: false, initialPosition: 0 });
videoRef?.current?.replayAsync();
}
},
@@ -156,6 +164,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
showControls: true,
showEpisodeModal: false,
showNextEpisodeOverlay: false,
initialPosition: 0,
});
},
}));

View File

@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { SettingsManager } from '@/services/storage';
import { api } from '@/services/api';
import useHomeStore from './homeStore';
interface SettingsState {
apiBaseUrl: string;
@@ -26,6 +27,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
await SettingsManager.save({ apiBaseUrl });
api.setBaseUrl(apiBaseUrl);
set({ isModalVisible: false });
useHomeStore.getState().fetchInitialData();
},
showModal: () => set({ isModalVisible: true }),
hideModal: () => set({ isModalVisible: false }),