fix: tv playback (#820)

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Signed-off-by: lancechant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Uruk <contact@uruk.dev>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
This commit is contained in:
lance chant
2025-08-07 10:12:40 +02:00
committed by GitHub
parent 89fd7f0e34
commit 89b34eddc1
68 changed files with 1412 additions and 1786 deletions

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react";
import { Platform, type ViewProps } from "react-native";
import { Platform } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
@@ -11,12 +11,6 @@ import GoogleCast, {
} from "react-native-google-cast";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
width?: number;
height?: number;
background?: "blur" | "transparent";
}
export function Chromecast({
width = 48,
height = 48,

View File

@@ -1 +0,0 @@
export * from "zeego/context-menu";

View File

@@ -1,10 +1,8 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useAtom } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import Animated, {
Easing,
@@ -17,7 +15,6 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
@@ -37,12 +34,7 @@ export const PlayButton: React.FC<Props> = ({
selectedOptions,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
const { t } = useTranslation();
const [colorAtom] = useAtom(itemThemeColorAtom);
const _api = useAtomValue(apiAtom);
const _user = useAtomValue(userAtom);
const router = useRouter();

View File

@@ -56,7 +56,7 @@ export function InfiniteHorizontalScroll({
};
});
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey,
queryFn,
getNextPageParam: (lastPage, pages) => {

View File

@@ -2,7 +2,7 @@ import { useRouter, useSegments } from "expo-router";
import type React from "react";
import { type PropsWithChildren, useCallback, useMemo } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import * as ContextMenu from "@/components/ContextMenu";
import * as ContextMenu from "zeego/context-menu";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {

View File

@@ -1,10 +1,5 @@
import { Platform, Text as RNText, type TextProps } from "react-native";
import { UITextView } from "react-native-uitextview";
export function Text(
props: TextProps & {
uiTextView?: boolean;
},
) {
export function Text(props: TextProps) {
const { style, ...otherProps } = props;
if (Platform.isTV)
return (
@@ -16,7 +11,7 @@ export function Text(
);
return (
<UITextView
<RNText
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}

View File

@@ -60,9 +60,9 @@ interface DownloadCardProps extends TouchableOpacityProps {
}
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { processes, startDownload } = useDownload();
const { startDownload } = useDownload();
const router = useRouter();
const { removeProcess, setProcesses } = useDownload();
const { removeProcess } = useDownload();
const [settings] = useSettings();
const queryClient = useQueryClient();

View File

@@ -23,7 +23,7 @@ interface EpisodeCardProps extends TouchableOpacityProps {
item: BaseItemDto;
}
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();

View File

@@ -72,7 +72,6 @@ export const FilterSheet = <T,>({
renderItemLabel,
showSearch = true,
multiple = false,
...props
}: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["80%"], []);

View File

@@ -50,11 +50,8 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => {
const {
jellyseerrUser,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { jellyseerrRegion: region, jellyseerrLocale: locale } =
useJellyseerr();
const { t } = useTranslation();
const releases = useMemo(

View File

@@ -40,7 +40,6 @@ const ParallaxSlideShow = <T,>({
renderItem,
keyExtractor,
onEndReached,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => {
const insets = useSafeAreaInsets();

View File

@@ -38,16 +38,7 @@ const RequestModal = forwardRef<
Props & Omit<ViewProps, "id">
>(
(
{
id,
title,
requestBody,
type,
isAnime = false,
onRequested,
onDismiss,
...props
},
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
ref,
) => {
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();

View File

@@ -24,7 +24,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
[slide],
);
const { data, isFetching, isLoading } = useQuery({
const { data } = useQuery({
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
queryFn: async () => {
return jellyseerrApi?.getGenreSliders(

View File

@@ -11,11 +11,7 @@ import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
const { jellyseerrApi } = useJellyseerr();
const {
data: details,
isLoading,
isError,
} = useQuery({
const { data: details } = useQuery({
queryKey: [
"jellyseerr",
"detail",
@@ -57,11 +53,7 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
}) => {
const { jellyseerrApi } = useJellyseerr();
const {
data: requests,
isLoading,
isError,
} = useQuery({
const { data: requests } = useQuery({
queryKey: ["jellyseerr", "recent_requests"],
queryFn: async () => jellyseerrApi?.requests(),
enabled: !!jellyseerrApi,

View File

@@ -82,7 +82,6 @@ const ListItemContent = ({
showArrow,
iconAfter,
children,
...props
}: Props) => {
return (
<>

View File

@@ -9,7 +9,7 @@ interface Props extends ViewProps {
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text uiTextView selectable className='font-bold text-2xl mb-1'>
<Text selectable className='font-bold text-2xl mb-1'>
{item?.Name}
</Text>
<Text className='opacity-50'>{item?.ProductionYear}</Text>

View File

@@ -38,7 +38,6 @@ const JellyseerrPoster: React.FC<Props> = ({
horizontal,
showDownloadInfo,
mediaRequest,
...props
}) => {
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
const loadingOpacity = useSharedValue(1);

View File

@@ -40,7 +40,7 @@ export const SearchItemWrapper = <T,>({
onEndReachedThreshold={1}
onEndReached={onEndReached}
//@ts-ignore
renderItem={({ item, index }) => (item ? renderItem(item) : <></>)}
renderItem={({ item }) => (item ? renderItem(item) : null)}
/>
</>
);

View File

@@ -12,7 +12,7 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text uiTextView className='font-bold text-2xl' selectable>
<Text className='font-bold text-2xl' selectable>
{item?.Name}
</Text>
<View className='flex flex-row items-center mb-1'>

View File

@@ -57,7 +57,7 @@ const JellyseerrSeasonEpisodes: React.FC<{
);
};
const RenderItem = ({ item, index }: any) => {
const RenderItem = ({ item }: any) => {
const {
jellyseerrApi,
jellyseerrRegion: region,

View File

@@ -27,7 +27,7 @@ type Props = {
export const seasonIndexAtom = atom<SeasonIndexState>({});
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
export const SeasonPicker: React.FC<Props> = ({ item }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);

View File

@@ -10,7 +10,7 @@ import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {}
export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
export const AppLanguageSelector: React.FC<Props> = () => {
const isTv = Platform.isTV;
const [settings, updateSettings] = useSettings();
const { t } = useTranslation();

View File

@@ -8,7 +8,7 @@ import { ListItem } from "../list/ListItem";
export const Dashboard = () => {
const [settings, _updateSettings] = useSettings();
const { sessions = [], isLoading } = useSessions({} as useSessionsProps);
const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter();
const { t } = useTranslation();

View File

@@ -1,3 +1,3 @@
export default function DownloadSettings({ ...props }) {
export default function DownloadSettings() {
return null;
}

View File

@@ -14,12 +14,8 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const JellyseerrSettings = () => {
const {
jellyseerrApi,
jellyseerrUser,
setJellyseerrUser,
clearAllJellyseerData,
} = useJellyseerr();
const { jellyseerrUser, setJellyseerrUser, clearAllJellyseerData } =
useJellyseerr();
const { t } = useTranslation();

View File

@@ -16,7 +16,7 @@ export const StorageSettings = () => {
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { data: size, isLoading: appSizeLoading } = useQuery({
const { data: size } = useQuery({
queryKey: ["appSize", appSizeUsage],
queryFn: async () => {
const app = await appSizeUsage;

View File

@@ -1,16 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useEffect, useRef } from "react";
import { Platform, StyleSheet, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { useSharedValue } from "react-native-reanimated";
import type { VolumeResult } from "react-native-volume-manager";
const VolumeManager = Platform.isTV
? null
: require("react-native-volume-manager");
import { Ionicons } from "@expo/vector-icons";
import type { VolumeResult } from "react-native-volume-manager";
interface AudioSliderProps {
setVisibility: (show: boolean) => void;
}
@@ -47,7 +46,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
const handleValueChange = async (value: number) => {
volume.value = value;
await VolumeManager.setVolume(value / 100);
// await VolumeManager.setVolume(value / 100);
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
VolumeManager.showNativeVolumeUI({ enabled: false });
@@ -55,7 +54,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
useEffect(() => {
if (isTv) return;
const volumeListener = VolumeManager.addVolumeListener(
const _volumeListener = VolumeManager.addVolumeListener(
(result: VolumeResult) => {
volume.value = result.volume * 100;
setVisibility(true);
@@ -73,7 +72,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
);
return () => {
volumeListener.remove();
// volumeListener.remove();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}

View File

@@ -20,6 +20,7 @@ import {
import {
Platform,
TouchableOpacity,
useTVEventHandler,
useWindowDimensions,
View,
} from "react-native";
@@ -156,6 +157,134 @@ export const Controls: FC<Props> = ({
prefetchAllTrickplayImages();
}, []);
const remoteScrubProgress = useSharedValue<number | null>(null);
const isRemoteScrubbing = useSharedValue(false);
const SCRUB_INTERVAL = isVlc ? secondsToMs(10) : msToTicks(secondsToMs(10));
const [showRemoteBubble, setShowRemoteBubble] = useState(false);
const [longPressScrubMode, setLongPressScrubMode] = useState<
"FF" | "RW" | null
>(null);
useTVEventHandler((evt) => {
if (!evt) return;
switch (evt.eventType) {
case "longLeft": {
setLongPressScrubMode((prev) => (!prev ? "RW" : null));
break;
}
case "longRight": {
setLongPressScrubMode((prev) => (!prev ? "FF" : null));
break;
}
case "left":
case "right": {
isRemoteScrubbing.value = true;
setShowRemoteBubble(true);
const direction = evt.eventType === "left" ? -1 : 1;
const base = remoteScrubProgress.value ?? progress.value;
const updated = Math.max(
min.value,
Math.min(max.value, base + direction * SCRUB_INTERVAL),
);
remoteScrubProgress.value = updated;
const progressInTicks = isVlc ? msToTicks(updated) : updated;
calculateTrickplayUrl(progressInTicks);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
break;
}
case "select": {
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
progress.value = remoteScrubProgress.value;
const seekTarget = isVlc
? Math.max(0, remoteScrubProgress.value)
: Math.max(0, ticksToSeconds(remoteScrubProgress.value));
seek(seekTarget);
if (isPlaying) play();
isRemoteScrubbing.value = false;
remoteScrubProgress.value = null;
setShowRemoteBubble(false);
} else {
togglePlay();
}
break;
}
case "down":
case "up":
// cancel scrubbing on other directions
isRemoteScrubbing.value = false;
remoteScrubProgress.value = null;
setShowRemoteBubble(false);
break;
default:
break;
}
if (!showControls) toggleControls();
});
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
useEffect(() => {
let isActive = true;
let seekTime = 10;
const scrubWithLongPress = () => {
if (!isActive || !longPressScrubMode) return;
setIsSliding(true);
const scrubFn =
longPressScrubMode === "FF" ? handleSeekForward : handleSeekBackward;
scrubFn(seekTime);
seekTime *= 1.1;
longPressTimeoutRef.current = setTimeout(scrubWithLongPress, 300);
};
if (longPressScrubMode) {
isActive = true;
scrubWithLongPress();
}
return () => {
isActive = false;
setIsSliding(false);
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
longPressTimeoutRef.current = null;
}
};
}, [longPressScrubMode]);
const effectiveProgress = useSharedValue(0);
// Recompute progress whenever remote scrubbing is active
useAnimatedReaction(
() => ({
isScrubbing: isRemoteScrubbing.value,
scrub: remoteScrubProgress.value,
actual: progress.value,
}),
(current) => {
effectiveProgress.value =
current.isScrubbing && current.scrub != null
? current.scrub
: current.actual;
},
[],
);
useEffect(() => {
if (item) {
progress.value = isVlc
@@ -374,20 +503,19 @@ export const Controls: FC<Props> = ({
pause();
isSeeking.value = true;
}, [showControls, isPlaying]);
}, [showControls, isPlaying, pause]);
const handleSliderComplete = useCallback(
async (value: number) => {
isSeeking.value = false;
progress.value = value;
setIsSliding(false);
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
if (wasPlayingRef.current) {
play();
}
},
[isVlc],
[isVlc, seek, play],
);
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
@@ -424,7 +552,43 @@ export const Controls: FC<Props> = ({
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings, isPlaying, isVlc]);
}, [settings, isPlaying, isVlc, play, seek]);
const handleSeekBackward = useCallback(
async (seconds: number) => {
wasPlayingRef.current = isPlaying;
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? Math.max(0, curr - secondsToMs(seconds))
: Math.max(0, ticksToSeconds(curr) - seconds);
seek(newTime);
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
},
[isPlaying, isVlc, seek],
);
const handleSeekForward = useCallback(
async (seconds: number) => {
wasPlayingRef.current = isPlaying;
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(seconds)
: ticksToSeconds(curr) + seconds;
seek(Math.max(0, newTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
},
[isPlaying, isVlc, seek],
);
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) {
@@ -446,7 +610,7 @@ export const Controls: FC<Props> = ({
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings, isPlaying, isVlc]);
}, [settings, isPlaying, isVlc, play, seek]);
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
@@ -667,80 +831,87 @@ export const Controls: FC<Props> = ({
>
<BrightnessSlider />
</View>
<TouchableOpacity onPress={handleSkipBackward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
opacity: showControls ? 1 : 0,
}}
>
<Ionicons
name='refresh-outline'
size={50}
color='white'
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.rewindSkipTime}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay();
}}
>
{!isBuffering ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={50}
color='white'
{!Platform.isTV && (
<TouchableOpacity onPress={handleSkipBackward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
opacity: showControls ? 1 : 0,
}}
/>
) : (
<Loader size={"large"} />
)}
</TouchableOpacity>
>
<Ionicons
name='refresh-outline'
size={50}
color='white'
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.rewindSkipTime}
</Text>
</View>
</TouchableOpacity>
)}
<TouchableOpacity onPress={handleSkipForward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
opacity: showControls ? 1 : 0,
<View
style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}
>
<TouchableOpacity
onPress={() => {
togglePlay();
}}
>
<Ionicons name='refresh-outline' size={50} color='white' />
<Text
{!isBuffering ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={50}
color='white'
style={{
opacity: showControls ? 1 : 0,
}}
/>
) : (
<Loader size={"large"} />
)}
</TouchableOpacity>
</View>
{!Platform.isTV && (
<TouchableOpacity onPress={handleSkipForward}>
<View
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
position: "relative",
justifyContent: "center",
alignItems: "center",
opacity: showControls ? 1 : 0,
}}
>
{settings?.forwardSkipTime}
</Text>
</View>
</TouchableOpacity>
<Ionicons name='refresh-outline' size={50} color='white' />
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.forwardSkipTime}
</Text>
</View>
</TouchableOpacity>
)}
<View
style={{
position: "absolute",
@@ -850,10 +1021,12 @@ export const Controls: FC<Props> = ({
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => isSliding && memoizedRenderBubble()}
renderBubble={() =>
(isSliding || showRemoteBubble) && memoizedRenderBubble()
}
sliderHeight={10}
thumbWidth={0}
progress={progress}
progress={effectiveProgress}
minimumValue={min}
maximumValue={max}
/>

View File

@@ -93,7 +93,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
[seasons, seasonIndex],
);
const { data: episodes, isFetching } = useQuery({
const { data: episodes } = useQuery({
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];