mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
Fix more bugs (#939)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user