Compare commits

...

4 Commits

Author SHA1 Message Date
Fredrik Burmester
aff009de92 chore: version 2025-08-18 07:48:57 +02:00
Alex
1924efbef2 Fix more bugs (#939)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-17 15:25:51 +10:00
Alex
3b53d76a18 Hotfix/offline playback remaining bugs (#937)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-16 18:11:55 +10:00
Fredrik Burmester
b7221e5599 chore: version bump 2025-08-15 21:45:37 +02:00
16 changed files with 440 additions and 283 deletions

View File

@@ -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",

View File

@@ -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) => {

View File

@@ -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();

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>
))}

View File

@@ -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>
);
};

View File

@@ -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' />

View File

@@ -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}
/>
) : (
<>

View File

@@ -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>
)}

View File

@@ -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);
}
};

View File

@@ -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"

View File

@@ -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,
},
},
});

View File

@@ -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",

View File

@@ -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]);

View File

@@ -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,
};
};