mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
4 Commits
renovate/n
...
v0.30.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aff009de92 | ||
|
|
1924efbef2 | ||
|
|
3b53d76a18 | ||
|
|
b7221e5599 |
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.29.13",
|
||||
"version": "0.30.2",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 57,
|
||||
"versionCode": 58,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
|
||||
@@ -39,26 +39,44 @@ export default function page() {
|
||||
}
|
||||
}, [getDownloadedItems]);
|
||||
|
||||
// Group episodes by season in a single pass
|
||||
const seasonGroups = useMemo(() => {
|
||||
const groups: Record<number, BaseItemDto[]> = {};
|
||||
|
||||
series.forEach((episode) => {
|
||||
const seasonNumber = episode.item.ParentIndexNumber;
|
||||
if (seasonNumber !== undefined && seasonNumber !== null) {
|
||||
if (!groups[seasonNumber]) {
|
||||
groups[seasonNumber] = [];
|
||||
}
|
||||
groups[seasonNumber].push(episode.item);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort episodes within each season
|
||||
Object.values(groups).forEach((episodes) => {
|
||||
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [series]);
|
||||
|
||||
// Get unique seasons (just the season numbers, sorted)
|
||||
const uniqueSeasons = useMemo(() => {
|
||||
const seasonNumbers = Object.keys(seasonGroups)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
|
||||
}, [seasonGroups]);
|
||||
|
||||
const seasonIndex =
|
||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
||||
episodeSeasonIndex ||
|
||||
"";
|
||||
|
||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||
const seasons: Record<string, BaseItemDto[]> = {};
|
||||
|
||||
series?.forEach((episode) => {
|
||||
if (!seasons[episode.item.ParentIndexNumber!]) {
|
||||
seasons[episode.item.ParentIndexNumber!] = [];
|
||||
}
|
||||
|
||||
seasons[episode.item.ParentIndexNumber!].push(episode.item);
|
||||
});
|
||||
return (
|
||||
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
|
||||
[]
|
||||
);
|
||||
}, [series, seasonIndex]);
|
||||
return seasonGroups[Number(seasonIndex)] ?? [];
|
||||
}, [seasonGroups, seasonIndex]);
|
||||
|
||||
const initialSeasonIndex = useMemo(
|
||||
() =>
|
||||
@@ -102,7 +120,7 @@ export default function page() {
|
||||
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
||||
<SeasonDropdown
|
||||
item={series[0].item}
|
||||
seasons={series.map((s) => s.item)}
|
||||
seasons={uniqueSeasons}
|
||||
state={seasonIndexState}
|
||||
initialSeasonIndex={initialSeasonIndex!}
|
||||
onSelect={(season) => {
|
||||
|
||||
@@ -471,6 +471,10 @@ export default function page() {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
item.Id,
|
||||
msToTicks(progress.get()),
|
||||
{
|
||||
AudioStreamIndex: audioIndex ?? -1,
|
||||
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!Platform.isTV) await deactivateKeepAwake();
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -66,6 +66,7 @@
|
||||
"react-native-ios-context-menu": "^3.1.0",
|
||||
"react-native-ios-utilities": "5.1.8",
|
||||
"react-native-mmkv": "2.12.2",
|
||||
"react-native-pager-view": "^6.9.1",
|
||||
"react-native-reanimated": "~3.16.7",
|
||||
"react-native-reanimated-carousel": "4.0.2",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
|
||||
@@ -9,13 +9,14 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { type Href, router, useFocusEffect } from "expo-router";
|
||||
import { type Href, router } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
@@ -32,6 +33,13 @@ import ProgressCircle from "./ProgressCircle";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
mediaSource: MediaSourceInfo | undefined;
|
||||
audioIndex: number | undefined;
|
||||
subtitleIndex: number;
|
||||
};
|
||||
|
||||
interface DownloadProps extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
MissingDownloadIconComponent: () => React.ReactElement;
|
||||
@@ -60,18 +68,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
useDownload();
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
MediaSourceInfo | undefined | null
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
|
||||
settings?.defaultBitrate ?? {
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(items[0], settings);
|
||||
|
||||
const userCanDownload = useMemo(
|
||||
() => user?.Policy?.EnableContentDownloading,
|
||||
@@ -98,6 +104,24 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
[items, downloadedFiles],
|
||||
);
|
||||
|
||||
// Initialize selectedOptions with default values
|
||||
useEffect(() => {
|
||||
if (itemsNotDownloaded.length === 1) {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
}
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
itemsNotDownloaded.length,
|
||||
]);
|
||||
|
||||
const itemsToDownload = useMemo(() => {
|
||||
if (downloadUnwatchedOnly) {
|
||||
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
|
||||
@@ -153,7 +177,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
!api ||
|
||||
!user?.Id ||
|
||||
items.some((p) => !p.Id) ||
|
||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
||||
(itemsNotDownloaded.length === 1 && !selectedOptions?.mediaSource?.Id)
|
||||
) {
|
||||
throw new Error(
|
||||
"DownloadItem ~ initiateDownload: No api or user or item",
|
||||
@@ -164,9 +188,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
itemsNotDownloaded.length > 1
|
||||
? getDefaultPlaySettings(item, settings!)
|
||||
: {
|
||||
mediaSource: selectedMediaSource,
|
||||
audioIndex: selectedAudioStream,
|
||||
subtitleIndex: selectedSubtitleStream,
|
||||
mediaSource: selectedOptions?.mediaSource,
|
||||
audioIndex: selectedOptions?.audioIndex,
|
||||
subtitleIndex: selectedOptions?.subtitleIndex,
|
||||
};
|
||||
|
||||
const downloadDetails = await getDownloadUrl({
|
||||
@@ -176,7 +200,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
mediaSource: mediaSource!,
|
||||
audioStreamIndex: audioIndex ?? -1,
|
||||
subtitleStreamIndex: subtitleIndex ?? -1,
|
||||
maxBitrate,
|
||||
maxBitrate: selectedOptions?.bitrate || defaultBitrate,
|
||||
deviceId: api.deviceInfo.id,
|
||||
});
|
||||
|
||||
@@ -205,18 +229,21 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await startBackgroundDownload(url, item, mediaSource, maxBitrate);
|
||||
await startBackgroundDownload(
|
||||
url,
|
||||
item,
|
||||
mediaSource,
|
||||
selectedOptions?.bitrate || defaultBitrate,
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
itemsNotDownloaded,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
selectedOptions,
|
||||
settings,
|
||||
maxBitrate,
|
||||
defaultBitrate,
|
||||
startBackgroundDownload,
|
||||
],
|
||||
);
|
||||
@@ -246,18 +273,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
),
|
||||
[],
|
||||
);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
if (itemsNotDownloaded.length !== 1) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(items[0], settings);
|
||||
setSelectedMediaSource(mediaSource ?? undefined);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [items, itemsNotDownloaded, settings]),
|
||||
);
|
||||
|
||||
const renderButtonContent = () => {
|
||||
if (processes.length > 0 && itemsProcesses.length > 0) {
|
||||
@@ -332,8 +347,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
<View className='flex flex-col space-y-2 w-full items-start'>
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={setMaxBitrate}
|
||||
selected={maxBitrate}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.bitrate}
|
||||
/>
|
||||
{itemsNotDownloaded.length > 1 && (
|
||||
<View className='flex flex-row items-center justify-between w-full py-2'>
|
||||
@@ -345,27 +364,51 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
</View>
|
||||
)}
|
||||
{itemsNotDownloaded.length === 1 && (
|
||||
<>
|
||||
<View>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
onChange={setSelectedMediaSource}
|
||||
selected={selectedMediaSource}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.mediaSource}
|
||||
/>
|
||||
{selectedMediaSource && (
|
||||
{selectedOptions?.mediaSource && (
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<AudioTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
audioIndex: val,
|
||||
},
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.audioIndex}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
subtitleIndex: val,
|
||||
},
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.subtitleIndex}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
@@ -24,36 +24,27 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
const selectedName = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||
(x) => x.Type === "Video",
|
||||
)?.DisplayTitle || "",
|
||||
[item, selected],
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const commonPrefix = useMemo(() => {
|
||||
const mediaSources = item.MediaSources || [];
|
||||
if (!mediaSources.length) return "";
|
||||
|
||||
let commonPrefix = "";
|
||||
for (let i = 0; i < mediaSources[0].Name!.length; i++) {
|
||||
const char = mediaSources[0].Name![i];
|
||||
if (mediaSources.every((source) => source.Name![i] === char)) {
|
||||
commonPrefix += char;
|
||||
} else {
|
||||
commonPrefix = commonPrefix.slice(0, -1);
|
||||
break;
|
||||
}
|
||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||
if (videoStream?.DisplayTitle) {
|
||||
return videoStream.DisplayTitle;
|
||||
}
|
||||
return commonPrefix;
|
||||
}, [item.MediaSources]);
|
||||
|
||||
const name = (name?: string | null) => {
|
||||
return name?.replace(commonPrefix, "").toLowerCase();
|
||||
};
|
||||
// Fallback to source name
|
||||
if (source.Name) {
|
||||
return source.Name;
|
||||
}
|
||||
|
||||
// Last resort fallback
|
||||
return `Source ${source.Id}`;
|
||||
}, []);
|
||||
|
||||
const selectedName = useMemo(() => {
|
||||
if (!selected) return "";
|
||||
return getDisplayName(selected);
|
||||
}, [selected, getDisplayName]);
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
@@ -93,7 +84,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{`${name(source.Name)}`}
|
||||
{getDisplayName(source)}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FlashList, type FlashListProps } from "@shopify/flash-list";
|
||||
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import React, { useImperativeHandle, useRef } from "react";
|
||||
import { View, type ViewStyle } from "react-native";
|
||||
import { Text } from "./Text";
|
||||
|
||||
@@ -19,64 +19,58 @@ interface HorizontalScrollProps<T>
|
||||
keyExtractor?: (item: T, index: number) => string;
|
||||
containerStyle?: ViewStyle;
|
||||
contentContainerStyle?: ViewStyle;
|
||||
loadingContainerStyle?: ViewStyle;
|
||||
height?: number;
|
||||
loading?: boolean;
|
||||
extraData?: any;
|
||||
noItemsText?: string;
|
||||
}
|
||||
|
||||
export const HorizontalScroll = forwardRef<
|
||||
HorizontalScrollRef,
|
||||
HorizontalScrollProps<any>
|
||||
>(
|
||||
<T,>(
|
||||
{
|
||||
data = [],
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
loadingContainerStyle,
|
||||
loading = false,
|
||||
height = 164,
|
||||
extraData,
|
||||
noItemsText,
|
||||
...props
|
||||
}: HorizontalScrollProps<T>,
|
||||
ref: React.ForwardedRef<HorizontalScrollRef>,
|
||||
) => {
|
||||
const flashListRef = useRef<FlashList<T>>(null);
|
||||
export const HorizontalScroll = <T,>(
|
||||
props: HorizontalScrollProps<T> & {
|
||||
ref?: React.ForwardedRef<HorizontalScrollRef>;
|
||||
},
|
||||
) => {
|
||||
const {
|
||||
data = [],
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
loading = false,
|
||||
height = 164,
|
||||
extraData,
|
||||
noItemsText,
|
||||
ref,
|
||||
...restProps
|
||||
} = props;
|
||||
const flashListRef = useRef<FlashList<T>>(null);
|
||||
|
||||
useImperativeHandle(ref!, () => ({
|
||||
scrollToIndex: (index: number, viewOffset: number) => {
|
||||
flashListRef.current?.scrollToIndex({
|
||||
index,
|
||||
animated: true,
|
||||
viewPosition: 0,
|
||||
viewOffset,
|
||||
});
|
||||
},
|
||||
}));
|
||||
useImperativeHandle(ref!, () => ({
|
||||
scrollToIndex: (index: number, viewOffset: number) => {
|
||||
flashListRef.current?.scrollToIndex({
|
||||
index,
|
||||
animated: true,
|
||||
viewPosition: 0,
|
||||
viewOffset,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const renderFlashListItem = ({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: T;
|
||||
index: number;
|
||||
}) => <View className='mr-2'>{renderItem(item, index)}</View>;
|
||||
|
||||
if (!data || loading) {
|
||||
return (
|
||||
<View className='px-4 mb-2'>
|
||||
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
|
||||
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
|
||||
<View className='mr-2'>{renderItem(item, index)}</View>
|
||||
);
|
||||
|
||||
if (!data || loading) {
|
||||
return (
|
||||
<View className='px-4 mb-2'>
|
||||
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
|
||||
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<FlashList<T>
|
||||
ref={flashListRef}
|
||||
data={data}
|
||||
@@ -97,8 +91,8 @@ export const HorizontalScroll = forwardRef<
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{...props}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -157,7 +157,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
<TouchableOpacity
|
||||
disabled={cancelJobMutation.isPending}
|
||||
onPress={() => cancelJobMutation.mutate(process.id)}
|
||||
className='ml-auto'
|
||||
className='ml-auto p-2 rounded-full'
|
||||
>
|
||||
{cancelJobMutation.isPending ? (
|
||||
<ActivityIndicator size='small' color='white' />
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { debounce } from "lodash";
|
||||
import {
|
||||
type Dispatch,
|
||||
@@ -41,10 +40,8 @@ import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {
|
||||
formatTimeString,
|
||||
@@ -124,7 +121,6 @@ export const Controls: FC<Props> = ({
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const [episodeView, setEpisodeView] = useState(false);
|
||||
const [isSliding, setIsSliding] = useState(false);
|
||||
@@ -346,7 +342,9 @@ export const Controls: FC<Props> = ({
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined,
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
...(offline && { offline: "true" }),
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||
@@ -438,25 +436,6 @@ export const Controls: FC<Props> = ({
|
||||
[goToNextItem],
|
||||
);
|
||||
|
||||
const goToItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (offline) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId,
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
return;
|
||||
}
|
||||
const gotoItem = await getItemById(api, itemId);
|
||||
if (!gotoItem) return;
|
||||
goToItemCommon(gotoItem);
|
||||
},
|
||||
[goToItemCommon, api],
|
||||
);
|
||||
|
||||
const updateTimes = useCallback(
|
||||
(currentProgress: number, maxValue: number) => {
|
||||
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
|
||||
@@ -715,7 +694,7 @@ export const Controls: FC<Props> = ({
|
||||
<EpisodeList
|
||||
item={item}
|
||||
close={() => setEpisodeView(false)}
|
||||
goToItem={goToItem}
|
||||
goToItem={goToItemCommon}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -27,7 +27,7 @@ import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
close: () => void;
|
||||
goToItem: (itemId: string) => Promise<void>;
|
||||
goToItem: (item: BaseItemDto) => void;
|
||||
};
|
||||
|
||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||
@@ -221,23 +221,24 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
ref={scrollViewRef}
|
||||
data={episodes}
|
||||
extraData={item}
|
||||
renderItem={(_item, _idx) => (
|
||||
// Note otherItem is the item that is being rendered, not the item that is currently selected
|
||||
renderItem={(otherItem, _idx) => (
|
||||
<View
|
||||
key={_item.Id}
|
||||
key={otherItem.Id}
|
||||
style={{}}
|
||||
className={`flex flex-col w-44 ${
|
||||
item.Id !== _item.Id ? "opacity-75" : ""
|
||||
item.Id !== otherItem.Id ? "opacity-75" : ""
|
||||
}`}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
goToItem(_item.Id);
|
||||
goToItem(otherItem);
|
||||
}}
|
||||
>
|
||||
<ContinueWatchingPoster
|
||||
item={_item}
|
||||
item={otherItem}
|
||||
useEpisodePoster
|
||||
showPlayButton={_item.Id !== item.Id}
|
||||
showPlayButton={otherItem.Id !== item.Id}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View className='shrink'>
|
||||
@@ -248,20 +249,20 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
height: 36, // lineHeight * 2 for consistent two-line space
|
||||
}}
|
||||
>
|
||||
{_item.Name}
|
||||
{otherItem.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-xs text-neutral-475'>
|
||||
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
|
||||
{`S${otherItem.ParentIndexNumber?.toString()}:E${otherItem.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
<Text className='text-xs text-neutral-500'>
|
||||
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||
{runtimeTicksToSeconds(otherItem.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={5}
|
||||
numberOfLines={7}
|
||||
className='text-xs text-neutral-500 shrink'
|
||||
>
|
||||
{_item.Overview}
|
||||
{otherItem.Overview}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -181,12 +181,11 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
}
|
||||
if (getAudioTracks) {
|
||||
const audioData = await getAudioTracks();
|
||||
|
||||
const allAudio =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
||||
if (!mediaSource?.TranscodingUrl) {
|
||||
const vlcIndex = audioData?.at(idx)?.index ?? -1;
|
||||
const vlcIndex = audioData?.at(idx + 1)?.index ?? -1;
|
||||
return {
|
||||
name: audio.DisplayTitle ?? "Undefined Audio",
|
||||
index: audio.Index ?? -1,
|
||||
@@ -201,6 +200,15 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
|
||||
};
|
||||
});
|
||||
|
||||
// Add a "Disable Audio" option if its not transcoding.
|
||||
if (!mediaSource?.TranscodingUrl) {
|
||||
audioTracks.unshift({
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
setTrack: () => setTrackParams("audio", -1, -1),
|
||||
});
|
||||
}
|
||||
setAudioTracks(audioTracks);
|
||||
}
|
||||
};
|
||||
|
||||
6
eas.json
6
eas.json
@@ -46,14 +46,14 @@
|
||||
},
|
||||
"production": {
|
||||
"environment": "production",
|
||||
"channel": "0.29.13",
|
||||
"channel": "0.30.2",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"environment": "production",
|
||||
"channel": "0.29.13",
|
||||
"channel": "0.30.2",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
@@ -61,7 +61,7 @@
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"environment": "production",
|
||||
"channel": "0.29.13",
|
||||
"channel": "0.30.2",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -152,22 +152,37 @@ export const usePlaybackManager = ({
|
||||
|
||||
// Handle local state update for downloaded items
|
||||
if (localItem) {
|
||||
const runTimeTicks = localItem.item.RunTimeTicks ?? 0;
|
||||
const playedPercentage =
|
||||
runTimeTicks > 0 ? (positionTicks / runTimeTicks) * 100 : 0;
|
||||
|
||||
// Jellyfin thresholds
|
||||
const MINIMUM_PERCENTAGE = 5; // 5% minimum to save progress
|
||||
const PLAYED_THRESHOLD_PERCENTAGE = 90; // 90% to mark as played
|
||||
|
||||
const isItemConsideredPlayed =
|
||||
(localItem.item.UserData?.PlayedPercentage ?? 0) > 90;
|
||||
playedPercentage > PLAYED_THRESHOLD_PERCENTAGE;
|
||||
const meetsMinimumPercentage = playedPercentage >= MINIMUM_PERCENTAGE;
|
||||
|
||||
const shouldSaveProgress =
|
||||
meetsMinimumPercentage && !isItemConsideredPlayed;
|
||||
|
||||
updateDownloadedItem(itemId, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...localItem.item.UserData,
|
||||
PlaybackPositionTicks: isItemConsideredPlayed
|
||||
? 0
|
||||
: Math.floor(positionTicks),
|
||||
PlaybackPositionTicks:
|
||||
isItemConsideredPlayed || !shouldSaveProgress
|
||||
? 0
|
||||
: Math.floor(positionTicks),
|
||||
Played: isItemConsideredPlayed,
|
||||
LastPlayedDate: new Date().toISOString(),
|
||||
PlayedPercentage: isItemConsideredPlayed
|
||||
? 0
|
||||
: (positionTicks / localItem.item.RunTimeTicks!) * 100,
|
||||
PlayedPercentage:
|
||||
isItemConsideredPlayed || !shouldSaveProgress
|
||||
? 0
|
||||
: playedPercentage,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"react-native-ios-context-menu": "^3.1.0",
|
||||
"react-native-ios-utilities": "5.1.8",
|
||||
"react-native-mmkv": "2.12.2",
|
||||
"react-native-pager-view": "^6.9.1",
|
||||
"react-native-reanimated": "~3.16.7",
|
||||
"react-native-reanimated-carousel": "4.0.2",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
|
||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.29.13" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.30.2" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -93,7 +93,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.29.13"`,
|
||||
}, DeviceId="${deviceId}", Version="0.30.2"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
|
||||
@@ -3,9 +3,136 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models/base-item-kind";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import download from "@/utils/profiles/download";
|
||||
|
||||
interface StreamResult {
|
||||
url: string;
|
||||
sessionId: string | null;
|
||||
mediaSource: MediaSourceInfo | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the actual streaming URL - handles both transcoded and direct play logic
|
||||
* Returns only the URL string
|
||||
*/
|
||||
const getPlaybackUrl = (
|
||||
api: Api,
|
||||
itemId: string,
|
||||
mediaSource: MediaSourceInfo | undefined,
|
||||
params: {
|
||||
subtitleStreamIndex?: number;
|
||||
audioStreamIndex?: number;
|
||||
deviceId?: string | null;
|
||||
startTimeTicks?: number;
|
||||
maxStreamingBitrate?: number;
|
||||
userId: string;
|
||||
playSessionId?: string | null;
|
||||
container?: string;
|
||||
static?: string;
|
||||
},
|
||||
): string => {
|
||||
let transcodeUrl = mediaSource?.TranscodingUrl;
|
||||
|
||||
// Handle transcoded URL if available
|
||||
if (transcodeUrl) {
|
||||
// For regular streaming, change subtitle method to HLS for transcoded URL
|
||||
if (params.subtitleStreamIndex === -1) {
|
||||
transcodeUrl = transcodeUrl.replace(
|
||||
"SubtitleMethod=Encode",
|
||||
"SubtitleMethod=Hls",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Video is being transcoded:", transcodeUrl);
|
||||
return `${api.basePath}${transcodeUrl}`;
|
||||
}
|
||||
|
||||
// Fall back to direct play
|
||||
const streamParams = new URLSearchParams({
|
||||
static: params.static || "true",
|
||||
container: params.container || "mp4",
|
||||
mediaSourceId: mediaSource?.Id || "",
|
||||
subtitleStreamIndex: params.subtitleStreamIndex?.toString() || "",
|
||||
audioStreamIndex: params.audioStreamIndex?.toString() || "",
|
||||
deviceId: params.deviceId || api.deviceInfo.id,
|
||||
api_key: api.accessToken,
|
||||
startTimeTicks: params.startTimeTicks?.toString() || "0",
|
||||
maxStreamingBitrate: params.maxStreamingBitrate?.toString() || "",
|
||||
userId: params.userId,
|
||||
});
|
||||
|
||||
// Add additional parameters if provided
|
||||
if (params.playSessionId) {
|
||||
streamParams.append("playSessionId", params.playSessionId);
|
||||
}
|
||||
|
||||
const directPlayUrl = `${api.basePath}/Videos/${itemId}/stream?${streamParams.toString()}`;
|
||||
|
||||
console.log("Video is being direct played:", directPlayUrl);
|
||||
return directPlayUrl;
|
||||
};
|
||||
|
||||
/** Wrapper around {@link getPlaybackUrl} that applies download-specific transformations */
|
||||
const getDownloadUrl = (
|
||||
api: Api,
|
||||
itemId: string,
|
||||
mediaSource: MediaSourceInfo | undefined,
|
||||
sessionId: string | null | undefined,
|
||||
params: {
|
||||
subtitleStreamIndex?: number;
|
||||
audioStreamIndex?: number;
|
||||
deviceId?: string | null;
|
||||
startTimeTicks?: number;
|
||||
maxStreamingBitrate?: number;
|
||||
userId: string;
|
||||
playSessionId?: string | null;
|
||||
},
|
||||
): StreamResult => {
|
||||
// First, handle download-specific transcoding modifications
|
||||
let downloadMediaSource = mediaSource;
|
||||
if (mediaSource?.TranscodingUrl) {
|
||||
downloadMediaSource = {
|
||||
...mediaSource,
|
||||
TranscodingUrl: mediaSource.TranscodingUrl.replace(
|
||||
"master.m3u8",
|
||||
"stream",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Get the base URL with download-specific parameters
|
||||
let url = getPlaybackUrl(api, itemId, downloadMediaSource, {
|
||||
...params,
|
||||
container: "ts",
|
||||
static: "false",
|
||||
});
|
||||
|
||||
// If it's a direct play URL, add download-specific parameters
|
||||
if (!mediaSource?.TranscodingUrl) {
|
||||
const urlObj = new URL(url);
|
||||
const downloadParams = {
|
||||
subtitleMethod: "Embed",
|
||||
enableSubtitlesInManifest: "true",
|
||||
allowVideoStreamCopy: "true",
|
||||
allowAudioStreamCopy: "true",
|
||||
};
|
||||
|
||||
Object.entries(downloadParams).forEach(([key, value]) => {
|
||||
urlObj.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
url = urlObj.toString();
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
sessionId: sessionId || null,
|
||||
mediaSource,
|
||||
};
|
||||
};
|
||||
|
||||
export const getStreamUrl = async ({
|
||||
api,
|
||||
item,
|
||||
@@ -44,6 +171,47 @@ export const getStreamUrl = async ({
|
||||
let mediaSource: MediaSourceInfo | undefined;
|
||||
let sessionId: string | null | undefined;
|
||||
|
||||
// Please do not remove this we need this for live TV to be working correctly.
|
||||
if (item.Type === BaseItemKind.Program) {
|
||||
console.log("Item is of type program...");
|
||||
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
{
|
||||
userId,
|
||||
itemId: item.ChannelId!,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
params: {
|
||||
startTimeTicks: 0,
|
||||
isPlayback: true,
|
||||
autoOpenLiveStream: true,
|
||||
maxStreamingBitrate,
|
||||
audioStreamIndex,
|
||||
},
|
||||
data: {
|
||||
deviceProfile,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
sessionId = res.data.PlaySessionId || null;
|
||||
mediaSource = res.data.MediaSources?.[0];
|
||||
const url = getPlaybackUrl(api, item.ChannelId!, mediaSource, {
|
||||
subtitleStreamIndex,
|
||||
audioStreamIndex,
|
||||
deviceId,
|
||||
startTimeTicks: 0,
|
||||
maxStreamingBitrate,
|
||||
userId,
|
||||
});
|
||||
|
||||
return {
|
||||
url,
|
||||
sessionId: sessionId || null,
|
||||
mediaSource,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
{
|
||||
itemId: item.Id!,
|
||||
@@ -70,46 +238,20 @@ export const getStreamUrl = async ({
|
||||
|
||||
sessionId = res.data.PlaySessionId || null;
|
||||
mediaSource = res.data.MediaSources?.[0];
|
||||
let transcodeUrl = mediaSource?.TranscodingUrl;
|
||||
|
||||
if (transcodeUrl) {
|
||||
// We need to change the subtitle method to hls for the transcoded url.
|
||||
if (subtitleStreamIndex === -1) {
|
||||
transcodeUrl = transcodeUrl.replace(
|
||||
"SubtitleMethod=Encode",
|
||||
"SubtitleMethod=Hls",
|
||||
);
|
||||
}
|
||||
console.log("Video is being transcoded:", transcodeUrl);
|
||||
return {
|
||||
url: `${api.basePath}${transcodeUrl}`,
|
||||
sessionId,
|
||||
mediaSource,
|
||||
};
|
||||
}
|
||||
|
||||
const streamParams = new URLSearchParams({
|
||||
static: "true",
|
||||
container: "mp4",
|
||||
mediaSourceId: mediaSource?.Id || "",
|
||||
subtitleStreamIndex: subtitleStreamIndex?.toString() || "",
|
||||
audioStreamIndex: audioStreamIndex?.toString() || "",
|
||||
deviceId: deviceId || api.deviceInfo.id,
|
||||
api_key: api.accessToken,
|
||||
startTimeTicks: startTimeTicks.toString(),
|
||||
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
|
||||
userId: userId,
|
||||
const url = getPlaybackUrl(api, item.Id!, mediaSource, {
|
||||
subtitleStreamIndex,
|
||||
audioStreamIndex,
|
||||
deviceId,
|
||||
startTimeTicks,
|
||||
maxStreamingBitrate,
|
||||
userId,
|
||||
playSessionId: playSessionId || undefined,
|
||||
});
|
||||
|
||||
const directPlayUrl = `${
|
||||
api.basePath
|
||||
}/Videos/${item.Id}/stream?${streamParams.toString()}`;
|
||||
|
||||
console.log("Video is being direct played:", directPlayUrl);
|
||||
|
||||
return {
|
||||
url: directPlayUrl,
|
||||
sessionId: sessionId || playSessionId || null,
|
||||
url,
|
||||
sessionId: sessionId || null,
|
||||
mediaSource,
|
||||
};
|
||||
};
|
||||
@@ -142,9 +284,6 @@ export const getDownloadStreamUrl = async ({
|
||||
return null;
|
||||
}
|
||||
|
||||
let mediaSource: MediaSourceInfo | undefined;
|
||||
let sessionId: string | null | undefined;
|
||||
|
||||
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
{
|
||||
itemId: item.Id!,
|
||||
@@ -169,53 +308,16 @@ export const getDownloadStreamUrl = async ({
|
||||
console.error("Error getting playback info:", res.status, res.statusText);
|
||||
}
|
||||
|
||||
sessionId = res.data.PlaySessionId || null;
|
||||
mediaSource = res.data.MediaSources?.[0];
|
||||
let transcodeUrl = mediaSource?.TranscodingUrl;
|
||||
const sessionId = res.data.PlaySessionId || null;
|
||||
const mediaSource = res.data.MediaSources?.[0];
|
||||
|
||||
if (transcodeUrl) {
|
||||
transcodeUrl = transcodeUrl.replace("master.m3u8", "stream");
|
||||
console.log("Video is being transcoded:", transcodeUrl);
|
||||
return {
|
||||
url: `${api.basePath}${transcodeUrl}`,
|
||||
sessionId,
|
||||
mediaSource,
|
||||
};
|
||||
}
|
||||
|
||||
const downloadParams = {
|
||||
// We need to disable static so we can have a remux with subtitle.
|
||||
subtitleMethod: "Embed",
|
||||
enableSubtitlesInManifest: true,
|
||||
allowVideoStreamCopy: true,
|
||||
allowAudioStreamCopy: true,
|
||||
playSessionId: sessionId || "",
|
||||
};
|
||||
|
||||
const streamParams = new URLSearchParams({
|
||||
static: "false",
|
||||
container: "ts",
|
||||
mediaSourceId: mediaSource?.Id || "",
|
||||
subtitleStreamIndex: subtitleStreamIndex?.toString() || "",
|
||||
audioStreamIndex: audioStreamIndex?.toString() || "",
|
||||
deviceId: deviceId || api.deviceInfo.id,
|
||||
api_key: api.accessToken,
|
||||
startTimeTicks: "0",
|
||||
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
|
||||
userId: userId,
|
||||
return getDownloadUrl(api, item.Id!, mediaSource, sessionId, {
|
||||
subtitleStreamIndex,
|
||||
audioStreamIndex,
|
||||
deviceId,
|
||||
startTimeTicks: 0,
|
||||
maxStreamingBitrate,
|
||||
userId,
|
||||
playSessionId: sessionId || undefined,
|
||||
});
|
||||
|
||||
Object.entries(downloadParams).forEach(([key, value]) => {
|
||||
streamParams.append(key, value.toString());
|
||||
});
|
||||
|
||||
const directPlayUrl = `${
|
||||
api.basePath
|
||||
}/Videos/${item.Id}/stream?${streamParams.toString()}`;
|
||||
|
||||
return {
|
||||
url: directPlayUrl,
|
||||
sessionId: sessionId || null,
|
||||
mediaSource,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user