Add Prettier configuration and refactor code for consistent formatting

- Introduced a .prettierrc file to standardize code formatting.
- Updated import statements and JSX attributes in NotFoundScreen, HomeScreen, PlayScreen, and PlayerControls for consistent use of double quotes.
- Refactored styles in various components to use double quotes for string values.
- Added SeekingBar component to enhance video playback experience.
This commit is contained in:
zimplexing
2025-07-08 16:58:06 +08:00
parent bd22fa2996
commit 5b4c8db317
9 changed files with 333 additions and 268 deletions

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"tabWidth": 2,
"useTabs": false,
"singleQuote": false,
"printWidth": 120
}

View File

@@ -1,13 +1,14 @@
import {Link, Stack} from 'expo-router'; import { Link, Stack } from "expo-router";
import {StyleSheet} from 'react-native'; import { StyleSheet } from "react-native";
import {ThemedText} from '@/components/ThemedText'; import { ThemedText } from "@/components/ThemedText";
import {ThemedView} from '@/components/ThemedView'; import { ThemedView } from "@/components/ThemedView";
import React from "react";
export default function NotFoundScreen() { export default function NotFoundScreen() {
return ( return (
<> <>
<Stack.Screen options={{title: 'Oops!'}} /> <Stack.Screen options={{ title: "Oops!" }} />
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ThemedText type="title">This screen doesn't exist.</ThemedText> <ThemedText type="title">This screen doesn't exist.</ThemedText>
<Link href="/" style={styles.link}> <Link href="/" style={styles.link}>
@@ -21,8 +22,8 @@ export default function NotFoundScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
padding: 20, padding: 20,
}, },
link: { link: {

View File

@@ -1,18 +1,18 @@
import React, { useEffect, useCallback, useRef } from 'react'; import React, { useEffect, useCallback, useRef } from "react";
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Dimensions } from 'react-native'; import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Dimensions } from "react-native";
import { ThemedView } from '@/components/ThemedView'; import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from "@/components/ThemedText";
import { api } from '@/services/api'; import { api } from "@/services/api";
import VideoCard from '@/components/VideoCard.tv'; import VideoCard from "@/components/VideoCard.tv";
import { useFocusEffect, useRouter } from 'expo-router'; import { useFocusEffect, useRouter } from "expo-router";
import { useColorScheme } from 'react-native'; import { useColorScheme } from "react-native";
import { Search, Settings } from 'lucide-react-native'; import { Search, Settings } from "lucide-react-native";
import { SettingsModal } from '@/components/SettingsModal'; import { SettingsModal } from "@/components/SettingsModal";
import useHomeStore, { RowItem, Category } from '@/stores/homeStore'; import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from "@/stores/settingsStore";
const NUM_COLUMNS = 5; const NUM_COLUMNS = 5;
const { width } = Dimensions.get('window'); const { width } = Dimensions.get("window");
const ITEM_WIDTH = width / NUM_COLUMNS - 24; const ITEM_WIDTH = width / NUM_COLUMNS - 24;
export default function HomeScreen() { export default function HomeScreen() {
@@ -33,7 +33,7 @@ export default function HomeScreen() {
refreshPlayRecords, refreshPlayRecords,
} = useHomeStore(); } = useHomeStore();
const showSettingsModal = useSettingsStore(state => state.showModal); const showSettingsModal = useSettingsStore((state) => state.showModal);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -99,15 +99,15 @@ export default function HomeScreen() {
<View style={styles.rightHeaderButtons}> <View style={styles.rightHeaderButtons}>
<Pressable <Pressable
style={({ focused }) => [styles.searchButton, focused && styles.searchButtonFocused]} style={({ focused }) => [styles.searchButton, focused && styles.searchButtonFocused]}
onPress={() => router.push({ pathname: '/search' })} onPress={() => router.push({ pathname: "/search" })}
> >
<Search color={colorScheme === 'dark' ? 'white' : 'black'} size={24} /> <Search color={colorScheme === "dark" ? "white" : "black"} size={24} />
</Pressable> </Pressable>
<Pressable <Pressable
style={({ focused }) => [styles.searchButton, focused && styles.searchButtonFocused]} style={({ focused }) => [styles.searchButton, focused && styles.searchButtonFocused]}
onPress={showSettingsModal} onPress={showSettingsModal}
> >
<Settings color={colorScheme === 'dark' ? 'white' : 'black'} size={24} /> <Settings color={colorScheme === "dark" ? "white" : "black"} size={24} />
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -117,7 +117,7 @@ export default function HomeScreen() {
<FlatList <FlatList
data={categories} data={categories}
renderItem={renderCategory} renderItem={renderCategory}
keyExtractor={item => item.title} keyExtractor={(item) => item.title}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.categoryListContent} contentContainerStyle={styles.categoryListContent}
@@ -166,25 +166,25 @@ const styles = StyleSheet.create({
centerContainer: { centerContainer: {
flex: 1, flex: 1,
paddingTop: 20, paddingTop: 20,
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
}, },
// Header // Header
headerContainer: { headerContainer: {
flexDirection: 'row', flexDirection: "row",
justifyContent: 'space-between', justifyContent: "space-between",
alignItems: 'center', alignItems: "center",
paddingHorizontal: 24, paddingHorizontal: 24,
marginBottom: 10, marginBottom: 10,
}, },
headerTitle: { headerTitle: {
fontSize: 32, fontSize: 32,
fontWeight: 'bold', fontWeight: "bold",
paddingTop: 16, paddingTop: 16,
}, },
rightHeaderButtons: { rightHeaderButtons: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
}, },
searchButton: { searchButton: {
padding: 10, padding: 10,
@@ -192,7 +192,7 @@ const styles = StyleSheet.create({
marginLeft: 10, marginLeft: 10,
}, },
searchButtonFocused: { searchButtonFocused: {
backgroundColor: '#007AFF', backgroundColor: "#007AFF",
transform: [{ scale: 1.1 }], transform: [{ scale: 1.1 }],
}, },
// Category Selector // Category Selector
@@ -209,18 +209,18 @@ const styles = StyleSheet.create({
marginHorizontal: 5, marginHorizontal: 5,
}, },
categoryButtonSelected: { categoryButtonSelected: {
backgroundColor: '#007AFF', // A bright blue for selected state backgroundColor: "#007AFF", // A bright blue for selected state
}, },
categoryButtonFocused: { categoryButtonFocused: {
backgroundColor: '#0056b3', // A darker blue for focused state backgroundColor: "#0056b3", // A darker blue for focused state
elevation: 5, elevation: 5,
}, },
categoryText: { categoryText: {
fontSize: 16, fontSize: 16,
fontWeight: '500', fontWeight: "500",
}, },
categoryTextSelected: { categoryTextSelected: {
color: '#FFFFFF', color: "#FFFFFF",
}, },
// Content Grid // Content Grid
listContent: { listContent: {
@@ -230,6 +230,6 @@ const styles = StyleSheet.create({
itemContainer: { itemContainer: {
margin: 8, margin: 8,
width: ITEM_WIDTH, width: ITEM_WIDTH,
alignItems: 'center', alignItems: "center",
}, },
}); });

View File

@@ -1,15 +1,16 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from "react";
import { View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; import { View, StyleSheet, TouchableOpacity, ActivityIndicator } from "react-native";
import { useLocalSearchParams } from 'expo-router'; import { useLocalSearchParams } from "expo-router";
import { Video, ResizeMode } from 'expo-av'; import { Video, ResizeMode } from "expo-av";
import { useKeepAwake } from 'expo-keep-awake'; import { useKeepAwake } from "expo-keep-awake";
import { ThemedView } from '@/components/ThemedView'; import { ThemedView } from "@/components/ThemedView";
import { PlayerControls } from '@/components/PlayerControls'; import { PlayerControls } from "@/components/PlayerControls";
import { EpisodeSelectionModal } from '@/components/EpisodeSelectionModal'; import { EpisodeSelectionModal } from "@/components/EpisodeSelectionModal";
import { NextEpisodeOverlay } from '@/components/NextEpisodeOverlay'; import { SeekingBar } from "@/components/SeekingBar";
import { LoadingOverlay } from '@/components/LoadingOverlay'; import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
import usePlayerStore from '@/stores/playerStore'; import { LoadingOverlay } from "@/components/LoadingOverlay";
import { useTVRemoteHandler } from '@/hooks/useTVRemoteHandler'; import usePlayerStore from "@/stores/playerStore";
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
export default function PlayScreen() { export default function PlayScreen() {
const videoRef = useRef<Video>(null); const videoRef = useRef<Video>(null);
@@ -45,27 +46,14 @@ export default function PlayScreen() {
useEffect(() => { useEffect(() => {
setVideoRef(videoRef); setVideoRef(videoRef);
if (source && id) { if (source && id) {
loadVideo(source, id, parseInt(episodeIndex || '0', 10), parseInt(position || '0', 10)); loadVideo(source, id, parseInt(episodeIndex || "0", 10), parseInt(position || "0", 10));
} }
return () => { return () => {
reset(); // Reset state when component unmounts reset(); // Reset state when component unmounts
}; };
}, [source, id, episodeIndex, position, setVideoRef, loadVideo, reset]); }, [source, id, episodeIndex, position, setVideoRef, loadVideo, reset]);
const { currentFocus, setCurrentFocus } = useTVRemoteHandler({ const { onScreenPress } = useTVRemoteHandler();
showControls,
setShowControls,
showEpisodeModal,
onPlayPause: togglePlayPause,
onSeek: seek,
onShowEpisodes: () => setShowEpisodeModal(true),
onPlayNextEpisode: () => {
if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
setShowControls(false);
}
},
});
if (!detail && isLoading) { if (!detail && isLoading) {
return ( return (
@@ -79,14 +67,7 @@ export default function PlayScreen() {
return ( return (
<ThemedView focusable style={styles.container}> <ThemedView focusable style={styles.container}>
<TouchableOpacity <TouchableOpacity activeOpacity={1} style={styles.videoContainer} onPress={onScreenPress}>
activeOpacity={1}
style={styles.videoContainer}
onPress={() => {
setShowControls(!showControls);
setCurrentFocus(null);
}}
>
<Video <Video
ref={videoRef} ref={videoRef}
style={styles.videoPlayer} style={styles.videoPlayer}
@@ -104,7 +85,9 @@ export default function PlayScreen() {
shouldPlay shouldPlay
/> />
{showControls && <PlayerControls currentFocus={currentFocus} setShowControls={setShowControls} />} {showControls && <PlayerControls showControls={showControls} setShowControls={setShowControls} />}
<SeekingBar />
<LoadingOverlay visible={isLoading} /> <LoadingOverlay visible={isLoading} />
@@ -117,8 +100,8 @@ export default function PlayScreen() {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: 'black' }, container: { flex: 1, backgroundColor: "black" },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' }, centered: { flex: 1, justifyContent: "center", alignItems: "center" },
videoContainer: { videoContainer: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
}, },

View File

@@ -4,25 +4,26 @@ import { Pressable, StyleSheet, StyleProp, ViewStyle } from "react-native";
interface MediaButtonProps { interface MediaButtonProps {
onPress: () => void; onPress: () => void;
children: React.ReactNode; children: React.ReactNode;
isFocused?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
hasTVPreferredFocus?: boolean;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
} }
export const MediaButton: React.FC<MediaButtonProps> = ({ export const MediaButton: React.FC<MediaButtonProps> = ({
onPress, onPress,
children, children,
isFocused = false,
isDisabled = false, isDisabled = false,
hasTVPreferredFocus = false,
style, style,
}) => { }) => {
return ( return (
<Pressable <Pressable
hasTVPreferredFocus={hasTVPreferredFocus}
onPress={onPress} onPress={onPress}
disabled={isDisabled} disabled={isDisabled}
style={[ style={({ focused }) => [
styles.mediaControlButton, styles.mediaControlButton,
isFocused && styles.focusedButton, focused && styles.focusedButton,
isDisabled && styles.disabledButton, isDisabled && styles.disabledButton,
style, style,
]} ]}

View File

@@ -1,19 +1,19 @@
import React from 'react'; import React, { useCallback, useState } from "react";
import { View, Text, StyleSheet, TouchableOpacity, Pressable } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Pressable } from "react-native";
import { useRouter } from 'expo-router'; import { useRouter } from "expo-router";
import { AVPlaybackStatus } from 'expo-av'; import { AVPlaybackStatus } from "expo-av";
import { ArrowLeft, Pause, Play, SkipForward, List, ChevronsRight, ChevronsLeft } from 'lucide-react-native'; import { ArrowLeft, Pause, Play, SkipForward, List, ChevronsRight, ChevronsLeft } from "lucide-react-native";
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from "@/components/ThemedText";
import { MediaButton } from '@/components/MediaButton'; import { MediaButton } from "@/components/MediaButton";
import usePlayerStore from '@/stores/playerStore'; import usePlayerStore from "@/stores/playerStore";
interface PlayerControlsProps { interface PlayerControlsProps {
currentFocus: string | null; showControls: boolean;
setShowControls: (show: boolean) => void; setShowControls: (show: boolean) => void;
} }
export const PlayerControls: React.FC<PlayerControlsProps> = ({ currentFocus, setShowControls }) => { export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, setShowControls }) => {
const router = useRouter(); const router = useRouter();
const { const {
detail, detail,
@@ -28,35 +28,30 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ currentFocus, se
setShowEpisodeModal, setShowEpisodeModal,
} = usePlayerStore(); } = usePlayerStore();
const videoTitle = detail?.videoInfo?.title || ''; const videoTitle = detail?.videoInfo?.title || "";
const currentEpisode = detail?.episodes[currentEpisodeIndex]; const currentEpisode = detail?.episodes[currentEpisodeIndex];
const currentEpisodeTitle = currentEpisode?.title; const currentEpisodeTitle = currentEpisode?.title;
const hasNextEpisode = currentEpisodeIndex < (detail?.episodes.length || 0) - 1; const hasNextEpisode = currentEpisodeIndex < (detail?.episodes.length || 0) - 1;
const formatTime = (milliseconds: number) => { const formatTime = (milliseconds: number) => {
if (!milliseconds) return '00:00'; if (!milliseconds) return "00:00";
const totalSeconds = Math.floor(milliseconds / 1000); const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60); const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60; const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}; };
const onPlayNextEpisode = () => { const onPlayNextEpisode = () => {
if (hasNextEpisode) { if (hasNextEpisode) {
playEpisode(currentEpisodeIndex + 1); playEpisode(currentEpisodeIndex + 1);
setShowControls(false);
} }
}; };
return ( return (
<View style={styles.controlsOverlay}> <View style={styles.controlsOverlay}>
<View style={styles.topControls}> <View style={styles.topControls}>
<TouchableOpacity style={styles.controlButton} onPress={() => router.back()}>
<ArrowLeft color="white" size={24} />
</TouchableOpacity>
<Text style={styles.controlTitle}> <Text style={styles.controlTitle}>
{videoTitle} {currentEpisodeTitle ? `- ${currentEpisodeTitle}` : ''} {videoTitle} {currentEpisodeTitle ? `- ${currentEpisodeTitle}` : ""}
</Text> </Text>
</View> </View>
@@ -74,18 +69,18 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ currentFocus, se
<Pressable style={styles.progressBarTouchable} /> <Pressable style={styles.progressBarTouchable} />
</View> </View>
<ThemedText style={{ color: 'white', marginTop: 5 }}> <ThemedText style={{ color: "white", marginTop: 5 }}>
{status?.isLoaded {status?.isLoaded
? `${formatTime(status.positionMillis)} / ${formatTime(status.durationMillis || 0)}` ? `${formatTime(status.positionMillis)} / ${formatTime(status.durationMillis || 0)}`
: '00:00 / 00:00'} : "00:00 / 00:00"}
</ThemedText> </ThemedText>
<View style={styles.bottomControls}> <View style={styles.bottomControls}>
<MediaButton onPress={() => seek(false)} isFocused={currentFocus === 'skipBack'}> <MediaButton onPress={() => seek(-15000)}>
<ChevronsLeft color="white" size={24} /> <ChevronsLeft color="white" size={24} />
</MediaButton> </MediaButton>
<MediaButton onPress={togglePlayPause} isFocused={currentFocus === 'playPause'}> <MediaButton onPress={togglePlayPause} hasTVPreferredFocus={showControls}>
{status?.isLoaded && status.isPlaying ? ( {status?.isLoaded && status.isPlaying ? (
<Pause color="white" size={24} /> <Pause color="white" size={24} />
) : ( ) : (
@@ -93,19 +88,15 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ currentFocus, se
)} )}
</MediaButton> </MediaButton>
<MediaButton <MediaButton onPress={onPlayNextEpisode} isDisabled={!hasNextEpisode}>
onPress={onPlayNextEpisode} <SkipForward color={hasNextEpisode ? "white" : "#666"} size={24} />
isDisabled={!hasNextEpisode}
isFocused={currentFocus === 'nextEpisode'}
>
<SkipForward color={hasNextEpisode ? 'white' : '#666'} size={24} />
</MediaButton> </MediaButton>
<MediaButton onPress={() => seek(true)} isFocused={currentFocus === 'skipForward'}> <MediaButton onPress={() => seek(15000)}>
<ChevronsRight color="white" size={24} /> <ChevronsRight color="white" size={24} />
</MediaButton> </MediaButton>
<MediaButton onPress={() => setShowEpisodeModal(true)} isFocused={currentFocus === 'episodes'}> <MediaButton onPress={() => setShowEpisodeModal(true)}>
<List color="white" size={24} /> <List color="white" size={24} />
</MediaButton> </MediaButton>
</View> </View>
@@ -117,58 +108,58 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ currentFocus, se
const styles = StyleSheet.create({ const styles = StyleSheet.create({
controlsOverlay: { controlsOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.4)', backgroundColor: "rgba(0, 0, 0, 0.4)",
justifyContent: 'space-between', justifyContent: "space-between",
padding: 20, padding: 20,
}, },
topControls: { topControls: {
flexDirection: 'row', flexDirection: "row",
justifyContent: 'space-between', justifyContent: "space-between",
alignItems: 'center', alignItems: "center",
}, },
controlTitle: { controlTitle: {
color: 'white', color: "white",
fontSize: 16, fontSize: 16,
fontWeight: 'bold', fontWeight: "bold",
flex: 1, flex: 1,
textAlign: 'center', textAlign: "center",
marginHorizontal: 10, marginHorizontal: 10,
}, },
bottomControlsContainer: { bottomControlsContainer: {
width: '100%', width: "100%",
alignItems: 'center', alignItems: "center",
}, },
bottomControls: { bottomControls: {
flexDirection: 'row', flexDirection: "row",
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
gap: 10, gap: 10,
flexWrap: 'wrap', flexWrap: "wrap",
marginTop: 15, marginTop: 15,
}, },
progressBarContainer: { progressBarContainer: {
width: '100%', width: "100%",
height: 8, height: 8,
position: 'relative', position: "relative",
marginTop: 10, marginTop: 10,
}, },
progressBarBackground: { progressBarBackground: {
position: 'absolute', position: "absolute",
left: 0, left: 0,
right: 0, right: 0,
height: 8, height: 8,
backgroundColor: 'rgba(255, 255, 255, 0.3)', backgroundColor: "rgba(255, 255, 255, 0.3)",
borderRadius: 4, borderRadius: 4,
}, },
progressBarFilled: { progressBarFilled: {
position: 'absolute', position: "absolute",
left: 0, left: 0,
height: 8, height: 8,
backgroundColor: '#ff0000', backgroundColor: "#ff0000",
borderRadius: 4, borderRadius: 4,
}, },
progressBarTouchable: { progressBarTouchable: {
position: 'absolute', position: "absolute",
left: 0, left: 0,
right: 0, right: 0,
height: 30, height: 30,
@@ -177,20 +168,20 @@ const styles = StyleSheet.create({
}, },
controlButton: { controlButton: {
padding: 10, padding: 10,
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
}, },
topRightContainer: { topRightContainer: {
padding: 10, padding: 10,
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
minWidth: 44, // Match TouchableOpacity default size for alignment minWidth: 44, // Match TouchableOpacity default size for alignment
}, },
resolutionText: { resolutionText: {
color: 'white', color: "white",
fontSize: 16, fontSize: 16,
fontWeight: 'bold', fontWeight: "bold",
backgroundColor: 'rgba(0,0,0,0.5)', backgroundColor: "rgba(0,0,0,0.5)",
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 4, paddingVertical: 4,
borderRadius: 6, borderRadius: 6,

86
components/SeekingBar.tsx Normal file
View File

@@ -0,0 +1,86 @@
import React from "react";
import { View, StyleSheet, Text } from "react-native";
import usePlayerStore from "@/stores/playerStore";
const formatTime = (milliseconds: number) => {
if (isNaN(milliseconds) || milliseconds < 0) {
return "00:00";
}
const totalSeconds = Math.floor(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
export const SeekingBar = () => {
const { isSeeking, seekPosition, status } = usePlayerStore();
if (!isSeeking || !status?.isLoaded) {
return null;
}
const durationMillis = status.durationMillis || 0;
const currentPositionMillis = seekPosition * durationMillis;
return (
<View style={styles.seekingContainer}>
<Text style={styles.timeText}>
{formatTime(currentPositionMillis)} / {formatTime(durationMillis)}
</Text>
<View style={styles.seekingBarContainer}>
<View style={styles.seekingBarBackground} />
<View
style={[
styles.seekingBarFilled,
{
width: `${seekPosition * 100}%`,
},
]}
/>
</View>
</View>
);
};
const styles = StyleSheet.create({
seekingContainer: {
position: "absolute",
bottom: 80,
left: "5%",
right: "5%",
alignItems: "center",
},
timeText: {
color: "white",
fontSize: 18,
fontWeight: "bold",
backgroundColor: "rgba(0,0,0,0.6)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
marginBottom: 10,
},
seekingBarContainer: {
width: "100%",
height: 5,
backgroundColor: "rgba(255, 255, 255, 0.3)",
borderRadius: 2.5,
},
seekingBarBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(255, 255, 255, 0.3)",
borderRadius: 2.5,
},
seekingBarFilled: {
height: "100%",
backgroundColor: "#ff0000",
borderRadius: 2.5,
},
});

View File

@@ -1,118 +1,97 @@
import { useState, useEffect, useRef } from "react"; import { useEffect, useRef, useCallback } from "react";
import { useTVEventHandler } from "react-native"; import { useTVEventHandler, HWEvent } from "react-native";
import usePlayerStore from "@/stores/playerStore";
interface TVRemoteHandlerProps { // 定时器延迟时间(毫秒)
showControls: boolean; const CONTROLS_TIMEOUT = 5000;
setShowControls: (show: boolean) => void;
showEpisodeModal: boolean;
onPlayPause: () => void;
onSeek: (forward: boolean) => void;
onShowEpisodes: () => void;
onPlayNextEpisode: () => void;
}
const focusGraph: Record<string, Record<string, string>> = { /**
skipBack: { right: "playPause" }, * 管理播放器控件的显示/隐藏、遥控器事件和自动隐藏定时器。
playPause: { left: "skipBack", right: "nextEpisode" }, * @returns onScreenPress - 一个函数,用于处理屏幕点击事件,以显示控件并重置定时器。
nextEpisode: { left: "playPause", right: "skipForward" }, */
skipForward: { left: "nextEpisode", right: "episodes" }, export const useTVRemoteHandler = () => {
episodes: { left: "skipForward" }, const { showControls, setShowControls, showEpisodeModal, togglePlayPause, seek } = usePlayerStore();
};
export const useTVRemoteHandler = ({
showControls,
setShowControls,
showEpisodeModal,
onPlayPause,
onSeek,
onShowEpisodes,
onPlayNextEpisode,
}: TVRemoteHandlerProps) => {
const [currentFocus, setCurrentFocus] = useState<string | null>(null);
const controlsTimer = useRef<NodeJS.Timeout | null>(null); const controlsTimer = useRef<NodeJS.Timeout | null>(null);
const actionMap: Record<string, () => void> = { // 重置或启动隐藏控件的定时器
playPause: onPlayPause, const resetTimer = useCallback(() => {
skipBack: () => onSeek(false), // 清除之前的定时器
skipForward: () => onSeek(true),
nextEpisode: onPlayNextEpisode,
episodes: onShowEpisodes,
};
// Centralized timer logic driven by state changes.
useEffect(() => {
if (controlsTimer.current) { if (controlsTimer.current) {
clearTimeout(controlsTimer.current); clearTimeout(controlsTimer.current);
} }
// 设置新的定时器
controlsTimer.current = setTimeout(() => {
setShowControls(false);
}, CONTROLS_TIMEOUT);
}, [setShowControls]);
// Only set a timer to hide controls if they are shown AND no element is focused. // 当控件显示时,启动定时器
useEffect(() => {
if (showControls) { if (showControls) {
controlsTimer.current = setTimeout(() => { resetTimer();
setShowControls(false); } else {
}, 5000); // 如果控件被隐藏,清除定时器
if (controlsTimer.current) {
clearTimeout(controlsTimer.current);
}
} }
// 组件卸载时清除定时器
return () => { return () => {
if (controlsTimer.current) { if (controlsTimer.current) {
clearTimeout(controlsTimer.current); clearTimeout(controlsTimer.current);
} }
}; };
}, [showControls, currentFocus]); }, [showControls, resetTimer]);
useTVEventHandler((event) => { // 处理遥控器事件
if (showEpisodeModal) { const handleTVEvent = useCallback(
return; (event: HWEvent) => {
} // 如果剧集选择模态框显示,则不处理任何事件
if (showEpisodeModal) {
// If controls are hidden, 'select' should toggle play/pause immediately return;
// and other interactions will just show the controls.
if (!showControls) {
if (event.eventType === "select") {
onPlayPause();
setShowControls(true);
} else if (["up", "down", "left", "right"].includes(event.eventType)) {
setShowControls(true);
} }
return;
}
// --- Event handling when controls are visible --- resetTimer();
if (currentFocus === null) { if (!showControls) {
// When no specific element is focused on the control bar switch (event.eventType) {
switch (event.eventType) { case "select":
case "left": togglePlayPause();
onSeek(false); setShowControls(true);
break; break;
case "right": case "left":
onSeek(true); seek(-15000); // 快退15秒
break; break;
case "select": case "right":
onPlayPause(); seek(15000); // 快进15秒
break; break;
case "down": case "longLeft":
setCurrentFocus("playPause"); seek(-60000); // 快退60秒
break; break;
case "longRight":
seek(60000); // 快进60秒
break;
}
} }
} else { },
// When an element on the control bar is focused [showControls, showEpisodeModal, setShowControls, resetTimer, togglePlayPause, seek]
switch (event.eventType) { );
case "left":
case "right":
const nextFocus = focusGraph[currentFocus]?.[event.eventType];
if (nextFocus) {
setCurrentFocus(nextFocus);
}
break;
case "up":
setCurrentFocus(null);
break;
case "select":
actionMap[currentFocus]?.();
break;
}
}
});
return { currentFocus, setCurrentFocus }; useTVEventHandler(handleTVEvent);
// 处理屏幕点击事件
const onScreenPress = () => {
// 切换控件的显示状态
const newShowControls = !showControls;
setShowControls(newShowControls);
// 如果控件变为显示状态,则重置定时器
if (newShowControls) {
resetTimer();
}
};
return { onScreenPress };
}; };

View File

@@ -1,8 +1,8 @@
import { create } from 'zustand'; import { create } from "zustand";
import { AVPlaybackStatus, Video } from 'expo-av'; import { AVPlaybackStatus, Video } from "expo-av";
import { RefObject } from 'react'; import { RefObject } from "react";
import { api, VideoDetail as ApiVideoDetail } from '@/services/api'; import { api, VideoDetail as ApiVideoDetail } from "@/services/api";
import { PlayRecordManager } from '@/services/storage'; import { PlayRecordManager } from "@/services/storage";
interface Episode { interface Episode {
url: string; url: string;
@@ -10,7 +10,7 @@ interface Episode {
} }
interface VideoDetail { interface VideoDetail {
videoInfo: ApiVideoDetail['videoInfo']; videoInfo: ApiVideoDetail["videoInfo"];
episodes: Episode[]; episodes: Episode[];
} }
@@ -32,13 +32,14 @@ interface PlayerState {
loadVideo: (source: string, id: string, episodeIndex: number, position?: number) => Promise<void>; loadVideo: (source: string, id: string, episodeIndex: number, position?: number) => Promise<void>;
playEpisode: (index: number) => void; playEpisode: (index: number) => void;
togglePlayPause: () => void; togglePlayPause: () => void;
seek: (forward: boolean) => void; seek: (duration: number) => void;
handlePlaybackStatusUpdate: (newStatus: AVPlaybackStatus) => void; handlePlaybackStatusUpdate: (newStatus: AVPlaybackStatus) => void;
setLoading: (loading: boolean) => void; setLoading: (loading: boolean) => void;
setShowControls: (show: boolean) => void; setShowControls: (show: boolean) => void;
setShowEpisodeModal: (show: boolean) => void; setShowEpisodeModal: (show: boolean) => void;
setShowNextEpisodeOverlay: (show: boolean) => void; setShowNextEpisodeOverlay: (show: boolean) => void;
reset: () => void; reset: () => void;
_seekTimeout?: NodeJS.Timeout;
} }
const usePlayerStore = create<PlayerState>((set, get) => ({ const usePlayerStore = create<PlayerState>((set, get) => ({
@@ -48,13 +49,14 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
currentEpisodeIndex: 0, currentEpisodeIndex: 0,
status: null, status: null,
isLoading: true, isLoading: true,
showControls: true, showControls: false,
showEpisodeModal: false, showEpisodeModal: false,
showNextEpisodeOverlay: false, showNextEpisodeOverlay: false,
isSeeking: false, isSeeking: false,
seekPosition: 0, seekPosition: 0,
progressPosition: 0, progressPosition: 0,
initialPosition: 0, initialPosition: 0,
_seekTimeout: undefined,
setVideoRef: (ref) => set({ videoRef: ref }), setVideoRef: (ref) => set({ videoRef: ref }),
@@ -84,7 +86,13 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
playEpisode: (index) => { playEpisode: (index) => {
const { episodes, videoRef } = get(); const { episodes, videoRef } = get();
if (index >= 0 && index < episodes.length) { if (index >= 0 && index < episodes.length) {
set({ currentEpisodeIndex: index, showNextEpisodeOverlay: false, initialPosition: 0 }); set({
currentEpisodeIndex: index,
showNextEpisodeOverlay: false,
initialPosition: 0,
progressPosition: 0,
seekPosition: 0,
});
videoRef?.current?.replayAsync(); videoRef?.current?.replayAsync();
} }
}, },
@@ -100,39 +108,49 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
} }
}, },
seek: (forward) => { seek: (duration) => {
const { status, videoRef } = get(); const { status, videoRef } = get();
if (status?.isLoaded) { if (!status?.isLoaded || !status.durationMillis) return;
const newPosition = status.positionMillis + (forward ? 15000 : -15000);
videoRef?.current?.setPositionAsync(Math.max(0, newPosition)); const newPosition = Math.max(0, Math.min(status.positionMillis + duration, status.durationMillis));
videoRef?.current?.setPositionAsync(newPosition);
set({
isSeeking: true,
seekPosition: newPosition / status.durationMillis,
});
if (get()._seekTimeout) {
clearTimeout(get()._seekTimeout);
} }
const timeoutId = setTimeout(() => set({ isSeeking: false }), 1000);
set({ _seekTimeout: timeoutId });
}, },
handlePlaybackStatusUpdate: (newStatus) => { handlePlaybackStatusUpdate: (newStatus) => {
set({ status: newStatus });
if (!newStatus.isLoaded) { if (!newStatus.isLoaded) {
if (newStatus.error) { if (newStatus.error) {
console.error(`Playback Error: ${newStatus.error}`); console.error(`Playback Error: ${newStatus.error}`);
} }
set({ status: newStatus });
return; return;
} }
const progressPosition = newStatus.durationMillis ? newStatus.positionMillis / newStatus.durationMillis : 0;
set({ status: newStatus, progressPosition });
const { detail, currentEpisodeIndex, episodes } = get(); const { detail, currentEpisodeIndex, episodes } = get();
if (detail && newStatus.durationMillis) { if (detail && newStatus.durationMillis) {
const { videoInfo } = detail; const { videoInfo } = detail;
PlayRecordManager.save( PlayRecordManager.save(videoInfo.source, videoInfo.id, {
videoInfo.source, title: videoInfo.title,
videoInfo.id, cover: videoInfo.cover || "",
{ index: currentEpisodeIndex,
title: videoInfo.title, total_episodes: episodes.length,
cover: videoInfo.cover || '', play_time: newStatus.positionMillis,
index: currentEpisodeIndex, total_time: newStatus.durationMillis,
total_episodes: episodes.length, source_name: videoInfo.source_name,
play_time: newStatus.positionMillis, });
total_time: newStatus.durationMillis,
source_name: videoInfo.source_name,
}
);
const isNearEnd = newStatus.positionMillis / newStatus.durationMillis > 0.95; const isNearEnd = newStatus.positionMillis / newStatus.durationMillis > 0.95;
if (isNearEnd && currentEpisodeIndex < episodes.length - 1) { if (isNearEnd && currentEpisodeIndex < episodes.length - 1) {
@@ -142,13 +160,13 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
} }
} }
if (newStatus.didJustFinish) { if (newStatus.didJustFinish) {
const { playEpisode, currentEpisodeIndex, episodes } = get(); const { playEpisode, currentEpisodeIndex, episodes } = get();
if (currentEpisodeIndex < episodes.length - 1) { if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1); playEpisode(currentEpisodeIndex + 1);
} }
} }
}, },
setLoading: (loading) => set({ isLoading: loading }), setLoading: (loading) => set({ isLoading: loading }),
setShowControls: (show) => set({ showControls: show }), setShowControls: (show) => set({ showControls: show }),
setShowEpisodeModal: (show) => set({ showEpisodeModal: show }), setShowEpisodeModal: (show) => set({ showEpisodeModal: show }),
@@ -161,7 +179,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
currentEpisodeIndex: 0, currentEpisodeIndex: 0,
status: null, status: null,
isLoading: true, isLoading: true,
showControls: true, showControls: false,
showEpisodeModal: false, showEpisodeModal: false,
showNextEpisodeOverlay: false, showNextEpisodeOverlay: false,
initialPosition: 0, initialPosition: 0,
@@ -169,4 +187,4 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
}, },
})); }));
export default usePlayerStore; export default usePlayerStore;