Compare commits

..

3 Commits

Author SHA1 Message Date
Uruk
5e81576c68 feat(home): adds pull to refresh functionality
Adds pull-to-refresh functionality on the home screen.

This change enhances user experience by allowing users to
manually refresh the home screen content. It also ensures
that the loading indicator is correctly managed during
refresh operations, using a try-finally block.
2026-02-11 19:09:01 +01:00
Uruk
a16d73688f feat(home): refresh data on app foreground
adds a hook that refetches home data when the app
returns to the foreground or starts from a cold start.

this ensures the home screen always displays the
most up-to-date content.
2026-02-10 23:10:07 +01:00
Uruk
ac0d3fcb86 refactor(item): improves top people display logic
Improves the logic for displaying the top people in the item sections.

The previous implementation simply sliced the people array which
could result in duplicate people being displayed.

This change ensures that the top people displayed are unique by
filtering duplicates based on their id, limiting the number of
displayed people to three.
2026-02-10 19:13:44 +01:00
16 changed files with 152 additions and 529 deletions

View File

@@ -48,7 +48,6 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { import {
getMpvAudioId, getMpvAudioId,
@@ -134,7 +133,7 @@ export default function page() {
const audioIndexFromUrl = audioIndexStr const audioIndexFromUrl = audioIndexStr
? Number.parseInt(audioIndexStr, 10) ? Number.parseInt(audioIndexStr, 10)
: undefined; : undefined;
const subtitleIndexFromUrl = subtitleIndexStr const subtitleIndex = subtitleIndexStr
? Number.parseInt(subtitleIndexStr, 10) ? Number.parseInt(subtitleIndexStr, 10)
: -1; : -1;
const bitrateValue = bitrateValueStr const bitrateValue = bitrateValueStr
@@ -161,24 +160,6 @@ export default function page() {
return undefined; return undefined;
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]); }, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
// Resolve subtitle index: use URL param if provided, otherwise use stored index for offline playback
const subtitleIndex = useMemo(() => {
if (subtitleIndexFromUrl !== undefined) {
return subtitleIndexFromUrl;
}
if (
offline &&
downloadedItem?.userData?.subtitleStreamIndex !== undefined
) {
return downloadedItem.userData.subtitleStreamIndex;
}
return -1;
}, [
subtitleIndexFromUrl,
offline,
downloadedItem?.userData?.subtitleStreamIndex,
]);
// Get the playback speed for this item based on settings // Get the playback speed for this item based on settings
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed( const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
item, item,
@@ -424,8 +405,8 @@ export default function page() {
return { return {
ItemId: item.Id, ItemId: item.Id,
AudioStreamIndex: audioIndex, AudioStreamIndex: audioIndex ? audioIndex : undefined,
SubtitleStreamIndex: subtitleIndex, SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
MediaSourceId: mediaSourceId, MediaSourceId: mediaSourceId,
PositionTicks: msToTicks(progress.get()), PositionTicks: msToTicks(progress.get()),
IsPaused: !isPlaying, IsPaused: !isPlaying,
@@ -523,31 +504,6 @@ export default function page() {
return ticksToSeconds(getInitialPlaybackTicks()); return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]); }, [getInitialPlaybackTicks]);
/** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */
const nowPlayingMetadata = useMemo(() => {
if (!item || !api) return undefined;
const artworkUri = getPrimaryImageUrl({
api,
item,
quality: 90,
width: 500,
});
return {
title: item.Name || "",
artist:
item.Type === "Episode"
? item.SeriesName || ""
: item.AlbumArtist || "",
albumTitle:
item.Type === "Episode" && item.SeasonName
? item.SeasonName
: undefined,
artworkUri: artworkUri || undefined,
};
}, [item, api]);
/** Build video source config for MPV */ /** Build video source config for MPV */
const videoSource = useMemo<MpvVideoSource | undefined>(() => { const videoSource = useMemo<MpvVideoSource | undefined>(() => {
if (!stream?.url) return undefined; if (!stream?.url) return undefined;
@@ -976,7 +932,6 @@ export default function page() {
ref={videoRef} ref={videoRef}
source={videoSource} source={videoSource}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
nowPlayingMetadata={nowPlayingMetadata}
onProgress={onProgress} onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged} onPlaybackStateChange={onPlaybackStateChanged}
onLoad={() => setIsVideoLoaded(true)} onLoad={() => setIsVideoLoaded(true)}

View File

@@ -25,7 +25,6 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -54,9 +53,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, itemWithSources }) => { ({ item, itemWithSources }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { getDownloadedItemById } = useDownload();
const downloadedItem =
isOffline && item.Id ? getDownloadedItemById(item.Id) : null;
const { settings } = useSettings(); const { settings } = useSettings();
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const navigation = useNavigation(); const navigation = useNavigation();
@@ -95,29 +91,17 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
// Needs to automatically change the selected to the default values for default indexes. // Needs to automatically change the selected to the default values for default indexes.
useEffect(() => { useEffect(() => {
// When offline, use the indices stored in userData (the last-used tracks for this file)
// rather than the server's defaults, so MediaSourceButton reflects what will actually play.
const offlineUserData = downloadedItem?.userData;
setSelectedOptions(() => ({ setSelectedOptions(() => ({
bitrate: defaultBitrate, bitrate: defaultBitrate,
mediaSource: defaultMediaSource ?? undefined, mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: subtitleIndex: defaultSubtitleIndex ?? -1,
offlineUserData && !offlineUserData.isTranscoded audioIndex: defaultAudioIndex,
? offlineUserData.subtitleStreamIndex
: (defaultSubtitleIndex ?? -1),
audioIndex:
offlineUserData && !offlineUserData.isTranscoded
? offlineUserData.audioStreamIndex
: defaultAudioIndex,
})); }));
}, [ }, [
defaultAudioIndex, defaultAudioIndex,
defaultBitrate, defaultBitrate,
defaultSubtitleIndex, defaultSubtitleIndex,
defaultMediaSource, defaultMediaSource,
downloadedItem?.userData?.audioStreamIndex,
downloadedItem?.userData?.subtitleStreamIndex,
]); ]);
useEffect(() => { useEffect(() => {
@@ -248,12 +232,14 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
colors={itemColors} colors={itemColors}
/> />
<View className='w-1' /> <View className='w-1' />
{!isOffline && (
<MediaSourceButton <MediaSourceButton
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions} setSelectedOptions={setSelectedOptions}
item={itemWithSources} item={itemWithSources}
colors={itemColors} colors={itemColors}
/> />
)}
</View> </View>
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (

View File

@@ -7,8 +7,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { useDownload } from "@/providers/DownloadProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { BITRATES } from "./BitRateSheet"; import { BITRATES } from "./BitRateSheet";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown"; import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
@@ -30,14 +28,6 @@ export const MediaSourceButton: React.FC<Props> = ({
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const isOffline = useOfflineMode();
const { getDownloadedItemById } = useDownload();
// For transcoded downloads there's only one burned-in track — nothing to pick
const isTranscodedDownload = useMemo(() => {
if (!isOffline || !item?.Id) return false;
return getDownloadedItemById(item.Id)?.userData?.isTranscoded === true;
}, [isOffline, item?.Id, getDownloadedItemById]);
const effectiveColors = colors || { const effectiveColors = colors || {
primary: "#7c3aed", primary: "#7c3aed",
@@ -82,7 +72,6 @@ export const MediaSourceButton: React.FC<Props> = ({
const optionGroups: OptionGroup[] = useMemo(() => { const optionGroups: OptionGroup[] = useMemo(() => {
const groups: OptionGroup[] = []; const groups: OptionGroup[] = [];
if (!isOffline) {
// Bitrate group // Bitrate group
groups.push({ groups.push({
title: t("item_card.quality"), title: t("item_card.quality"),
@@ -112,7 +101,6 @@ export const MediaSourceButton: React.FC<Props> = ({
})), })),
}); });
} }
}
// Audio track group // Audio track group
if (audioStreams.length > 0) { if (audioStreams.length > 0) {
@@ -162,7 +150,6 @@ export const MediaSourceButton: React.FC<Props> = ({
return groups; return groups;
}, [ }, [
item, item,
isOffline,
selectedOptions, selectedOptions,
audioStreams, audioStreams,
subtitleStreams, subtitleStreams,
@@ -191,8 +178,6 @@ export const MediaSourceButton: React.FC<Props> = ({
</TouchableOpacity> </TouchableOpacity>
); );
if (isTranscodedDownload) return null;
return ( return (
<PlatformDropdown <PlatformDropdown
groups={optionGroups} groups={optionGroups}

View File

@@ -96,23 +96,14 @@ export const PlayButton: React.FC<Props> = ({
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id!, itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0", playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false", offline: isOffline ? "true" : "false",
}); });
if (selectedOptions.audioIndex !== undefined) {
queryParams.set("audioIndex", selectedOptions.audioIndex.toString());
}
if (selectedOptions.subtitleIndex !== undefined) {
queryParams.set(
"subtitleIndex",
selectedOptions.subtitleIndex.toString(),
);
}
const queryString = queryParams.toString(); const queryString = queryParams.toString();
if (!client) { if (!client) {
@@ -301,29 +292,6 @@ export const PlayButton: React.FC<Props> = ({
t, t,
]); ]);
const buildOfflineQueryParams = useCallback(
(downloadedItem: NonNullable<ReturnType<typeof getDownloadedItemById>>) => {
const isTranscoded = downloadedItem.userData?.isTranscoded === true;
const audioIdx = isTranscoded
? downloadedItem.userData?.audioStreamIndex
: selectedOptions.audioIndex;
const subtitleIdx = isTranscoded
? downloadedItem.userData?.subtitleStreamIndex
: selectedOptions.subtitleIndex;
const params = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
if (audioIdx !== undefined) params.set("audioIndex", audioIdx.toString());
if (subtitleIdx !== undefined)
params.set("subtitleIndex", subtitleIdx.toString());
return params;
},
[item, selectedOptions],
);
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
if (!item) return; if (!item) return;
@@ -334,7 +302,13 @@ export const PlayButton: React.FC<Props> = ({
// If already in offline mode, play downloaded file directly // If already in offline mode, play downloaded file directly
if (isOffline && downloadedItem) { if (isOffline && downloadedItem) {
goToPlayer(buildOfflineQueryParams(downloadedItem).toString()); const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
return; return;
} }
@@ -357,9 +331,13 @@ export const PlayButton: React.FC<Props> = ({
<Button <Button
onPress={() => { onPress={() => {
hideModal(); hideModal();
goToPlayer( const queryParams = new URLSearchParams({
buildOfflineQueryParams(downloadedItem).toString(), itemId: item.Id!,
); offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}} }}
color='purple' color='purple'
> >
@@ -396,7 +374,13 @@ export const PlayButton: React.FC<Props> = ({
{ {
text: t("player.downloaded_file_yes"), text: t("player.downloaded_file_yes"),
onPress: () => { onPress: () => {
goToPlayer(buildOfflineQueryParams(downloadedItem).toString()); const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}, },
isPreferred: true, isPreferred: true,
}, },

View File

@@ -35,6 +35,7 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefetchHomeOnForeground } from "@/hooks/useRefetchHomeOnForeground";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider"; import { useIntroSheet } from "@/providers/IntroSheetProvider";
@@ -107,6 +108,9 @@ export const Home = () => {
prevIsConnected.current = isConnected; prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]); }, [isConnected, invalidateCache]);
// Refresh home data on mount (cold start) and when app returns to foreground
useRefetchHomeOnForeground();
const hasDownloads = useMemo(() => { const hasDownloads = useMemo(() => {
if (Platform.isTV) return false; if (Platform.isTV) return false;
return downloadedItems.length > 0; return downloadedItems.length > 0;
@@ -196,10 +200,13 @@ export const Home = () => {
const refetch = async () => { const refetch = async () => {
setLoading(true); setLoading(true);
try {
setLoadedSections(new Set()); setLoadedSections(new Set());
await refreshStreamyfinPluginSettings(); await refreshStreamyfinPluginSettings();
await invalidateCache(); await invalidateCache();
} finally {
setLoading(false); setLoading(false);
}
}; };
const createCollectionConfig = useCallback( const createCollectionConfig = useCallback(

View File

@@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Platform, Platform,
RefreshControl,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
@@ -37,6 +38,7 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefetchHomeOnForeground } from "@/hooks/useRefetchHomeOnForeground";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -67,8 +69,8 @@ export const HomeWithCarousel = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [_loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings(); const { settings, refreshStreamyfinPluginSettings } = useSettings();
const [loading, setLoading] = useState(false);
const headerOverlayOffset = Platform.isTV ? 0 : 60; const headerOverlayOffset = Platform.isTV ? 0 : 60;
const navigation = useNavigation(); const navigation = useNavigation();
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>(); const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
@@ -91,6 +93,19 @@ export const HomeWithCarousel = () => {
prevIsConnected.current = isConnected; prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]); }, [isConnected, invalidateCache]);
// Refresh home data on mount (cold start) and when app returns to foreground
useRefetchHomeOnForeground();
const refetch = async () => {
setLoading(true);
try {
await refreshStreamyfinPluginSettings();
await invalidateCache();
} finally {
setLoading(false);
}
};
const hasDownloads = useMemo(() => { const hasDownloads = useMemo(() => {
if (Platform.isTV) return false; if (Platform.isTV) return false;
return downloadedItems.length > 0; return downloadedItems.length > 0;
@@ -178,13 +193,6 @@ export const HomeWithCarousel = () => {
); );
}, [userViews]); }, [userViews]);
const _refetch = async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
};
const createCollectionConfig = useCallback( const createCollectionConfig = useCallback(
( (
title: string, title: string,
@@ -540,8 +548,18 @@ export const HomeWithCarousel = () => {
nestedScrollEnabled nestedScrollEnabled
contentInsetAdjustmentBehavior='never' contentInsetAdjustmentBehavior='never'
scrollEventThrottle={16} scrollEventThrottle={16}
bounces={false} bounces={!Platform.isTV}
overScrollMode='never' overScrollMode='never'
refreshControl={
!Platform.isTV ? (
<RefreshControl
refreshing={loading}
onRefresh={refetch}
tintColor='white'
colors={["white"]}
/>
) : undefined
}
style={{ marginTop: -headerOverlayOffset }} style={{ marginTop: -headerOverlayOffset }}
contentContainerStyle={{ paddingTop: headerOverlayOffset }} contentContainerStyle={{ paddingTop: headerOverlayOffset }}
onScroll={(event) => { onScroll={(event) => {
@@ -570,7 +588,7 @@ export const HomeWithCarousel = () => {
settings.streamyStatsPromotedWatchlists; settings.streamyStatsPromotedWatchlists;
const streamystatsSections = const streamystatsSections =
index === streamystatsIndex && hasStreamystatsContent ? ( index === streamystatsIndex && hasStreamystatsContent ? (
<> <View key='streamystats-sections'>
{settings.streamyStatsMovieRecommendations && ( {settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations <StreamystatsRecommendations
title={t( title={t(
@@ -590,7 +608,7 @@ export const HomeWithCarousel = () => {
{settings.streamyStatsPromotedWatchlists && ( {settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists /> <StreamystatsPromotedWatchlists />
)} )}
</> </View>
) : null; ) : null;
if (section.type === "InfiniteScrollingCollectionList") { if (section.type === "InfiniteScrollingCollectionList") {

View File

@@ -37,7 +37,18 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
return { ...item, People: people } as BaseItemDto; return { ...item, People: people } as BaseItemDto;
}, [item, people]); }, [item, people]);
const topPeople = useMemo(() => people.slice(0, 3), [people]); const topPeople = useMemo(() => {
const seen = new Set<string>();
const unique: BaseItemPerson[] = [];
for (const person of people) {
if (person.Id && !seen.has(person.Id)) {
seen.add(person.Id);
unique.push(person);
}
if (unique.length >= 3) break;
}
return unique;
}, [people]);
const renderActorSection = useCallback( const renderActorSection = useCallback(
(person: BaseItemPerson, idx: number, total: number) => { (person: BaseItemPerson, idx: number, total: number) => {

View File

@@ -1,7 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react"; import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { getDownloadedItemById } from "@/providers/Downloads/database";
import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
@@ -16,27 +15,12 @@ export const useDownloadedFileOpener = () => {
console.error("Attempted to open a file without an ID."); console.error("Attempted to open a file without an ID.");
return; return;
} }
const downloadedItem = getDownloadedItemById(item.Id);
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id, itemId: item.Id,
offline: "true", offline: "true",
playbackPosition: playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0", item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
}); });
if (downloadedItem?.userData?.audioStreamIndex !== undefined) {
queryParams.set(
"audioIndex",
downloadedItem.userData.audioStreamIndex.toString(),
);
}
if (downloadedItem?.userData?.subtitleStreamIndex !== undefined) {
queryParams.set(
"subtitleIndex",
downloadedItem.userData.subtitleStreamIndex.toString(),
);
}
try { try {
router.push(`/player/direct-player?${queryParams.toString()}`); router.push(`/player/direct-player?${queryParams.toString()}`);
} catch (error) { } catch (error) {

View File

@@ -186,20 +186,6 @@ export const usePlaybackManager = ({
: playedPercentage, : playedPercentage,
}, },
}, },
// Sync selected audio/subtitle tracks so next playback resumes with
// the same tracks the user had active — but only for non-transcoded
// downloads where the user can freely switch tracks.
userData: localItem.userData.isTranscoded
? localItem.userData
: {
...localItem.userData,
audioStreamIndex:
playbackProgressInfo.AudioStreamIndex ??
localItem.userData.audioStreamIndex,
subtitleStreamIndex:
playbackProgressInfo.SubtitleStreamIndex ??
localItem.userData.subtitleStreamIndex,
},
}); });
// Force invalidate queries so they refetch from updated local database // Force invalidate queries so they refetch from updated local database
queryClient.invalidateQueries({ queryKey: ["item", itemId] }); queryClient.invalidateQueries({ queryKey: ["item", itemId] });

View File

@@ -0,0 +1,32 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { AppState } from "react-native";
/**
* Refetches active home queries on mount (cold start) and whenever
* the app returns to the foreground from background.
*/
export function useRefetchHomeOnForeground() {
const queryClient = useQueryClient();
useEffect(() => {
// On mount (cold start), use cancelRefetch: false so we don't cancel
// an in-flight initial fetch that React Query already started.
queryClient.refetchQueries(
{ queryKey: ["home"], type: "active" },
{ cancelRefetch: false },
);
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") {
// On foreground return, force a fresh refetch
queryClient.refetchQueries(
{ queryKey: ["home"], type: "active" },
{ cancelRefetch: true },
);
}
});
return () => subscription.remove();
}, [queryClient]);
}

View File

@@ -43,12 +43,6 @@ class MpvPlayerModule : Module() {
view.loadVideo(config) view.loadVideo(config)
} }
// Now Playing metadata for media controls (iOS-only, no-op on Android)
// Android handles media session differently via MediaSessionCompat
Prop("nowPlayingMetadata") { _: MpvPlayerView, _: Map<String, String>? ->
// No-op on Android - media session integration would require MediaSessionCompat
}
// Async function to play video // Async function to play video
AsyncFunction("play") { view: MpvPlayerView -> AsyncFunction("play") { view: MpvPlayerView ->
view.play() view.play()

View File

@@ -10,7 +10,6 @@ protocol MPVLayerRendererDelegate: AnyObject {
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool) func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
func renderer(_ renderer: MPVLayerRenderer, didSelectAudioOutput audioOutput: String)
} }
/// MPV player using vo_avfoundation for video output. /// MPV player using vo_avfoundation for video output.
@@ -348,8 +347,7 @@ final class MPVLayerRenderer {
("pause", MPV_FORMAT_FLAG), ("pause", MPV_FORMAT_FLAG),
("track-list/count", MPV_FORMAT_INT64), ("track-list/count", MPV_FORMAT_INT64),
("paused-for-cache", MPV_FORMAT_FLAG), ("paused-for-cache", MPV_FORMAT_FLAG),
("demuxer-cache-duration", MPV_FORMAT_DOUBLE), ("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
("current-ao", MPV_FORMAT_STRING)
] ]
for (name, format) in properties { for (name, format) in properties {
mpv_observe_property(handle, 0, name, format) mpv_observe_property(handle, 0, name, format)
@@ -554,15 +552,6 @@ final class MPVLayerRenderer {
self.delegate?.renderer(self, didBecomeTracksReady: true) self.delegate?.renderer(self, didBecomeTracksReady: true)
} }
} }
case "current-ao":
// Audio output is now active - notify delegate
if let aoName = getStringProperty(handle: handle, name: name) {
print("[MPV] 🔊 Audio output selected: \(aoName)")
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.delegate?.renderer(self, didSelectAudioOutput: aoName)
}
}
default: default:
break break
} }

View File

@@ -1,188 +0,0 @@
import Foundation
import MediaPlayer
import UIKit
import AVFoundation
/// Simple manager for Now Playing info and remote commands.
/// Stores all state internally and updates Now Playing when ready.
class MPVNowPlayingManager {
static let shared = MPVNowPlayingManager()
// State
private var title: String?
private var artist: String?
private var albumTitle: String?
private var cachedArtwork: MPMediaItemArtwork?
private var duration: TimeInterval = 0
private var position: TimeInterval = 0
private var isPlaying: Bool = false
private var isCommandsSetup = false
private var artworkTask: URLSessionDataTask?
private init() {}
// MARK: - Audio Session
func activateAudioSession() {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .moviePlayback)
try session.setActive(true)
print("[NowPlaying] Audio session activated")
} catch {
print("[NowPlaying] Audio session error: \(error)")
}
}
func deactivateAudioSession() {
do {
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
print("[NowPlaying] Audio session deactivated")
} catch {
print("[NowPlaying] Deactivation error: \(error)")
}
}
// MARK: - Remote Commands
func setupRemoteCommands(
playHandler: @escaping () -> Void,
pauseHandler: @escaping () -> Void,
toggleHandler: @escaping () -> Void,
seekHandler: @escaping (TimeInterval) -> Void,
skipForward: @escaping (TimeInterval) -> Void,
skipBackward: @escaping (TimeInterval) -> Void
) {
guard !isCommandsSetup else { return }
isCommandsSetup = true
DispatchQueue.main.async {
UIApplication.shared.beginReceivingRemoteControlEvents()
}
let cc = MPRemoteCommandCenter.shared()
cc.playCommand.isEnabled = true
cc.playCommand.addTarget { _ in playHandler(); return .success }
cc.pauseCommand.isEnabled = true
cc.pauseCommand.addTarget { _ in pauseHandler(); return .success }
cc.togglePlayPauseCommand.isEnabled = true
cc.togglePlayPauseCommand.addTarget { _ in toggleHandler(); return .success }
cc.skipForwardCommand.isEnabled = true
cc.skipForwardCommand.preferredIntervals = [15]
cc.skipForwardCommand.addTarget { e in
if let ev = e as? MPSkipIntervalCommandEvent { skipForward(ev.interval) }
return .success
}
cc.skipBackwardCommand.isEnabled = true
cc.skipBackwardCommand.preferredIntervals = [15]
cc.skipBackwardCommand.addTarget { e in
if let ev = e as? MPSkipIntervalCommandEvent { skipBackward(ev.interval) }
return .success
}
cc.changePlaybackPositionCommand.isEnabled = true
cc.changePlaybackPositionCommand.addTarget { e in
if let ev = e as? MPChangePlaybackPositionCommandEvent { seekHandler(ev.positionTime) }
return .success
}
print("[NowPlaying] Remote commands ready")
}
func cleanupRemoteCommands() {
guard isCommandsSetup else { return }
let cc = MPRemoteCommandCenter.shared()
cc.playCommand.removeTarget(nil)
cc.pauseCommand.removeTarget(nil)
cc.togglePlayPauseCommand.removeTarget(nil)
cc.skipForwardCommand.removeTarget(nil)
cc.skipBackwardCommand.removeTarget(nil)
cc.changePlaybackPositionCommand.removeTarget(nil)
DispatchQueue.main.async {
UIApplication.shared.endReceivingRemoteControlEvents()
}
isCommandsSetup = false
print("[NowPlaying] Remote commands cleaned up")
}
// MARK: - State Updates (call these whenever data changes)
/// Set metadata (title, artist, artwork URL)
func setMetadata(title: String?, artist: String?, albumTitle: String?, artworkUrl: String?) {
self.title = title
self.artist = artist
self.albumTitle = albumTitle
print("[NowPlaying] Metadata: \(title ?? "nil")")
// Load artwork async
artworkTask?.cancel()
if let urlString = artworkUrl, let url = URL(string: urlString) {
artworkTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
if let data = data, let image = UIImage(data: data) {
self?.cachedArtwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
print("[NowPlaying] Artwork loaded")
DispatchQueue.main.async { self?.refresh() }
}
}
artworkTask?.resume()
}
refresh()
}
/// Update playback state (position, duration, playing)
func updatePlayback(position: TimeInterval, duration: TimeInterval, isPlaying: Bool) {
self.position = position
self.duration = duration
self.isPlaying = isPlaying
refresh()
}
/// Clear everything
func clear() {
artworkTask?.cancel()
title = nil
artist = nil
albumTitle = nil
cachedArtwork = nil
duration = 0
position = 0
isPlaying = false
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
print("[NowPlaying] Cleared")
}
// MARK: - Private
/// Refresh Now Playing info if we have enough data
private func refresh() {
guard duration > 0 else {
print("[NowPlaying] refresh skipped - duration is 0")
return
}
var info: [String: Any] = [
MPMediaItemPropertyPlaybackDuration: duration,
MPNowPlayingInfoPropertyElapsedPlaybackTime: position,
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0
]
if let title { info[MPMediaItemPropertyTitle] = title }
if let artist { info[MPMediaItemPropertyArtist] = artist }
if let albumTitle { info[MPMediaItemPropertyAlbumTitle] = albumTitle }
if let cachedArtwork { info[MPMediaItemPropertyArtwork] = cachedArtwork }
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
print("[NowPlaying] ✅ Set info: title=\(title ?? "nil"), dur=\(Int(duration))s, pos=\(Int(position))s, rate=\(isPlaying ? 1.0 : 0.0)")
}
}

View File

@@ -43,21 +43,6 @@ public class MpvPlayerModule: Module {
view.loadVideo(config: config) view.loadVideo(config: config)
} }
// Now Playing metadata for iOS Control Center and Lock Screen
Prop("nowPlayingMetadata") { (view: MpvPlayerView, metadata: [String: Any]?) in
guard let metadata = metadata else { return }
// Convert Any values to String, filtering out nil/null values
var stringMetadata: [String: String] = [:]
for (key, value) in metadata {
if let stringValue = value as? String {
stringMetadata[key] = stringValue
}
}
if !stringMetadata.isEmpty {
view.setNowPlayingMetadata(stringMetadata)
}
}
// Async function to play video // Async function to play video
AsyncFunction("play") { (view: MpvPlayerView) in AsyncFunction("play") { (view: MpvPlayerView) in
view.play() view.play()

View File

@@ -1,7 +1,6 @@
import AVFoundation import AVFoundation
import CoreMedia import CoreMedia
import ExpoModulesCore import ExpoModulesCore
import MediaPlayer
import UIKit import UIKit
/// Configuration for loading a video /// Configuration for loading a video
@@ -42,6 +41,7 @@ class MpvPlayerView: ExpoView {
private var renderer: MPVLayerRenderer? private var renderer: MPVLayerRenderer?
private var videoContainer: UIView! private var videoContainer: UIView!
private var pipController: PiPController? private var pipController: PiPController?
let onLoad = EventDispatcher() let onLoad = EventDispatcher()
let onPlaybackStateChange = EventDispatcher() let onPlaybackStateChange = EventDispatcher()
let onProgress = EventDispatcher() let onProgress = EventDispatcher()
@@ -54,13 +54,10 @@ class MpvPlayerView: ExpoView {
private var intendedPlayState: Bool = false private var intendedPlayState: Bool = false
private var _isZoomedToFill: Bool = false private var _isZoomedToFill: Bool = false
// Reference to now playing manager
private let nowPlayingManager = MPVNowPlayingManager.shared
required init(appContext: AppContext? = nil) { required init(appContext: AppContext? = nil) {
super.init(appContext: appContext) super.init(appContext: appContext)
setupNotifications()
setupView() setupView()
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
} }
private func setupView() { private func setupView() {
@@ -112,77 +109,6 @@ class MpvPlayerView: ExpoView {
CATransaction.commit() CATransaction.commit()
} }
// MARK: - Audio Session & Notifications
private func setupNotifications() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
NotificationCenter.default.addObserver(
self, selector: #selector(handleAudioSessionInterruption),
name: AVAudioSession.interruptionNotification, object: nil)
}
@objc func handleAudioSessionInterruption(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
// Interruption began - pause the video
print("[MPV] Audio session interrupted - pausing video")
self.pause()
case .ended:
// Interruption ended - check if we should resume
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
print("[MPV] Audio session interruption ended - can resume")
// Don't auto-resume - let user manually resume playback
} else {
print("[MPV] Audio session interruption ended - should not resume")
}
}
@unknown default:
break
}
}
private func setupRemoteCommands() {
nowPlayingManager.setupRemoteCommands(
playHandler: { [weak self] in self?.play() },
pauseHandler: { [weak self] in self?.pause() },
toggleHandler: { [weak self] in
guard let self else { return }
if self.intendedPlayState { self.pause() } else { self.play() }
},
seekHandler: { [weak self] time in self?.seekTo(position: time) },
skipForward: { [weak self] interval in self?.seekBy(offset: interval) },
skipBackward: { [weak self] interval in self?.seekBy(offset: -interval) }
)
}
// MARK: - Now Playing Info
func setNowPlayingMetadata(_ metadata: [String: String]) {
print("[MPV] setNowPlayingMetadata: \(metadata["title"] ?? "nil")")
nowPlayingManager.setMetadata(
title: metadata["title"],
artist: metadata["artist"],
albumTitle: metadata["albumTitle"],
artworkUrl: metadata["artworkUri"]
)
}
private func clearNowPlayingInfo() {
nowPlayingManager.cleanupRemoteCommands()
nowPlayingManager.deactivateAudioSession()
nowPlayingManager.clear()
}
func loadVideo(config: VideoLoadConfig) { func loadVideo(config: VideoLoadConfig) {
// Skip reload if same URL is already playing // Skip reload if same URL is already playing
if currentURL == config.url { if currentURL == config.url {
@@ -223,7 +149,6 @@ class MpvPlayerView: ExpoView {
func play() { func play() {
intendedPlayState = true intendedPlayState = true
setupRemoteCommands()
renderer?.play() renderer?.play()
pipController?.setPlaybackRate(1.0) pipController?.setPlaybackRate(1.0)
pipController?.updatePlaybackState() pipController?.updatePlaybackState()
@@ -237,17 +162,10 @@ class MpvPlayerView: ExpoView {
} }
func seekTo(position: Double) { func seekTo(position: Double) {
// Update cached position and Now Playing immediately for smooth Control Center feedback
cachedPosition = position
syncNowPlaying(isPlaying: !isPaused())
renderer?.seek(to: position) renderer?.seek(to: position)
} }
func seekBy(offset: Double) { func seekBy(offset: Double) {
// Update cached position and Now Playing immediately for smooth Control Center feedback
let newPosition = max(0, min(cachedPosition + offset, cachedDuration))
cachedPosition = newPosition
syncNowPlaying(isPlaying: !isPaused())
renderer?.seek(by: offset) renderer?.seek(by: offset)
} }
@@ -374,28 +292,19 @@ class MpvPlayerView: ExpoView {
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
renderer?.stop() renderer?.stop()
displayLayer.removeFromSuperlayer() displayLayer.removeFromSuperlayer()
clearNowPlayingInfo()
NotificationCenter.default.removeObserver(self)
} }
} }
// MARK: - MPVLayerRendererDelegate // MARK: - MPVLayerRendererDelegate
extension MpvPlayerView: MPVLayerRendererDelegate { extension MpvPlayerView: MPVLayerRendererDelegate {
// MARK: - Single location for Now Playing updates
private func syncNowPlaying(isPlaying: Bool) {
print("[MPV] syncNowPlaying: pos=\(Int(cachedPosition))s, dur=\(Int(cachedDuration))s, playing=\(isPlaying)")
nowPlayingManager.updatePlayback(position: cachedPosition, duration: cachedDuration, isPlaying: isPlaying)
}
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) { func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position cachedPosition = position
cachedDuration = duration cachedDuration = duration
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
// Update PiP current time for progress bar
if self.pipController?.isPictureInPictureActive == true { if self.pipController?.isPictureInPictureActive == true {
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration) self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
} }
@@ -412,10 +321,12 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) { func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
// Don't update intendedPlayState here - it's only set by user actions (play/pause)
// This prevents PiP UI flicker during seeking
print("[MPV] didChangePause: isPaused=\(isPaused), cachedDuration=\(self.cachedDuration)") // Sync timebase rate with actual playback state
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0) self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
self.syncNowPlaying(isPlaying: !isPaused)
self.onPlaybackStateChange([ self.onPlaybackStateChange([
"isPaused": isPaused, "isPaused": isPaused,
"isPlaying": !isPaused, "isPlaying": !isPaused,
@@ -447,13 +358,6 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
self.onTracksReady([:]) self.onTracksReady([:])
} }
} }
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
// Audio output is now active - this is the right time to activate audio session and set Now Playing
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
nowPlayingManager.activateAudioSession()
syncNowPlaying(isPlaying: !isPaused())
}
} }
// MARK: - PiPControllerDelegate // MARK: - PiPControllerDelegate

View File

@@ -25,13 +25,6 @@ export type OnErrorEventPayload = {
export type OnTracksReadyEventPayload = Record<string, never>; export type OnTracksReadyEventPayload = Record<string, never>;
export type NowPlayingMetadata = {
title?: string;
artist?: string;
albumTitle?: string;
artworkUri?: string;
};
export type MpvPlayerModuleEvents = { export type MpvPlayerModuleEvents = {
onChange: (params: ChangeEventPayload) => void; onChange: (params: ChangeEventPayload) => void;
}; };
@@ -55,8 +48,6 @@ export type VideoSource = {
export type MpvPlayerViewProps = { export type MpvPlayerViewProps = {
source?: VideoSource; source?: VideoSource;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
/** Metadata for iOS Control Center and Lock Screen now playing info */
nowPlayingMetadata?: NowPlayingMetadata;
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void; onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
onPlaybackStateChange?: (event: { onPlaybackStateChange?: (event: {
nativeEvent: OnPlaybackStateChangePayload; nativeEvent: OnPlaybackStateChangePayload;