This commit is contained in:
Fredrik Burmester
2024-10-06 15:11:06 +02:00
parent 0233862fc1
commit 862e783de1
8 changed files with 484 additions and 360 deletions

View File

@@ -1,31 +1,30 @@
import { Controls } from "@/components/video-player/Controls";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { settingsAtom, useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Video, { OnProgressData, VideoRef } from "react-native-video";
import settings from "./(tabs)/(home)/settings";
import iosFmp4 from "@/utils/profiles/iosFmp4";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import {
PlaybackType,
usePlaySettings,
} from "@/providers/PlaySettingsProvider";
import { StatusBar, View } from "react-native";
import React from "react";
import { Controls } from "@/components/video-player/Controls";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { useSharedValue } from "react-native-reanimated";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import * as Haptics from "expo-haptics";
import { useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Dimensions, Pressable, StatusBar, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
export default function page() {
const { playSettings, setPlaySettings, playUrl, reportStopPlayback } =
@@ -38,24 +37,31 @@ export default function page() {
const poster = usePoster(playSettings, api);
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
const windowDimensions = Dimensions.get("window");
const screenDimensions = Dimensions.get("screen");
const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN
);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
useEffect(() => {
console.log("play-video ~", playUrl);
});
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
return null;
const togglePlay = useCallback(
(ticks: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
console.log("togglePlay", ticks);
if (isPlaying) {
setIsPlaying(false);
videoRef.current?.pause();
reportPlaybackProgress({
api,
@@ -65,6 +71,7 @@ export default function page() {
IsPaused: true,
});
} else {
setIsPlaying(true);
videoRef.current?.resume();
reportPlaybackProgress({
api,
@@ -78,15 +85,19 @@ export default function page() {
[isPlaying, api, playSettings?.item?.Id, videoRef]
);
const play = useCallback(() => {
setIsPlaying(true);
videoRef.current?.resume();
}, [videoRef]);
useEffect(() => {
if (!isPlaying) {
togglePlay(playSettings.item?.UserData?.PlaybackPositionTicks || 0);
}
}, [isPlaying]);
play();
}, []);
const onProgress = useCallback(
(data: OnProgressData) => {
if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBuffering(data.playableDuration === 0);
@@ -104,27 +115,65 @@ export default function page() {
[playSettings?.item.Id, isPlaying, api]
);
useEffect(() => {
const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => {
setOrientation(
orientationToOrientationLock(event.orientationInfo.orientation)
);
});
ScreenOrientation.getOrientationAsync().then((orientation) => {
setOrientation(orientationToOrientationLock(orientation));
});
return () => {
orientationSubscription.remove();
};
}, []);
const isLandscape = useMemo(() => {
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
? true
: false;
}, [orientation]);
return (
<View className="relative h-screen w-screen flex flex-col items-center justify-center">
<View
style={{
width: screenDimensions.width,
height: screenDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<StatusBar hidden />
<Video
ref={videoRef}
source={videoSource}
paused={!isPlaying}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeArea ? "cover" : "contain"}
onProgress={onProgress}
onLoad={(data) => {}}
onError={() => {}}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => setIsPlaying(state.isPlaying)}
/>
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full"
>
<Video
ref={videoRef}
source={videoSource}
paused={!isPlaying}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => setIsPlaying(state.isPlaying)}
/>
</Pressable>
<Controls
item={playSettings.item}
videoRef={videoRef}
@@ -133,6 +182,12 @@ export default function page() {
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
isLandscape={isLandscape}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
/>
</View>
);

View File

@@ -1,7 +1,8 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
export type Bitrate = {
key: string;
@@ -9,7 +10,7 @@ export type Bitrate = {
height?: number;
};
const BITRATES: Bitrate[] = [
export const BITRATES: Bitrate[] = [
{
key: "Max",
value: undefined,
@@ -42,17 +43,11 @@ const BITRATES: Bitrate[] = [
];
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;
selected: Bitrate;
inverted?: boolean;
}
export const BitrateSelector: React.FC<Props> = ({
onChange,
selected,
inverted,
...props
}) => {
export const BitrateSelector: React.FC<Props> = ({ inverted, ...props }) => {
const { setPlaySettings, playSettings } = usePlaySettings();
const sorted = useMemo(() => {
if (inverted)
return BITRATES.sort(
@@ -63,6 +58,18 @@ export const BitrateSelector: React.FC<Props> = ({
);
}, []);
const selected = useMemo(() => {
return sorted.find((b) => b.value === playSettings?.bitrate?.value);
}, [playSettings?.bitrate]);
// Set default bitrate on load
useEffect(() => {
setPlaySettings((prev) => ({
...prev,
bitrate: BITRATES[0],
}));
}, []);
return (
<View
className="flex shrink"
@@ -77,7 +84,7 @@ export const BitrateSelector: React.FC<Props> = ({
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{BITRATES.find((b) => b.value === selected.value)?.key}
{selected?.key}
</Text>
</TouchableOpacity>
</View>
@@ -96,7 +103,10 @@ export const BitrateSelector: React.FC<Props> = ({
<DropdownMenu.Item
key={b.key}
onSelect={() => {
onChange(b);
setPlaySettings((prev) => ({
...prev,
bitrate: b,
}));
}}
>
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>

View File

@@ -13,13 +13,14 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom, useAtomValue } from "jotai";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
@@ -29,7 +30,6 @@ import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
@@ -112,14 +112,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const insets = useSafeAreaInsets();
// useFocusEffect(
// useCallback(() => {
// return () => {
// setPlaySettings(null);
// };
// }, [setPlaySettings])
// );
return (
<View
className="flex-1 relative"
@@ -171,11 +163,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && (
<View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector
className="mr-1"
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<BitrateSelector className="mr-1" />
<MediaSourceSelector className="mr-1" />
<AudioTrackSelector className="mr-1" />
<SubtitleTrackSelector />

View File

@@ -63,7 +63,10 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
}, [url]);
const onPress = async () => {
if (!url || !item) return;
if (!url || !item) {
console.warn("No URL or item provided to PlayButton");
return;
}
if (!client) {
router.push("/play-video");
return;

View File

@@ -4,11 +4,7 @@ import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
@@ -16,30 +12,28 @@ import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
BackHandler,
Dimensions,
Share,
TouchableOpacity,
View,
} from "react-native";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
import Animated, {
runOnJS,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { OnProgressData, ReactVideoProps, VideoRef } from "react-native-video";
import { VideoRef } from "react-native-video";
import { Text } from "../common/Text";
import { itemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader";
import { Text } from "../common/Text";
const windowDimensions = Dimensions.get("window");
const screenDimensions = Dimensions.get("screen");
interface Props {
item: BaseItemDto;
@@ -49,6 +43,12 @@ interface Props {
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
progress: SharedValue<number>;
isBuffering: boolean;
showControls: boolean;
setShowControls: (shown: boolean) => void;
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
isLandscape: boolean;
}
export const Controls: React.FC<Props> = ({
@@ -58,7 +58,13 @@ export const Controls: React.FC<Props> = ({
isPlaying,
isSeeking,
progress,
isBuffering,
cacheProgress,
showControls,
setShowControls,
isLandscape,
ignoreSafeAreas,
setIgnoreSafeAreas,
}) => {
const [settings] = useSettings();
const [api] = useAtom(apiAtom);
@@ -66,60 +72,62 @@ export const Controls: React.FC<Props> = ({
const segments = useSegments();
const insets = useSafeAreaInsets();
const screenDimensions = Dimensions.get("screen");
const op = useSharedValue<number>(1);
const tr = useSharedValue<number>(10);
const animatedStyles = useAnimatedStyle(() => {
return {
opacity: op.value,
};
});
const animatedTopStyles = useAnimatedStyle(() => {
return {
opacity: op.value,
transform: [
{
translateY: -tr.value,
},
],
};
});
const animatedBottomStyles = useAnimatedStyle(() => {
return {
opacity: op.value,
transform: [
{
translateY: tr.value,
},
],
};
});
useEffect(() => {
if (showControls || isBuffering) {
op.value = withTiming(1, { duration: 200 });
tr.value = withTiming(0, { duration: 200 });
} else {
op.value = withTiming(0, { duration: 200 });
tr.value = withTiming(10, { duration: 200 });
}
}, [showControls, isBuffering]);
const { previousItem, nextItem } = useAdjacentEpisodes({ item });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
useTrickplay(item);
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBufferingState] = useState(true);
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN
);
// Seconds
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0);
const [currentTime, setCurrentTime] = useState(0); // Seconds
const [remainingTime, setRemainingTime] = useState(0); // Seconds
const min = useSharedValue(0);
const max = useSharedValue(item.RunTimeTicks || 0);
const [dimensions, setDimensions] = useState({
window: windowDimensions,
screen: screenDimensions,
});
useEffect(() => {
const dimensionsSubscription = Dimensions.addEventListener(
"change",
({ window, screen }) => {
setDimensions({ window, screen });
}
);
const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => {
setOrientation(
orientationToOrientationLock(event.orientationInfo.orientation)
);
});
ScreenOrientation.getOrientationAsync().then((orientation) => {
setOrientation(orientationToOrientationLock(orientation));
});
return () => {
dimensionsSubscription.remove();
orientationSubscription.remove();
};
}, []);
const from = useMemo(() => segments[2], [segments]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - current);
const remaining = ticksToSeconds(maxValue - currentProgress);
setCurrentTime(current);
setRemainingTime(remaining);
@@ -153,13 +161,6 @@ export const Controls: React.FC<Props> = ({
[updateTimes]
);
const isLandscape = useMemo(() => {
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
? true
: false;
}, [orientation]);
useEffect(() => {
if (item) {
progress.value = item?.UserData?.PlaybackPositionTicks || 0;
@@ -173,6 +174,9 @@ export const Controls: React.FC<Props> = ({
progress.value = value;
isSeeking.value = false;
videoRef.current?.seek(value / 10000000);
setTimeout(() => {
videoRef.current?.resume();
}, 200);
};
const handleSliderChange = (value: number) => {
@@ -182,7 +186,7 @@ export const Controls: React.FC<Props> = ({
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
isSeeking.value = true;
}, []);
}, [showControls]);
const handleSkipBackward = useCallback(async () => {
if (!settings) return;
@@ -190,6 +194,9 @@ export const Controls: React.FC<Props> = ({
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
setTimeout(() => {
videoRef.current?.resume();
}, 200);
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
@@ -202,6 +209,9 @@ export const Controls: React.FC<Props> = ({
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
setTimeout(() => {
videoRef.current?.resume();
}, 200);
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
@@ -222,230 +232,280 @@ export const Controls: React.FC<Props> = ({
router.push(url);
}, [nextItem, from, router]);
const toggleIgnoreSafeArea = useCallback(() => {
setIgnoreSafeArea((prev) => !prev);
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
}, []);
return (
<View className="absolute h-screen w-screen top-0 left-0">
<View className="relative">
{(showControls || isBuffering) && (
<View
pointerEvents="none"
className=" bg-black/50 z-0 w-screen h-screen absolute top-0 left-0"
></View>
)}
{isBuffering && (
<View
pointerEvents="none"
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
>
<Loader />
</View>
)}
{showSkipButton && (
<View
style={[
{
position: "absolute",
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
)}
{showSkipCreditButton && (
<View
pointerEvents={showSkipCreditButton ? "auto" : "none"}
className={`z-10 absolute bottom-16 right-4 ${
showSkipCreditButton ? "opacity-100" : "opacity-0"
}`}
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
)}
<View className="absolute top-16 right-4 flex flex-row items-center space-x-2 z-10">
<TouchableOpacity
onPress={toggleIgnoreSafeArea}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeArea ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
pointerEvents={showControls ? "auto" : "none"}
className={`absolute bottom-4 left-0 w-screen p-4 ${
showControls ? "opacity-100" : "opacity-0"
}`}
<View
style={[
{
position: "absolute",
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
},
]}
>
<View
style={[
{
position: "absolute",
bottom: isLandscape ? insets.bottom + 55 : insets.bottom + 97,
right: isLandscape ? insets.right : insets.right,
},
]}
className={`z-10 p-4
${showSkipButton ? "opacity-100" : "opacity-0"}
`}
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
)}
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
)}
{item?.Type === "Audio" && (
<Text className="text-xs opacity-50">{item?.Album}</Text>
)}
</View>
<View
className={`flex ${
isLandscape
? "flex-row space-x-6 py-2 px-4 rounded-full"
: "flex-col-reverse py-4 px-4 rounded-2xl"
}
items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={handleGoToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay(progress.value);
}}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={handleGoToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
);
<View
style={{
position: "absolute",
bottom: isLandscape ? insets.bottom + 52 : insets.bottom + 94,
right: isLandscape ? insets.right : insets.right,
height: 70,
}}
pointerEvents={showSkipCreditButton ? "auto" : "none"}
className={`z-10 p-4 ${
showSkipCreditButton ? "opacity-100" : "opacity-0"
}`}
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
<Pressable
onPress={() => {
toggleControls();
}}
>
<Animated.View
style={[
{
position: "absolute",
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
},
animatedStyles,
]}
className={`bg-black/50 z-0`}
></Animated.View>
</Pressable>
<View
style={{
position: "absolute",
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
}}
pointerEvents="none"
className={`flex flex-col items-center justify-center
${isBuffering ? "opacity-100" : "opacity-0"}
`}
>
<Loader />
</View>
<Animated.View
style={[
{
position: "absolute",
top: insets.top,
right: insets.right,
},
animatedTopStyles,
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4`}
>
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</Animated.View>
<Animated.View
style={[
{
position: "absolute",
width: screenDimensions.width - insets.left - insets.right,
maxHeight: screenDimensions.height,
left: insets.left,
bottom: insets.bottom,
},
animatedBottomStyles,
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-col p-4 `}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
)}
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
)}
{item?.Type === "Audio" && (
<Text className="text-xs opacity-50">{item?.Album}</Text>
)}
</View>
<View
className={`flex ${
isLandscape
? "flex-row space-x-6 py-2 px-4 rounded-full"
: "flex-col-reverse py-4 px-4 rounded-2xl"
}
items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={handleGoToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay(progress.value);
}}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={handleGoToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)}
</Text>
</View>
</View>
</View>
</View>
</Animated.View>
</View>
);
};

View File

@@ -63,6 +63,9 @@ export const useCreditSkipper = (
if (!creditTimestamps || !videoRef.current) return;
try {
videoRef.current.seek(creditTimestamps.Credits.End);
setTimeout(() => {
videoRef.current?.resume();
}, 200);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}

View File

@@ -59,6 +59,9 @@ export const useIntroSkipper = (
if (!introTimestamps || !videoRef.current) return;
try {
videoRef.current.seek(introTimestamps.IntroEnd);
setTimeout(() => {
videoRef.current?.resume();
}, 200);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}

View File

@@ -17,13 +17,14 @@ import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { Bitrate } from "@/components/BitrateSelector";
export type PlaybackType = {
item?: BaseItemDto | null;
mediaSource?: MediaSourceInfo | null;
subtitleIndex?: number | null;
audioIndex?: number | null;
quality?: any | null;
bitrate?: Bitrate | null;
};
type PlaySettingsContextType = {
@@ -64,6 +65,7 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
useEffect(() => {
const fetchPlayUrl = async () => {
console.log("something changed, fetching url", playSettings?.item?.Id);
if (!api || !user || !settings) {
setPlayUrl(null);
return;
@@ -80,7 +82,7 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
item: playSettings?.item,
mediaSourceId: playSettings?.mediaSource?.Id,
startTimeTicks: 0,
maxStreamingBitrate: 0,
maxStreamingBitrate: playSettings?.bitrate?.value,
audioStreamIndex: playSettings?.audioIndex
? playSettings?.audioIndex
: 0,