mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-04 03:36:29 +08:00
Add toast notifications for intro and outro time settings, update player store and media button for new time tracking
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user