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

Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
This commit is contained in:
Alex
2025-08-17 15:25:51 +10:00
committed by GitHub
parent 3b53d76a18
commit 1924efbef2
6 changed files with 119 additions and 112 deletions

View File

@@ -39,26 +39,44 @@ export default function page() {
} }
}, [getDownloadedItems]); }, [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 = const seasonIndex =
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] || seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
episodeSeasonIndex || episodeSeasonIndex ||
""; "";
const groupBySeason = useMemo<BaseItemDto[]>(() => { const groupBySeason = useMemo<BaseItemDto[]>(() => {
const seasons: Record<string, BaseItemDto[]> = {}; return seasonGroups[Number(seasonIndex)] ?? [];
}, [seasonGroups, seasonIndex]);
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]);
const initialSeasonIndex = useMemo( const initialSeasonIndex = useMemo(
() => () =>
@@ -102,7 +120,7 @@ export default function page() {
<View className='flex flex-row items-center justify-start my-2 px-4'> <View className='flex flex-row items-center justify-start my-2 px-4'>
<SeasonDropdown <SeasonDropdown
item={series[0].item} item={series[0].item}
seasons={series.map((s) => s.item)} seasons={uniqueSeasons}
state={seasonIndexState} state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!} initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => { onSelect={(season) => {

View File

@@ -1,5 +1,5 @@
import { FlashList, type FlashListProps } from "@shopify/flash-list"; 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 { View, type ViewStyle } from "react-native";
import { Text } from "./Text"; import { Text } from "./Text";
@@ -19,64 +19,58 @@ interface HorizontalScrollProps<T>
keyExtractor?: (item: T, index: number) => string; keyExtractor?: (item: T, index: number) => string;
containerStyle?: ViewStyle; containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle; contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
height?: number; height?: number;
loading?: boolean; loading?: boolean;
extraData?: any; extraData?: any;
noItemsText?: string; noItemsText?: string;
} }
export const HorizontalScroll = forwardRef< export const HorizontalScroll = <T,>(
HorizontalScrollRef, props: HorizontalScrollProps<T> & {
HorizontalScrollProps<any> ref?: React.ForwardedRef<HorizontalScrollRef>;
>( },
<T,>( ) => {
{ const {
data = [], data = [],
keyExtractor, keyExtractor,
renderItem, renderItem,
containerStyle, containerStyle,
contentContainerStyle, contentContainerStyle,
loadingContainerStyle, loading = false,
loading = false, height = 164,
height = 164, extraData,
extraData, noItemsText,
noItemsText, ref,
...props ...restProps
}: HorizontalScrollProps<T>, } = props;
ref: React.ForwardedRef<HorizontalScrollRef>, const flashListRef = useRef<FlashList<T>>(null);
) => {
const flashListRef = useRef<FlashList<T>>(null);
useImperativeHandle(ref!, () => ({ useImperativeHandle(ref!, () => ({
scrollToIndex: (index: number, viewOffset: number) => { scrollToIndex: (index: number, viewOffset: number) => {
flashListRef.current?.scrollToIndex({ flashListRef.current?.scrollToIndex({
index, index,
animated: true, animated: true,
viewPosition: 0, viewPosition: 0,
viewOffset, viewOffset,
}); });
}, },
})); }));
const renderFlashListItem = ({ const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
item, <View className='mr-2'>{renderItem(item, index)}</View>
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>
);
}
if (!data || loading) {
return ( 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> <FlashList<T>
ref={flashListRef} ref={flashListRef}
data={data} data={data}
@@ -97,8 +91,8 @@ export const HorizontalScroll = forwardRef<
</Text> </Text>
</View> </View>
)} )}
{...props} {...restProps}
/> />
); </View>
}, );
); };

View File

@@ -157,7 +157,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
<TouchableOpacity <TouchableOpacity
disabled={cancelJobMutation.isPending} disabled={cancelJobMutation.isPending}
onPress={() => cancelJobMutation.mutate(process.id)} onPress={() => cancelJobMutation.mutate(process.id)}
className='ml-auto' className='ml-auto p-2 rounded-full'
> >
{cancelJobMutation.isPending ? ( {cancelJobMutation.isPending ? (
<ActivityIndicator size='small' color='white' /> <ActivityIndicator size='small' color='white' />

View File

@@ -5,7 +5,6 @@ import type {
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useAtom } from "jotai";
import { debounce } from "lodash"; import { debounce } from "lodash";
import { import {
type Dispatch, type Dispatch,
@@ -41,10 +40,8 @@ import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay"; import { useTrickplay } from "@/hooks/useTrickplay";
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types"; import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings, VideoPlayer } from "@/utils/atoms/settings"; import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { import {
formatTimeString, formatTimeString,
@@ -124,7 +121,6 @@ export const Controls: FC<Props> = ({
const [settings, updateSettings] = useSettings(); const [settings, updateSettings] = useSettings();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [episodeView, setEpisodeView] = useState(false); const [episodeView, setEpisodeView] = useState(false);
const [isSliding, setIsSliding] = useState(false); const [isSliding, setIsSliding] = useState(false);
@@ -346,7 +342,9 @@ export const Controls: FC<Props> = ({
previousIndexes, previousIndexes,
mediaSource ?? undefined, mediaSource ?? undefined,
); );
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
...(offline && { offline: "true" }),
itemId: item.Id ?? "", itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "", audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "", subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
@@ -438,25 +436,6 @@ export const Controls: FC<Props> = ({
[goToNextItem], [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( const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => { (currentProgress: number, maxValue: number) => {
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress); const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
@@ -715,7 +694,7 @@ export const Controls: FC<Props> = ({
<EpisodeList <EpisodeList
item={item} item={item}
close={() => setEpisodeView(false)} close={() => setEpisodeView(false)}
goToItem={goToItem} goToItem={goToItemCommon}
/> />
) : ( ) : (
<> <>

View File

@@ -27,7 +27,7 @@ import { runtimeTicksToSeconds } from "@/utils/time";
type Props = { type Props = {
item: BaseItemDto; item: BaseItemDto;
close: () => void; close: () => void;
goToItem: (itemId: string) => Promise<void>; goToItem: (item: BaseItemDto) => void;
}; };
export const seasonIndexAtom = atom<SeasonIndexState>({}); export const seasonIndexAtom = atom<SeasonIndexState>({});
@@ -221,23 +221,24 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
ref={scrollViewRef} ref={scrollViewRef}
data={episodes} data={episodes}
extraData={item} 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 <View
key={_item.Id} key={otherItem.Id}
style={{}} style={{}}
className={`flex flex-col w-44 ${ className={`flex flex-col w-44 ${
item.Id !== _item.Id ? "opacity-75" : "" item.Id !== otherItem.Id ? "opacity-75" : ""
}`} }`}
> >
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
goToItem(_item.Id); goToItem(otherItem);
}} }}
> >
<ContinueWatchingPoster <ContinueWatchingPoster
item={_item} item={otherItem}
useEpisodePoster useEpisodePoster
showPlayButton={_item.Id !== item.Id} showPlayButton={otherItem.Id !== item.Id}
/> />
</TouchableOpacity> </TouchableOpacity>
<View className='shrink'> <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 height: 36, // lineHeight * 2 for consistent two-line space
}} }}
> >
{_item.Name} {otherItem.Name}
</Text> </Text>
<Text numberOfLines={1} className='text-xs text-neutral-475'> <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>
<Text className='text-xs text-neutral-500'> <Text className='text-xs text-neutral-500'>
{runtimeTicksToSeconds(_item.RunTimeTicks)} {runtimeTicksToSeconds(otherItem.RunTimeTicks)}
</Text> </Text>
</View> </View>
<Text <Text
numberOfLines={5} numberOfLines={7}
className='text-xs text-neutral-500 shrink' className='text-xs text-neutral-500 shrink'
> >
{_item.Overview} {otherItem.Overview}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -152,22 +152,37 @@ export const usePlaybackManager = ({
// Handle local state update for downloaded items // Handle local state update for downloaded items
if (localItem) { 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 = const isItemConsideredPlayed =
(localItem.item.UserData?.PlayedPercentage ?? 0) > 90; playedPercentage > PLAYED_THRESHOLD_PERCENTAGE;
const meetsMinimumPercentage = playedPercentage >= MINIMUM_PERCENTAGE;
const shouldSaveProgress =
meetsMinimumPercentage && !isItemConsideredPlayed;
updateDownloadedItem(itemId, { updateDownloadedItem(itemId, {
...localItem, ...localItem,
item: { item: {
...localItem.item, ...localItem.item,
UserData: { UserData: {
...localItem.item.UserData, ...localItem.item.UserData,
PlaybackPositionTicks: isItemConsideredPlayed PlaybackPositionTicks:
? 0 isItemConsideredPlayed || !shouldSaveProgress
: Math.floor(positionTicks), ? 0
: Math.floor(positionTicks),
Played: isItemConsideredPlayed, Played: isItemConsideredPlayed,
LastPlayedDate: new Date().toISOString(), LastPlayedDate: new Date().toISOString(),
PlayedPercentage: isItemConsideredPlayed PlayedPercentage:
? 0 isItemConsideredPlayed || !shouldSaveProgress
: (positionTicks / localItem.item.RunTimeTicks!) * 100, ? 0
: playedPercentage,
}, },
}, },
}); });