diff --git a/bun.lockb b/bun.lockb index d52330da..318a72c9 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index 810b851f..cd5bacd4 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -4,6 +4,7 @@ import { useNavigationBarVisibility } from "@/hooks/useNavigationBarVisibility"; import { useTrickplay } from "@/hooks/useTrickplay"; import { apiAtom } from "@/providers/JellyfinProvider"; import { usePlayback } from "@/providers/PlaybackProvider"; +import { parseM3U8ForSubtitles } from "@/utils/hls/parseM3U8ForSubtitles"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; import { writeToLog } from "@/utils/log"; @@ -34,6 +35,16 @@ import Video from "react-native-video"; import { Text } from "./common/Text"; import { itemRouter } from "./common/TouchableItemRouter"; import { Loader } from "./Loader"; +import * as ScreenOrientation from "expo-screen-orientation"; +import { useSettings } from "@/utils/atoms/settings"; + +async function setOrientation(orientation: ScreenOrientation.OrientationLock) { + await ScreenOrientation.lockAsync(orientation); +} + +async function resetOrientation() { + await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT); +} export const CurrentlyPlayingBar: React.FC = () => { const { @@ -45,7 +56,6 @@ export const CurrentlyPlayingBar: React.FC = () => { setIsPlaying, isPlaying, videoRef, - progressTicks, onProgress, isBuffering: _isBuffering, setIsBuffering, @@ -53,6 +63,8 @@ export const CurrentlyPlayingBar: React.FC = () => { useNavigationBarVisibility(isPlaying); + const [settings] = useSettings(); + const insets = useSafeAreaInsets(); const segments = useSegments(); const router = useRouter(); @@ -69,7 +81,7 @@ export const CurrentlyPlayingBar: React.FC = () => { const screenHeight = Dimensions.get("window").height; const screenWidth = Dimensions.get("window").width; - const progress = useSharedValue(progressTicks || 0); + const progress = useSharedValue(0); const min = useSharedValue(0); const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0); const sliding = useRef(false); @@ -222,6 +234,56 @@ export const CurrentlyPlayingBar: React.FC = () => { showControls(); }, [currentlyPlaying]); + const { data: subtitleTracks } = useQuery({ + queryKey: ["subtitleTracks", currentlyPlaying?.url], + queryFn: async () => { + if (!currentlyPlaying?.url) { + console.log("No item url"); + return null; + } + + const tracks = await parseM3U8ForSubtitles(currentlyPlaying.url); + + console.log("Subtitle tracks", tracks); + return tracks; + }, + }); + + /** + * This should clean up all values if curentlyPlaying sets to null or changes + */ + useEffect(() => { + if (!currentlyPlaying) { + // Reset all local state and shared values + progress.value = 0; + min.value = 0; + max.value = 0; + cacheProgress.value = 0; + localIsBuffering.value = false; + sliding.current = false; + hideControls(); + + resetOrientation(); + } else { + // Initialize or update values based on the new currentlyPlaying item + progress.value = + currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0; + max.value = currentlyPlaying.item.RunTimeTicks || 0; + showControls(); + setOrientation( + settings?.defaultVideoOrientation || + ScreenOrientation.OrientationLock.LANDSCAPE_LEFT + ); + } + + // Cleanup function + return () => { + // Cancel any subscriptions or timers if you have any + // clearTimeout(timerId); + // unsubscribe(); + }; + }, [currentlyPlaying, settings]); + if (!api || !currentlyPlaying) return null; return ( @@ -232,68 +294,6 @@ export const CurrentlyPlayingBar: React.FC = () => { backgroundColor: "black", }} > - - - { - if (!isVisible) return; - toggleIgnoreSafeArea(); - }} - className="aspect-square rounded flex flex-col items-center justify-center p-2" - > - - - { - if (!isVisible) return; - stopPlayback(); - }} - className="aspect-square rounded flex flex-col items-center justify-center p-2" - > - - - - - - - - { - if (!isVisible) return; - skipIntro(); - }} - className="flex flex-col items-center justify-center px-2 py-1.5 bg-purple-600 rounded-full" - > - Skip intro - - - - { @@ -340,6 +340,15 @@ export const CurrentlyPlayingBar: React.FC = () => { subtitleStyle={{ fontSize: 16, }} + onTextTracks={(e) => { + console.log("onTextTracks ~", e.textTracks); + }} + onTextTrackDataChanged={(e) => { + console.log("onTextTrackDataChanged ~", e); + }} + onVideoTracks={(e) => { + console.log("onVideoTracks ~", e.videoTracks); + }} source={videoSource} onPlaybackStateChanged={(e) => { if (e.isPlaying === true) { @@ -362,7 +371,10 @@ export const CurrentlyPlayingBar: React.FC = () => { setIsPlaying(false); }} renderLoader={ - + } @@ -371,6 +383,87 @@ export const CurrentlyPlayingBar: React.FC = () => { + + + + + + + { + if (!isVisible) return; + skipIntro(); + }} + className="flex flex-col items-center justify-center px-2 py-1.5 bg-purple-600 rounded-full" + > + Skip intro + + + + + + + { + if (!isVisible) return; + toggleIgnoreSafeArea(); + }} + className="aspect-square rounded flex flex-col items-center justify-center p-2" + > + + + { + if (!isVisible) return; + stopPlayback(); + }} + className="aspect-square rounded flex flex-col items-center justify-center p-2" + > + + + + + { )} - + { - - - - ); }; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index b92779d4..92dba1a7 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -56,7 +56,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { useState(null); const [selectedAudioStream, setSelectedAudioStream] = useState(-1); const [selectedSubtitleStream, setSelectedSubtitleStream] = - useState(0); + useState(-1); const [maxBitrate, setMaxBitrate] = useState({ key: "Max", value: undefined, diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index 902ce309..8d8e577d 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -2,6 +2,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { DefaultLanguageOption, DownloadOptions, + ScreenOrientationEnum, useSettings, } from "@/utils/atoms/settings"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; @@ -21,6 +22,7 @@ import { Input } from "../common/Input"; import { useState } from "react"; import { Button } from "../Button"; import { MediaToggles } from "./MediaToggles"; +import * as ScreenOrientation from "expo-screen-orientation"; interface Props extends ViewProps {} @@ -56,6 +58,8 @@ export const SettingToggles: React.FC = ({ ...props }) => { staleTime: 0, }); + if (!settings) return null; + return ( {/* @@ -88,7 +92,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { updateSettings({ autoRotate: value })} /> @@ -102,7 +106,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { updateSettings({ openFullScreenVideoPlayerByDefault: value }) } @@ -118,7 +122,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { { updateSettings({ openInVLC: value, forceDirectPlay: value }); }} @@ -141,13 +145,13 @@ export const SettingToggles: React.FC = ({ ...props }) => { updateSettings({ usePopularPlugin: value }) } /> - {settings?.usePopularPlugin && ( + {settings.usePopularPlugin && ( {mediaListCollections?.map((mlc) => ( = ({ ...props }) => { {mlc.Name} { if (!settings.mediaListCollectionIds) { updateSettings({ @@ -171,11 +173,11 @@ export const SettingToggles: React.FC = ({ ...props }) => { updateSettings({ mediaListCollectionIds: - settings?.mediaListCollectionIds.includes(mlc.Id!) - ? settings?.mediaListCollectionIds.filter( + settings.mediaListCollectionIds.includes(mlc.Id!) + ? settings.mediaListCollectionIds.filter( (id) => id !== mlc.Id ) - : [...settings?.mediaListCollectionIds, mlc.Id!], + : [...settings.mediaListCollectionIds, mlc.Id!], }); }} /> @@ -206,7 +208,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { updateSettings({ forceDirectPlay: value }) } @@ -216,7 +218,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { @@ -229,7 +231,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { - {settings?.deviceProfile} + {settings.deviceProfile} = ({ ...props }) => { + + Video orientation + + Set the full screen video player orientation. + + + + + + + {ScreenOrientationEnum[settings.defaultVideoOrientation]} + + + + + Orientation + { + updateSettings({ + defaultVideoOrientation: + ScreenOrientation.OrientationLock.DEFAULT, + }); + }} + > + + { + ScreenOrientationEnum[ + ScreenOrientation.OrientationLock.DEFAULT + ] + } + + + { + updateSettings({ + defaultVideoOrientation: + ScreenOrientation.OrientationLock.PORTRAIT_UP, + }); + }} + > + + { + ScreenOrientationEnum[ + ScreenOrientation.OrientationLock.PORTRAIT_UP + ] + } + + + { + updateSettings({ + defaultVideoOrientation: + ScreenOrientation.OrientationLock.LANDSCAPE_LEFT, + }); + }} + > + + { + ScreenOrientationEnum[ + ScreenOrientation.OrientationLock.LANDSCAPE_LEFT + ] + } + + + { + updateSettings({ + defaultVideoOrientation: + ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT, + }); + }} + > + + { + ScreenOrientationEnum[ + ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT + ] + } + + + + + + Search engine @@ -284,7 +386,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { - {settings?.searchEngine} + {settings.searchEngine} = ({ ...props }) => { - {settings?.searchEngine === "Marlin" && ( + {settings.searchEngine === "Marlin" && ( <> @@ -346,7 +448,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { - {settings?.marlinServerUrl} + {settings.marlinServerUrl} diff --git a/hooks/useAdjacentEpisodes.ts b/hooks/useAdjacentEpisodes.ts index ed371383..6cf47b18 100644 --- a/hooks/useAdjacentEpisodes.ts +++ b/hooks/useAdjacentEpisodes.ts @@ -39,10 +39,6 @@ export const useAdjacentEpisodes = ({ limit: 1, }); - console.log( - "Prev: ", - res.data.Items?.map((i) => i.Name) - ); return res.data.Items?.[0] || null; }, enabled: currentlyPlaying?.item.Type === "Episode", @@ -71,10 +67,6 @@ export const useAdjacentEpisodes = ({ limit: 1, }); - console.log( - "Next: ", - res.data.Items?.map((i) => i.Name) - ); return res.data.Items?.[0] || null; }, enabled: currentlyPlaying?.item.Type === "Episode", diff --git a/package.json b/package.json index 1b38dfc5..7ef47c29 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "@expo/vector-icons": "^14.0.2", "@gorhom/bottom-sheet": "^4", "@jellyfin/sdk": "^0.10.0", - "@kesha-antonov/react-native-background-downloader": "^3.2.0", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/netinfo": "11.3.1", "@react-native-menu/menu": "^1.1.2", diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index d7f52acd..d98a79a3 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -25,6 +25,10 @@ import { debounce } from "lodash"; import { Alert } from "react-native"; import { OnProgressData, type VideoRef } from "react-native-video"; import { apiAtom, userAtom } from "./JellyfinProvider"; +import { + parseM3U8ForSubtitles, + SubtitleTrack, +} from "@/utils/hls/parseM3U8ForSubtitles"; export type CurrentlyPlayingState = { url: string; @@ -55,6 +59,7 @@ interface PlaybackContextType { startDownloadedFilePlayback: ( currentlyPlaying: CurrentlyPlayingState | null ) => void; + subtitles: SubtitleTrack[]; } const PlaybackContext = createContext(null); @@ -77,6 +82,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ const [progressTicks, setProgressTicks] = useState(0); const [volume, _setVolume] = useState(null); const [session, setSession] = useState(null); + const [subtitles, setSubtitles] = useState([]); const [currentlyPlaying, setCurrentlyPlaying] = useState(null); @@ -141,12 +147,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ setSession(res.data); setCurrentlyPlaying(state); setIsPlaying(true); - - if (settings?.openFullScreenVideoPlayerByDefault) { - setTimeout(() => { - presentFullscreenPlayer(); - }, 300); - } } else { setCurrentlyPlaying(null); setIsFullscreen(false); @@ -164,11 +164,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ onPress: () => { setCurrentlyPlaying(state); setIsPlaying(true); - if (settings?.openFullScreenVideoPlayerByDefault) { - setTimeout(() => { - presentFullscreenPlayer(); - }, 300); - } }, }, { @@ -375,6 +370,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ presentFullscreenPlayer, dismissFullscreenPlayer, startDownloadedFilePlayback, + subtitles, }} > {children} diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 94229cd1..88528cf7 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -1,6 +1,7 @@ import { atom, useAtom } from "jotai"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useEffect } from "react"; +import * as ScreenOrientation from "expo-screen-orientation"; export type DownloadQuality = "original" | "high" | "low"; @@ -9,6 +10,22 @@ export type DownloadOption = { value: DownloadQuality; }; +export const ScreenOrientationEnum: Record< + ScreenOrientation.OrientationLock, + string +> = { + [ScreenOrientation.OrientationLock.DEFAULT]: "Default", + [ScreenOrientation.OrientationLock.ALL]: "All", + [ScreenOrientation.OrientationLock.PORTRAIT]: "Portrait", + [ScreenOrientation.OrientationLock.PORTRAIT_UP]: "Portrait Up", + [ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "Portrait Down", + [ScreenOrientation.OrientationLock.LANDSCAPE]: "Landscape", + [ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "Landscape Left", + [ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "Landscape Right", + [ScreenOrientation.OrientationLock.OTHER]: "Other", + [ScreenOrientation.OrientationLock.UNKNOWN]: "Unknown", +}; + export const DownloadOptions: DownloadOption[] = [ { label: "Original quality", @@ -53,6 +70,7 @@ type Settings = { defaultSubtitleLanguage: DefaultLanguageOption | null; defaultAudioLanguage: DefaultLanguageOption | null; showHomeTitles: boolean; + defaultVideoOrientation: ScreenOrientation.OrientationLock; }; /** @@ -86,6 +104,7 @@ const loadSettings = async (): Promise => { defaultAudioLanguage: null, defaultSubtitleLanguage: null, showHomeTitles: true, + defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT, }; try { diff --git a/utils/hls/parseM3U8ForSubtitles.ts b/utils/hls/parseM3U8ForSubtitles.ts new file mode 100644 index 00000000..fb1902b0 --- /dev/null +++ b/utils/hls/parseM3U8ForSubtitles.ts @@ -0,0 +1,55 @@ +import axios from "axios"; + +export interface SubtitleTrack { + index: number; + name: string; + uri: string; + language: string; + default: boolean; + forced: boolean; + autoSelect: boolean; +} + +export async function parseM3U8ForSubtitles( + url: string +): Promise { + try { + const response = await axios.get(url, { responseType: "text" }); + const lines = response.data.split(/\r?\n/); + const subtitleTracks: SubtitleTrack[] = []; + let index = 0; + + lines.forEach((line: string) => { + if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) { + const attributes = parseAttributes(line); + const track: SubtitleTrack = { + index: index++, + name: attributes["NAME"] || "", + uri: attributes["URI"] || "", + language: attributes["LANGUAGE"] || "", + default: attributes["DEFAULT"] === "YES", + forced: attributes["FORCED"] === "YES", + autoSelect: attributes["AUTOSELECT"] === "YES", + }; + subtitleTracks.push(track); + } + }); + + return subtitleTracks; + } catch (error) { + console.error("Failed to fetch or parse the M3U8 file:", error); + throw error; + } +} + +function parseAttributes(line: string): { [key: string]: string } { + const attributes: { [key: string]: string } = {}; + const parts = line.split(","); + parts.forEach((part) => { + const [key, value] = part.split("="); + if (key && value) { + attributes[key.trim()] = value.replace(/"/g, "").trim(); + } + }); + return attributes; +} diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index b5e46780..337c355c 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -5,6 +5,7 @@ import { MediaSourceInfo, PlaybackInfoResponse, } from "@jellyfin/sdk/lib/generated-client/models"; +import { getAuthHeaders } from "../jellyfin"; export const getStreamUrl = async ({ api, @@ -15,7 +16,7 @@ export const getStreamUrl = async ({ sessionData, deviceProfile = ios, audioStreamIndex = 0, - subtitleStreamIndex = 0, + subtitleStreamIndex = undefined, forceDirectPlay = false, height, mediaSourceId, @@ -39,6 +40,9 @@ export const getStreamUrl = async ({ const itemId = item.Id; + /** + * Build the stream URL for videos + */ const response = await api.axiosInstance.post( `${api.basePath}/Items/${itemId}/PlaybackInfo`, { @@ -58,9 +62,7 @@ export const getStreamUrl = async ({ EnableMpegtsM2TsMode: false, }, { - headers: { - Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, - }, + headers: getAuthHeaders(api), } ); @@ -80,10 +82,8 @@ export const getStreamUrl = async ({ if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) { if (item.MediaType === "Video") { - console.log("Using direct stream for video!"); url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`; } else if (item.MediaType === "Audio") { - console.log("Using direct stream for audio!"); const searchParams = new URLSearchParams({ UserId: userId, DeviceId: api.deviceInfo.id,