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,