From c7703df3ce230f775dc62f048d8889c44f6509c3 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 15 Sep 2024 16:39:26 +0200 Subject: [PATCH] wip --- app.json | 4 +- components/CurrentlyPlayingBar.tsx | 405 ++++++++++++++++++---- components/ItemContent.tsx | 2 + components/PlayButton.tsx | 2 +- components/common/TouchableItemRouter.tsx | 87 +++-- eas.json | 4 +- providers/JellyfinProvider.tsx | 4 +- providers/PlaybackProvider.tsx | 7 +- 8 files changed, 390 insertions(+), 125 deletions(-) diff --git a/app.json b/app.json index a7b07c72..b1f99dc8 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.13.1", + "version": "0.14.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -33,7 +33,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 39, + "versionCode": 40, "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png" }, diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index ec959c64..2d4dcb50 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -6,10 +6,11 @@ import { writeToLog } from "@/utils/log"; import { runtimeTicksToMinutes, runtimeTicksToSeconds } from "@/utils/time"; import { Ionicons } from "@expo/vector-icons"; import { BlurView } from "expo-blur"; -import { useSegments } from "expo-router"; +import { useRouter, useSegments } from "expo-router"; import { useAtom } from "jotai"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + ActivityIndicator, Alert, Dimensions, Pressable, @@ -19,7 +20,9 @@ import { import { Slider } from "react-native-awesome-slider"; import "react-native-gesture-handler"; import Animated, { + SharedValue, useAnimatedStyle, + useDerivedValue, useSharedValue, withTiming, } from "react-native-reanimated"; @@ -27,9 +30,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import Video from "react-native-video"; import { Text } from "./common/Text"; import { Loader } from "./Loader"; +import { Image } from "expo-image"; +import { Api } from "@jellyfin/sdk"; +import { useQuery } from "@tanstack/react-query"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { itemRouter } from "./common/TouchableItemRouter"; export const CurrentlyPlayingBar: React.FC = () => { - const segments = useSegments(); const { currentlyPlaying, pauseVideo, @@ -40,41 +47,50 @@ export const CurrentlyPlayingBar: React.FC = () => { isPlaying, videoRef, presentFullscreenPlayer, + progressTicks, onProgress, + isBuffering: _isBuffering, + setIsBuffering, } = usePlayback(); const insets = useSafeAreaInsets(); + const segments = useSegments(); + const router = useRouter(); const [api] = useAtom(apiAtom); + const from = useMemo(() => segments[2], [segments]); + const [ignoreSafeArea, setIgnoreSafeArea] = useState(false); const screenHeight = Dimensions.get("window").height; const screenWidth = Dimensions.get("window").width; const controlsOpacity = useSharedValue(1); - const progress = useSharedValue(0); + + const progress = useSharedValue(progressTicks || 0); const min = useSharedValue(0); const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0); const sliding = useRef(false); - const hideControlsTimerRef = useRef(null); - - const from = useMemo(() => segments[2] || "(home)", [segments]); + const localIsBuffering = useSharedValue(false); + // const hideControlsTimerRef = useRef(null); const toggleIgnoreSafeArea = () => { setIgnoreSafeArea((prev) => !prev); }; const showControls = () => { - controlsOpacity.value = withTiming(1, { duration: 300 }); + controlsOpacity.value = 1; }; const hideControls = () => { - controlsOpacity.value = withTiming(0, { duration: 300 }); + controlsOpacity.value = 0; }; const animatedControlsStyle = useAnimatedStyle(() => { return { - opacity: controlsOpacity.value, + opacity: withTiming(controlsOpacity.value > 0 ? 1 : 0, { + duration: 300, + }), }; }); @@ -119,29 +135,29 @@ export const CurrentlyPlayingBar: React.FC = () => { const showControlsAndResetTimer = () => { showControls(); - resetHideControlsTimer(); + // resetHideControlsTimer(); }; - const resetHideControlsTimer = () => { - if (hideControlsTimerRef.current) { - clearTimeout(hideControlsTimerRef.current); - } - hideControlsTimerRef.current = setTimeout(() => { - hideControls(); - }, 3000); - }; + // const resetHideControlsTimer = () => { + // if (hideControlsTimerRef.current) { + // clearTimeout(hideControlsTimerRef.current); + // } + // hideControlsTimerRef.current = setTimeout(() => { + // hideControls(); + // }, 3000); + // }; - useEffect(() => { - if (controlsOpacity.value > 0) { - resetHideControlsTimer(); - } + // useEffect(() => { + // if (controlsOpacity.value > 0) { + // resetHideControlsTimer(); + // } - return () => { - if (hideControlsTimerRef.current) { - clearTimeout(hideControlsTimerRef.current); - } - }; - }, [controlsOpacity.value]); + // return () => { + // if (hideControlsTimerRef.current) { + // clearTimeout(hideControlsTimerRef.current); + // } + // }; + // }, [controlsOpacity.value]); useEffect(() => { max.value = currentlyPlaying?.item.RunTimeTicks || 0; @@ -158,24 +174,181 @@ export const CurrentlyPlayingBar: React.FC = () => { : screenWidth - (insets.left + insets.right), }; + const animatedLoaderStyle = useAnimatedStyle(() => { + return { + opacity: withTiming(localIsBuffering.value === true ? 1 : 0, { + duration: 300, + }), + }; + }); + + const animatedVideoContainerStyle = useAnimatedStyle(() => { + return { + opacity: withTiming( + controlsOpacity.value > 0 || localIsBuffering.value === true ? 0.5 : 1, + { + duration: 300, + } + ), + }; + }); + + const trickplayInfo = useMemo(() => { + if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) { + return null; + } + + const mediaSourceId = currentlyPlaying.item.Id; + const trickplayData = currentlyPlaying.item.Trickplay[mediaSourceId]; + + if (!trickplayData) { + return null; + } + + // Get the first available resolution + const firstResolution = Object.keys(trickplayData)[0]; + return firstResolution + ? { + resolution: firstResolution, + aspectRatio: + trickplayData[firstResolution].Width! / + trickplayData[firstResolution].Height!, + data: trickplayData[firstResolution], + } + : null; + }, [currentlyPlaying]); + + const [trickPlayUrl, _setTrickPlayUrl] = useState<{ + x: number; + y: number; + url: string; + } | null>(null); + + const setTrickplayUrl = ( + info: typeof trickplayInfo | null, + progress: SharedValue, + api: Api, + id: string + ) => { + if (!info || !id || !api) { + return null; + } + + const { data, resolution } = info; + const { Interval, TileWidth, TileHeight, Height, Width, ThumbnailCount } = + data; + + if ( + !Interval || + !TileWidth || + !TileHeight || + !Height || + !Width || + !ThumbnailCount || + !resolution + ) { + throw new Error("Invalid trickplay data"); + } + + const currentSecond = Math.max(0, Math.floor(progress.value / 10000000)); // Convert ticks to seconds + + const cols = TileWidth; + const rows = TileHeight; + const imagesPerTile = cols * rows; + const imageIndex = Math.floor(currentSecond / (Interval / 1000)); // Interval is in ms + const tileIndex = Math.floor(imageIndex / imagesPerTile); + + const positionInTile = imageIndex % imagesPerTile; + const rowInTile = Math.floor(positionInTile / cols); + const colInTile = positionInTile % cols; + + const res = { + x: rowInTile, + y: colInTile, + url: `${api.basePath}/Videos/${id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`, + }; + + _setTrickPlayUrl(res); + }; + + const { data: previousItem } = useQuery({ + queryKey: [ + "previousItem", + currentlyPlaying?.item.ParentId, + currentlyPlaying?.item.IndexNumber, + ], + queryFn: async () => { + if ( + !api || + !currentlyPlaying?.item.ParentId || + currentlyPlaying?.item.IndexNumber === undefined || + currentlyPlaying?.item.IndexNumber === null || + currentlyPlaying.item.IndexNumber - 2 < 0 + ) { + console.log("No previous item"); + return null; + } + + const res = await getItemsApi(api).getItems({ + parentId: currentlyPlaying.item.ParentId!, + startIndex: currentlyPlaying.item.IndexNumber! - 2, + limit: 1, + }); + + console.log( + "Prev: ", + res.data.Items?.map((i) => i.Name) + ); + return res.data.Items?.[0]; + }, + enabled: currentlyPlaying?.item.Type === "Episode", + }); + + const { data: nextItem } = useQuery({ + queryKey: [ + "nextItem", + currentlyPlaying?.item.ParentId, + currentlyPlaying?.item.IndexNumber, + ], + queryFn: async () => { + if ( + !api || + !currentlyPlaying?.item.ParentId || + currentlyPlaying?.item.IndexNumber === undefined || + currentlyPlaying?.item.IndexNumber === null + ) { + console.log("No next item"); + return null; + } + + const res = await getItemsApi(api).getItems({ + parentId: currentlyPlaying.item.ParentId!, + startIndex: currentlyPlaying.item.IndexNumber!, + limit: 1, + }); + + console.log( + "Next: ", + res.data.Items?.map((i) => i.Name) + ); + return res.data.Items?.[0]; + }, + enabled: currentlyPlaying?.item.Type === "Episode", + }); + + const prevButtonEnabled = useMemo(() => { + return !!previousItem; + }, [previousItem]); + + const nextButtonEnabled = useMemo(() => { + return !!nextItem; + }, [nextItem]); + if (!api || !currentlyPlaying) return null; return ( - - { - + { if (controlsOpacity.value > 0) { @@ -223,7 +398,10 @@ export const CurrentlyPlayingBar: React.FC = () => { showControlsAndResetTimer(); } }} - style={{ width: "100%", height: "100%" }} + style={{ + width: "100%", + height: "100%", + }} > {videoSource && ( - + { )} - + - + { + if (controlsOpacity.value === 0) return; + if (prevButtonEnabled === false) return; + if (!previousItem || !from) return; + const url = itemRouter(previousItem, from); + stopPlayback(); + // @ts-ignore + router.push(url); + }} + > + + { if (controlsOpacity.value === 0) return; const curr = await videoRef.current?.getCurrentPosition(); if (!curr) return; videoRef.current?.seek(Math.max(0, curr - 15)); - resetHideControlsTimer(); + // resetHideControlsTimer(); }} > { if (controlsOpacity.value === 0) return; if (isPlaying) pauseVideo(); else playVideo(); - resetHideControlsTimer(); + // resetHideControlsTimer(); }} > { const curr = await videoRef.current?.getCurrentPosition(); if (!curr) return; videoRef.current?.seek(Math.max(0, curr + 15)); - resetHideControlsTimer(); + // resetHideControlsTimer(); }} > - + { + if (controlsOpacity.value === 0) return; + if (nextButtonEnabled === false) return; + if (!nextItem || !from) return; + const url = itemRouter(nextItem, from); + stopPlayback(); + // @ts-ignore + router.push(url); + }} + > + + { if (controlsOpacity.value === 0) return; const tick = Math.floor(val); progress.value = tick; - resetHideControlsTimer(); + setTrickplayUrl( + trickplayInfo, + progress, + api, + currentlyPlaying.item.Id! + ); + // resetHideControlsTimer(); }} containerStyle={{ borderRadius: 100, }} - bubble={(s) => runtimeTicksToMinutes(s)} + renderBubble={() => { + if (!trickPlayUrl || !trickplayInfo) { + return null; + } + const { x, y, url } = trickPlayUrl; + + const tileWidth = 200; + const tileHeight = 200 / trickplayInfo.aspectRatio!; + return ( + + + + ); + }} sliderHeight={8} thumbWidth={0} progress={progress} @@ -418,7 +663,29 @@ export const CurrentlyPlayingBar: React.FC = () => { - + + + + + diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 2bdb18db..33642e82 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -117,6 +117,8 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { itemId: id, }); + console.log("itemID", res?.Id); + return res; }, enabled: !!id && !!api, diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index b5409af0..9428e80f 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -57,7 +57,7 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { const directStream = useMemo(() => { return !url?.includes("m3u8"); - }, []); + }, [url]); const onPress = async () => { if (!url || !item) return; diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index e78f57a4..b81afafe 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -8,6 +8,42 @@ interface Props extends TouchableOpacityProps { item: BaseItemDto; } +export const itemRouter = (item: BaseItemDto, from: string) => { + if (item.Type === "Series") { + return `/(auth)/(tabs)/${from}/series/${item.Id}`; + } + + if (item.Type === "MusicAlbum") { + return `/(auth)/(tabs)/${from}/albums/${item.Id}`; + } + + if (item.Type === "Audio") { + return `/(auth)/(tabs)/${from}/albums/${item.AlbumId}`; + } + + if (item.Type === "MusicArtist") { + return `/(auth)/(tabs)/${from}/artists/${item.Id}`; + } + + if (item.Type === "Person") { + return `/(auth)/(tabs)/${from}/actors/${item.Id}`; + } + + if (item.Type === "BoxSet") { + return `/(auth)/(tabs)/${from}/collections/${item.Id}`; + } + + if (item.Type === "UserView") { + return `/(auth)/(tabs)/${from}/collections/${item.Id}`; + } + + if (item.Type === "CollectionFolder") { + return `/(auth)/(tabs)/(libraries)/${item.Id}`; + } + + return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`; +}; + export const TouchableItemRouter: React.FC> = ({ item, children, @@ -23,54 +59,9 @@ export const TouchableItemRouter: React.FC> = ({ { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - - if (item.Type === "Series") { - router.push(`/(auth)/(tabs)/${from}/series/${item.Id}`); - return; - } - - if (item.Type === "MusicAlbum") { - router.push(`/(auth)/(tabs)/${from}/albums/${item.Id}`); - return; - } - - if (item.Type === "Audio") { - router.push(`/(auth)/(tabs)/${from}/albums/${item.AlbumId}`); - return; - } - - if (item.Type === "MusicArtist") { - router.push(`/(auth)/(tabs)/${from}/artists/${item.Id}`); - return; - } - - if (item.Type === "Person") { - router.push(`/(auth)/(tabs)/${from}/actors/${item.Id}`); - return; - } - - if (item.Type === "BoxSet") { - router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`); - return; - } - - if (item.Type === "UserView") { - router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`); - return; - } - - if (item.Type === "CollectionFolder") { - router.push(`/(auth)/(tabs)/(libraries)/${item.Id}`); - return; - } - - // Same as default - // if (item.Type === "Episode") { - // router.push(`/items/${item.Id}`); - // return; - // } - - router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`); + const url = itemRouter(item, from); + // @ts-ignore + router.push(url); }} {...props} > diff --git a/eas.json b/eas.json index f0ab0f9e..b7d775da 100644 --- a/eas.json +++ b/eas.json @@ -21,13 +21,13 @@ } }, "production": { - "channel": "0.13.1", + "channel": "0.14.0", "android": { "image": "latest" } }, "production-apk": { - "channel": "0.13.1", + "channel": "0.14.0", "android": { "buildType": "apk", "image": "latest" diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 41146805..967f6445 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -63,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.13.1" }, + clientInfo: { name: "Streamyfin", version: "0.14.0" }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, }) ); @@ -97,7 +97,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.13.1"`, + }, DeviceId="${deviceId}", Version="0.14.0"`, }; }, [deviceId]); diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index d66b6c2d..a83d3688 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -45,6 +45,8 @@ interface PlaybackContextType { dismissFullscreenPlayer: () => void; setIsFullscreen: (isFullscreen: boolean) => void; setIsPlaying: (isPlaying: boolean) => void; + isBuffering: boolean; + setIsBuffering: (val: boolean) => void; onProgress: (data: OnProgressData) => void; setVolume: (volume: number) => void; setCurrentlyPlayingState: ( @@ -70,6 +72,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ const previousVolume = useRef(null); const [isPlaying, _setIsPlaying] = useState(false); + const [isBuffering, setIsBuffering] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [progressTicks, setProgressTicks] = useState(0); const [volume, _setVolume] = useState(null); @@ -254,7 +257,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ const onProgress = useCallback( debounce((e: OnProgressData) => { _onProgress(e); - }, 1000), + }, 500), [_onProgress] ); @@ -352,6 +355,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({