Compare commits

...

33 Commits

Author SHA1 Message Date
Fredrik Burmester
e65ed3db0e fix: text 2024-08-13 21:00:33 +02:00
Fredrik Burmester
cb9dfe2c83 feat: app store link 2024-08-13 21:00:08 +02:00
Fredrik Burmester
bc4b07c76b fix: padding 2024-08-13 20:29:40 +02:00
Fredrik Burmester
150eb1809f feat: prev/next episode buttons 2024-08-13 20:26:02 +02:00
Fredrik Burmester
8afe7dc5e4 chore: version 2024-08-13 20:25:50 +02:00
Fredrik Burmester
855e00a676 fix: refactor 2024-08-13 20:25:30 +02:00
Fredrik Burmester
5289c0519f chore: update readme 2024-08-13 16:43:58 +02:00
Fredrik Burmester
4b1eb2218f fix: invalid config 2024-08-13 16:43:50 +02:00
Fredrik Burmester
a99e7b950e fix: pip 2024-08-13 16:21:56 +02:00
Fredrik Burmester
51fc2a0edb chore: versions 2024-08-13 16:02:47 +02:00
Fredrik Burmester
3a13503d1d fix: enable background playback 2024-08-13 16:01:52 +02:00
Fredrik Burmester
2fdf90ab4b fix: enable background play 2024-08-13 16:00:50 +02:00
Fredrik Burmester
6fed0c1c77 fix: change colors 2024-08-13 16:00:43 +02:00
Fredrik Burmester
ee7ff3444e chore: todo 2024-08-13 16:00:39 +02:00
Fredrik Burmester
dec175a300 fix: route to download page 2024-08-13 16:00:26 +02:00
Fredrik Burmester
27099d3184 fix: improve pause/play logic 2024-08-13 16:00:07 +02:00
Fredrik Burmester
bfad77dd7a fix: change color to purple 2024-08-13 15:59:34 +02:00
Fredrik Burmester
74a33f8f82 fix: update download list when a download is finished 2024-08-13 15:59:27 +02:00
Fredrik Burmester
75de878618 fix: show loader for videos but not music 2024-08-13 14:41:37 +02:00
Fredrik Burmester
9628285701 fix: remove parsing of the url 2024-08-13 14:00:18 +02:00
Fredrik Burmester
b206be6bcf chore: version 2024-08-13 13:36:04 +02:00
Fredrik Burmester
656d4ba46b Merge branch 'feat/audio-select' 2024-08-13 13:27:09 +02:00
Fredrik Burmester
b1025c81ae Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-08-13 13:26:33 +02:00
Fredrik Burmester
b05b43c12e feat: poster 2024-08-13 13:26:31 +02:00
Fredrik Burmester
11f9d0fe33 feat: support audio streams 2024-08-13 13:26:27 +02:00
Fredrik Burmester
0498f2e718 chore: version 2024-08-13 11:29:40 +02:00
Fredrik Burmester
077f99fd46 fix: display any collection
fixes #21
2024-08-13 11:29:33 +02:00
Fredrik Burmester
cc72186a80 feat: audio and subtitle picker 2024-08-12 22:24:51 +02:00
Fredrik Burmester
65837cd303 Merge branch 'master' into feat/audio-select 2024-08-12 20:45:49 +02:00
Fredrik Burmester
d5ee79d740 wip 2024-08-12 19:38:17 +02:00
Fredrik Burmester
040ef3b79a Merge branch 'master' into feat/audio-select 2024-08-12 19:26:48 +02:00
Fredrik Burmester
07c0f81f36 Merge branch 'master' into feat/audio-select 2024-08-12 19:17:52 +02:00
Fredrik Burmester
a62e5d24da wip 2024-08-12 19:09:56 +02:00
31 changed files with 761 additions and 187 deletions

View File

@@ -12,10 +12,11 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
## 🌟 Features
- 🔗 Connect to your Jellyfin instance: Easily link your Jellyfin server and access your media library.
- 📱 Native video player: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
- 📥 Download media (Experimental): Save your media locally and watch it offline.
- 📡 Chromecast media (Experimental): Cast your media to any Chromecast-enabled device.
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
## 🧪 Experimental Features
@@ -25,15 +26,21 @@ Streamyfin includes some exciting experimental features like media downloading a
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
## 🛠️ Beta testing (iOS/Android)
## Get it now
## TestFlight
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB">
<img height=75 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/>
</a>
### TestFlight
Get the latest updates by using the TestFlight version of the app.
<a href="https://testflight.apple.com/join/CWBaAAK2">
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
</a>
## Play Store Open Beta
### Play Store Open Beta
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin">
<img height=75 alt="Get the beta on Google Play" src="./assets/en_badge_web_generic.png"/>

View File

@@ -2,8 +2,8 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.3.2",
"orientation": "portrait",
"version": "0.4.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
@@ -13,25 +13,33 @@
"backgroundColor": "#29164B"
},
"jsEngine": "hermes",
"assetBundlePatterns": ["**/*"],
"assetBundlePatterns": [
"**/*"
],
"ios": {
"requireFullScreen": true,
"infoPlist": {
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
"NSMicrophoneUsageDescription": "The app needs access to your microphone."
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
"UIBackgroundModes": [
"audio"
],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true,
"NSExceptionDomains": {
"*": {
"NSExceptionAllowsInsecureHTTPLoads": true
}
}
}
},
"supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin"
},
"android": {
"jsEngine": "hermes",
"versionCode": 10,
"orientation": "default",
"androidNavigationBar": {
"visible": true,
"barStyle": "dark-content",
"backgroundColor": "#000000"
},
"versionCode": 14,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
},
@@ -61,6 +69,7 @@
"react-native-video",
{
"enableNotificationControls": true,
"enableBackgroundAudio": true,
"androidExtensions": {
"useExoplayerRtsp": false,
"useExoplayerSmoothStreaming": false,
@@ -97,7 +106,8 @@
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
]
],
"expo-video"
],
"experiments": {
"typedRoutes": true

View File

@@ -3,12 +3,15 @@ import { Loading } from "@/components/Loading";
import MoviePoster from "@/components/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
BaseItemDto,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
ScrollView,
@@ -23,16 +26,21 @@ const page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
useEffect(() => {
console.log("CollectionId", collectionId);
}, [collectionId]);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () =>
(api &&
(
await getItemsApi(api).getItems({
userId: user?.Id,
})
).data.Items?.find((item) => item.Id == collectionId)) ||
null,
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [collectionId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id,
staleTime: 0,
});
@@ -45,40 +53,84 @@ const page: React.FC = () => {
}>({
queryKey: ["collection-items", collectionId, startIndex],
queryFn: async () => {
if (!api) return [];
if (!api || !collectionId)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await api.axiosInstance.get(
`${api.basePath}/Users/${user?.Id}/Items`,
{
params: {
SortBy:
collection?.CollectionType === "movies"
? "SortName,ProductionYear"
: "SortName",
SortOrder: "Ascending",
IncludeItemTypes:
collection?.CollectionType === "movies" ? "Movie" : "Series",
Recursive: true,
Fields:
collection?.CollectionType === "movies"
? "PrimaryImageAspectRatio,MediaSourceCount"
: "PrimaryImageAspectRatio",
ImageTypeLimit: 1,
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
ParentId: collectionId,
Limit: 100,
StartIndex: startIndex,
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
);
const sortBy: ItemSortBy[] = [];
return response.data || [];
switch (collection?.CollectionType) {
case "movies":
sortBy.push("SortName", "ProductionYear");
break;
case "boxsets":
sortBy.push("IsFolder", "SortName");
break;
default:
sortBy.push("SortName");
break;
}
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: collectionId,
limit: 100,
startIndex,
sortBy,
sortOrder: ["Ascending"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!collection && !!api,
enabled: !!collectionId && !!api,
});
// const { data, isLoading, isError } = useQuery<{
// Items: BaseItemDto[];
// TotalRecordCount: number;
// }>({
// queryKey: ["collection-items", collectionId, startIndex],
// queryFn: async () => {
// if (!api) return [];
// const response = await api.axiosInstance.get(
// `${api.basePath}/Users/${user?.Id}/Items`,
// {
// params: {
// SortBy:
// collection?.CollectionType === "movies"
// ? "SortName,ProductionYear"
// : "SortName",
// SortOrder: "Ascending",
// IncludeItemTypes:
// collection?.CollectionType === "movies" ? "Movie" : "Series",
// Recursive: true,
// Fields:
// collection?.CollectionType === "movies"
// ? "PrimaryImageAspectRatio,MediaSourceCount"
// : "PrimaryImageAspectRatio",
// ImageTypeLimit: 1,
// EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
// ParentId: collectionId,
// Limit: 100,
// StartIndex: startIndex,
// },
// headers: {
// Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
// },
// },
// );
// return response.data || [];
// },
// enabled: !!collection && !!api,
// });
const totalItems = useMemo(() => {
return data?.TotalRecordCount;
@@ -91,7 +143,8 @@ const page: React.FC = () => {
<Text className="font-bold text-3xl mb-2">{collection?.Name}</Text>
<View className="flex flex-row items-center justify-between">
<Text>
{startIndex + 1}-{startIndex + 100} of {totalItems}
{startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "}
{totalItems}
</Text>
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
@@ -125,7 +178,7 @@ const page: React.FC = () => {
</View>
) : (
<View className="flex flex-row flex-wrap">
{data?.Items?.map((item: any, index: number) => (
{data?.Items?.map((item: BaseItemDto, index: number) => (
<TouchableOpacity
style={{
maxWidth: "33%",
@@ -134,10 +187,12 @@ const page: React.FC = () => {
}}
key={index}
onPress={() => {
if (collection?.CollectionType === "movies") {
router.push(`/items/${item.Id}/page`);
} else if (collection?.CollectionType === "tvshows") {
if (item?.Type === "Series") {
router.push(`/series/${item.Id}/page`);
} else if (item.IsFolder) {
router.push(`/collections/${item?.Id}/page`);
} else {
router.push(`/items/${item.Id}/page`);
}
}}
>

View File

@@ -16,14 +16,18 @@ import { runningProcesses } from "@/utils/atoms/downloads";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { FFmpegKit } from "ffmpeg-kit-react-native";
import * as FileSystem from "expo-file-system";
const downloads: React.FC = () => {
const [process, setProcess] = useAtom(runningProcesses);
const { data: downloadedFiles, isLoading } = useQuery({
queryKey: ["downloaded_files"],
queryKey: ["downloaded_files", process?.item.Id],
queryFn: async () =>
JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]",
) as BaseItemDto[],
staleTime: 0,
});
const movies = useMemo(
@@ -41,8 +45,6 @@ const downloads: React.FC = () => {
return Object.values(series);
}, [downloadedFiles]);
const [process, setProcess] = useAtom(runningProcesses);
const eta = useMemo(() => {
const length = process?.item?.RunTimeTicks || 0;
@@ -77,7 +79,7 @@ const downloads: React.FC = () => {
<View>
<Text className="font-semibold">{process.item.Name}</Text>
<Text className="text-xs opacity-50">{process.item.Type}</Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-red-600">
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
<Text className="text-xs">
{process.progress.toFixed(0)}%
</Text>
@@ -97,7 +99,7 @@ const downloads: React.FC = () => {
</TouchableOpacity>
<View
className={`
absolute bottom-0 left-0 h-1 bg-red-600
absolute bottom-0 left-0 h-1 bg-purple-600
`}
style={{
width: process.progress

View File

@@ -11,7 +11,7 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
ScrollView,
@@ -34,6 +34,12 @@ import CastContext, {
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios12 from "@/utils/profiles/ios12";
import { currentlyPlayingItemAtom } from "@/components/CurrentlyPlayingBar";
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Button } from "@/components/Button";
import { Ionicons } from "@expo/vector-icons";
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
const page: React.FC = () => {
const local = useLocalSearchParams();
@@ -45,7 +51,9 @@ const page: React.FC = () => {
const castDevice = useCastDevice();
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
@@ -95,7 +103,13 @@ const page: React.FC = () => {
});
const { data: playbackUrl } = useQuery({
queryKey: ["playbackUrl", item?.Id, maxBitrate, castDevice],
queryKey: [
"playbackUrl",
item?.Id,
maxBitrate,
castDevice,
selectedAudioStream,
],
queryFn: async () => {
if (!api || !user?.Id || !sessionData) return null;
@@ -107,8 +121,12 @@ const page: React.FC = () => {
maxStreamingBitrate: maxBitrate.value,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
audioStreamIndex: selectedAudioStream,
subtitleStreamIndex: selectedSubtitleStream,
});
console.log("Transcode URL: ", url);
return url;
},
enabled: !!sessionData,
@@ -187,7 +205,7 @@ const page: React.FC = () => {
</>
}
>
<View className="flex flex-col px-4 mb-4 pt-4">
<View className="flex flex-col px-4 pt-4">
<View className="flex flex-col">
{item.Type === "Episode" ? (
<>
@@ -204,7 +222,6 @@ const page: React.FC = () => {
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
<PlayedStatus item={item} />
</View>
<View>
<View className="flex flex-row items-center self-center">
@@ -229,7 +246,6 @@ const page: React.FC = () => {
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
<PlayedStatus item={item} />
</View>
<Text className="text-center opacity-50">
{item?.ProductionYear}
@@ -239,23 +255,43 @@ const page: React.FC = () => {
</View>
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackUrl && (
{playbackUrl ? (
<DownloadItem item={item} playbackUrl={playbackUrl} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}
<PlayedStatus item={item} />
<Chromecast />
</View>
<Text>{item.Overview}</Text>
</View>
<View className="flex flex-col p-4">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<PlayButton
item={item}
chromecastReady={chromecastReady}
onPress={onPressPlay}
/>
<View className="flex flex-col p-4 w-full">
<View className="flex flex-row items-center space-x-2 w-full">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<AudioTrackSelector
item={item}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
item={item}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton
item={item}
chromecastReady={false}
onPress={onPressPlay}
className="grow"
/>
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>
<ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 ">

View File

@@ -38,7 +38,7 @@ const page: React.FC = () => {
itemId: seriesId,
}),
enabled: !!seriesId && !!api,
staleTime: 0,
staleTime: 60,
});
const backdropUrl = useMemo(

View File

@@ -78,7 +78,7 @@ export default function RootLayout() {
<JellyfinProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack screenOptions={{}}>
<Stack>
<Stack.Screen
name="(auth)/(tabs)"
options={{

View File

@@ -3,6 +3,7 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { AxiosError } from "axios";
import { useAtom } from "jotai";
import React, { useMemo, useState } from "react";
import { KeyboardAvoidingView, Platform, View } from "react-native";
@@ -18,6 +19,7 @@ const Login: React.FC = () => {
const [api] = useAtom(apiAtom);
const [serverURL, setServerURL] = useState<string>("");
const [error, setError] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
@@ -36,31 +38,25 @@ const Login: React.FC = () => {
await login(credentials.username, credentials.password);
}
} catch (error) {
console.error(error);
const e = error as AxiosError | z.ZodError;
if (e instanceof z.ZodError) {
setError("An error occured.");
} else {
if (e.response?.status === 401) {
setError("Invalid credentials.");
} else {
setError(
"A network error occurred. Did you enter the correct server URL?",
);
}
}
} finally {
setLoading(false);
}
};
const parsedServerURL = useMemo(() => {
let parsedServerURL = serverURL.trim();
if (parsedServerURL) {
parsedServerURL = parsedServerURL.endsWith("/")
? parsedServerURL.replace("/", "")
: parsedServerURL;
parsedServerURL = parsedServerURL.startsWith("http")
? parsedServerURL
: "http://" + parsedServerURL;
return parsedServerURL;
}
return "";
}, [serverURL]);
const handleConnect = (url: string) => {
setServer({ address: url });
setServer({ address: url.trim() });
};
if (api?.basePath) {
@@ -122,6 +118,8 @@ const Login: React.FC = () => {
/>
</View>
<Text className="text-red-600 mb-2">{error}</Text>
<Button onPress={handleLogin} loading={loading}>
Log in
</Button>
@@ -150,9 +148,7 @@ const Login: React.FC = () => {
textContentType="URL"
maxLength={500}
/>
<Button onPress={() => handleConnect(parsedServerURL)}>
Connect
</Button>
<Button onPress={() => handleConnect(serverURL)}>Connect</Button>
</View>
</View>
</KeyboardAvoidingView>

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
bun.lockb

Binary file not shown.

View File

@@ -0,0 +1,80 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
onChange: (value: number) => void;
selected: number;
}
export const AudioTrackSelector: React.FC<Props> = ({
item,
onChange,
selected,
...props
}) => {
const audioStreams = useMemo(
() =>
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
[item],
);
const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected],
);
useEffect(() => {
const index = item.MediaSources?.[0].DefaultAudioStreamIndex;
if (index !== undefined && index !== null) onChange(index);
}, []);
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{tc(selectedAudioSteam?.DisplayTitle, 13)}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
{audioStreams?.map((audio, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (audio.Index !== null && audio.Index !== undefined)
onChange(audio.Index);
}}
>
<DropdownMenu.ItemTitle>
{audio.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -27,20 +27,24 @@ const BITRATES: Bitrate[] = [
},
];
type Props = {
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;
selected: Bitrate;
};
}
export const BitrateSelector: React.FC<Props> = ({ onChange, selected }) => {
export const BitrateSelector: React.FC<Props> = ({
onChange,
selected,
...props
}) => {
return (
<View className="flex flex-row items-center justify-between">
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<TouchableOpacity className="bg-neutral-900 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{BITRATES.find((b) => b.value === selected.value)?.key}
</Text>

View File

@@ -7,7 +7,7 @@ interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
className?: string;
textClassName?: string;
disabled?: boolean;
children?: string;
children?: string | ReactNode;
loading?: boolean;
color?: "purple" | "red" | "black";
iconRight?: ReactNode;

View File

@@ -1,5 +1,6 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect } from "react";
import { View } from "react-native";
import {
CastButton,
useCastDevice,
@@ -30,5 +31,9 @@ export const Chromecast: React.FC<Props> = () => {
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
return <CastButton style={{ tintColor: "white", height: 48, width: 48 }} />;
return (
<View className="rounded h-12 aspect-square flex items-center justify-center">
<CastButton style={{ tintColor: "white", height: 48, width: 48 }} />
</View>
);
};

View File

@@ -61,7 +61,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
style={{
width: `${progress}%`,
}}
className={`absolute bottom-0 left-0 h-1 bg-red-600 w-full`}
className={`absolute bottom-0 left-0 h-1 bg-purple-600 w-full`}
></View>
</>
)}

View File

@@ -7,18 +7,13 @@ import {
import { Text } from "./common/Text";
import { Ionicons } from "@expo/vector-icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Video, { OnProgressData, VideoRef } from "react-native-video";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios12 from "@/utils/profiles/ios12";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import Animated, {
@@ -28,6 +23,8 @@ import Animated, {
} from "react-native-reanimated";
import { useRouter, useSegments } from "expo-router";
import { BlurView } from "expo-blur";
import { writeToLog } from "@/utils/log";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
export const currentlyPlayingItemAtom = atom<{
item: BaseItemDto;
@@ -35,13 +32,10 @@ export const currentlyPlayingItemAtom = atom<{
} | null>(null);
export const CurrentlyPlayingBar: React.FC = () => {
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
const castDevice = useCastDevice();
const client = useRemoteMediaClient();
const queryClient = useQueryClient();
const segments = useSegments();
@@ -173,6 +167,17 @@ export const CurrentlyPlayingBar: React.FC = () => {
[item],
);
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 70,
width: 200,
}),
[item],
);
useEffect(() => {
if (cp?.playbackUrl) {
play();
@@ -203,31 +208,61 @@ export const CurrentlyPlayingBar: React.FC = () => {
onPress={() => {
videoRef.current?.presentFullscreenPlayer();
}}
className="aspect-video h-full bg-neutral-800 rounded-md overflow-hidden"
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
`}
>
{cp.playbackUrl && (
<Video
ref={videoRef}
allowsExternalPlayback
style={{ width: "100%", height: "100%" }}
playWhenInactive={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
controls={false}
pictureInPicture={true}
poster={
backdropUrl && item?.Type === "Audio"
? backdropUrl
: undefined
}
paused={paused}
onProgress={(e) => onProgress(e)}
subtitleStyle={{
fontSize: 16,
}}
source={{
uri: cp.playbackUrl,
isNetwork: true,
startPosition,
}}
controls={false}
ref={videoRef}
onBuffer={(e) =>
e.isBuffering ? console.log("Buffering...") : null
}
onProgress={(e) => onProgress(e)}
paused={paused}
onFullscreenPlayerDidDismiss={() => {
play();
onPlaybackStateChanged={(e) => {
if (e.isPlaying) {
setPaused(false);
} else if (e.isSeeking) {
return;
} else {
setPaused(true);
}
}}
onError={(e) => {
console.log(e);
writeToLog(
"ERROR",
"Video playback error: " + JSON.stringify(e),
);
}}
ignoreSilentSwitch="ignore"
renderLoader={
<View className="flex flex-col items-center justify-center h-full">
<ActivityIndicator size={"small"} color={"white"} />
</View>
item?.Type !== "Audio" && (
<View className="flex flex-col items-center justify-center h-full">
<ActivityIndicator size={"small"} color={"white"} />
</View>
)
}
/>
)}

View File

@@ -64,12 +64,16 @@ export const DownloadItem: React.FC<DownloadProps> = ({
}, [process]);
if (isLoading) {
return <ActivityIndicator size={"small"} color={"white"} />;
return (
<View className="rounded h-12 aspect-square flex items-center justify-center">
<ActivityIndicator size={"small"} color={"white"} />
</View>
);
}
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
return (
<View style={{ opacity: 0.5 }}>
<View className="rounded h-12 aspect-square flex items-center justify-center opacity-50">
<Ionicons name="cloud-download-outline" size={24} color="white" />
</View>
);
@@ -77,21 +81,22 @@ export const DownloadItem: React.FC<DownloadProps> = ({
if (process && process.item.Id !== item.Id!) {
return (
<TouchableOpacity onPress={() => {}} style={{ opacity: 0.5 }}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
<TouchableOpacity onPress={() => {}}>
<View className="rounded h-12 aspect-square flex items-center justify-center opacity-50">
<Ionicons name="cloud-download-outline" size={24} color="white" />
</View>
</TouchableOpacity>
);
}
return (
<View>
{process ? (
<TouchableOpacity
onPress={() => {
cancelRemuxing();
}}
className="flex flex-row items-center"
>
if (process) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<View className="rounded h-12 aspect-square flex items-center justify-center">
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
@@ -118,27 +123,32 @@ export const DownloadItem: React.FC<DownloadProps> = ({
<Text>{process.speed.toFixed(2)}x</Text>
</View>
) : null}
</TouchableOpacity>
) : downloaded ? (
<TouchableOpacity
onPress={() => {
router.push(
`/(auth)/player/offline/page?url=${item.Id}.mp4&itemId=${item.Id}`,
);
}}
>
<Ionicons name="cloud-download" size={26} color="#16a34a" />
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => {
// downloadFile();
startRemuxing();
}}
>
</View>
</TouchableOpacity>
);
} else if (downloaded) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<View className="rounded h-12 aspect-square flex items-center justify-center">
<Ionicons name="cloud-download" size={26} color="#9333ea" />
</View>
</TouchableOpacity>
);
} else {
return (
<TouchableOpacity
onPress={() => {
startRemuxing();
}}
>
<View className="rounded h-12 aspect-square flex items-center justify-center">
<Ionicons name="cloud-download-outline" size={26} color="white" />
</TouchableOpacity>
)}
</View>
);
</View>
</TouchableOpacity>
);
}
};

View File

@@ -0,0 +1,56 @@
import { useVideoPlayer, VideoView } from "expo-video";
import { useEffect, useRef, useState } from "react";
import {
PixelRatio,
StyleSheet,
View,
Button,
TouchableOpacity,
} from "react-native";
const videoSource =
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
interface Props {
videoSource: string;
}
export const NewVideoPlayer: React.FC<Props> = ({ videoSource }) => {
const ref = useRef<VideoView | null>(null);
const [isPlaying, setIsPlaying] = useState(true);
const player = useVideoPlayer(videoSource, (player) => {
player.loop = true;
player.play();
});
useEffect(() => {
const subscription = player.addListener("playingChange", (isPlaying) => {
setIsPlaying(isPlaying);
});
return () => {
subscription.remove();
};
}, [player]);
return (
<TouchableOpacity
onPress={() => {
ref.current?.enterFullscreen();
}}
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
`}
>
<VideoView
ref={ref}
style={{
width: "100%",
height: "100%",
}}
player={player}
allowsFullscreen
allowsPictureInPicture
/>
</TouchableOpacity>
);
};

View File

@@ -30,7 +30,7 @@ type VideoPlayerProps = {
onChangePlaybackURL: (url: string | null) => void;
};
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
export const OldVideoPlayer: React.FC<VideoPlayerProps> = ({
itemId,
onChangePlaybackURL,
}) => {

View File

@@ -89,7 +89,9 @@ export const ParallaxScrollView: React.FC<Props> = ({
>
{headerImage}
</Animated.View>
<View className="flex-1 overflow-hidden bg-black">{children}</View>
<View className="flex-1 overflow-hidden bg-black pb-24">
{children}
</View>
</Animated.ScrollView>
</View>
);

View File

@@ -1,21 +1,19 @@
import { useState } from "react";
import { Button } from "./Button";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { currentlyPlayingItemAtom } from "./CurrentlyPlayingBar";
import { useAtom } from "jotai";
import { Feather, Ionicons } from "@expo/vector-icons";
import { runtimeTicksToMinutes } from "@/utils/time";
type Props = {
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
onPress: () => void;
chromecastReady: boolean;
};
}
export const PlayButton: React.FC<Props> = ({
item,
onPress,
chromecastReady,
...props
}) => {
return (
<Button
@@ -27,6 +25,7 @@ export const PlayButton: React.FC<Props> = ({
<Ionicons name="play-circle" size={24} color="white" />
)
}
{...props}
>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Button>

View File

@@ -47,7 +47,9 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
invalidateQueries();
}}
>
<Ionicons name="checkmark-circle" size={26} color="white" />
<View className="rounded h-12 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle" size={26} color="white" />
</View>
</TouchableOpacity>
) : (
<TouchableOpacity
@@ -61,7 +63,9 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
invalidateQueries();
}}
>
<Ionicons name="checkmark-circle-outline" size={26} color="white" />
<View className="rounded h-12 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle-outline" size={26} color="white" />
</View>
</TouchableOpacity>
)}
</View>

View File

@@ -0,0 +1,92 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
onChange: (value: number) => void;
selected: number;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
item,
onChange,
selected,
...props
}) => {
const subtitleStreams = useMemo(
() =>
item.MediaSources?.[0].MediaStreams?.filter(
(x) => x.Type === "Subtitle",
) ?? [],
[item],
);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected),
[subtitleStreams, selected],
);
useEffect(() => {
const index = item.MediaSources?.[0].DefaultSubtitleStreamIndex;
if (index !== undefined && index !== null) {
onChange(index);
} else {
// Get first subtitle stream
const firstSubtitle = subtitleStreams.find((x) => x.Index !== undefined);
if (firstSubtitle?.Index !== undefined) {
onChange(firstSubtitle.Index);
}
}
}, []);
if (subtitleStreams.length === 0) return null;
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{tc(selectedSubtitleSteam?.DisplayTitle, 13)}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
{subtitleStreams?.map((subtitle, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (subtitle.Index !== undefined && subtitle.Index !== null)
onChange(subtitle.Index);
}}
>
<DropdownMenu.ItemTitle>
{subtitle.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -13,6 +13,22 @@ export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const { deleteFile } = useFiles();
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
// const fetchFileSize = async () => {
// try {
// const filePath = `${FileSystem.documentDirectory}/${item.Id}.mp4`;
// const info = await FileSystem.getInfoAsync(filePath);
// return info.exists ? info.size : null;
// } catch (e) {
// console.log(e);
// return null;
// }
// };
// const { data: fileSize } = useQuery({
// queryKey: ["fileSize", item?.Id],
// queryFn: fetchFileSize,
// });
const openFile = useCallback(() => {
setCp({
item,
@@ -43,6 +59,12 @@ export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
<Text className=" text-xs opacity-50">
Episode {item.IndexNumber}
</Text>
{/* <Text className=" text-xs opacity-50">
Size:{" "}
{fileSize
? `${(fileSize / 1000000).toFixed(0)} MB`
: "Calculating..."}{" "}
</Text> */}
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content

View File

@@ -9,11 +9,23 @@ import { useCallback } from "react";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import { currentlyPlayingItemAtom } from "../CurrentlyPlayingBar";
import { useQuery } from "@tanstack/react-query";
export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const { deleteFile } = useFiles();
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
// const fetchFileSize = async () => {
// const filePath = `${FileSystem.documentDirectory}/${item.Id}.mp4`;
// const info = await FileSystem.getInfoAsync(filePath);
// return info.exists ? info.size : null;
// };
// const { data: fileSize } = useQuery({
// queryKey: ["fileSize", item?.Id],
// queryFn: fetchFileSize,
// });
const openFile = useCallback(() => {
setCp({
item,
@@ -41,11 +53,17 @@ export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
<Text className=" font-bold">{item.Name}</Text>
<View className="flex flex-row items-center justify-between">
<View className="flex flex-col">
<Text className=" text-xs opacity-50">{item.ProductionYear}</Text>
<Text className=" text-xs opacity-50">
{runtimeTicksToMinutes(item.RunTimeTicks)}
</Text>
{/* <Text className=" text-xs opacity-50">
Size:{" "}
{fileSize
? `${(fileSize / 1000000).toFixed(0)} MB`
: "Calculating..."}{" "}
</Text>*/}
</View>
</TouchableOpacity>
</ContextMenu.Trigger>

View File

@@ -0,0 +1,107 @@
import { Ionicons } from "@expo/vector-icons";
import { Button } from "../Button";
import { useRouter } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useMemo } from "react";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
type?: "next" | "previous";
}
export const NextEpisodeButton: React.FC<Props> = ({
item,
type = "next",
...props
}) => {
const router = useRouter();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
// const { data: seasons } = useQuery({
// queryKey: ["seasons", item.SeriesId],
// queryFn: async () => {
// if (
// !api ||
// !user?.Id ||
// !item?.Id ||
// !item?.SeriesId ||
// !item?.IndexNumber
// )
// return [];
// const response = await getItemsApi(api).getItems({
// parentId: item?.SeriesId,
// });
// console.log("seasons ~", type, response.data);
// return (response.data.Items as BaseItemDto[]) ?? [];
// },
// enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
// });
// const nextSeason = useMemo(() => {
// if (!seasons) return null;
// const currentSeasonIndex = seasons.findIndex(
// (season) => season.Id === item.SeasonId,
// );
// if (currentSeasonIndex === seasons.length - 1) return null;
// return seasons[currentSeasonIndex + 1];
// }, [seasons]);
const { data: nextEpisode } = useQuery({
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
queryFn: async () => {
if (
!api ||
!user?.Id ||
!item?.Id ||
!item?.ParentId ||
!item?.IndexNumber
)
return null;
const response = await getItemsApi(api).getItems({
parentId: item?.ParentId,
limit: 1,
startIndex: type === "next" ? item.IndexNumber : item.IndexNumber - 2,
});
console.log("NextEpisode ~", type, response.data);
return (response.data.Items?.[0] as BaseItemDto) || null;
},
enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
});
const disabled = useMemo(() => {
if (!nextEpisode) return true;
if (nextEpisode.Id === item.Id) return true;
return false;
}, [nextEpisode, type]);
if (item.Type !== "Episode") return null;
return (
<Button
onPress={() => router.replace(`/items/${nextEpisode?.Id}/page`)}
className={`h-12 aspect-square`}
disabled={disabled}
{...props}
>
{type === "next" ? (
<Ionicons name="chevron-forward" size={24} color="white" />
) : (
<Ionicons name="chevron-back" size={24} color="white" />
)}
</Button>
);
};

View File

@@ -12,5 +12,5 @@ export const Colors = {
tint: tintColorDark,
icon: "#9BA1A6",
tabIconDefault: "#9BA1A6",
tabIconSelected: "#EE4B2B",
tabIconSelected: "#9333ea",
};

View File

@@ -21,13 +21,13 @@
}
},
"production": {
"channel": "0.3.1",
"channel": "0.4.1",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.3.1",
"channel": "0.4.1",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -45,6 +45,7 @@
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.22",
"expo-video": "^1.2.4",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"jotai": "^2.9.1",

View File

@@ -14,6 +14,8 @@ export const getStreamUrl = async ({
maxStreamingBitrate,
sessionData,
deviceProfile = ios12,
audioStreamIndex = 0,
subtitleStreamIndex = 0,
}: {
api: Api | null | undefined;
item: BaseItemDto | null | undefined;
@@ -22,6 +24,8 @@ export const getStreamUrl = async ({
maxStreamingBitrate?: number;
sessionData: PlaybackInfoResponse;
deviceProfile: any;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
}) => {
if (!api || !userId || !item?.Id) {
return null;
@@ -40,6 +44,8 @@ export const getStreamUrl = async ({
AutoOpenLiveStream: true,
MediaSourceId: itemId,
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
AudioStreamIndex: audioStreamIndex,
SubtitleStreamIndex: subtitleStreamIndex,
},
{
headers: {
@@ -58,8 +64,28 @@ export const getStreamUrl = async ({
}
if (mediaSource.SupportsDirectPlay) {
console.log("Using direct stream!");
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
if (item.MediaType === "Video") {
console.log("Using direct stream for video!");
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
} else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({
UserId: userId,
DeviceId: api.deviceInfo.id,
MaxStreamingBitrate: "140000000",
Container:
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
TranscodingContainer: "mp4",
TranscodingProtocol: "hls",
AudioCodec: "aac",
api_key: api.accessToken,
PlaySessionId: sessionData.PlaySessionId,
StartTimeTicks: "0",
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
return `${api.basePath}/Audio/${itemId}/universal?${searchParams.toString()}`;
}
}
console.log("Using transcoded stream!");

7
utils/textTools.ts Normal file
View File

@@ -0,0 +1,7 @@
/*
* Truncate a text longer than a certain length
*/
export const tc = (text: string | null | undefined, length: number = 20) => {
if (!text) return "";
return text.length > length ? text.substr(0, length) + "..." : text;
};