Compare commits

...

2 Commits

Author SHA1 Message Date
Uruk
a41802f30e fix: Improves video player reliability and performance
Prevents crashes by adding safeguards that check if video is loaded before calling player methods

Removes performance monitoring hook that was causing unnecessary overhead during playback

Reorganizes code structure by removing excessive comment sections and consolidating related functionality for better maintainability

Updates Biome linter to latest version for improved code formatting and analysis
2025-08-11 01:03:06 +02:00
Uruk
6686da2bea refactor: Refactors video player with state consolidation and performance optimizations
Consolidates scattered video state into a unified reducer pattern for better state management and predictability.

Adds performance monitoring hooks to identify rendering bottlenecks and implements memory cleanup mechanisms including periodic cache clearing and proper component disposal.

Optimizes VLC initialization with reduced network caching settings and improved track pre-selection logic.

Wraps unsafe player method calls with error-safe helpers to prevent crashes from missing or failed operations.

Improves data fetching with proper abort controller cleanup and consolidated playback state reporting.
2025-08-10 23:48:32 +02:00
4 changed files with 439 additions and 370 deletions

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-native/no-inline-styles */
import { import {
type BaseItemDto, type BaseItemDto,
type MediaSourceInfo, type MediaSourceInfo,
@@ -13,7 +14,14 @@ import {
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { router, useGlobalSearchParams, useNavigation } from "expo-router"; import { router, useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native"; import { Alert, Platform, View } from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
@@ -47,49 +55,74 @@ const downloadProvider = !Platform.isTV
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas"; const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
export default function page() { /* Playback state reducer to consolidate related state */
interface VideoState {
isPlaying: boolean;
isMuted: boolean;
isBuffering: boolean;
isVideoLoaded: boolean;
isPipStarted: boolean;
}
type VideoAction =
| { type: "PLAYING_CHANGED"; value: boolean }
| { type: "BUFFERING_CHANGED"; value: boolean }
| { type: "VIDEO_LOADED" }
| { type: "MUTED_CHANGED"; value: boolean }
| { type: "PIP_CHANGED"; value: boolean };
const videoReducer = (state: VideoState, action: VideoAction): VideoState => {
switch (action.type) {
case "PLAYING_CHANGED":
return { ...state, isPlaying: action.value };
case "BUFFERING_CHANGED":
return { ...state, isBuffering: action.value };
case "VIDEO_LOADED":
// Mark video as loaded and buffering false here
return { ...state, isVideoLoaded: true, isBuffering: false };
case "MUTED_CHANGED":
return { ...state, isMuted: action.value };
case "PIP_CHANGED":
return { ...state, isPipStarted: action.value };
default:
return state;
}
};
const initialVideoState: VideoState = {
isPlaying: false,
isMuted: false,
isBuffering: true,
isVideoLoaded: false,
isPipStarted: false,
};
export default function DirectPlayerPage() {
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation();
/* Consolidated video playback state */
const [videoState, dispatch] = useReducer(videoReducer, initialVideoState);
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => { const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
// Load persisted state from storage return storage.getBoolean(IGNORE_SAFE_AREAS_KEY) ?? false;
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
return saved ?? false;
}); });
const [isPlaying, setIsPlaying] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true); const insets = useSafeAreaInsets();
const [isVideoLoaded, setIsVideoLoaded] = useState(false); const [settings] = useSettings();
const [isPipStarted, setIsPipStarted] = useState(false);
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0); const cacheProgress = useSharedValue(0);
const lightHapticFeedback = useHaptic("light");
const VolumeManager = Platform.isTV const VolumeManager = Platform.isTV
? null ? null
: require("react-native-volume-manager"); : require("react-native-volume-manager");
const getDownloadedItem = downloadProvider.useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
lightHapticFeedback();
}, []);
// Persist ignoreSafeAreas state whenever it changes
useEffect(() => {
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
}, [ignoreSafeAreas]);
const { const {
itemId, itemId,
audioIndex: audioIndexStr, audioIndex: audioIndexStr,
@@ -105,71 +138,82 @@ export default function page() {
mediaSourceId: string; mediaSourceId: string;
bitrateValue: string; bitrateValue: string;
offline: string; offline: string;
/** Playback position in ticks. */
playbackPosition?: string; playbackPosition?: string;
}>(); }>();
const [settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true";
const audioIndex = audioIndexStr const offline = offlineStr === "true";
? Number.parseInt(audioIndexStr, 10) const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
: undefined; const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const subtitleIndex = subtitleIndexStr
? Number.parseInt(subtitleIndexStr, 10)
: -1;
const bitrateValue = bitrateValueStr const bitrateValue = bitrateValueStr
? Number.parseInt(bitrateValueStr, 10) ? parseInt(bitrateValueStr, 10)
: BITRATES[0].value; : BITRATES[0].value;
const setShowControls = useCallback(
(show: boolean) => {
_setShowControls(show);
lightHapticFeedback();
},
[lightHapticFeedback],
);
useEffect(() => {
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
}, [ignoreSafeAreas]);
/* Fetch the item info */
const [item, setItem] = useState<BaseItemDto | null>(null); const [item, setItem] = useState<BaseItemDto | null>(null);
const [itemStatus, setItemStatus] = useState({ const [itemStatus, setItemStatus] = useState({
isLoading: true, isLoading: true,
isError: false, isError: false,
}); });
const getDownloadedItem = downloadProvider.useDownload();
/** Gets the initial playback position from the URL or the item's user data. */
const getInitialPlaybackTicks = useCallback((): number => { const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) { if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10); return parseInt(playbackPositionFromUrl, 10);
} }
return item?.UserData?.PlaybackPositionTicks ?? 0; return item?.UserData?.PlaybackPositionTicks ?? 0;
}, [playbackPositionFromUrl, item]); }, [playbackPositionFromUrl, item]);
useEffect(() => { useEffect(() => {
const fetchItemData = async () => { if (!itemId) return;
const controller = new AbortController();
(async () => {
setItemStatus({ isLoading: true, isError: false }); setItemStatus({ isLoading: true, isError: false });
try { try {
let fetchedItem: BaseItemDto | null = null; let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) { if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId); const data = await getDownloadedItem.getDownloadedItem(itemId);
if (data) fetchedItem = data.item as BaseItemDto; fetchedItem = data?.item as BaseItemDto | null;
} else { } else {
const res = await getUserLibraryApi(api!).getItem({ const res = await getUserLibraryApi(api!).getItem(
itemId, { itemId, userId: user?.Id },
userId: user?.Id, { signal: controller.signal },
}); );
fetchedItem = res.data; fetchedItem = res.data;
} }
setItem(fetchedItem); if (!controller.signal.aborted) {
setItemStatus({ isLoading: false, isError: false }); setItem(fetchedItem);
setItemStatus({ isLoading: false, isError: false });
}
} catch (error) { } catch (error) {
console.error("Failed to fetch item:", error); if (!controller.signal.aborted) {
setItemStatus({ isLoading: false, isError: true }); console.error("Failed to fetch item:", error);
setItemStatus({ isLoading: false, isError: true });
}
} }
}; })();
if (itemId) { return () => controller.abort();
fetchItemData(); }, [itemId, offline, api, user?.Id, getDownloadedItem]);
}
}, [itemId, offline, api, user?.Id]);
/* Fetch stream info */
interface Stream { interface Stream {
mediaSource: MediaSourceInfo; mediaSource: MediaSourceInfo;
sessionId: string; sessionId: string;
url: string; url: string;
} }
const [stream, setStream] = useState<Stream | null>(null); const [stream, setStream] = useState<Stream | null>(null);
const [streamStatus, setStreamStatus] = useState({ const [streamStatus, setStreamStatus] = useState({
isLoading: true, isLoading: true,
@@ -179,18 +223,16 @@ export default function page() {
useEffect(() => { useEffect(() => {
const fetchStreamData = async () => { const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false }); setStreamStatus({ isLoading: true, isError: false });
const native = await generateDeviceProfile();
try { try {
const native = await generateDeviceProfile();
let result: Stream | null = null; let result: Stream | null = null;
if (offline && !Platform.isTV) { if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId); const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return; if (!data?.mediaSource) return;
const url = await getDownloadedFileUrl(data.item.Id!); const url = await getDownloadedFileUrl(data.item.Id!);
if (item) { result = { mediaSource: data.mediaSource, sessionId: "", url };
result = { mediaSource: data.mediaSource, sessionId: "", url }; } else if (item) {
}
} else {
if (!item) return;
const res = await getStreamUrl({ const res = await getStreamUrl({
api, api,
item, item,
@@ -198,7 +240,7 @@ export default function page() {
userId: user?.Id, userId: user?.Id,
audioStreamIndex: audioIndex, audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue, maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId, mediaSourceId,
subtitleStreamIndex: subtitleIndex, subtitleStreamIndex: subtitleIndex,
deviceProfile: native, deviceProfile: native,
}); });
@@ -213,6 +255,7 @@ export default function page() {
} }
result = { mediaSource, sessionId, url }; result = { mediaSource, sessionId, url };
} }
setStream(result); setStream(result);
setStreamStatus({ isLoading: false, isError: false }); setStreamStatus({ isLoading: false, isError: false });
} catch (error) { } catch (error) {
@@ -220,219 +263,310 @@ export default function page() {
setStreamStatus({ isLoading: false, isError: true }); setStreamStatus({ isLoading: false, isError: true });
} }
}; };
fetchStreamData(); fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
useEffect(() => {
if (!stream) return;
const reportPlaybackStart = async () => {
await getPlaystateApi(api!).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
});
};
reportPlaybackStart();
}, [stream]);
const togglePlay = async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
reportPlaybackProgress();
} else {
videoRef.current?.play();
await getPlaystateApi(api!).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
});
}
};
const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!,
});
revalidateProgressCache();
}, [ }, [
itemId,
mediaSourceId,
bitrateValue,
api, api,
item, item,
mediaSourceId, user?.Id,
stream,
progress,
offline, offline,
revalidateProgressCache, getInitialPlaybackTicks,
audioIndex,
subtitleIndex,
]); ]);
const stop = useCallback(() => { const revalidateProgressCache = useInvalidatePlaybackProgressCache();
reportPlaybackStopped();
setIsPlaybackStopped(true);
videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]);
useEffect(() => { /* Memoized playback state info for reporting */
const beforeRemoveListener = navigation.addListener("beforeRemove", stop); const currentPlayStateInfo = useMemo(() => {
return () => { if (!stream) return null;
beforeRemoveListener();
};
}, [navigation, stop]);
const currentPlayStateInfo = () => {
if (!stream) return;
return { return {
itemId: item?.Id!, itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined, audioStreamIndex: audioIndex,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, subtitleStreamIndex: subtitleIndex,
mediaSourceId: mediaSourceId, mediaSourceId,
positionTicks: msToTicks(progress.get()), positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying, isPaused: !videoState.isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", playMethod: stream.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId, playSessionId: stream.sessionId,
isMuted: isMuted, isMuted: videoState.isMuted,
canSeek: true, canSeek: true,
repeatMode: RepeatMode.RepeatNone, repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default, playbackOrder: PlaybackOrder.Default,
}; };
};
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent;
if (isBuffering) {
setIsBuffering(false);
}
progress.set(currentTime);
// Update the playback position in the URL.
router.setParams({
playbackPosition: msToTicks(currentTime).toString(),
});
if (offline) return;
if (!item?.Id || !stream) return;
reportPlaybackProgress();
},
[
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
isPlaying,
stream,
isSeeking,
isPlaybackStopped,
isBuffering,
],
);
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
const reportPlaybackProgress = useCallback(async () => {
if (!api || offline || !stream) return;
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
});
}, [ }, [
api,
isPlaying,
offline,
stream,
item?.Id, item?.Id,
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
mediaSourceId, mediaSourceId,
progress, progress,
videoState.isPlaying,
videoState.isMuted,
stream,
]); ]);
/** Gets the initial playback position in seconds. */ /* Playback progress reporting */
const startPosition = useMemo(() => { const reportPlaybackProgress = useCallback(async () => {
if (offline) return 0; if (!api || offline || !stream || !currentPlayStateInfo) return;
return ticksToSeconds(getInitialPlaybackTicks()); await getPlaystateApi(api).reportPlaybackProgress({
}, [offline, getInitialPlaybackTicks]); playbackProgressInfo: currentPlayStateInfo as PlaybackProgressInfo,
});
}, [api, offline, stream, currentPlayStateInfo]);
/* Report playback stopped */
const reportPlaybackStopped = useCallback(async () => {
if (offline || !stream) return;
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId,
positionTicks: msToTicks(progress.get()),
playSessionId: stream.sessionId,
});
revalidateProgressCache();
}, [
api,
item?.Id,
mediaSourceId,
progress,
stream,
offline,
revalidateProgressCache,
]);
/* Toggle play/pause */
const togglePlay = useCallback(async () => {
lightHapticFeedback();
const playing = videoState.isPlaying;
dispatch({ type: "PLAYING_CHANGED", value: !playing });
if (playing) {
await videoRef.current?.pause();
reportPlaybackProgress();
} else {
await videoRef.current?.play();
if (currentPlayStateInfo) {
await getPlaystateApi(api!).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo as PlaybackStartInfo,
});
}
}
}, [
videoState.isPlaying,
lightHapticFeedback,
reportPlaybackProgress,
api,
currentPlayStateInfo,
]);
/* Stop playback and clean up */
const stop = useCallback(() => {
reportPlaybackStopped();
setIsPlaybackStopped(true);
videoRef.current?.stop();
}, [reportPlaybackStopped]);
useEffect(() => {
const unsubscribe = navigation.addListener("beforeRemove", stop);
return unsubscribe;
}, [navigation, stop]);
/* VLC init options optimized for performance */
const optimizedInitOptions = useMemo(() => {
const opts = [`--sub-text-scale=${settings.subtitleSize}`];
// Reduce buffering memory usage
opts.push("--network-caching=300", "--file-caching=300");
if (Platform.OS === "android") opts.push("--aout=opensles");
if (Platform.OS === "ios") opts.push("--ios-hw-decoding");
// Pre-selection of audio & subtitle tracks handled here
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
const allAudio =
stream?.mediaSource.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
)?.sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) ?? [];
if (subtitleIndex >= 0) {
const chosenSubtitleTrack = allSubs.find(
(s) => s.Index === subtitleIndex,
);
const textSubs = allSubs.filter((s) => s.IsTextSubtitleStream);
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
const finalIdx = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack);
opts.push(`--sub-track=${finalIdx}`);
}
}
if (notTranscoding && audioIndex !== undefined) {
const chosenAudioTrack = allAudio.find((a) => a.Index === audioIndex);
if (chosenAudioTrack)
opts.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
return opts;
}, [settings.subtitleSize, stream?.mediaSource, subtitleIndex, audioIndex]);
/* On Picture-In-Picture started or stopped */
const onPipStarted = useCallback((e: PipStartedPayload) => {
dispatch({ type: "PIP_CHANGED", value: e.nativeEvent.pipStarted });
}, []);
/* Progress event handler */
const onProgress = useCallback(
(data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return;
if (videoState.isBuffering)
dispatch({ type: "BUFFERING_CHANGED", value: false });
const { currentTime } = data.nativeEvent;
progress.set(currentTime);
router.setParams({ playbackPosition: msToTicks(currentTime).toString() });
if (!offline) reportPlaybackProgress();
},
[
isSeeking,
isPlaybackStopped,
progress,
offline,
reportPlaybackProgress,
videoState.isBuffering,
],
);
/* Playback state changes */
const onPlaybackStateChanged = useCallback(
async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
switch (state) {
case "Playing":
dispatch({ type: "PLAYING_CHANGED", value: true });
await activateKeepAwakeAsync();
reportPlaybackProgress();
break;
case "Paused":
dispatch({ type: "PLAYING_CHANGED", value: false });
await deactivateKeepAwake();
reportPlaybackProgress();
break;
default:
dispatch({ type: "BUFFERING_CHANGED", value: !!isBuffering });
dispatch({ type: "PLAYING_CHANGED", value: !!isPlaying });
}
},
[reportPlaybackProgress],
);
/* Safe wrapper for player methods that skips calls if video not loaded */
const safeMethod =
<T extends unknown[]>(
fn: ((...args: T) => any) | undefined,
name: string,
) =>
async (...args: T) => {
// New safeguard: skip calling if video not loaded yet
if (!videoState.isVideoLoaded) {
writeToLog("WARN", `${name} skipped - video not loaded yet`);
return;
}
if (!fn) {
writeToLog("ERROR", `${name} fn missing`, {
isVideoLoaded: videoState.isVideoLoaded,
});
return;
}
try {
return await fn(...args);
} catch (error) {
writeToLog("ERROR", `Error in ${name}`, {
error,
isVideoLoaded: videoState.isVideoLoaded,
});
}
};
const play = useCallback(
() => safeMethod(videoRef.current?.play, "play")(),
[videoRef],
);
const pause = useCallback(
() => safeMethod(videoRef.current?.pause, "pause")(),
[videoRef],
);
const startPictureInPicture = useCallback(
() => safeMethod(videoRef.current?.startPictureInPicture, "PiP")(),
[videoRef],
);
const seek = useCallback(
(t: number) => safeMethod(videoRef.current?.seekTo, "seek")(t),
[videoRef],
);
const getAudioTracks = useCallback(
() => safeMethod(videoRef.current?.getAudioTracks, "getAudioTracks")(),
[videoRef],
);
const getSubtitleTracks = useCallback(
() =>
safeMethod(videoRef.current?.getSubtitleTracks, "getSubtitleTracks")(),
[videoRef],
);
const setAudioTrack = useCallback(
(i: number) =>
safeMethod(videoRef.current?.setAudioTrack, "setAudioTrack")(i),
[videoRef],
);
const setSubtitleTrack = useCallback(
(i: number) =>
safeMethod(videoRef.current?.setSubtitleTrack, "setSubtitleTrack")(i),
[videoRef],
);
const setSubtitleURL = useCallback(
(url: string, n: string) =>
safeMethod(videoRef.current?.setSubtitleURL, "setSubtitleURL")(url, n),
[videoRef],
);
/* Volume handlers */
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
const volumeUpCb = useCallback(async () => { const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return; if (Platform.isTV) return;
const { volume } = await VolumeManager.getVolume();
try { await VolumeManager.setVolume(Math.min(volume + 0.1, 1));
const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.min(currentVolume + 0.1, 1.0);
await VolumeManager.setVolume(newVolume);
} catch (error) {
console.error("Error adjusting volume:", error);
}
}, []); }, []);
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
const toggleMuteCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const currentVolumePercent = currentVolume * 100;
if (currentVolumePercent > 0) {
// Currently not muted, so mute
setPreviousVolume(currentVolumePercent);
await VolumeManager.setVolume(0);
setIsMuted(true);
} else {
// Currently muted, so restore previous volume
const volumeToRestore = previousVolume || 50; // Default to 50% if no previous volume
await VolumeManager.setVolume(volumeToRestore / 100);
setPreviousVolume(null);
setIsMuted(false);
}
} catch (error) {
console.error("Error toggling mute:", error);
}
}, [previousVolume]);
const volumeDownCb = useCallback(async () => { const volumeDownCb = useCallback(async () => {
if (Platform.isTV) return; if (Platform.isTV) return;
const { volume } = await VolumeManager.getVolume();
try { await VolumeManager.setVolume(Math.max(volume - 0.1, 0));
const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
console.log(
"Volume Down",
Math.round(currentVolume * 100),
"→",
Math.round(newVolume * 100),
);
await VolumeManager.setVolume(newVolume);
} catch (error) {
console.error("Error adjusting volume:", error);
}
}, []); }, []);
const setVolumeCb = useCallback(async (v: number) => {
const setVolumeCb = useCallback(async (newVolume: number) => {
if (Platform.isTV) return; if (Platform.isTV) return;
await VolumeManager.setVolume(Math.max(0, Math.min(v, 100)) / 100);
try {
const clampedVolume = Math.max(0, Math.min(newVolume, 100));
console.log("Setting volume to", clampedVolume);
await VolumeManager.setVolume(clampedVolume / 100);
} catch (error) {
console.error("Error setting volume:", error);
}
}, []); }, []);
const toggleMuteCb = useCallback(async () => {
if (Platform.isTV) return;
const { volume } = await VolumeManager.getVolume();
const percent = volume * 100;
if (percent > 0) {
setPreviousVolume(percent);
await VolumeManager.setVolume(0);
dispatch({ type: "MUTED_CHANGED", value: true });
} else {
const restore = previousVolume || 50;
await VolumeManager.setVolume(restore / 100);
setPreviousVolume(null);
dispatch({ type: "MUTED_CHANGED", value: false });
}
}, [previousVolume]);
useWebSocket({ useWebSocket({
isPlaying: isPlaying, isPlaying: videoState.isPlaying,
togglePlay: togglePlay, togglePlay,
stopPlayback: stop, stopPlayback: stop,
offline, offline,
toggleMute: toggleMuteCb, toggleMute: toggleMuteCb,
@@ -441,118 +575,44 @@ export default function page() {
setVolume: setVolumeCb, setVolume: setVolumeCb,
}); });
const onPlaybackStateChanged = useCallback( /* Calculate start position in seconds */
async (e: PlaybackStatePayload) => { const startPosition = useMemo(
const { state, isBuffering, isPlaying } = e.nativeEvent; () => (offline ? 0 : ticksToSeconds(getInitialPlaybackTicks())),
if (state === "Playing") { [offline, getInitialPlaybackTicks],
setIsPlaying(true);
reportPlaybackProgress();
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
if (state === "Paused") {
setIsPlaying(false);
reportPlaybackProgress();
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
},
[reportPlaybackProgress],
); );
const allAudio = /* Conditionally render based on loading and error state */
stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio",
) || [];
// Move all the external subtitles last, because vlc places them last.
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle",
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex,
);
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) { if (itemStatus.isError || streamStatus.isError) {
return ( return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'> <View className='w-screen h-screen items-center justify-center bg-black'>
<Text className='text-white'>{t("player.error")}</Text> <Text className='text-white'>{t("player.error")}</Text>
</View> </View>
); );
} }
// Then show loader while either side is still fetching or data isnt present
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) { if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
// …loader UI…
return ( return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'> <View className='w-screen h-screen items-center justify-center bg-black'>
<Loader /> <Loader />
</View> </View>
); );
} }
if (itemStatus.isError || streamStatus.isError) const allSubs =
return ( stream?.mediaSource.MediaStreams?.filter((s) => s.Type === "Subtitle") ||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'> [];
<Text className='text-white'>{t("player.error")}</Text> const externalSubtitles = allSubs
</View> .filter((s) => s.DeliveryMethod === "External")
); .map((s) => ({
name: s.DisplayTitle,
DeliveryUrl: api?.basePath + s.DeliveryUrl,
}));
return ( return (
<View style={{ flex: 1, backgroundColor: "black" }}> <View style={{ flex: 1, backgroundColor: "black" }}>
<View <View
style={{ style={{
display: "flex",
width: "100%", width: "100%",
height: "100%", height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
paddingLeft: ignoreSafeAreas ? 0 : insets.left, paddingLeft: ignoreSafeAreas ? 0 : insets.left,
paddingRight: ignoreSafeAreas ? 0 : insets.right, paddingRight: ignoreSafeAreas ? 0 : insets.right,
}} }}
@@ -560,21 +620,20 @@ export default function page() {
<VlcPlayerView <VlcPlayerView
ref={videoRef} ref={videoRef}
source={{ source={{
uri: stream?.url || "", uri: stream.url,
autoplay: true, autoplay: true,
isNetwork: true, isNetwork: true,
startPosition, startPosition,
externalSubtitles, externalSubtitles,
initOptions, initOptions: optimizedInitOptions,
}} }}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress} onVideoProgress={onProgress}
progressUpdateInterval={1000} progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged} onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted} onPipStarted={onPipStarted}
onVideoLoadEnd={() => { // Mark video as loaded on load end to enable player method calls safely
setIsVideoLoaded(true); onVideoLoadEnd={() => dispatch({ type: "VIDEO_LOADED" })}
}}
onVideoError={(e) => { onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(
@@ -585,33 +644,39 @@ export default function page() {
}} }}
/> />
</View> </View>
{!isPipStarted && isMounted === true && item && (
{!videoState.isPipStarted && (
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream.mediaSource}
item={item} item={item}
videoRef={videoRef} videoRef={videoRef}
togglePlay={togglePlay} togglePlay={togglePlay}
isPlaying={isPlaying} isPlaying={videoState.isPlaying}
isSeeking={isSeeking} isSeeking={isSeeking}
progress={progress} progress={progress}
cacheProgress={cacheProgress} cacheProgress={cacheProgress}
isBuffering={isBuffering} isBuffering={videoState.isBuffering}
showControls={showControls} showControls={showControls}
setShowControls={setShowControls} setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas} setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas} ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded} isVideoLoaded={videoState.isVideoLoaded}
startPictureInPicture={videoRef.current?.startPictureInPicture} startPictureInPicture={startPictureInPicture}
play={videoRef.current?.play} play={play}
pause={videoRef.current?.pause} pause={pause}
seek={videoRef.current?.seekTo} seek={seek}
enableTrickplay={true} enableTrickplay
getAudioTracks={videoRef.current?.getAudioTracks} // Pass undefined for player methods until the video is loaded to avoid crashes
getSubtitleTracks={videoRef.current?.getSubtitleTracks} getAudioTracks={videoState.isVideoLoaded ? getAudioTracks : undefined}
getSubtitleTracks={
videoState.isVideoLoaded ? getSubtitleTracks : undefined
}
offline={offline} offline={offline}
setSubtitleTrack={videoRef.current?.setSubtitleTrack} setSubtitleTrack={
setSubtitleURL={videoRef.current?.setSubtitleURL} videoState.isVideoLoaded ? setSubtitleTrack : undefined
setAudioTrack={videoRef.current?.setAudioTrack} }
setSubtitleURL={videoState.isVideoLoaded ? setSubtitleURL : undefined}
setAudioTrack={videoState.isVideoLoaded ? setAudioTrack : undefined}
isVlc isVlc
/> />
)} )}

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.1.3/schema.json", "$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
"files": { "files": {
"includes": [ "includes": [
"**/*", "**/*",

View File

@@ -85,7 +85,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.0", "@babel/core": "^7.20.0",
"@biomejs/biome": "^2.1.3", "@biomejs/biome": "^2.1.4",
"@react-native-community/cli": "^19", "@react-native-community/cli": "^19",
"@react-native-tvos/config-tv": "^0.1.1", "@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
@@ -94,7 +94,7 @@
"@types/react-test-renderer": "^19.0.0", "@types/react-test-renderer": "^19.0.0",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.1.2", "lint-staged": "^16.1.5",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.1.1", "react-test-renderer": "19.1.1",
"typescript": "~5.8.3", "typescript": "~5.8.3",
@@ -297,23 +297,23 @@
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], "@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"@biomejs/biome": ["@biomejs/biome@2.1.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.1.3", "@biomejs/cli-darwin-x64": "2.1.3", "@biomejs/cli-linux-arm64": "2.1.3", "@biomejs/cli-linux-arm64-musl": "2.1.3", "@biomejs/cli-linux-x64": "2.1.3", "@biomejs/cli-linux-x64-musl": "2.1.3", "@biomejs/cli-win32-arm64": "2.1.3", "@biomejs/cli-win32-x64": "2.1.3" }, "bin": { "biome": "bin/biome" } }, "sha512-KE/tegvJIxTkl7gJbGWSgun7G6X/n2M6C35COT6ctYrAy7SiPyNvi6JtoQERVK/VRbttZfgGq96j2bFmhmnH4w=="], "@biomejs/biome": ["@biomejs/biome@2.1.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.1.4", "@biomejs/cli-darwin-x64": "2.1.4", "@biomejs/cli-linux-arm64": "2.1.4", "@biomejs/cli-linux-arm64-musl": "2.1.4", "@biomejs/cli-linux-x64": "2.1.4", "@biomejs/cli-linux-x64-musl": "2.1.4", "@biomejs/cli-win32-arm64": "2.1.4", "@biomejs/cli-win32-x64": "2.1.4" }, "bin": { "biome": "bin/biome" } }, "sha512-QWlrqyxsU0FCebuMnkvBIkxvPqH89afiJzjMl+z67ybutse590jgeaFdDurE9XYtzpjRGTI1tlUZPGWmbKsElA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LFLkSWRoSGS1wVUD/BE6Nlt2dSn0ulH3XImzg2O/36BoToJHKXjSxzPEMAqT9QvwVtk7/9AQhZpTneERU9qaXA=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sCrNENE74I9MV090Wq/9Dg7EhPudx3+5OiSoQOkIe3DLPzFARuL1dOwCWhKCpA3I5RHmbrsbNSRfZwCabwd8Qg=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Q/4OTw8P9No9QeowyxswcWdm0n2MsdCwWcc5NcKQQvzwPjwuPdf8dpPPf4r+x0RWKBtl1FLiAUtJvBlri6DnYw=="], "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-gOEICJbTCy6iruBywBDcG4X5rHMbqCPs3clh3UQ+hRKlgvJTk4NHWQAyHOXvaLe+AxD1/TNX1jbZeffBJzcrOw=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-2hS6LgylRqMFmAZCOFwYrf77QMdUwJp49oe8PX/O8+P2yKZMSpyQTf3Eo5ewnsMFUEmYbPOskafdV1ds1MZMJA=="], "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-juhEkdkKR4nbUi5k/KRp1ocGPNWLgFRD4NrHZSveYrD6i98pyvuzmS9yFYgOZa5JhaVqo0HPnci0+YuzSwT2fw=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-KXouFSBnoxAWZYDQrnNRzZBbt5s9UJkIm40hdvSL9mBxSSoxRFQJbtg1hP3aa8A2SnXyQHxQfpiVeJlczZt76w=="], "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-nYr7H0CyAJPaLupFE2cH16KZmRC5Z9PEftiA2vWxk+CsFkPZQ6dBRdcC6RuS+zJlPc/JOd8xw3uCCt9Pv41WvQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-NxlSCBhLvQtWGagEztfAZ4WcE1AkMTntZV65ZvR+J9jp06+EtOYEBPQndA70ZGhHbEDG57bR6uNvqkd1WrEYVA=="], "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Eoy9ycbhpJVYuR+LskV9s3uyaIkp89+qqgqhGQsWnp/I02Uqg2fXFblHJOpGZR8AxdB9ADy87oFVxn9MpFKUrw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-KaLAxnROouzIWtl6a0Y88r/4hW5oDUJTIqQorOTVQITaKQsKjZX4XCUmHIhdEk8zMnaiLZzRTAwk1yIAl+mIew=="], "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lvwvb2SQQHctHUKvBKptR6PLFCM7JfRjpCCrDaTmvB7EeZ5/dQJPhTYBf36BE/B4CRWR2ZiBLRYhK7hhXBCZAg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-V9CUZCtWH4u0YwyCYbQ3W5F4ZGPWp2C2TYcsiWFNNyRfmOW1j/TY/jAurl33SaRjgZPO5UUhGyr9m6BN9t84NQ=="], "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-3WRYte7orvyi6TRfIZkDN9Jzoogbv+gSvR+b9VOXUg1We1XrjBg6WljADeVEaKTvOcpVdH0a90TwyOQ6ue4fGw=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-dxy599q6lgp8ANPpR8sDMscwdp9oOumEsVXuVCVT9N2vAho8uYXlCz53JhxX6LtJOXaE73qzgkGQ7QqvFlMC0g=="], "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-tBc+W7anBPSFXGAoQW+f/+svkpt8/uXfRwDzN1DvnatkRMt16KIYpEi/iw8u9GahJlFv98kgHcIrSsZHZTR0sw=="],
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.9.2", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-IZZKllcaqCGsKIgeXmYFGU95IXxbBpXtwKws4Lg2GJw/qqAYYsPFEl0JBvnymSD7G1zkHYEilg5UHuTd0NmX7A=="], "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.9.2", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-IZZKllcaqCGsKIgeXmYFGU95IXxbBpXtwKws4Lg2GJw/qqAYYsPFEl0JBvnymSD7G1zkHYEilg5UHuTd0NmX7A=="],
@@ -1335,9 +1335,9 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lint-staged": ["lint-staged@16.1.2", "", { "dependencies": { "chalk": "^5.4.1", "commander": "^14.0.0", "debug": "^4.4.1", "lilconfig": "^3.1.3", "listr2": "^8.3.3", "micromatch": "^4.0.8", "nano-spawn": "^1.0.2", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-sQKw2Si2g9KUZNY3XNvRuDq4UJqpHwF0/FQzZR2M7I5MvtpWvibikCjUVJzZdGE0ByurEl3KQNvsGetd1ty1/Q=="], "lint-staged": ["lint-staged@16.1.5", "", { "dependencies": { "chalk": "^5.5.0", "commander": "^14.0.0", "debug": "^4.4.1", "lilconfig": "^3.1.3", "listr2": "^9.0.1", "micromatch": "^4.0.8", "nano-spawn": "^1.0.2", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-uAeQQwByI6dfV7wpt/gVqg+jAPaSp8WwOA8kKC/dv1qw14oGpnpAisY65ibGHUGDUv0rYaZ8CAJZ/1U8hUvC2A=="],
"listr2": ["listr2@8.3.3", "", { "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ=="], "listr2": ["listr2@9.0.1", "", { "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -1987,7 +1987,7 @@
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="], "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -2081,6 +2081,8 @@
"@react-native-community/cli-doctor/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "@react-native-community/cli-doctor/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@react-native-community/cli-doctor/yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="],
"@react-native-community/cli-server-api/pretty-format": ["pretty-format@26.6.2", "", { "dependencies": { "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", "ansi-styles": "^4.0.0", "react-is": "^17.0.1" } }, "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg=="], "@react-native-community/cli-server-api/pretty-format": ["pretty-format@26.6.2", "", { "dependencies": { "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", "ansi-styles": "^4.0.0", "react-is": "^17.0.1" } }, "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg=="],
"@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
@@ -2167,7 +2169,7 @@
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"lint-staged/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], "lint-staged/chalk": ["chalk@5.5.0", "", {}, "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg=="],
"lint-staged/commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="], "lint-staged/commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
@@ -2201,6 +2203,8 @@
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
"postcss-load-config/yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],

View File

@@ -100,7 +100,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.0", "@babel/core": "^7.20.0",
"@biomejs/biome": "^2.1.3", "@biomejs/biome": "^2.1.4",
"@react-native-community/cli": "^19", "@react-native-community/cli": "^19",
"@react-native-tvos/config-tv": "^0.1.1", "@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
@@ -109,7 +109,7 @@
"@types/react-test-renderer": "^19.0.0", "@types/react-test-renderer": "^19.0.0",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.1.2", "lint-staged": "^16.1.5",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.1.1", "react-test-renderer": "19.1.1",
"typescript": "~5.8.3" "typescript": "~5.8.3"