Add toast notifications for intro and outro time settings, update player store and media button for new time tracking

This commit is contained in:
zimplexing
2025-07-08 22:07:14 +08:00
parent 5043b33222
commit 30724a1e19
10 changed files with 135 additions and 32 deletions

View File

@@ -4,6 +4,7 @@ import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { Platform } from "react-native";
import Toast from "react-native-toast-message";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -43,6 +44,7 @@ export default function RootLayout() {
<Stack.Screen name="search" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<Toast />
</ThemeProvider>
);
}

View File

@@ -34,6 +34,7 @@ export default function PlayScreen() {
showSourceModal,
showNextEpisodeOverlay,
initialPosition,
introEndTime,
setVideoRef,
loadVideo,
playEpisode,
@@ -102,8 +103,9 @@ export default function PlayScreen() {
resizeMode={ResizeMode.CONTAIN}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
onLoad={() => {
if (initialPosition > 0) {
videoRef.current?.setPositionAsync(initialPosition);
const jumpPosition = introEndTime || initialPosition;
if (jumpPosition > 0) {
videoRef.current?.setPositionAsync(jumpPosition);
}
usePlayerStore.setState({ isLoading: false });
}}

View File

@@ -1,11 +1,16 @@
import React, { ComponentProps } from "react";
import { StyledButton } from "./StyledButton";
import { StyleSheet } from "react-native";
import { StyleSheet, View, Text } from "react-native";
type StyledButtonProps = ComponentProps<typeof StyledButton>;
type StyledButtonProps = ComponentProps<typeof StyledButton> & {
timeLabel?: string;
};
export const MediaButton = (props: StyledButtonProps) => (
<StyledButton {...props} style={[styles.mediaControlButton, props.style]} variant="ghost" />
export const MediaButton = ({ timeLabel, ...props }: StyledButtonProps) => (
<View>
<StyledButton {...props} style={[styles.mediaControlButton, props.style]} variant="ghost" />
{timeLabel && <Text style={styles.timeLabel}>{timeLabel}</Text>}
</View>
);
const styles = StyleSheet.create({
@@ -13,4 +18,15 @@ const styles = StyleSheet.create({
padding: 12,
minWidth: 80,
},
timeLabel: {
position: "absolute",
top: 14,
right: 12,
color: "white",
fontSize: 10,
fontWeight: "bold",
backgroundColor: "rgba(0,0,0,0.6)",
paddingHorizontal: 4,
borderRadius: 3,
},
});

View File

@@ -2,7 +2,17 @@ import React, { useCallback, useState } from "react";
import { View, Text, StyleSheet, TouchableOpacity, Pressable } from "react-native";
import { useRouter } from "expo-router";
import { AVPlaybackStatus } from "expo-av";
import { ArrowLeft, Pause, Play, SkipForward, List, ChevronsRight, ChevronsLeft, Tv } from "lucide-react-native";
import {
Pause,
Play,
SkipForward,
List,
ChevronsRight,
ChevronsLeft,
Tv,
ArrowDownToDot,
ArrowUpFromDot,
} from "lucide-react-native";
import { ThemedText } from "@/components/ThemedText";
import { MediaButton } from "@/components/MediaButton";
@@ -28,6 +38,10 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
playEpisode,
setShowEpisodeModal,
setShowSourceModal,
setIntroEndTime,
setOutroStartTime,
introEndTime,
outroStartTime,
} = usePlayerStore();
const videoTitle = detail?.videoInfo?.title || "";
@@ -81,8 +95,8 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
</ThemedText>
<View style={styles.bottomControls}>
<MediaButton onPress={() => seek(-15000)}>
<ChevronsLeft color="white" size={24} />
<MediaButton onPress={setIntroEndTime} timeLabel={introEndTime ? formatTime(introEndTime) : undefined}>
<ArrowDownToDot color="white" size={24} />
</MediaButton>
<MediaButton onPress={togglePlayPause} hasTVPreferredFocus={showControls}>
@@ -97,8 +111,8 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
<SkipForward color={hasNextEpisode ? "white" : "#666"} size={24} />
</MediaButton>
<MediaButton onPress={() => seek(15000)}>
<ChevronsRight color="white" size={24} />
<MediaButton onPress={setOutroStartTime} timeLabel={outroStartTime ? formatTime(outroStartTime) : undefined}>
<ArrowUpFromDot color="white" size={24} />
</MediaButton>
<MediaButton onPress={() => setShowEpisodeModal(true)}>

View File

@@ -67,7 +67,6 @@ export const useTVRemoteHandler = () => {
if (event.eventType === "longRight" || event.eventType === "longLeft") {
if (event.eventKeyAction === 1) {
if (fastForwardIntervalRef.current) {
console.log("Long right key released, stopping fast forward.");
clearInterval(fastForwardIntervalRef.current);
fastForwardIntervalRef.current = null;
}
@@ -82,8 +81,6 @@ export const useTVRemoteHandler = () => {
return;
}
console.log("TV Event:", event);
switch (event.eventType) {
case "select":
togglePlayPause();

View File

@@ -50,6 +50,7 @@
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "3.31.1",
"react-native-svg": "^15.12.0",
"react-native-toast-message": "^2.3.3",
"react-native-web": "~0.19.10",
"zustand": "^5.0.6"
},

View File

@@ -10,7 +10,10 @@ const STORAGE_KEYS = {
} as const;
// --- Type Definitions (aligned with api.ts) ---
export type PlayRecord = ApiPlayRecord;
export interface PlayRecord extends ApiPlayRecord {
introEndTime?: number;
outroStartTime?: number;
}
export interface FavoriteItem {
id: string;

View File

@@ -1,8 +1,9 @@
import { create } from "zustand";
import Toast from "react-native-toast-message";
import { AVPlaybackStatus, Video } from "expo-av";
import { RefObject } from "react";
import { api, VideoDetail as ApiVideoDetail, SearchResult } from "@/services/api";
import { PlayRecordManager } from "@/services/storage";
import { PlayRecord, PlayRecordManager } from "@/services/storage";
interface Episode {
url: string;
@@ -32,6 +33,8 @@ interface PlayerState {
seekPosition: number;
progressPosition: number;
initialPosition: number;
introEndTime?: number;
outroStartTime?: number;
setVideoRef: (ref: RefObject<Video>) => void;
loadVideo: (source: string, id: string, episodeIndex: number, position?: number) => Promise<void>;
switchSource: (newSourceIndex: number) => Promise<void>;
@@ -44,8 +47,12 @@ interface PlayerState {
setShowEpisodeModal: (show: boolean) => void;
setShowSourceModal: (show: boolean) => void;
setShowNextEpisodeOverlay: (show: boolean) => void;
setIntroEndTime: () => void;
setOutroStartTime: () => void;
reset: () => void;
_seekTimeout?: NodeJS.Timeout;
// Internal helper
_savePlayRecord: (updates?: Partial<PlayRecord>) => void;
}
const usePlayerStore = create<PlayerState>((set, get) => ({
@@ -65,6 +72,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
seekPosition: 0,
progressPosition: 0,
initialPosition: 0,
introEndTime: undefined,
outroStartTime: undefined,
_seekTimeout: undefined,
setVideoRef: (ref) => set({ videoRef: ref }),
@@ -85,6 +94,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
const searchResults = await api.searchVideos(videoDetail.videoInfo.title);
const sources = searchResults.results.filter((r) => r.title === videoDetail.videoInfo.title);
const currentSourceIndex = sources.findIndex((s) => s.source === source && s.id.toString() === id);
const playRecord = await PlayRecordManager.get(source, id);
set({
detail: { videoInfo: videoDetail.videoInfo, episodes, sources },
@@ -93,6 +103,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
currentSourceIndex: currentSourceIndex !== -1 ? currentSourceIndex : 0,
currentEpisodeIndex: episodeIndex,
isLoading: false,
introEndTime: playRecord?.introEndTime,
outroStartTime: playRecord?.outroStartTime,
});
} catch (error) {
console.error("Failed to load video details", error);
@@ -175,6 +187,56 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
set({ _seekTimeout: timeoutId });
},
setIntroEndTime: () => {
const { status, detail } = get();
if (status?.isLoaded && detail) {
const introEndTime = status.positionMillis;
set({ introEndTime });
get()._savePlayRecord({ introEndTime });
Toast.show({
type: "success",
text1: "设置成功",
text2: "片头时间已记录。",
});
}
},
setOutroStartTime: () => {
const { status, detail } = get();
if (status?.isLoaded && detail) {
const outroStartTime = status.positionMillis;
set({ outroStartTime });
get()._savePlayRecord({ outroStartTime });
Toast.show({
type: "success",
text1: "设置成功",
text2: "片尾时间已记录。",
});
}
},
_savePlayRecord: (updates = {}) => {
const { detail, currentEpisodeIndex, episodes, status, introEndTime, outroStartTime } = get();
if (detail && status?.isLoaded) {
const { videoInfo } = detail;
const existingRecord = {
introEndTime,
outroStartTime,
};
PlayRecordManager.save(videoInfo.source, videoInfo.id, {
title: videoInfo.title,
cover: videoInfo.cover || "",
index: currentEpisodeIndex,
total_episodes: episodes.length,
play_time: status.positionMillis,
total_time: status.durationMillis || 0,
source_name: videoInfo.source_name,
...existingRecord,
...updates,
});
}
},
handlePlaybackStatusUpdate: (newStatus) => {
if (!newStatus.isLoaded) {
if (newStatus.error) {
@@ -184,35 +246,34 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
return;
}
const progressPosition = newStatus.durationMillis ? newStatus.positionMillis / newStatus.durationMillis : 0;
set({ status: newStatus, progressPosition });
const { detail, currentEpisodeIndex, episodes, outroStartTime, playEpisode } = get();
if (outroStartTime && newStatus.positionMillis >= outroStartTime) {
if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
return; // Stop further processing for this update
}
}
const { detail, currentEpisodeIndex, episodes } = get();
if (detail && newStatus.durationMillis) {
const { videoInfo } = detail;
PlayRecordManager.save(videoInfo.source, videoInfo.id, {
title: videoInfo.title,
cover: videoInfo.cover || "",
index: currentEpisodeIndex,
total_episodes: episodes.length,
play_time: newStatus.positionMillis,
total_time: newStatus.durationMillis,
source_name: videoInfo.source_name,
});
get()._savePlayRecord();
const isNearEnd = newStatus.positionMillis / newStatus.durationMillis > 0.95;
if (isNearEnd && currentEpisodeIndex < episodes.length - 1) {
if (isNearEnd && currentEpisodeIndex < episodes.length - 1 && !outroStartTime) {
set({ showNextEpisodeOverlay: true });
} else {
set({ showNextEpisodeOverlay: false });
}
}
if (newStatus.didJustFinish) {
const { playEpisode, currentEpisodeIndex, episodes } = get();
if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
}
}
const progressPosition = newStatus.durationMillis ? newStatus.positionMillis / newStatus.durationMillis : 0;
set({ status: newStatus, progressPosition });
},
setLoading: (loading) => set({ isLoading: loading }),
@@ -235,6 +296,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
showSourceModal: false,
showNextEpisodeOverlay: false,
initialPosition: 0,
introEndTime: undefined,
outroStartTime: undefined,
});
},
}));

View File

@@ -14,7 +14,7 @@ interface SettingsState {
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
apiBaseUrl: '',
apiBaseUrl: 'https://orion-tv.edu.deal',
isModalVisible: false,
loadSettings: async () => {
const settings = await SettingsManager.get();

View File

@@ -7108,6 +7108,11 @@ react-native-svg@^15.12.0:
css-tree "^1.1.3"
warn-once "0.1.1"
react-native-toast-message@^2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/react-native-toast-message/-/react-native-toast-message-2.3.3.tgz#e301508d386a9902ff6b4559ecc6674f8cfdf97a"
integrity sha512-4IIUHwUPvKHu4gjD0Vj2aGQzqPATiblL1ey8tOqsxOWRPGGu52iIbL8M/mCz4uyqecvPdIcMY38AfwRuUADfQQ==
react-native-web@~0.19.10:
version "0.19.13"
resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.19.13.tgz#2d84849bf0251ec0e3a8072fda7f9a7c29375331"