mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: KSPlayer as an option for iOS + other improvements (#1266)
This commit is contained in:
committed by
GitHub
parent
d1795c9df8
commit
74d86b5d12
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,6 +51,7 @@ npm-debug.*
|
||||
.ruby-lsp
|
||||
.cursor/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# Environment and Configuration
|
||||
expo-env.d.ts
|
||||
@@ -66,3 +67,5 @@ streamyfin-4fec1-firebase-adminsdk.json
|
||||
# Version and Backup Files
|
||||
/version-backup-*
|
||||
modules/background-downloader/android/build/*
|
||||
/modules/sf-player/android/build
|
||||
/modules/music-controls/android/build
|
||||
|
||||
@@ -104,6 +104,7 @@ You can contribute translations directly on our [Crowdin project page](https://c
|
||||
1. Use node `>20`
|
||||
2. Install dependencies `bun i && bun run submodule-reload`
|
||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
|
||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
||||
4. run `npm run prebuild`
|
||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
||||
|
||||
@@ -6,6 +6,9 @@ module.exports = ({ config }) => {
|
||||
"react-native-google-cast",
|
||||
{ useDefaultExpandedMediaControls: true },
|
||||
]);
|
||||
|
||||
// KSPlayer for iOS (GPU acceleration + native PiP)
|
||||
config.plugins.push("./plugins/withKSPlayer.js");
|
||||
}
|
||||
|
||||
// Only override googleServicesFile if env var is set
|
||||
|
||||
23
app.json
23
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.48.0",
|
||||
"version": "0.50.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 85,
|
||||
"versionCode": 88,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
@@ -53,29 +53,16 @@
|
||||
"@react-native-tvos/config-tv",
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
"enableNotificationControls": true,
|
||||
"enableBackgroundAudio": true,
|
||||
"androidExtensions": {
|
||||
"useExoplayerRtsp": false,
|
||||
"useExoplayerSmoothStreaming": false,
|
||||
"useExoplayerHls": true,
|
||||
"useExoplayerDash": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"./plugins/withExcludeMedia3Dash.js",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"deploymentTarget": "15.6",
|
||||
"useFrameworks": "static"
|
||||
"deploymentTarget": "15.6"
|
||||
},
|
||||
"android": {
|
||||
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||
"compileSdkVersion": 35,
|
||||
"compileSdkVersion": 36,
|
||||
"targetSdkVersion": 35,
|
||||
"buildToolsVersion": "35.0.0",
|
||||
"kotlinVersion": "2.0.21",
|
||||
|
||||
212
app/(auth)/(tabs)/(favorites)/see-all.tsx
Normal file
212
app/(auth)/(tabs)/(favorites)/see-all.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useWindowDimensions, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
type FavoriteTypes =
|
||||
| "Series"
|
||||
| "Movie"
|
||||
| "Episode"
|
||||
| "Video"
|
||||
| "BoxSet"
|
||||
| "Playlist";
|
||||
|
||||
const favoriteTypes: readonly FavoriteTypes[] = [
|
||||
"Series",
|
||||
"Movie",
|
||||
"Episode",
|
||||
"Video",
|
||||
"BoxSet",
|
||||
"Playlist",
|
||||
] as const;
|
||||
|
||||
function isFavoriteType(value: unknown): value is FavoriteTypes {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(favoriteTypes as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export default function FavoritesSeeAllScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const searchParams = useLocalSearchParams<{
|
||||
type?: string;
|
||||
title?: string;
|
||||
}>();
|
||||
const typeParam = searchParams.type;
|
||||
const titleParam = searchParams.title;
|
||||
|
||||
const itemType = useMemo(() => {
|
||||
if (!isFavoriteType(typeParam)) return null;
|
||||
return typeParam as BaseItemKind;
|
||||
}, [typeParam]);
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (typeof titleParam === "string" && titleParam.trim().length > 0)
|
||||
return titleParam;
|
||||
return "";
|
||||
}, [titleParam]);
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({ pageParam }: { pageParam: number }): Promise<BaseItemDto[]> => {
|
||||
if (!api || !user?.Id || !itemType) return [];
|
||||
|
||||
const response = await getItemsApi(api as Api).getItems({
|
||||
userId: user.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
excludeLocationTypes: ["Virtual"],
|
||||
enableTotalRecordCount: true,
|
||||
startIndex: pageParam,
|
||||
limit: pageSize,
|
||||
includeItemTypes: [itemType],
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
[api, itemType, user?.Id],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["favorites", "see-all", itemType],
|
||||
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||
return pages.reduce((acc, page) => acc + page.length, 0);
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!itemType,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (screenWidth < 350) return 2;
|
||||
if (screenWidth < 600) return 3;
|
||||
if (screenWidth < 900) return 5;
|
||||
return 6;
|
||||
}, [screenWidth]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
index % nrOfCols === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % nrOfCols === 0
|
||||
? "flex-start"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<ItemPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[nrOfCols],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [fetchNextPage, hasNextPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerTitle: headerTitle,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: true,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
{!itemType ? (
|
||||
<View className='flex-1 items-center justify-center px-6'>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("favorites.noData", { defaultValue: "No items found." })}
|
||||
</Text>
|
||||
</View>
|
||||
) : isLoading ? (
|
||||
<View className='justify-center items-center h-full'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<FlashList
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.8}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full py-12'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("home.no_items", { defaultValue: "No items" })}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
isFetching ? (
|
||||
<View style={{ paddingVertical: 16 }}>
|
||||
<Loader />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -238,6 +238,42 @@ export default function IndexLayout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/streamystats/page'
|
||||
options={{
|
||||
title: "Streamystats",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/kefinTweaks/page'
|
||||
options={{
|
||||
title: "KefinTweaks",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/intro/page'
|
||||
options={{
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ScrollView } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
||||
className='px-4'
|
||||
>
|
||||
<KefinTweaksSettings />
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export default function page() {
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [navigation, value]);
|
||||
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
@@ -75,7 +75,10 @@ export default function page() {
|
||||
<DisabledSetting disabled={disabled} className='px-4'>
|
||||
<ListGroup>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||
disabled={
|
||||
pluginSettings?.searchEngine?.locked === true ||
|
||||
!!pluginSettings?.streamyStatsServerUrl?.value
|
||||
}
|
||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||
>
|
||||
<ListItem
|
||||
@@ -89,6 +92,7 @@ export default function page() {
|
||||
>
|
||||
<Switch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
searchEngine: value ? "Marlin" : "Jellyfin",
|
||||
|
||||
245
app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
Normal file
245
app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Linking,
|
||||
ScrollView,
|
||||
Switch,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const {
|
||||
settings,
|
||||
updateSettings,
|
||||
pluginSettings,
|
||||
refreshStreamyfinPluginSettings,
|
||||
} = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Local state for all editable fields
|
||||
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
|
||||
const [useForSearch, setUseForSearch] = useState<boolean>(
|
||||
settings?.searchEngine === "Streamystats",
|
||||
);
|
||||
const [movieRecs, setMovieRecs] = useState<boolean>(
|
||||
settings?.streamyStatsMovieRecommendations ?? false,
|
||||
);
|
||||
const [seriesRecs, setSeriesRecs] = useState<boolean>(
|
||||
settings?.streamyStatsSeriesRecommendations ?? false,
|
||||
);
|
||||
const [promotedWatchlists, setPromotedWatchlists] = useState<boolean>(
|
||||
settings?.streamyStatsPromotedWatchlists ?? false,
|
||||
);
|
||||
|
||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||
const isStreamystatsEnabled = !!url;
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
updateSettings({
|
||||
streamyStatsServerUrl: cleanUrl,
|
||||
searchEngine: useForSearch ? "Streamystats" : "Jellyfin",
|
||||
streamyStatsMovieRecommendations: movieRecs,
|
||||
streamyStatsSeriesRecommendations: seriesRecs,
|
||||
streamyStatsPromotedWatchlists: promotedWatchlists,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
||||
toast.success(t("home.settings.plugins.streamystats.toasts.saved"));
|
||||
}, [
|
||||
url,
|
||||
useForSearch,
|
||||
movieRecs,
|
||||
seriesRecs,
|
||||
promotedWatchlists,
|
||||
updateSettings,
|
||||
queryClient,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Set up header save button
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={onSave}>
|
||||
<Text className='text-blue-500 font-medium'>
|
||||
{t("home.settings.plugins.streamystats.save")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation, onSave, t]);
|
||||
|
||||
const handleClearStreamystats = useCallback(() => {
|
||||
setUrl("");
|
||||
setUseForSearch(false);
|
||||
setMovieRecs(false);
|
||||
setSeriesRecs(false);
|
||||
setPromotedWatchlists(false);
|
||||
updateSettings({
|
||||
streamyStatsServerUrl: "",
|
||||
searchEngine: "Jellyfin",
|
||||
streamyStatsMovieRecommendations: false,
|
||||
streamyStatsSeriesRecommendations: false,
|
||||
streamyStatsPromotedWatchlists: false,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
toast.success(t("home.settings.plugins.streamystats.toasts.disabled"));
|
||||
}, [updateSettings, queryClient, t]);
|
||||
|
||||
const handleOpenLink = () => {
|
||||
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
||||
};
|
||||
|
||||
const handleRefreshFromServer = useCallback(async () => {
|
||||
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
||||
// Update local state with new values
|
||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||
setUrl(newUrl);
|
||||
if (newUrl) {
|
||||
setUseForSearch(true);
|
||||
}
|
||||
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
||||
}, [refreshStreamyfinPluginSettings, t]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className='px-4'>
|
||||
<ListGroup className='flex-1'>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.url")}
|
||||
disabledByAdmin={isUrlLocked}
|
||||
>
|
||||
<TextInput
|
||||
editable={!isUrlLocked}
|
||||
className='text-white text-right flex-1'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||
)}
|
||||
value={url}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={setUrl}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
{t(
|
||||
"home.settings.plugins.streamystats.read_more_about_streamystats",
|
||||
)}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
<ListGroup
|
||||
title={t("home.settings.plugins.streamystats.features_title")}
|
||||
className='mt-4'
|
||||
>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
||||
>
|
||||
<Switch
|
||||
value={useForSearch}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
onValueChange={setUseForSearch}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
value={movieRecs}
|
||||
onValueChange={setMovieRecs}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
value={seriesRecs}
|
||||
onValueChange={setSeriesRecs}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
value={promotedWatchlists}
|
||||
onValueChange={setPromotedWatchlists}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleRefreshFromServer}
|
||||
className='mt-6 py-3 rounded-xl bg-neutral-800'
|
||||
>
|
||||
<Text className='text-center text-blue-500'>
|
||||
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
||||
{!isUrlLocked && isStreamystatsEnabled && (
|
||||
<TouchableOpacity
|
||||
onPress={handleClearStreamystats}
|
||||
className='mt-3 mb-4 py-3 rounded-xl bg-neutral-800'
|
||||
>
|
||||
<Text className='text-center text-red-500'>
|
||||
{t("home.settings.plugins.streamystats.disable_streamystats")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -21,14 +21,16 @@ const Page: React.FC = () => {
|
||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||
const isOffline = offline === "true";
|
||||
|
||||
const { data: item, isError } = useItemQuery(id, false, undefined, [
|
||||
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
|
||||
// (especially important for plugins like Gelato)
|
||||
const { data: item, isError } = useItemQuery(id, isOffline, undefined, [
|
||||
ItemFields.MediaSources,
|
||||
ItemFields.MediaSourceCount,
|
||||
ItemFields.MediaStreams,
|
||||
]);
|
||||
|
||||
// preload media sources
|
||||
const { data: itemWithSources } = useItemQuery(id, false, undefined, []);
|
||||
// Lazily preload item with full media sources in background
|
||||
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
|
||||
|
||||
export default function AlbumDetailScreen() {
|
||||
const { albumId } = useLocalSearchParams<{ albumId: string }>();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const { playQueue } = useMusicPlayer();
|
||||
|
||||
const { data: album, isLoading: loadingAlbum } = useQuery({
|
||||
queryKey: ["music-album", albumId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserLibraryApi(api!).getItem({
|
||||
userId: user?.Id,
|
||||
itemId: albumId!,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!albumId,
|
||||
});
|
||||
|
||||
const { data: tracks, isLoading: loadingTracks } = useQuery({
|
||||
queryKey: ["music-album-tracks", albumId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: albumId,
|
||||
sortBy: ["IndexNumber"],
|
||||
sortOrder: ["Ascending"],
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!albumId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: album?.Name ?? "" });
|
||||
}, [album?.Name, navigation]);
|
||||
|
||||
const imageUrl = useMemo(
|
||||
() => (album ? getPrimaryImageUrl({ api, item: album }) : null),
|
||||
[api, album],
|
||||
);
|
||||
|
||||
const totalDuration = useMemo(() => {
|
||||
if (!tracks) return "";
|
||||
const totalTicks = tracks.reduce(
|
||||
(acc, track) => acc + (track.RunTimeTicks || 0),
|
||||
0,
|
||||
);
|
||||
return runtimeTicksToMinutes(totalTicks);
|
||||
}, [tracks]);
|
||||
|
||||
const handlePlayAll = useCallback(() => {
|
||||
if (tracks && tracks.length > 0) {
|
||||
playQueue(tracks, 0);
|
||||
}
|
||||
}, [playQueue, tracks]);
|
||||
|
||||
const handleShuffle = useCallback(() => {
|
||||
if (tracks && tracks.length > 0) {
|
||||
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||
playQueue(shuffled, 0);
|
||||
}
|
||||
}, [playQueue, tracks]);
|
||||
|
||||
const isLoading = loadingAlbum || loadingTracks;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!album) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>{t("music.album_not_found")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={tracks || []}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
<View className='items-center px-4 pt-4 pb-6'>
|
||||
{/* Album artwork */}
|
||||
<View
|
||||
style={{
|
||||
width: ARTWORK_SIZE,
|
||||
height: ARTWORK_SIZE,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Ionicons name='disc' size={60} color='#666' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Album info */}
|
||||
<Text className='text-white text-xl font-bold mt-4 text-center'>
|
||||
{album.Name}
|
||||
</Text>
|
||||
<Text className='text-purple-400 text-base mt-1'>
|
||||
{album.AlbumArtist || album.Artists?.join(", ")}
|
||||
</Text>
|
||||
<Text className='text-neutral-500 text-sm mt-1'>
|
||||
{album.ProductionYear && `${album.ProductionYear} • `}
|
||||
{tracks?.length} tracks • {totalDuration}
|
||||
</Text>
|
||||
|
||||
{/* Play buttons */}
|
||||
<View className='flex flex-row mt-4'>
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayAll}
|
||||
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
||||
>
|
||||
<Ionicons name='play' size={20} color='white' />
|
||||
<Text className='text-white font-medium ml-2'>
|
||||
{t("music.play")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleShuffle}
|
||||
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full'
|
||||
>
|
||||
<Ionicons name='shuffle' size={20} color='white' />
|
||||
<Text className='text-white font-medium ml-2'>
|
||||
{t("music.shuffle")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item, index }) => (
|
||||
<View className='px-4'>
|
||||
<MusicTrackItem
|
||||
track={item}
|
||||
index={index + 1}
|
||||
queue={tracks}
|
||||
showArtwork={false}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
const ARTWORK_SIZE = SCREEN_WIDTH * 0.4;
|
||||
|
||||
export default function ArtistDetailScreen() {
|
||||
const { artistId } = useLocalSearchParams<{ artistId: string }>();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const { playQueue } = useMusicPlayer();
|
||||
|
||||
const { data: artist, isLoading: loadingArtist } = useQuery({
|
||||
queryKey: ["music-artist", artistId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserLibraryApi(api!).getItem({
|
||||
userId: user?.Id,
|
||||
itemId: artistId!,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!artistId,
|
||||
});
|
||||
|
||||
const { data: albums, isLoading: loadingAlbums } = useQuery({
|
||||
queryKey: ["music-artist-albums", artistId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
artistIds: [artistId!],
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
sortBy: ["ProductionYear", "SortName"],
|
||||
sortOrder: ["Descending", "Ascending"],
|
||||
recursive: true,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!artistId,
|
||||
});
|
||||
|
||||
const { data: topTracks, isLoading: loadingTracks } = useQuery({
|
||||
queryKey: ["music-artist-top-tracks", artistId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
artistIds: [artistId!],
|
||||
includeItemTypes: ["Audio"],
|
||||
sortBy: ["PlayCount"],
|
||||
sortOrder: ["Descending"],
|
||||
limit: 10,
|
||||
recursive: true,
|
||||
filters: ["IsPlayed"],
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!artistId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: artist?.Name ?? "" });
|
||||
}, [artist?.Name, navigation]);
|
||||
|
||||
const imageUrl = useMemo(
|
||||
() => (artist ? getPrimaryImageUrl({ api, item: artist }) : null),
|
||||
[api, artist],
|
||||
);
|
||||
|
||||
const handlePlayAllTracks = useCallback(() => {
|
||||
if (topTracks && topTracks.length > 0) {
|
||||
playQueue(topTracks, 0);
|
||||
}
|
||||
}, [playQueue, topTracks]);
|
||||
|
||||
const isLoading = loadingArtist || loadingAlbums || loadingTracks;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!artist) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>{t("music.artist_not_found")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = [];
|
||||
|
||||
// Top tracks section
|
||||
if (topTracks && topTracks.length > 0) {
|
||||
sections.push({
|
||||
id: "top-tracks",
|
||||
title: t("music.top_tracks"),
|
||||
type: "tracks" as const,
|
||||
data: topTracks,
|
||||
});
|
||||
}
|
||||
|
||||
// Albums section
|
||||
if (albums && albums.length > 0) {
|
||||
sections.push({
|
||||
id: "albums",
|
||||
title: t("music.tabs.albums"),
|
||||
type: "albums" as const,
|
||||
data: albums,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={sections}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
<View className='items-center px-4 pt-4 pb-6'>
|
||||
{/* Artist image */}
|
||||
<View
|
||||
style={{
|
||||
width: ARTWORK_SIZE,
|
||||
height: ARTWORK_SIZE,
|
||||
borderRadius: ARTWORK_SIZE / 2,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Ionicons name='person' size={60} color='#666' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Artist info */}
|
||||
<Text className='text-white text-2xl font-bold mt-4 text-center'>
|
||||
{artist.Name}
|
||||
</Text>
|
||||
<Text className='text-neutral-500 text-sm mt-1'>
|
||||
{albums?.length || 0} {t("music.tabs.albums").toLowerCase()}
|
||||
</Text>
|
||||
|
||||
{/* Play button */}
|
||||
{topTracks && topTracks.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayAllTracks}
|
||||
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mt-4'
|
||||
>
|
||||
<Ionicons name='play' size={20} color='white' />
|
||||
<Text className='text-white font-medium ml-2'>
|
||||
{t("music.play_top_tracks")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item: section }) => (
|
||||
<View className='mb-6'>
|
||||
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
|
||||
{section.type === "albums" ? (
|
||||
<HorizontalScroll
|
||||
data={section.data}
|
||||
height={200}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||
/>
|
||||
) : (
|
||||
<View className='px-4'>
|
||||
{section.data.slice(0, 5).map((track, index) => (
|
||||
<MusicTrackItem
|
||||
key={track.Id}
|
||||
track={track}
|
||||
index={index + 1}
|
||||
queue={section.data}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
|
||||
|
||||
export default function PlaylistDetailScreen() {
|
||||
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const { playQueue } = useMusicPlayer();
|
||||
|
||||
const { data: playlist, isLoading: loadingPlaylist } = useQuery({
|
||||
queryKey: ["music-playlist", playlistId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserLibraryApi(api!).getItem({
|
||||
userId: user?.Id,
|
||||
itemId: playlistId!,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!playlistId,
|
||||
});
|
||||
|
||||
const { data: tracks, isLoading: loadingTracks } = useQuery({
|
||||
queryKey: ["music-playlist-tracks", playlistId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: playlistId,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!playlistId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: playlist?.Name ?? "" });
|
||||
}, [playlist?.Name, navigation]);
|
||||
|
||||
const imageUrl = useMemo(
|
||||
() => (playlist ? getPrimaryImageUrl({ api, item: playlist }) : null),
|
||||
[api, playlist],
|
||||
);
|
||||
|
||||
const totalDuration = useMemo(() => {
|
||||
if (!tracks) return "";
|
||||
const totalTicks = tracks.reduce(
|
||||
(acc, track) => acc + (track.RunTimeTicks || 0),
|
||||
0,
|
||||
);
|
||||
return runtimeTicksToMinutes(totalTicks);
|
||||
}, [tracks]);
|
||||
|
||||
const handlePlayAll = useCallback(() => {
|
||||
if (tracks && tracks.length > 0) {
|
||||
playQueue(tracks, 0);
|
||||
}
|
||||
}, [playQueue, tracks]);
|
||||
|
||||
const handleShuffle = useCallback(() => {
|
||||
if (tracks && tracks.length > 0) {
|
||||
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||
playQueue(shuffled, 0);
|
||||
}
|
||||
}, [playQueue, tracks]);
|
||||
|
||||
const isLoading = loadingPlaylist || loadingTracks;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!playlist) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("music.playlist_not_found")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={tracks || []}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
<View className='items-center px-4 pt-4 pb-6'>
|
||||
{/* Playlist artwork */}
|
||||
<View
|
||||
style={{
|
||||
width: ARTWORK_SIZE,
|
||||
height: ARTWORK_SIZE,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Ionicons name='list' size={60} color='#666' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Playlist info */}
|
||||
<Text className='text-white text-xl font-bold mt-4 text-center'>
|
||||
{playlist.Name}
|
||||
</Text>
|
||||
<Text className='text-neutral-500 text-sm mt-1'>
|
||||
{tracks?.length} tracks • {totalDuration}
|
||||
</Text>
|
||||
|
||||
{/* Play buttons */}
|
||||
<View className='flex flex-row mt-4'>
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayAll}
|
||||
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
||||
>
|
||||
<Ionicons name='play' size={20} color='white' />
|
||||
<Text className='text-white font-medium ml-2'>
|
||||
{t("music.play")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleShuffle}
|
||||
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full'
|
||||
>
|
||||
<Ionicons name='shuffle' size={20} color='white' />
|
||||
<Text className='text-white font-medium ml-2'>
|
||||
{t("music.shuffle")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item, index }) => (
|
||||
<View className='px-4'>
|
||||
<MusicTrackItem track={item} index={index + 1} queue={tracks} />
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
@@ -27,7 +28,11 @@ import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
FilterByOption,
|
||||
FilterByPreferenceAtom,
|
||||
filterByAtom,
|
||||
genreFilterAtom,
|
||||
getFilterByPreference,
|
||||
getSortByPreference,
|
||||
getSortOrderPreference,
|
||||
SortByOption,
|
||||
@@ -39,8 +44,10 @@ import {
|
||||
sortOrderOptions,
|
||||
sortOrderPreferenceAtom,
|
||||
tagsFilterAtom,
|
||||
useFilterOptions,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -54,9 +61,13 @@ const Page = () => {
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
||||
const [filterBy, _setFilterBy] = useAtom(filterByAtom);
|
||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||
const [filterByPreference, setFilterByPreference] = useAtom(
|
||||
FilterByPreferenceAtom,
|
||||
);
|
||||
const [sortOrderPreference, setOrderByPreference] = useAtom(
|
||||
sortOrderPreferenceAtom,
|
||||
);
|
||||
|
||||
@@ -77,12 +88,20 @@ const Page = () => {
|
||||
} else {
|
||||
_setSortBy([SortByOption.SortName]);
|
||||
}
|
||||
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||
if (fp) {
|
||||
_setFilterBy([fp]);
|
||||
} else {
|
||||
_setFilterBy([]);
|
||||
}
|
||||
}, [
|
||||
libraryId,
|
||||
sortOrderPreference,
|
||||
sortByPreference,
|
||||
_setSortOrder,
|
||||
_setSortBy,
|
||||
filterByPreference,
|
||||
_setFilterBy,
|
||||
]);
|
||||
|
||||
const setSortBy = useCallback(
|
||||
@@ -100,14 +119,28 @@ const Page = () => {
|
||||
(sortOrder: SortOrderOption[]) => {
|
||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||
if (sortOrder[0] !== sop) {
|
||||
setOderByPreference({
|
||||
setOrderByPreference({
|
||||
...sortOrderPreference,
|
||||
[libraryId]: sortOrder[0],
|
||||
});
|
||||
}
|
||||
_setSortOrder(sortOrder);
|
||||
},
|
||||
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
|
||||
[libraryId, sortOrderPreference, setOrderByPreference, _setSortOrder],
|
||||
);
|
||||
|
||||
const setFilter = useCallback(
|
||||
(filterBy: FilterByOption[]) => {
|
||||
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||
if (filterBy[0] !== fp) {
|
||||
setFilterByPreference({
|
||||
...filterByPreference,
|
||||
[libraryId]: filterBy[0],
|
||||
});
|
||||
}
|
||||
_setFilterBy(filterBy);
|
||||
},
|
||||
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
|
||||
);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
@@ -168,6 +201,7 @@ const Page = () => {
|
||||
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0]],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
filters: filterBy as ItemFilter[],
|
||||
// true is needed for merged versions
|
||||
recursive: true,
|
||||
imageTypeLimit: 1,
|
||||
@@ -190,6 +224,7 @@ const Page = () => {
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
filterBy,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -203,6 +238,7 @@ const Page = () => {
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
filterBy,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
@@ -268,7 +304,8 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const generalFilters = useFilterOptions();
|
||||
const settings = useSettings();
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<FlatList
|
||||
@@ -404,6 +441,26 @@ const Page = () => {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "filterOptions",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='filters'
|
||||
queryFn={async () => generalFilters.map((s) => s.key)}
|
||||
set={setFilter}
|
||||
values={filterBy}
|
||||
title={t("library.filters.filter_by")}
|
||||
renderItemLabel={(item) =>
|
||||
generalFilters.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
@@ -424,6 +481,9 @@ const Page = () => {
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
filterBy,
|
||||
setFilter,
|
||||
settings,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ export default function index() {
|
||||
() =>
|
||||
data
|
||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||
.filter((l) => l.CollectionType !== "music")
|
||||
.filter((l) => l.CollectionType !== "books") || [],
|
||||
[data, settings?.hiddenLibraries],
|
||||
);
|
||||
|
||||
81
app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx
Normal file
81
app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
createMaterialTopTabNavigator,
|
||||
MaterialTopTabNavigationEventMap,
|
||||
MaterialTopTabNavigationOptions,
|
||||
} from "@react-navigation/material-top-tabs";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Navigator } = createMaterialTopTabNavigator();
|
||||
|
||||
const TAB_LABEL_FONT_SIZE = 13;
|
||||
const TAB_ITEM_HORIZONTAL_PADDING = 18;
|
||||
const TAB_ITEM_MIN_WIDTH = 110;
|
||||
|
||||
export const Tab = withLayoutContext<
|
||||
MaterialTopTabNavigationOptions,
|
||||
typeof Navigator,
|
||||
TabNavigationState<ParamListBase>,
|
||||
MaterialTopTabNavigationEventMap
|
||||
>(Navigator);
|
||||
|
||||
const Layout = () => {
|
||||
const { libraryId } = useLocalSearchParams<{ libraryId: string }>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: t("music.title") }} />
|
||||
<Tab
|
||||
initialRouteName='suggestions'
|
||||
keyboardDismissMode='none'
|
||||
screenOptions={{
|
||||
tabBarBounces: true,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: TAB_LABEL_FONT_SIZE,
|
||||
fontWeight: "600",
|
||||
flexWrap: "nowrap",
|
||||
},
|
||||
tabBarItemStyle: {
|
||||
width: "auto",
|
||||
minWidth: TAB_ITEM_MIN_WIDTH,
|
||||
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
|
||||
},
|
||||
tabBarStyle: { backgroundColor: "black" },
|
||||
animationEnabled: true,
|
||||
lazy: true,
|
||||
swipeEnabled: true,
|
||||
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
||||
tabBarScrollEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name='suggestions'
|
||||
initialParams={{ libraryId }}
|
||||
options={{ title: t("music.tabs.suggestions") }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name='albums'
|
||||
initialParams={{ libraryId }}
|
||||
options={{ title: t("music.tabs.albums") }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name='artists'
|
||||
initialParams={{ libraryId }}
|
||||
options={{ title: t("music.tabs.artists") }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name='playlists'
|
||||
initialParams={{ libraryId }}
|
||||
options={{ title: t("music.tabs.playlists") }}
|
||||
/>
|
||||
</Tab>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
136
app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx
Normal file
136
app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, RefreshControl, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
const ITEMS_PER_PAGE = 40;
|
||||
|
||||
export default function AlbumsScreen() {
|
||||
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||
const route = useRoute<any>();
|
||||
const libraryId =
|
||||
(Array.isArray(localParams.libraryId)
|
||||
? localParams.libraryId[0]
|
||||
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["music-albums", libraryId, user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
sortBy: ["SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
limit: ITEMS_PER_PAGE,
|
||||
startIndex: pageParam,
|
||||
recursive: true,
|
||||
});
|
||||
return {
|
||||
items: response.data.Items || [],
|
||||
totalCount: response.data.TotalRecordCount || 0,
|
||||
startIndex: pageParam,
|
||||
};
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!libraryId,
|
||||
});
|
||||
|
||||
const albums = useMemo(() => {
|
||||
return data?.pages.flatMap((page) => page.items) || [];
|
||||
}, [data]);
|
||||
|
||||
const numColumns = 2;
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
const gap = 12;
|
||||
const padding = 16;
|
||||
const itemWidth =
|
||||
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (albums.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>{t("music.no_albums")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={albums}
|
||||
numColumns={numColumns}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
paddingTop: 16,
|
||||
paddingHorizontal: padding,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
onRefresh={refetch}
|
||||
tintColor='#9334E9'
|
||||
/>
|
||||
}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
renderItem={({ item, index }) => (
|
||||
<View
|
||||
style={{
|
||||
width: itemWidth,
|
||||
marginRight: index % numColumns === 0 ? gap : 0,
|
||||
marginBottom: gap,
|
||||
}}
|
||||
>
|
||||
<MusicAlbumCard album={item} width={itemWidth} />
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
ListFooterComponent={
|
||||
isFetchingNextPage ? (
|
||||
<View className='py-4'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
170
app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
Normal file
170
app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, RefreshControl, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MusicArtistCard } from "@/components/music/MusicArtistCard";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Web uses Limit=100
|
||||
const ITEMS_PER_PAGE = 100;
|
||||
|
||||
export default function ArtistsScreen() {
|
||||
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||
const route = useRoute<any>();
|
||||
const libraryId =
|
||||
(Array.isArray(localParams.libraryId)
|
||||
? localParams.libraryId[0]
|
||||
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isReady = Boolean(api && user?.Id && libraryId);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["music-artists", libraryId, user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const response = await getArtistsApi(api!).getArtists({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
sortBy: ["SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
limit: ITEMS_PER_PAGE,
|
||||
startIndex: pageParam,
|
||||
});
|
||||
return {
|
||||
items: response.data.Items || [],
|
||||
totalCount: response.data.TotalRecordCount || 0,
|
||||
startIndex: pageParam,
|
||||
};
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: isReady,
|
||||
});
|
||||
|
||||
const artists = useMemo(() => {
|
||||
return data?.pages.flatMap((page) => page.items) || [];
|
||||
}, [data]);
|
||||
|
||||
const numColumns = 3;
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
const gap = 12;
|
||||
const padding = 16;
|
||||
const itemWidth =
|
||||
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (!api || !user?.Id) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!libraryId) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Missing music library id.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Failed to load artists: {(error as Error)?.message || "Unknown error"}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (artists.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>{t("music.no_artists")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={artists}
|
||||
numColumns={numColumns}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
paddingTop: 16,
|
||||
paddingHorizontal: padding,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
onRefresh={refetch}
|
||||
tintColor='#9334E9'
|
||||
/>
|
||||
}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
renderItem={({ item, index }) => (
|
||||
<View
|
||||
style={{
|
||||
width: itemWidth,
|
||||
marginRight: index % numColumns !== numColumns - 1 ? gap : 0,
|
||||
marginBottom: gap,
|
||||
}}
|
||||
>
|
||||
<MusicArtistCard artist={item} size={itemWidth} />
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
ListFooterComponent={
|
||||
isFetchingNextPage ? (
|
||||
<View className='py-4'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
170
app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
Normal file
170
app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, RefreshControl, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
const ITEMS_PER_PAGE = 40;
|
||||
|
||||
export default function PlaylistsScreen() {
|
||||
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||
const route = useRoute<any>();
|
||||
const libraryId =
|
||||
(Array.isArray(localParams.libraryId)
|
||||
? localParams.libraryId[0]
|
||||
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isReady = Boolean(api && user?.Id && libraryId);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["music-playlists", libraryId, user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
includeItemTypes: ["Playlist"],
|
||||
sortBy: ["SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
limit: ITEMS_PER_PAGE,
|
||||
startIndex: pageParam,
|
||||
recursive: true,
|
||||
mediaTypes: ["Audio"],
|
||||
});
|
||||
return {
|
||||
items: response.data.Items || [],
|
||||
totalCount: response.data.TotalRecordCount || 0,
|
||||
startIndex: pageParam,
|
||||
};
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: isReady,
|
||||
});
|
||||
|
||||
const playlists = useMemo(() => {
|
||||
return data?.pages.flatMap((page) => page.items) || [];
|
||||
}, [data]);
|
||||
|
||||
const numColumns = 2;
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
const gap = 12;
|
||||
const padding = 16;
|
||||
const itemWidth =
|
||||
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (!api || !user?.Id) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!libraryId) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Missing music library id.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Failed to load playlists:{" "}
|
||||
{(error as Error)?.message || "Unknown error"}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (playlists.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>{t("music.no_playlists")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={playlists}
|
||||
numColumns={numColumns}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
paddingTop: 16,
|
||||
paddingHorizontal: padding,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
onRefresh={refetch}
|
||||
tintColor='#9334E9'
|
||||
/>
|
||||
}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
renderItem={({ item, index }) => (
|
||||
<View
|
||||
style={{
|
||||
width: itemWidth,
|
||||
marginRight: index % numColumns === 0 ? gap : 0,
|
||||
marginBottom: gap,
|
||||
}}
|
||||
>
|
||||
<MusicPlaylistCard playlist={item} width={itemWidth} />
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
ListFooterComponent={
|
||||
isFetchingNextPage ? (
|
||||
<View className='py-4'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
286
app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
Normal file
286
app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshControl, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { writeDebugLog } from "@/utils/log";
|
||||
|
||||
export default function SuggestionsScreen() {
|
||||
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||
const route = useRoute<any>();
|
||||
const libraryId =
|
||||
(Array.isArray(localParams.libraryId)
|
||||
? localParams.libraryId[0]
|
||||
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isReady = Boolean(api && user?.Id && libraryId);
|
||||
|
||||
writeDebugLog("Music suggestions params", {
|
||||
libraryId,
|
||||
localParams,
|
||||
routeParams: route?.params,
|
||||
isReady,
|
||||
});
|
||||
|
||||
// Latest audio - uses the same endpoint as web: /Users/{userId}/Items/Latest
|
||||
// This returns the most recently added albums
|
||||
const {
|
||||
data: latestAlbums,
|
||||
isLoading: loadingLatest,
|
||||
isError: isLatestError,
|
||||
error: latestError,
|
||||
refetch: refetchLatest,
|
||||
} = useQuery({
|
||||
queryKey: ["music-latest", libraryId, user?.Id],
|
||||
queryFn: async () => {
|
||||
// Prefer the exact endpoint the Web client calls (HAR):
|
||||
// /Users/{userId}/Items/Latest?IncludeItemTypes=Audio&ParentId=...
|
||||
// IMPORTANT: must use api.get(...) (not axiosInstance.get(fullUrl)) so the auth header is attached.
|
||||
const res = await api!.get<BaseItemDto[]>(
|
||||
`/Users/${user!.Id}/Items/Latest`,
|
||||
{
|
||||
params: {
|
||||
IncludeItemTypes: "Audio",
|
||||
Limit: 20,
|
||||
Fields: "PrimaryImageAspectRatio",
|
||||
ParentId: libraryId,
|
||||
ImageTypeLimit: 1,
|
||||
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
|
||||
EnableTotalRecordCount: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (Array.isArray(res.data) && res.data.length > 0) {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// Fallback: ask for albums directly via /Items (more reliable across server variants)
|
||||
const fallback = await getItemsApi(api!).getItems({
|
||||
userId: user!.Id,
|
||||
parentId: libraryId,
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
sortBy: ["DateCreated"],
|
||||
sortOrder: ["Descending"],
|
||||
limit: 20,
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
enableTotalRecordCount: false,
|
||||
});
|
||||
return fallback.data.Items || [];
|
||||
},
|
||||
enabled: isReady,
|
||||
});
|
||||
|
||||
// Recently played - matches web: SortBy=DatePlayed, Filters=IsPlayed
|
||||
const {
|
||||
data: recentlyPlayed,
|
||||
isLoading: loadingRecentlyPlayed,
|
||||
isError: isRecentlyPlayedError,
|
||||
error: recentlyPlayedError,
|
||||
refetch: refetchRecentlyPlayed,
|
||||
} = useQuery({
|
||||
queryKey: ["music-recently-played", libraryId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
includeItemTypes: ["Audio"],
|
||||
sortBy: ["DatePlayed"],
|
||||
sortOrder: ["Descending"],
|
||||
limit: 10,
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
filters: ["IsPlayed"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
enableTotalRecordCount: false,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: isReady,
|
||||
});
|
||||
|
||||
// Frequently played - matches web: SortBy=PlayCount, Filters=IsPlayed
|
||||
const {
|
||||
data: frequentlyPlayed,
|
||||
isLoading: loadingFrequent,
|
||||
isError: isFrequentError,
|
||||
error: frequentError,
|
||||
refetch: refetchFrequent,
|
||||
} = useQuery({
|
||||
queryKey: ["music-frequently-played", libraryId, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
includeItemTypes: ["Audio"],
|
||||
sortBy: ["PlayCount"],
|
||||
sortOrder: ["Descending"],
|
||||
limit: 10,
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
filters: ["IsPlayed"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
enableTotalRecordCount: false,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: isReady,
|
||||
});
|
||||
|
||||
const isLoading = loadingLatest || loadingRecentlyPlayed || loadingFrequent;
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
refetchLatest();
|
||||
refetchRecentlyPlayed();
|
||||
refetchFrequent();
|
||||
}, [refetchLatest, refetchRecentlyPlayed, refetchFrequent]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const result: {
|
||||
title: string;
|
||||
data: BaseItemDto[];
|
||||
type: "albums" | "tracks";
|
||||
}[] = [];
|
||||
|
||||
// Latest albums section
|
||||
if (latestAlbums && latestAlbums.length > 0) {
|
||||
result.push({
|
||||
title: t("music.recently_added"),
|
||||
data: latestAlbums,
|
||||
type: "albums",
|
||||
});
|
||||
}
|
||||
|
||||
// Recently played tracks
|
||||
if (recentlyPlayed && recentlyPlayed.length > 0) {
|
||||
result.push({
|
||||
title: t("music.recently_played"),
|
||||
data: recentlyPlayed,
|
||||
type: "tracks",
|
||||
});
|
||||
}
|
||||
|
||||
// Frequently played tracks
|
||||
if (frequentlyPlayed && frequentlyPlayed.length > 0) {
|
||||
result.push({
|
||||
title: t("music.frequently_played"),
|
||||
data: frequentlyPlayed,
|
||||
type: "tracks",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [latestAlbums, frequentlyPlayed, recentlyPlayed, t]);
|
||||
|
||||
if (!api || !user?.Id) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!libraryId) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Missing music library id.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLatestError || isRecentlyPlayedError || isFrequentError) {
|
||||
const msg =
|
||||
(latestError as Error | undefined)?.message ||
|
||||
(recentlyPlayedError as Error | undefined)?.message ||
|
||||
(frequentError as Error | undefined)?.message ||
|
||||
"Unknown error";
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Failed to load music: {msg}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black'>
|
||||
<Text className='text-neutral-500'>{t("music.no_suggestions")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={sections}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 100,
|
||||
paddingTop: 16,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor='#9334E9'
|
||||
/>
|
||||
}
|
||||
renderItem={({ item: section }) => (
|
||||
<View className='mb-6'>
|
||||
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
|
||||
{section.type === "albums" ? (
|
||||
<HorizontalScroll
|
||||
data={section.data}
|
||||
height={200}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||
/>
|
||||
) : (
|
||||
<View className='px-4'>
|
||||
{section.data.slice(0, 5).map((track, index, _tracks) => (
|
||||
<MusicTrackItem
|
||||
key={track.Id}
|
||||
track={track}
|
||||
index={index + 1}
|
||||
queue={section.data}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(item) => item.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { createStreamystatsApi } from "@/utils/streamystats";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
@@ -117,6 +118,54 @@ export default function search() {
|
||||
|
||||
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||
}
|
||||
|
||||
if (searchEngine === "Streamystats") {
|
||||
if (!settings?.streamyStatsServerUrl || !api.accessToken) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamyStatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const typeMap: Record<BaseItemKind, string> = {
|
||||
Movie: "movies",
|
||||
Series: "series",
|
||||
Episode: "episodes",
|
||||
Person: "actors",
|
||||
BoxSet: "movies",
|
||||
Audio: "audio",
|
||||
} as Record<BaseItemKind, string>;
|
||||
|
||||
const searchType = types.length === 1 ? typeMap[types[0]] : "media";
|
||||
const response = await streamyStatsApi.searchIds(
|
||||
query,
|
||||
searchType as "movies" | "series" | "episodes" | "actors" | "media",
|
||||
10,
|
||||
);
|
||||
|
||||
const allIds: string[] = [
|
||||
...(response.data.movies || []),
|
||||
...(response.data.series || []),
|
||||
...(response.data.episodes || []),
|
||||
...(response.data.actors || []),
|
||||
...(response.data.audio || []),
|
||||
];
|
||||
|
||||
if (!allIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemsResponse = await getItemsApi(api).getItems({
|
||||
ids: allIds,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return (itemsResponse.data.Items as BaseItemDto[]) || [];
|
||||
}
|
||||
|
||||
// Marlin search
|
||||
if (!settings?.marlinServerUrl) {
|
||||
return [];
|
||||
}
|
||||
@@ -141,12 +190,11 @@ export default function search() {
|
||||
});
|
||||
|
||||
return (response2.data.Items as BaseItemDto[]) || [];
|
||||
} catch (error) {
|
||||
console.error("Error during search:", error);
|
||||
return []; // Ensure an empty array is returned in case of an error
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[api, searchEngine, settings],
|
||||
[api, searchEngine, settings, user?.Id],
|
||||
);
|
||||
|
||||
type HeaderSearchBarRef = {
|
||||
|
||||
297
app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
Normal file
297
app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import {
|
||||
useDeleteWatchlist,
|
||||
useRemoveFromWatchlist,
|
||||
} from "@/hooks/useWatchlistMutations";
|
||||
import {
|
||||
useWatchlistDetailQuery,
|
||||
useWatchlistItemsQuery,
|
||||
} from "@/hooks/useWatchlists";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export default function WatchlistDetailScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
||||
const user = useAtomValue(userAtom);
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const { orientation } = useOrientation();
|
||||
|
||||
const watchlistIdNum = watchlistId
|
||||
? Number.parseInt(watchlistId, 10)
|
||||
: undefined;
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (screenWidth < 300) return 2;
|
||||
if (screenWidth < 500) return 3;
|
||||
if (screenWidth < 800) return 5;
|
||||
if (screenWidth < 1000) return 6;
|
||||
if (screenWidth < 1500) return 7;
|
||||
return 6;
|
||||
}, [screenWidth]);
|
||||
|
||||
const {
|
||||
data: watchlist,
|
||||
isLoading: watchlistLoading,
|
||||
refetch: refetchWatchlist,
|
||||
} = useWatchlistDetailQuery(watchlistIdNum);
|
||||
|
||||
const {
|
||||
data: items,
|
||||
isLoading: itemsLoading,
|
||||
refetch: refetchItems,
|
||||
} = useWatchlistItemsQuery(watchlistIdNum);
|
||||
|
||||
const deleteWatchlist = useDeleteWatchlist();
|
||||
const removeFromWatchlist = useRemoveFromWatchlist();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const isOwner = useMemo(
|
||||
() => watchlist?.userId === user?.Id,
|
||||
[watchlist?.userId, user?.Id],
|
||||
);
|
||||
|
||||
// Set up header
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerTitle: watchlist?.name || "",
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerRight: isOwner
|
||||
? () => (
|
||||
<View className='flex-row gap-2'>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/(tabs)/(watchlists)/edit/${watchlistId}`)
|
||||
}
|
||||
className='p-2'
|
||||
>
|
||||
<Ionicons name='pencil' size={20} color='white' />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleDelete} className='p-2'>
|
||||
<Ionicons name='trash-outline' size={20} color='#ef4444' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
}, [navigation, watchlist?.name, isOwner, watchlistId]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await Promise.all([refetchWatchlist(), refetchItems()]);
|
||||
setRefreshing(false);
|
||||
}, [refetchWatchlist, refetchItems]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
Alert.alert(
|
||||
t("watchlists.delete_confirm_title"),
|
||||
t("watchlists.delete_confirm_message", { name: watchlist?.name }),
|
||||
[
|
||||
{ text: t("watchlists.cancel_button"), style: "cancel" },
|
||||
{
|
||||
text: t("watchlists.delete_button"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
if (watchlistIdNum) {
|
||||
await deleteWatchlist.mutateAsync(watchlistIdNum);
|
||||
router.back();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [deleteWatchlist, watchlistIdNum, watchlist?.name, router, t]);
|
||||
|
||||
const handleRemoveItem = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
if (!watchlistIdNum || !item.Id) return;
|
||||
|
||||
Alert.alert(
|
||||
t("watchlists.remove_item_title"),
|
||||
t("watchlists.remove_item_message", { name: item.Name }),
|
||||
[
|
||||
{ text: t("watchlists.cancel_button"), style: "cancel" },
|
||||
{
|
||||
text: t("watchlists.remove_button"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await removeFromWatchlist.mutateAsync({
|
||||
watchlistId: watchlistIdNum,
|
||||
itemId: item.Id!,
|
||||
watchlistName: watchlist?.name,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
item={item}
|
||||
onLongPress={isOwner ? () => handleRemoveItem(item) : undefined}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
? index % nrOfCols === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % nrOfCols === 0
|
||||
? "flex-start"
|
||||
: "center"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<ItemPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[isOwner, handleRemoveItem, orientation, nrOfCols],
|
||||
);
|
||||
|
||||
const ListHeader = useMemo(
|
||||
() =>
|
||||
watchlist ? (
|
||||
<View className='px-4 pt-4 pb-6 mb-4 border-b border-neutral-800'>
|
||||
{watchlist.description && (
|
||||
<Text className='text-neutral-400 mb-2'>
|
||||
{watchlist.description}
|
||||
</Text>
|
||||
)}
|
||||
<View className='flex-row items-center gap-4'>
|
||||
<View className='flex-row items-center gap-1'>
|
||||
<Ionicons name='film-outline' size={14} color='#9ca3af' />
|
||||
<Text className='text-neutral-400 text-sm'>
|
||||
{items?.length ?? 0}{" "}
|
||||
{(items?.length ?? 0) === 1
|
||||
? t("watchlists.item")
|
||||
: t("watchlists.items")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex-row items-center gap-1'>
|
||||
<Ionicons
|
||||
name={
|
||||
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
|
||||
}
|
||||
size={14}
|
||||
color='#9ca3af'
|
||||
/>
|
||||
<Text className='text-neutral-400 text-sm'>
|
||||
{watchlist.isPublic
|
||||
? t("watchlists.public")
|
||||
: t("watchlists.private")}
|
||||
</Text>
|
||||
</View>
|
||||
{!isOwner && (
|
||||
<Text className='text-neutral-500 text-sm'>
|
||||
{t("watchlists.by_owner")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
) : null,
|
||||
[watchlist, items?.length, isOwner, t],
|
||||
);
|
||||
|
||||
const EmptyComponent = useMemo(
|
||||
() => (
|
||||
<View className='flex-1 items-center justify-center px-8 py-16'>
|
||||
<Ionicons name='film-outline' size={48} color='#4b5563' />
|
||||
<Text className='text-neutral-400 text-center mt-4'>
|
||||
{t("watchlists.empty_watchlist")}
|
||||
</Text>
|
||||
{isOwner && (
|
||||
<Text className='text-neutral-500 text-center mt-2 text-sm'>
|
||||
{t("watchlists.empty_watchlist_hint")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
[isOwner, t],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
if (watchlistLoading || itemsLoading) {
|
||||
return (
|
||||
<View className='flex-1 items-center justify-center'>
|
||||
<ActivityIndicator size='large' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!watchlist) {
|
||||
return (
|
||||
<View className='flex-1 items-center justify-center px-8'>
|
||||
<Text className='text-lg text-neutral-400'>
|
||||
{t("watchlists.not_found")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
key={orientation}
|
||||
data={items ?? []}
|
||||
numColumns={nrOfCols}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
ListHeaderComponent={ListHeader}
|
||||
ListEmptyComponent={EmptyComponent}
|
||||
extraData={[orientation, nrOfCols]}
|
||||
keyExtractor={keyExtractor}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
renderItem={renderItem}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
74
app/(auth)/(tabs)/(watchlists)/_layout.tsx
Normal file
74
app/(auth)/(tabs)/(watchlists)/_layout.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
||||
|
||||
export default function WatchlistsLayout() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("watchlists.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: streamystatsEnabled
|
||||
? () => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push("/(auth)/(tabs)/(watchlists)/create")
|
||||
}
|
||||
className='p-1.5'
|
||||
>
|
||||
<Ionicons name='add' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='[watchlistId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='create'
|
||||
options={{
|
||||
title: t("watchlists.create_title"),
|
||||
presentation: "modal",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "#171717" },
|
||||
headerTintColor: "white",
|
||||
contentStyle: { backgroundColor: "#171717" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='edit/[watchlistId]'
|
||||
options={{
|
||||
title: t("watchlists.edit_title"),
|
||||
presentation: "modal",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "#171717" },
|
||||
headerTintColor: "white",
|
||||
contentStyle: { backgroundColor: "#171717" },
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
221
app/(auth)/(tabs)/(watchlists)/create.tsx
Normal file
221
app/(auth)/(tabs)/(watchlists)/create.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Switch,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||
import type {
|
||||
StreamystatsWatchlistAllowedItemType,
|
||||
StreamystatsWatchlistSortOrder,
|
||||
} from "@/utils/streamystats/types";
|
||||
|
||||
const ITEM_TYPES: Array<{
|
||||
value: StreamystatsWatchlistAllowedItemType;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: null, label: "All Types" },
|
||||
{ value: "Movie", label: "Movies Only" },
|
||||
{ value: "Series", label: "Series Only" },
|
||||
{ value: "Episode", label: "Episodes Only" },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS: Array<{
|
||||
value: StreamystatsWatchlistSortOrder;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "custom", label: "Custom Order" },
|
||||
{ value: "name", label: "Name" },
|
||||
{ value: "dateAdded", label: "Date Added" },
|
||||
{ value: "releaseDate", label: "Release Date" },
|
||||
];
|
||||
|
||||
export default function CreateWatchlistScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const createWatchlist = useCreateWatchlist();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isPublic, setIsPublic] = useState(false);
|
||||
const [allowedItemType, setAllowedItemType] =
|
||||
useState<StreamystatsWatchlistAllowedItemType>(null);
|
||||
const [defaultSortOrder, setDefaultSortOrder] =
|
||||
useState<StreamystatsWatchlistSortOrder>("custom");
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
try {
|
||||
await createWatchlist.mutateAsync({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
isPublic,
|
||||
allowedItemType,
|
||||
defaultSortOrder,
|
||||
});
|
||||
router.back();
|
||||
} catch {
|
||||
// Error handled by mutation
|
||||
}
|
||||
}, [
|
||||
name,
|
||||
description,
|
||||
isPublic,
|
||||
allowedItemType,
|
||||
defaultSortOrder,
|
||||
createWatchlist,
|
||||
router,
|
||||
]);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className='flex-1'
|
||||
style={{ backgroundColor: "#171717" }}
|
||||
>
|
||||
<ScrollView
|
||||
className='flex-1'
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 20,
|
||||
}}
|
||||
keyboardShouldPersistTaps='handled'
|
||||
>
|
||||
{/* Name */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.name_label")} *
|
||||
</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder={t("watchlists.name_placeholder")}
|
||||
placeholderTextColor='#6b7280'
|
||||
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.description_label")}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder={t("watchlists.description_placeholder")}
|
||||
placeholderTextColor='#6b7280'
|
||||
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
textAlignVertical='top'
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Public Toggle */}
|
||||
<View className='px-4 py-4 flex-row items-center justify-between'>
|
||||
<View className='flex-1 mr-4'>
|
||||
<Text className='text-base font-medium text-white'>
|
||||
{t("watchlists.is_public_label")}
|
||||
</Text>
|
||||
<Text className='text-sm text-neutral-400 mt-1'>
|
||||
{t("watchlists.is_public_description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isPublic}
|
||||
onValueChange={setIsPublic}
|
||||
trackColor={{ false: "#374151", true: "#7c3aed" }}
|
||||
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Content Type */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.allowed_type_label")}
|
||||
</Text>
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
{ITEM_TYPES.map((type) => (
|
||||
<TouchableOpacity
|
||||
key={type.value ?? "all"}
|
||||
onPress={() => setAllowedItemType(type.value)}
|
||||
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
allowedItemType === type.value
|
||||
? "text-white font-medium"
|
||||
: "text-neutral-300"
|
||||
}
|
||||
>
|
||||
{type.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sort Order */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.sort_order_label")}
|
||||
</Text>
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
{SORT_OPTIONS.map((sort) => (
|
||||
<TouchableOpacity
|
||||
key={sort.value}
|
||||
onPress={() => setDefaultSortOrder(sort.value)}
|
||||
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
defaultSortOrder === sort.value
|
||||
? "text-white font-medium"
|
||||
: "text-neutral-300"
|
||||
}
|
||||
>
|
||||
{sort.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Create Button */}
|
||||
<View className='px-4 pt-4'>
|
||||
<Button
|
||||
onPress={handleCreate}
|
||||
disabled={!name.trim() || createWatchlist.isPending}
|
||||
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
|
||||
>
|
||||
{createWatchlist.isPending ? (
|
||||
<ActivityIndicator color='white' />
|
||||
) : (
|
||||
<View className='flex-row items-center'>
|
||||
<Ionicons name='add' size={20} color='white' />
|
||||
<Text className='text-white font-semibold text-base'>
|
||||
{t("watchlists.create_button")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
273
app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx
Normal file
273
app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Switch,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
|
||||
import type {
|
||||
StreamystatsWatchlistAllowedItemType,
|
||||
StreamystatsWatchlistSortOrder,
|
||||
} from "@/utils/streamystats/types";
|
||||
|
||||
const ITEM_TYPES: Array<{
|
||||
value: StreamystatsWatchlistAllowedItemType;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: null, label: "All Types" },
|
||||
{ value: "Movie", label: "Movies Only" },
|
||||
{ value: "Series", label: "Series Only" },
|
||||
{ value: "Episode", label: "Episodes Only" },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS: Array<{
|
||||
value: StreamystatsWatchlistSortOrder;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "custom", label: "Custom Order" },
|
||||
{ value: "name", label: "Name" },
|
||||
{ value: "dateAdded", label: "Date Added" },
|
||||
{ value: "releaseDate", label: "Release Date" },
|
||||
];
|
||||
|
||||
export default function EditWatchlistScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
||||
const watchlistIdNum = watchlistId
|
||||
? Number.parseInt(watchlistId, 10)
|
||||
: undefined;
|
||||
|
||||
const { data: watchlist, isLoading } =
|
||||
useWatchlistDetailQuery(watchlistIdNum);
|
||||
const updateWatchlist = useUpdateWatchlist();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isPublic, setIsPublic] = useState(false);
|
||||
const [allowedItemType, setAllowedItemType] =
|
||||
useState<StreamystatsWatchlistAllowedItemType>(null);
|
||||
const [defaultSortOrder, setDefaultSortOrder] =
|
||||
useState<StreamystatsWatchlistSortOrder>("custom");
|
||||
|
||||
// Initialize form with watchlist data
|
||||
useEffect(() => {
|
||||
if (watchlist) {
|
||||
setName(watchlist.name);
|
||||
setDescription(watchlist.description ?? "");
|
||||
setIsPublic(watchlist.isPublic);
|
||||
setAllowedItemType(
|
||||
(watchlist.allowedItemType as StreamystatsWatchlistAllowedItemType) ??
|
||||
null,
|
||||
);
|
||||
setDefaultSortOrder(
|
||||
(watchlist.defaultSortOrder as StreamystatsWatchlistSortOrder) ??
|
||||
"custom",
|
||||
);
|
||||
}
|
||||
}, [watchlist]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim() || !watchlistIdNum) return;
|
||||
|
||||
try {
|
||||
await updateWatchlist.mutateAsync({
|
||||
watchlistId: watchlistIdNum,
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
isPublic,
|
||||
allowedItemType,
|
||||
defaultSortOrder,
|
||||
},
|
||||
});
|
||||
router.back();
|
||||
} catch {
|
||||
// Error handled by mutation
|
||||
}
|
||||
}, [
|
||||
name,
|
||||
description,
|
||||
isPublic,
|
||||
allowedItemType,
|
||||
defaultSortOrder,
|
||||
watchlistIdNum,
|
||||
updateWatchlist,
|
||||
router,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
className='flex-1 items-center justify-center'
|
||||
style={{ backgroundColor: "#171717" }}
|
||||
>
|
||||
<ActivityIndicator size='large' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!watchlist) {
|
||||
return (
|
||||
<View
|
||||
className='flex-1 items-center justify-center px-8'
|
||||
style={{ backgroundColor: "#171717" }}
|
||||
>
|
||||
<Text className='text-lg text-neutral-400'>
|
||||
{t("watchlists.not_found")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className='flex-1'
|
||||
style={{ backgroundColor: "#171717" }}
|
||||
>
|
||||
<ScrollView
|
||||
className='flex-1'
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 20,
|
||||
}}
|
||||
keyboardShouldPersistTaps='handled'
|
||||
>
|
||||
{/* Name */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.name_label")} *
|
||||
</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder={t("watchlists.name_placeholder")}
|
||||
placeholderTextColor='#6b7280'
|
||||
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.description_label")}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder={t("watchlists.description_placeholder")}
|
||||
placeholderTextColor='#6b7280'
|
||||
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
textAlignVertical='top'
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Public Toggle */}
|
||||
<View className='px-4 py-4 flex-row items-center justify-between'>
|
||||
<View className='flex-1 mr-4'>
|
||||
<Text className='text-base font-medium text-white'>
|
||||
{t("watchlists.is_public_label")}
|
||||
</Text>
|
||||
<Text className='text-sm text-neutral-400 mt-1'>
|
||||
{t("watchlists.is_public_description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isPublic}
|
||||
onValueChange={setIsPublic}
|
||||
trackColor={{ false: "#374151", true: "#7c3aed" }}
|
||||
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Content Type */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.allowed_type_label")}
|
||||
</Text>
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
{ITEM_TYPES.map((type) => (
|
||||
<TouchableOpacity
|
||||
key={type.value ?? "all"}
|
||||
onPress={() => setAllowedItemType(type.value)}
|
||||
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
allowedItemType === type.value
|
||||
? "text-white font-medium"
|
||||
: "text-neutral-300"
|
||||
}
|
||||
>
|
||||
{type.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sort Order */}
|
||||
<View className='px-4 py-4'>
|
||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||
{t("watchlists.sort_order_label")}
|
||||
</Text>
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
{SORT_OPTIONS.map((sort) => (
|
||||
<TouchableOpacity
|
||||
key={sort.value}
|
||||
onPress={() => setDefaultSortOrder(sort.value)}
|
||||
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
defaultSortOrder === sort.value
|
||||
? "text-white font-medium"
|
||||
: "text-neutral-300"
|
||||
}
|
||||
>
|
||||
{sort.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Save Button */}
|
||||
<View className='px-4 pt-4'>
|
||||
<Button
|
||||
onPress={handleSave}
|
||||
disabled={!name.trim() || updateWatchlist.isPending}
|
||||
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
|
||||
>
|
||||
{updateWatchlist.isPending ? (
|
||||
<ActivityIndicator color='white' />
|
||||
) : (
|
||||
<View className='flex-row items-center'>
|
||||
<Ionicons name='checkmark' size={20} color='white' />
|
||||
<Text className='text-white font-semibold text-base'>
|
||||
{t("watchlists.save_button")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
239
app/(auth)/(tabs)/(watchlists)/index.tsx
Normal file
239
app/(auth)/(tabs)/(watchlists)/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
useStreamystatsEnabled,
|
||||
useWatchlistsQuery,
|
||||
} from "@/hooks/useWatchlists";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
||||
|
||||
interface WatchlistCardProps {
|
||||
watchlist: StreamystatsWatchlist;
|
||||
isOwner: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const WatchlistCard: React.FC<WatchlistCardProps> = ({
|
||||
watchlist,
|
||||
isOwner,
|
||||
onPress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
className='bg-neutral-900 rounded-xl p-4 mx-4 mb-3'
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className='flex-row items-center justify-between mb-2'>
|
||||
<Text className='text-lg font-semibold flex-1' numberOfLines={1}>
|
||||
{watchlist.name}
|
||||
</Text>
|
||||
<View className='flex-row items-center gap-2'>
|
||||
{isOwner && (
|
||||
<View className='bg-purple-600/20 px-2 py-1 rounded'>
|
||||
<Text className='text-purple-400 text-xs'>
|
||||
{t("watchlists.you")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Ionicons
|
||||
name={watchlist.isPublic ? "globe-outline" : "lock-closed-outline"}
|
||||
size={16}
|
||||
color='#9ca3af'
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{watchlist.description && (
|
||||
<Text className='text-neutral-400 text-sm mb-2' numberOfLines={2}>
|
||||
{watchlist.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View className='flex-row items-center gap-4'>
|
||||
<View className='flex-row items-center gap-1'>
|
||||
<Ionicons name='film-outline' size={14} color='#9ca3af' />
|
||||
<Text className='text-neutral-400 text-sm'>
|
||||
{watchlist.itemCount ?? 0}{" "}
|
||||
{(watchlist.itemCount ?? 0) === 1
|
||||
? t("watchlists.item")
|
||||
: t("watchlists.items")}
|
||||
</Text>
|
||||
</View>
|
||||
{watchlist.allowedItemType && (
|
||||
<View className='bg-neutral-800 px-2 py-0.5 rounded'>
|
||||
<Text className='text-neutral-400 text-xs'>
|
||||
{watchlist.allowedItemType}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyState: React.FC<{ onCreatePress: () => void }> = ({
|
||||
onCreatePress: _onCreatePress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View className='flex-1 items-center justify-center px-8'>
|
||||
<Ionicons name='list-outline' size={64} color='#4b5563' />
|
||||
<Text className='text-xl font-semibold mt-4 text-center'>
|
||||
{t("watchlists.empty_title")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
||||
{t("watchlists.empty_description")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const NotConfiguredState: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View className='flex-1 items-center justify-center px-8'>
|
||||
<Ionicons name='settings-outline' size={64} color='#4b5563' />
|
||||
<Text className='text-xl font-semibold mt-4 text-center'>
|
||||
{t("watchlists.not_configured_title")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
||||
{t("watchlists.not_configured_description")}
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() =>
|
||||
router.push(
|
||||
"/(auth)/(tabs)/(home)/settings/plugins/streamystats/page",
|
||||
)
|
||||
}
|
||||
className='px-6'
|
||||
>
|
||||
<Text className='font-semibold'>{t("watchlists.go_to_settings")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function WatchlistsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const user = useAtomValue(userAtom);
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
const { data: watchlists, isLoading, refetch } = useWatchlistsQuery();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await refetch();
|
||||
setRefreshing(false);
|
||||
}, [refetch]);
|
||||
|
||||
const handleCreatePress = useCallback(() => {
|
||||
router.push("/(auth)/(tabs)/(watchlists)/create");
|
||||
}, [router]);
|
||||
|
||||
const handleWatchlistPress = useCallback(
|
||||
(watchlistId: number) => {
|
||||
router.push(`/(auth)/(tabs)/(watchlists)/${watchlistId}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
// Separate watchlists into "mine" and "public"
|
||||
const { myWatchlists, publicWatchlists } = useMemo(() => {
|
||||
if (!watchlists) return { myWatchlists: [], publicWatchlists: [] };
|
||||
|
||||
const mine: StreamystatsWatchlist[] = [];
|
||||
const pub: StreamystatsWatchlist[] = [];
|
||||
|
||||
for (const w of watchlists) {
|
||||
if (w.userId === user?.Id) {
|
||||
mine.push(w);
|
||||
} else {
|
||||
pub.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
return { myWatchlists: mine, publicWatchlists: pub };
|
||||
}, [watchlists, user?.Id]);
|
||||
|
||||
// Combine into sections for FlashList
|
||||
const sections = useMemo(() => {
|
||||
const result: Array<
|
||||
| { type: "header"; title: string }
|
||||
| { type: "watchlist"; data: StreamystatsWatchlist; isOwner: boolean }
|
||||
> = [];
|
||||
|
||||
if (myWatchlists.length > 0) {
|
||||
result.push({ type: "header", title: t("watchlists.my_watchlists") });
|
||||
for (const w of myWatchlists) {
|
||||
result.push({ type: "watchlist", data: w, isOwner: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (publicWatchlists.length > 0) {
|
||||
result.push({ type: "header", title: t("watchlists.public_watchlists") });
|
||||
for (const w of publicWatchlists) {
|
||||
result.push({ type: "watchlist", data: w, isOwner: false });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [myWatchlists, publicWatchlists, t]);
|
||||
|
||||
if (!streamystatsEnabled) {
|
||||
return <NotConfiguredState />;
|
||||
}
|
||||
|
||||
if (!isLoading && (!watchlists || watchlists.length === 0)) {
|
||||
return <EmptyState onCreatePress={handleCreatePress} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={sections}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingTop: Platform.OS === "android" ? 10 : 0,
|
||||
paddingBottom: 100,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
renderItem={({ item }) => {
|
||||
if (item.type === "header") {
|
||||
return (
|
||||
<Text className='text-lg font-bold px-4 pt-4 pb-2'>
|
||||
{item.title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WatchlistCard
|
||||
watchlist={item.data}
|
||||
isOwner={item.isOwner}
|
||||
onPress={() => handleWatchlistPress(item.data.id)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
getItemType={(item) => item.type}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import type {
|
||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
@@ -47,7 +49,7 @@ export default function TabLayout() {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={{ flex: 1 }}>
|
||||
<SystemBars hidden={false} style='light' />
|
||||
<NativeTabs
|
||||
sidebarAdaptable={false}
|
||||
@@ -100,6 +102,17 @@ export default function TabLayout() {
|
||||
: (_e) => ({ sfSymbol: "heart.fill" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name='(watchlists)'
|
||||
options={{
|
||||
title: t("watchlists.title"),
|
||||
tabBarItemHidden: !settings?.streamyStatsServerUrl,
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name='(libraries)'
|
||||
options={{
|
||||
@@ -122,6 +135,8 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
</>
|
||||
<MiniPlayerBar />
|
||||
<MusicPlaybackEngine />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
539
app/(auth)/now-playing.tsx
Normal file
539
app/(auth)/now-playing.tsx
Normal file
@@ -0,0 +1,539 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
FlatList,
|
||||
Platform,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
type RepeatMode,
|
||||
useMusicPlayer,
|
||||
} from "@/providers/MusicPlayerProvider";
|
||||
import { formatDuration } from "@/utils/time";
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
||||
|
||||
type ViewMode = "player" | "queue";
|
||||
|
||||
export default function NowPlayingScreen() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("player");
|
||||
|
||||
const {
|
||||
currentTrack,
|
||||
queue,
|
||||
queueIndex,
|
||||
isPlaying,
|
||||
progress,
|
||||
duration,
|
||||
repeatMode,
|
||||
shuffleEnabled,
|
||||
togglePlayPause,
|
||||
next,
|
||||
previous,
|
||||
seek,
|
||||
setRepeatMode,
|
||||
toggleShuffle,
|
||||
jumpToIndex,
|
||||
removeFromQueue,
|
||||
stop,
|
||||
} = useMusicPlayer();
|
||||
|
||||
const sliderProgress = useSharedValue(0);
|
||||
const sliderMin = useSharedValue(0);
|
||||
const sliderMax = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
sliderProgress.value = progress;
|
||||
}, [progress, sliderProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
sliderMax.value = duration > 0 ? duration : 1;
|
||||
}, [duration, sliderMax]);
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (!api || !currentTrack) return null;
|
||||
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
||||
if (albumId) {
|
||||
return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`;
|
||||
}
|
||||
return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=600&maxWidth=600`;
|
||||
}, [api, currentTrack]);
|
||||
|
||||
const progressText = useMemo(() => {
|
||||
const progressTicks = progress * 10000000;
|
||||
return formatDuration(progressTicks);
|
||||
}, [progress]);
|
||||
|
||||
const durationText = useMemo(() => {
|
||||
const durationTicks = duration * 10000000;
|
||||
return formatDuration(durationTicks);
|
||||
}, [duration]);
|
||||
|
||||
const handleSliderComplete = useCallback(
|
||||
(value: number) => {
|
||||
seek(value);
|
||||
},
|
||||
[seek],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
stop();
|
||||
router.back();
|
||||
}, [stop, router]);
|
||||
|
||||
const cycleRepeatMode = useCallback(() => {
|
||||
const modes: RepeatMode[] = ["off", "all", "one"];
|
||||
const currentIndex = modes.indexOf(repeatMode);
|
||||
const nextMode = modes[(currentIndex + 1) % modes.length];
|
||||
setRepeatMode(nextMode);
|
||||
}, [repeatMode, setRepeatMode]);
|
||||
|
||||
const getRepeatIcon = (): string => {
|
||||
switch (repeatMode) {
|
||||
case "one":
|
||||
return "repeat";
|
||||
case "all":
|
||||
return "repeat";
|
||||
default:
|
||||
return "repeat";
|
||||
}
|
||||
};
|
||||
|
||||
const canGoNext = queueIndex < queue.length - 1 || repeatMode === "all";
|
||||
const canGoPrevious = queueIndex > 0 || progress > 3 || repeatMode === "all";
|
||||
|
||||
if (!currentTrack) {
|
||||
return (
|
||||
<View
|
||||
className='flex-1 bg-[#121212] items-center justify-center'
|
||||
style={{
|
||||
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||
}}
|
||||
>
|
||||
<Text className='text-neutral-500'>No track playing</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex-1 bg-[#121212]'
|
||||
style={{
|
||||
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
|
||||
<TouchableOpacity
|
||||
onPress={handleClose}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
className='p-2'
|
||||
>
|
||||
<Ionicons name='chevron-down' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className='flex-row'>
|
||||
<TouchableOpacity
|
||||
onPress={() => setViewMode("player")}
|
||||
className='px-3 py-1'
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
viewMode === "player"
|
||||
? "text-white font-semibold"
|
||||
: "text-neutral-500"
|
||||
}
|
||||
>
|
||||
Now Playing
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => setViewMode("queue")}
|
||||
className='px-3 py-1'
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
viewMode === "queue"
|
||||
? "text-white font-semibold"
|
||||
: "text-neutral-500"
|
||||
}
|
||||
>
|
||||
Queue ({queue.length})
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleStop}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
className='p-2'
|
||||
>
|
||||
<Ionicons name='close' size={24} color='#666' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{viewMode === "player" ? (
|
||||
<PlayerView
|
||||
api={api}
|
||||
currentTrack={currentTrack}
|
||||
imageUrl={imageUrl}
|
||||
sliderProgress={sliderProgress}
|
||||
sliderMin={sliderMin}
|
||||
sliderMax={sliderMax}
|
||||
progressText={progressText}
|
||||
durationText={durationText}
|
||||
isPlaying={isPlaying}
|
||||
repeatMode={repeatMode}
|
||||
shuffleEnabled={shuffleEnabled}
|
||||
canGoNext={canGoNext}
|
||||
canGoPrevious={canGoPrevious}
|
||||
onSliderComplete={handleSliderComplete}
|
||||
onTogglePlayPause={togglePlayPause}
|
||||
onNext={next}
|
||||
onPrevious={previous}
|
||||
onCycleRepeat={cycleRepeatMode}
|
||||
onToggleShuffle={toggleShuffle}
|
||||
getRepeatIcon={getRepeatIcon}
|
||||
queue={queue}
|
||||
queueIndex={queueIndex}
|
||||
/>
|
||||
) : (
|
||||
<QueueView
|
||||
api={api}
|
||||
queue={queue}
|
||||
queueIndex={queueIndex}
|
||||
onJumpToIndex={jumpToIndex}
|
||||
onRemoveFromQueue={removeFromQueue}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlayerViewProps {
|
||||
api: any;
|
||||
currentTrack: BaseItemDto;
|
||||
imageUrl: string | null;
|
||||
sliderProgress: any;
|
||||
sliderMin: any;
|
||||
sliderMax: any;
|
||||
progressText: string;
|
||||
durationText: string;
|
||||
isPlaying: boolean;
|
||||
repeatMode: RepeatMode;
|
||||
shuffleEnabled: boolean;
|
||||
canGoNext: boolean;
|
||||
canGoPrevious: boolean;
|
||||
onSliderComplete: (value: number) => void;
|
||||
onTogglePlayPause: () => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onCycleRepeat: () => void;
|
||||
onToggleShuffle: () => void;
|
||||
getRepeatIcon: () => string;
|
||||
queue: BaseItemDto[];
|
||||
queueIndex: number;
|
||||
}
|
||||
|
||||
const PlayerView: React.FC<PlayerViewProps> = ({
|
||||
currentTrack,
|
||||
imageUrl,
|
||||
sliderProgress,
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
progressText,
|
||||
durationText,
|
||||
isPlaying,
|
||||
repeatMode,
|
||||
shuffleEnabled,
|
||||
canGoNext,
|
||||
canGoPrevious,
|
||||
onSliderComplete,
|
||||
onTogglePlayPause,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onCycleRepeat,
|
||||
onToggleShuffle,
|
||||
getRepeatIcon,
|
||||
queue,
|
||||
queueIndex,
|
||||
}) => {
|
||||
return (
|
||||
<ScrollView className='flex-1 px-6' showsVerticalScrollIndicator={false}>
|
||||
{/* Album artwork */}
|
||||
<View
|
||||
className='self-center mb-8 mt-4'
|
||||
style={{
|
||||
width: ARTWORK_SIZE,
|
||||
height: ARTWORK_SIZE,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 16,
|
||||
elevation: 10,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Ionicons name='musical-note' size={80} color='#666' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Track info */}
|
||||
<View className='mb-6'>
|
||||
<Text numberOfLines={1} className='text-white text-2xl font-bold'>
|
||||
{currentTrack.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-purple-400 text-lg mt-1'>
|
||||
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
||||
</Text>
|
||||
{currentTrack.Album && (
|
||||
<Text numberOfLines={1} className='text-neutral-500 text-sm mt-1'>
|
||||
{currentTrack.Album}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Progress slider */}
|
||||
<View className='mb-4'>
|
||||
<Slider
|
||||
theme={{
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: "#9334E9",
|
||||
bubbleBackgroundColor: "#9334E9",
|
||||
bubbleTextColor: "#fff",
|
||||
}}
|
||||
progress={sliderProgress}
|
||||
minimumValue={sliderMin}
|
||||
maximumValue={sliderMax}
|
||||
onSlidingComplete={onSliderComplete}
|
||||
thumbWidth={16}
|
||||
sliderHeight={6}
|
||||
containerStyle={{ borderRadius: 10 }}
|
||||
renderBubble={() => null}
|
||||
/>
|
||||
<View className='flex flex-row justify-between px-1 mt-2'>
|
||||
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
|
||||
<Text className='text-neutral-500 text-xs'>{durationText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Main Controls */}
|
||||
<View className='flex flex-row items-center justify-center mb-4'>
|
||||
<TouchableOpacity
|
||||
onPress={onPrevious}
|
||||
disabled={!canGoPrevious}
|
||||
className='p-4'
|
||||
style={{ opacity: canGoPrevious ? 1 : 0.3 }}
|
||||
>
|
||||
<Ionicons name='play-skip-back' size={32} color='white' />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onTogglePlayPause}
|
||||
className='mx-8 bg-white rounded-full p-4'
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={36}
|
||||
color='#121212'
|
||||
style={isPlaying ? {} : { marginLeft: 4 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onNext}
|
||||
disabled={!canGoNext}
|
||||
className='p-4'
|
||||
style={{ opacity: canGoNext ? 1 : 0.3 }}
|
||||
>
|
||||
<Ionicons name='play-skip-forward' size={32} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Shuffle & Repeat Controls */}
|
||||
<View className='flex flex-row items-center justify-center mb-6'>
|
||||
<TouchableOpacity onPress={onToggleShuffle} className='p-3 mx-4'>
|
||||
<Ionicons
|
||||
name='shuffle'
|
||||
size={24}
|
||||
color={shuffleEnabled ? "#9334E9" : "#666"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={onCycleRepeat} className='p-3 mx-4 relative'>
|
||||
<Ionicons
|
||||
name={getRepeatIcon() as any}
|
||||
size={24}
|
||||
color={repeatMode !== "off" ? "#9334E9" : "#666"}
|
||||
/>
|
||||
{repeatMode === "one" && (
|
||||
<View className='absolute -top-1 -right-1 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
|
||||
<Text className='text-white text-[10px] font-bold'>1</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Queue info */}
|
||||
{queue.length > 1 && (
|
||||
<View className='items-center mb-8'>
|
||||
<Text className='text-neutral-500 text-sm'>
|
||||
{queueIndex + 1} of {queue.length}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
interface QueueViewProps {
|
||||
api: any;
|
||||
queue: BaseItemDto[];
|
||||
queueIndex: number;
|
||||
onJumpToIndex: (index: number) => void;
|
||||
onRemoveFromQueue: (index: number) => void;
|
||||
}
|
||||
|
||||
const QueueView: React.FC<QueueViewProps> = ({
|
||||
api,
|
||||
queue,
|
||||
queueIndex,
|
||||
onJumpToIndex,
|
||||
onRemoveFromQueue,
|
||||
}) => {
|
||||
const renderQueueItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => {
|
||||
const isCurrentTrack = index === queueIndex;
|
||||
const isPast = index < queueIndex;
|
||||
|
||||
const albumId = item.AlbumId || item.ParentId;
|
||||
const imageUrl = api
|
||||
? albumId
|
||||
? `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=80&maxWidth=80`
|
||||
: `${api.basePath}/Items/${item.Id}/Images/Primary?maxHeight=80&maxWidth=80`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => onJumpToIndex(index)}
|
||||
className={`flex-row items-center px-4 py-3 ${isCurrentTrack ? "bg-purple-900/30" : ""}`}
|
||||
style={{ opacity: isPast ? 0.5 : 1 }}
|
||||
>
|
||||
{/* Track number / Now playing indicator */}
|
||||
<View className='w-8 items-center'>
|
||||
{isCurrentTrack ? (
|
||||
<Ionicons name='musical-note' size={16} color='#9334E9' />
|
||||
) : (
|
||||
<Text className='text-neutral-500 text-sm'>{index + 1}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Album art */}
|
||||
<View className='w-12 h-12 rounded overflow-hidden bg-neutral-800 mr-3'>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center'>
|
||||
<Ionicons name='musical-note' size={16} color='#666' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Track info */}
|
||||
<View className='flex-1 mr-2'>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={`text-base ${isCurrentTrack ? "text-purple-400 font-semibold" : "text-white"}`}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-neutral-500 text-sm'>
|
||||
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Remove button (not for current track) */}
|
||||
{!isCurrentTrack && (
|
||||
<TouchableOpacity
|
||||
onPress={() => onRemoveFromQueue(index)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
className='p-2'
|
||||
>
|
||||
<Ionicons name='close' size={20} color='#666' />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
},
|
||||
[api, queueIndex, onJumpToIndex, onRemoveFromQueue],
|
||||
);
|
||||
|
||||
const _upNext = queue.slice(queueIndex + 1);
|
||||
const history = queue.slice(0, queueIndex);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={queue}
|
||||
keyExtractor={(item, index) => `${item.Id}-${index}`}
|
||||
renderItem={renderQueueItem}
|
||||
showsVerticalScrollIndicator={false}
|
||||
initialScrollIndex={queueIndex > 2 ? queueIndex - 2 : 0}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: 72,
|
||||
offset: 72 * index,
|
||||
index,
|
||||
})}
|
||||
ListHeaderComponent={
|
||||
<View className='px-4 py-2'>
|
||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||
{history.length > 0 ? "Playing from queue" : "Up next"}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View className='flex-1 items-center justify-center py-20'>
|
||||
<Text className='text-neutral-500'>Queue is empty</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,33 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function Layout() {
|
||||
const { settings } = useSettings();
|
||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.defaultVideoOrientation) {
|
||||
lockOrientation(settings.defaultVideoOrientation);
|
||||
}
|
||||
|
||||
// Re-apply orientation lock when app returns to foreground (iOS resets it)
|
||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||
if (nextAppState === "active" && settings?.defaultVideoOrientation) {
|
||||
lockOrientation(settings.defaultVideoOrientation);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
unlockOrientation();
|
||||
};
|
||||
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemBars hidden />
|
||||
|
||||
@@ -22,53 +22,69 @@ import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import {
|
||||
OUTLINE_THICKNESS,
|
||||
OutlineThickness,
|
||||
VLC_COLORS,
|
||||
VLCColor,
|
||||
} from "@/constants/SubtitleConstants";
|
||||
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { VlcPlayerView } from "@/modules";
|
||||
import type {
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/VlcPlayer.types";
|
||||
import {
|
||||
type PlaybackStatePayload,
|
||||
type ProgressUpdatePayload,
|
||||
type SfOnErrorEventPayload,
|
||||
type SfOnPictureInPictureChangePayload,
|
||||
type SfOnPlaybackStateChangePayload,
|
||||
type SfOnProgressEventPayload,
|
||||
SfPlayerView,
|
||||
type SfPlayerViewRef,
|
||||
type SfVideoSource,
|
||||
setHardwareDecode,
|
||||
type VlcPlayerSource,
|
||||
VlcPlayerView,
|
||||
type VlcPlayerViewRef,
|
||||
} from "@/modules";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { useSettings, VideoPlayerIOS } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
getMpvAudioId,
|
||||
getMpvSubtitleId,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
const videoRef = useRef<SfPlayerViewRef | VlcPlayerViewRef>(null);
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Determine which player to use:
|
||||
// - Android always uses VLC
|
||||
// - iOS uses user setting (KSPlayer by default, VLC optional)
|
||||
const useVlcPlayer =
|
||||
Platform.OS === "android" ||
|
||||
(Platform.OS === "ios" && settings.videoPlayerIOS === VideoPlayerIOS.VLC);
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [isPipMode, setIsPipMode] = useState(false);
|
||||
const [aspectRatio, setAspectRatio] = useState<
|
||||
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
||||
>("default");
|
||||
const [scaleFactor, setScaleFactor] = useState<
|
||||
1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | 1.9 | 2.0
|
||||
>(1.0);
|
||||
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
|
||||
"default",
|
||||
);
|
||||
const [isZoomedToFill, setIsZoomedToFill] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const [tracksReady, setTracksReady] = useState(false);
|
||||
const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
@@ -110,11 +126,10 @@ export default function page() {
|
||||
/** Playback position in ticks. */
|
||||
playbackPosition?: string;
|
||||
}>();
|
||||
const { settings } = useSettings();
|
||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||
|
||||
const offline = offlineStr === "true";
|
||||
const playbackManager = usePlaybackManager();
|
||||
const playbackManager = usePlaybackManager({ isOffline: offline });
|
||||
|
||||
const audioIndex = audioIndexStr
|
||||
? Number.parseInt(audioIndexStr, 10)
|
||||
@@ -141,7 +156,7 @@ export default function page() {
|
||||
return Number.parseInt(playbackPositionFromUrl, 10);
|
||||
}
|
||||
return item?.UserData?.PlaybackPositionTicks ?? 0;
|
||||
}, [playbackPositionFromUrl]);
|
||||
}, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItemData = async () => {
|
||||
@@ -174,6 +189,7 @@ export default function page() {
|
||||
}
|
||||
}, [itemId, offline, api, user?.Id]);
|
||||
|
||||
// Lock orientation based on user settings
|
||||
useEffect(() => {
|
||||
if (settings?.defaultVideoOrientation) {
|
||||
lockOrientation(settings.defaultVideoOrientation);
|
||||
@@ -182,7 +198,7 @@ export default function page() {
|
||||
return () => {
|
||||
unlockOrientation();
|
||||
};
|
||||
}, [settings?.defaultVideoOrientation]);
|
||||
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
|
||||
|
||||
interface Stream {
|
||||
mediaSource: MediaSourceInfo;
|
||||
@@ -230,21 +246,25 @@ export default function page() {
|
||||
return;
|
||||
}
|
||||
|
||||
const native = generateDeviceProfile();
|
||||
const transcoding = generateDeviceProfile({ transcode: true });
|
||||
// Calculate start ticks directly from item to avoid stale closure
|
||||
const startTicks = playbackPositionFromUrl
|
||||
? Number.parseInt(playbackPositionFromUrl, 10)
|
||||
: (item?.UserData?.PlaybackPositionTicks ?? 0);
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: getInitialPlaybackTicks(),
|
||||
startTimeTicks: startTicks,
|
||||
userId: user.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: bitrateValue ? transcoding : native,
|
||||
deviceProfile: generateDeviceProfile(),
|
||||
});
|
||||
if (!res) return;
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
@@ -273,14 +293,14 @@ export default function page() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream || !api) return;
|
||||
if (!stream || !api || offline) return;
|
||||
const reportPlaybackStart = async () => {
|
||||
await getPlaystateApi(api).reportPlaybackStart({
|
||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
||||
});
|
||||
};
|
||||
reportPlaybackStart();
|
||||
}, [stream, api]);
|
||||
}, [stream, api, offline]);
|
||||
|
||||
const togglePlay = async () => {
|
||||
lightHapticFeedback();
|
||||
@@ -292,17 +312,19 @@ export default function page() {
|
||||
);
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
await getPlaystateApi(api!).reportPlaybackStart({
|
||||
if (!offline && api) {
|
||||
await getPlaystateApi(api).reportPlaybackStart({
|
||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reportPlaybackStopped = useCallback(async () => {
|
||||
if (!item?.Id || !stream?.sessionId) return;
|
||||
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(progress.get());
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
await getPlaystateApi(api).onPlaybackStopped({
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: currentTimeInTicks,
|
||||
@@ -325,7 +347,8 @@ export default function page() {
|
||||
});
|
||||
reportPlaybackStopped();
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.stop();
|
||||
// KSPlayer doesn't have a stop method, use pause instead
|
||||
videoRef.current?.pause();
|
||||
revalidateProgressCache();
|
||||
}, [videoRef, reportPlaybackStopped, progress]);
|
||||
|
||||
@@ -338,6 +361,7 @@ export default function page() {
|
||||
|
||||
const currentPlayStateInfo = useCallback(() => {
|
||||
if (!stream || !item?.Id) return;
|
||||
|
||||
return {
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
@@ -379,11 +403,63 @@ export default function page() {
|
||||
[],
|
||||
);
|
||||
|
||||
const onProgress = useCallback(
|
||||
/** Progress handler for iOS (SfPlayer) - position in seconds */
|
||||
const onProgressSf = useCallback(
|
||||
async (data: { nativeEvent: SfOnProgressEventPayload }) => {
|
||||
if (isSeeking.get() || isPlaybackStopped) return;
|
||||
|
||||
const { position } = data.nativeEvent;
|
||||
// KSPlayer reports position in seconds, convert to ms
|
||||
const currentTime = position * 1000;
|
||||
|
||||
if (isBuffering) {
|
||||
setIsBuffering(false);
|
||||
}
|
||||
|
||||
progress.set(currentTime);
|
||||
|
||||
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
||||
const now = Date.now();
|
||||
const shouldUpdateUrl = wasJustSeeking.get();
|
||||
wasJustSeeking.value = false;
|
||||
|
||||
if (
|
||||
shouldUpdateUrl ||
|
||||
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
|
||||
) {
|
||||
router.setParams({
|
||||
playbackPosition: msToTicks(currentTime).toString(),
|
||||
});
|
||||
lastUrlUpdateTime.value = now;
|
||||
}
|
||||
|
||||
if (!item?.Id) return;
|
||||
|
||||
playbackManager.reportPlaybackProgress(
|
||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
);
|
||||
},
|
||||
[
|
||||
item?.Id,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
isPlaying,
|
||||
stream,
|
||||
isSeeking,
|
||||
isPlaybackStopped,
|
||||
isBuffering,
|
||||
],
|
||||
);
|
||||
|
||||
/** Progress handler for Android (VLC) - currentTime in milliseconds */
|
||||
const onProgressVlc = useCallback(
|
||||
async (data: ProgressUpdatePayload) => {
|
||||
if (isSeeking.get() || isPlaybackStopped) return;
|
||||
|
||||
const { currentTime } = data.nativeEvent;
|
||||
// VLC reports currentTime in milliseconds
|
||||
|
||||
if (isBuffering) {
|
||||
setIsBuffering(false);
|
||||
}
|
||||
@@ -429,6 +505,168 @@ export default function page() {
|
||||
return ticksToSeconds(getInitialPlaybackTicks());
|
||||
}, [getInitialPlaybackTicks]);
|
||||
|
||||
/** Build video source config for iOS (SfPlayer/KSPlayer) */
|
||||
const sfVideoSource = useMemo<SfVideoSource | undefined>(() => {
|
||||
if (!stream?.url || useVlcPlayer) return undefined;
|
||||
|
||||
const mediaSource = stream.mediaSource;
|
||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||
|
||||
// For offline playback, subtitles are embedded in the downloaded file
|
||||
// For online playback, get external subtitle URLs from server
|
||||
let externalSubs: string[] | undefined;
|
||||
if (!offline && api?.basePath) {
|
||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) =>
|
||||
s.Type === "Subtitle" &&
|
||||
s.DeliveryMethod === "External" &&
|
||||
s.DeliveryUrl,
|
||||
).map((s) => `${api.basePath}${s.DeliveryUrl}`);
|
||||
}
|
||||
|
||||
// Calculate track IDs for initial selection
|
||||
const initialSubtitleId = getMpvSubtitleId(
|
||||
mediaSource,
|
||||
subtitleIndex,
|
||||
isTranscoding,
|
||||
);
|
||||
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
|
||||
|
||||
// Calculate start position directly here to avoid timing issues
|
||||
const startTicks = playbackPositionFromUrl
|
||||
? Number.parseInt(playbackPositionFromUrl, 10)
|
||||
: (item?.UserData?.PlaybackPositionTicks ?? 0);
|
||||
const startPos = ticksToSeconds(startTicks);
|
||||
|
||||
// For transcoded streams, the server already handles seeking via startTimeTicks,
|
||||
// so we should NOT also tell the player to seek (would cause double-seeking).
|
||||
// For direct play/stream, the player needs to seek itself.
|
||||
const playerStartPos = isTranscoding ? 0 : startPos;
|
||||
|
||||
// Build source config - headers only needed for online streaming
|
||||
const source: SfVideoSource = {
|
||||
url: stream.url,
|
||||
startPosition: playerStartPos,
|
||||
autoplay: true,
|
||||
initialSubtitleId,
|
||||
initialAudioId,
|
||||
};
|
||||
|
||||
// Add external subtitles only for online playback
|
||||
if (externalSubs && externalSubs.length > 0) {
|
||||
source.externalSubtitles = externalSubs;
|
||||
}
|
||||
|
||||
// Add auth headers only for online streaming (not for local file:// URLs)
|
||||
if (!offline && api?.accessToken) {
|
||||
source.headers = {
|
||||
Authorization: `MediaBrowser Token="${api.accessToken}"`,
|
||||
};
|
||||
}
|
||||
|
||||
return source;
|
||||
}, [
|
||||
stream?.url,
|
||||
stream?.mediaSource,
|
||||
item?.UserData?.PlaybackPositionTicks,
|
||||
playbackPositionFromUrl,
|
||||
api?.basePath,
|
||||
api?.accessToken,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
offline,
|
||||
useVlcPlayer,
|
||||
]);
|
||||
|
||||
/** Build video source config for Android (VLC) */
|
||||
const vlcVideoSource = useMemo<VlcPlayerSource | undefined>(() => {
|
||||
if (!stream?.url || !useVlcPlayer) return undefined;
|
||||
|
||||
const mediaSource = stream.mediaSource;
|
||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||
|
||||
// For VLC, external subtitles need name and DeliveryUrl
|
||||
let externalSubs: { name: string; DeliveryUrl: string }[] | undefined;
|
||||
if (!offline && api?.basePath) {
|
||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) =>
|
||||
s.Type === "Subtitle" &&
|
||||
s.DeliveryMethod === "External" &&
|
||||
s.DeliveryUrl,
|
||||
).map((s) => ({
|
||||
name: s.DisplayTitle || s.Title || `Subtitle ${s.Index}`,
|
||||
DeliveryUrl: `${api.basePath}${s.DeliveryUrl}`,
|
||||
}));
|
||||
}
|
||||
|
||||
// Build VLC init options (required for VLC to work properly)
|
||||
const initOptions: string[] = [""];
|
||||
|
||||
// Get all subtitle and audio streams
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? [];
|
||||
const textSubs = allSubs.filter((s) => s.IsTextSubtitleStream);
|
||||
const allAudio =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
|
||||
|
||||
// Find chosen tracks
|
||||
const chosenSubtitleTrack = allSubs.find((s) => s.Index === subtitleIndex);
|
||||
const chosenAudioTrack = allAudio.find((a) => a.Index === audioIndex);
|
||||
|
||||
// Set subtitle track
|
||||
if (
|
||||
chosenSubtitleTrack &&
|
||||
(!isTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||
) {
|
||||
const finalIndex = !isTranscoding
|
||||
? allSubs.indexOf(chosenSubtitleTrack)
|
||||
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
||||
if (finalIndex >= 0) {
|
||||
initOptions.push(`--sub-track=${finalIndex}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set audio track
|
||||
if (!isTranscoding && chosenAudioTrack) {
|
||||
const audioTrackIndex = allAudio.indexOf(chosenAudioTrack);
|
||||
if (audioTrackIndex >= 0) {
|
||||
initOptions.push(`--audio-track=${audioTrackIndex}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add subtitle styling
|
||||
if (settings.subtitleSize) {
|
||||
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
|
||||
}
|
||||
initOptions.push("--sub-margin=40");
|
||||
|
||||
// For transcoded streams, the server already handles seeking via startTimeTicks,
|
||||
// so we should NOT also tell the player to seek (would cause double-seeking).
|
||||
// For direct play/stream, the player needs to seek itself.
|
||||
const playerStartPos = isTranscoding ? 0 : startPosition;
|
||||
|
||||
const source: VlcPlayerSource = {
|
||||
uri: stream.url,
|
||||
startPosition: playerStartPos,
|
||||
autoplay: true,
|
||||
isNetwork: !offline,
|
||||
externalSubtitles: externalSubs,
|
||||
initOptions,
|
||||
};
|
||||
|
||||
return source;
|
||||
}, [
|
||||
stream?.url,
|
||||
stream?.mediaSource,
|
||||
startPosition,
|
||||
useVlcPlayer,
|
||||
api?.basePath,
|
||||
offline,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
settings.subtitleSize,
|
||||
]);
|
||||
|
||||
const volumeUpCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
@@ -508,11 +746,55 @@ export default function page() {
|
||||
setVolume: setVolumeCb,
|
||||
});
|
||||
|
||||
const onPlaybackStateChanged = useCallback(
|
||||
async (e: PlaybackStatePayload) => {
|
||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||
if (state === "Playing") {
|
||||
/** Playback state handler for iOS (SfPlayer) */
|
||||
const onPlaybackStateChangedSf = useCallback(
|
||||
async (e: { nativeEvent: SfOnPlaybackStateChangePayload }) => {
|
||||
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
|
||||
|
||||
if (playing) {
|
||||
setIsPlaying(true);
|
||||
setIsBuffering(false);
|
||||
setHasPlaybackStarted(true);
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
);
|
||||
}
|
||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPaused) {
|
||||
setIsPlaying(false);
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
);
|
||||
}
|
||||
if (!Platform.isTV) await deactivateKeepAwake();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
setIsBuffering(true);
|
||||
}
|
||||
},
|
||||
[playbackManager, item?.Id, progress],
|
||||
);
|
||||
|
||||
/** Playback state handler for Android (VLC) */
|
||||
const onPlaybackStateChangedVlc = useCallback(
|
||||
async (e: PlaybackStatePayload) => {
|
||||
const {
|
||||
state,
|
||||
isBuffering: buffering,
|
||||
isPlaying: playing,
|
||||
} = e.nativeEvent;
|
||||
|
||||
if (state === "Playing" || playing) {
|
||||
setIsPlaying(true);
|
||||
setIsBuffering(false);
|
||||
setHasPlaybackStarted(true);
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
@@ -533,86 +815,38 @@ export default function page() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
setIsPlaying(true);
|
||||
setIsBuffering(false);
|
||||
} else if (isBuffering) {
|
||||
if (state === "Buffering" || buffering) {
|
||||
setIsBuffering(true);
|
||||
}
|
||||
},
|
||||
[playbackManager, item?.Id, progress],
|
||||
);
|
||||
|
||||
const allAudio =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(audio) => audio.Type === "Audio",
|
||||
) || [];
|
||||
|
||||
// Move all the external subtitles last, because vlc places them last.
|
||||
const allSubs =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(sub) => sub.Type === "Subtitle",
|
||||
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
|
||||
|
||||
const externalSubtitles = allSubs
|
||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||
.map((sub: any) => ({
|
||||
name: sub.DisplayTitle,
|
||||
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
|
||||
}));
|
||||
/** The text based subtitle tracks */
|
||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||
/** The user chosen subtitle track from the server */
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub) => sub.Index === subtitleIndex,
|
||||
);
|
||||
/** The user chosen audio track from the server */
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
/** Whether the stream we're playing is not transcoding*/
|
||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||
/** The initial options to pass to the VLC Player */
|
||||
const initOptions = [``];
|
||||
if (
|
||||
chosenSubtitleTrack &&
|
||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||
) {
|
||||
// If not transcoding, we can the index as normal.
|
||||
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
|
||||
const finalIndex = notTranscoding
|
||||
? allSubs.indexOf(chosenSubtitleTrack)
|
||||
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
||||
initOptions.push(`--sub-track=${finalIndex}`);
|
||||
|
||||
// Add VLC subtitle styling options from settings
|
||||
const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
|
||||
const backgroundColor = (settings.vlcBackgroundColor ??
|
||||
"Black") as VLCColor;
|
||||
const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
|
||||
const outlineThickness = (settings.vlcOutlineThickness ??
|
||||
"Normal") as OutlineThickness;
|
||||
const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
|
||||
const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
|
||||
const isBold = settings.vlcIsBold ?? false;
|
||||
// Add subtitle styling options
|
||||
initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
|
||||
initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
|
||||
initOptions.push(
|
||||
`--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
|
||||
);
|
||||
initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
|
||||
initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
|
||||
initOptions.push(
|
||||
`--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
|
||||
);
|
||||
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
|
||||
initOptions.push("--sub-margin=40");
|
||||
if (isBold) {
|
||||
initOptions.push("--freetype-bold");
|
||||
/** PiP handler for iOS (SfPlayer) */
|
||||
const onPictureInPictureChangeSf = useCallback(
|
||||
(e: { nativeEvent: SfOnPictureInPictureChangePayload }) => {
|
||||
const { isActive } = e.nativeEvent;
|
||||
setIsPipMode(isActive);
|
||||
// Hide controls when entering PiP
|
||||
if (isActive) {
|
||||
_setShowControls(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** PiP handler for Android (VLC) */
|
||||
const onPipStartedVlc = useCallback(
|
||||
(e: { nativeEvent: { pipStarted: boolean } }) => {
|
||||
const { pipStarted } = e.nativeEvent;
|
||||
setIsPipMode(pipStarted);
|
||||
// Hide controls when entering PiP
|
||||
if (pipStarted) {
|
||||
_setShowControls(false);
|
||||
}
|
||||
if (notTranscoding && chosenAudioTrack) {
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
@@ -626,6 +860,7 @@ export default function page() {
|
||||
const startPictureInPicture = useCallback(async () => {
|
||||
return videoRef.current?.startPictureInPicture?.();
|
||||
}, []);
|
||||
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.play?.();
|
||||
}, []);
|
||||
@@ -634,70 +869,65 @@ export default function page() {
|
||||
videoRef.current?.pause?.();
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((position: number) => {
|
||||
const seek = useCallback(
|
||||
(position: number) => {
|
||||
if (useVlcPlayer) {
|
||||
// VLC expects milliseconds
|
||||
videoRef.current?.seekTo?.(position);
|
||||
}, []);
|
||||
const getAudioTracks = useCallback(async () => {
|
||||
return videoRef.current?.getAudioTracks?.() || null;
|
||||
}, []);
|
||||
|
||||
const getSubtitleTracks = useCallback(async () => {
|
||||
return videoRef.current?.getSubtitleTracks?.() || null;
|
||||
}, []);
|
||||
|
||||
const setSubtitleTrack = useCallback((index: number) => {
|
||||
videoRef.current?.setSubtitleTrack?.(index);
|
||||
}, []);
|
||||
|
||||
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
|
||||
// Note: VlcPlayer type only expects url parameter
|
||||
videoRef.current?.setSubtitleURL?.(url);
|
||||
}, []);
|
||||
|
||||
const setAudioTrack = useCallback((index: number) => {
|
||||
videoRef.current?.setAudioTrack?.(index);
|
||||
}, []);
|
||||
|
||||
const setVideoAspectRatio = useCallback(
|
||||
async (aspectRatio: string | null) => {
|
||||
return (
|
||||
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
|
||||
Promise.resolve()
|
||||
);
|
||||
} else {
|
||||
// KSPlayer expects seconds, convert from ms
|
||||
videoRef.current?.seekTo?.(position / 1000);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[useVlcPlayer],
|
||||
);
|
||||
|
||||
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
|
||||
return (
|
||||
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
|
||||
const handleZoomToggle = useCallback(async () => {
|
||||
// Zoom toggle only supported when using SfPlayer (KSPlayer)
|
||||
if (useVlcPlayer) return;
|
||||
const newZoomState = !isZoomedToFill;
|
||||
setIsZoomedToFill(newZoomState);
|
||||
await (videoRef.current as SfPlayerViewRef)?.setVideoZoomToFill?.(
|
||||
newZoomState,
|
||||
);
|
||||
}, []);
|
||||
}, [isZoomedToFill, useVlcPlayer]);
|
||||
|
||||
// Prepare metadata for iOS native media controls
|
||||
const nowPlayingMetadata = useMemo(() => {
|
||||
if (!item || !api) return undefined;
|
||||
// Apply KSPlayer global settings before video loads (only when using KSPlayer)
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "ios" && !useVlcPlayer) {
|
||||
setHardwareDecode(settings.ksHardwareDecode);
|
||||
}
|
||||
}, [settings.ksHardwareDecode, useVlcPlayer]);
|
||||
|
||||
const artworkUri = getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 500,
|
||||
});
|
||||
// Apply subtitle settings when video loads (SfPlayer-specific)
|
||||
useEffect(() => {
|
||||
if (useVlcPlayer || !isVideoLoaded || !videoRef.current) return;
|
||||
|
||||
return {
|
||||
title: item.Name || "",
|
||||
artist:
|
||||
item.Type === "Episode"
|
||||
? item.SeriesName || ""
|
||||
: item.AlbumArtist || "",
|
||||
albumTitle:
|
||||
item.Type === "Episode" && item.SeasonName
|
||||
? item.SeasonName
|
||||
: undefined,
|
||||
artworkUri: artworkUri || undefined,
|
||||
const sfRef = videoRef.current as SfPlayerViewRef;
|
||||
const applySubtitleSettings = async () => {
|
||||
if (settings.mpvSubtitleScale !== undefined) {
|
||||
await sfRef?.setSubtitleScale?.(settings.mpvSubtitleScale);
|
||||
}
|
||||
if (settings.mpvSubtitleMarginY !== undefined) {
|
||||
await sfRef?.setSubtitleMarginY?.(settings.mpvSubtitleMarginY);
|
||||
}
|
||||
if (settings.mpvSubtitleAlignX !== undefined) {
|
||||
await sfRef?.setSubtitleAlignX?.(settings.mpvSubtitleAlignX);
|
||||
}
|
||||
if (settings.mpvSubtitleAlignY !== undefined) {
|
||||
await sfRef?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
|
||||
}
|
||||
if (settings.mpvSubtitleFontSize !== undefined) {
|
||||
await sfRef?.setSubtitleFontSize?.(settings.mpvSubtitleFontSize);
|
||||
}
|
||||
// Apply subtitle size from general settings
|
||||
if (settings.subtitleSize) {
|
||||
await sfRef?.setSubtitleFontSize?.(settings.subtitleSize);
|
||||
}
|
||||
};
|
||||
}, [item, api]);
|
||||
|
||||
applySubtitleSettings();
|
||||
}, [isVideoLoaded, settings, useVlcPlayer]);
|
||||
|
||||
// Show error UI first, before checking loading/missing‐data
|
||||
if (itemStatus.isError || streamStatus.isError) {
|
||||
@@ -708,7 +938,7 @@ export default function page() {
|
||||
);
|
||||
}
|
||||
|
||||
// Then show loader while either side is still fetching or data isn’t present
|
||||
// Then show loader while either side is still fetching or data isn't present
|
||||
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
||||
// …loader UI…
|
||||
return (
|
||||
@@ -726,6 +956,14 @@ export default function page() {
|
||||
);
|
||||
|
||||
return (
|
||||
<PlayerProvider
|
||||
playerRef={videoRef}
|
||||
item={item}
|
||||
mediaSource={stream?.mediaSource}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
tracksReady={tracksReady}
|
||||
>
|
||||
<VideoProvider>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -744,25 +982,16 @@ export default function page() {
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{useVlcPlayer ? (
|
||||
<VlcPlayerView
|
||||
ref={videoRef}
|
||||
source={{
|
||||
uri: stream?.url || "",
|
||||
autoplay: true,
|
||||
isNetwork: !offline,
|
||||
startPosition,
|
||||
externalSubtitles,
|
||||
initOptions,
|
||||
}}
|
||||
ref={videoRef as React.RefObject<VlcPlayerViewRef>}
|
||||
source={vlcVideoSource!}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
nowPlayingMetadata={nowPlayingMetadata}
|
||||
onVideoProgress={onProgress}
|
||||
progressUpdateInterval={1000}
|
||||
onVideoStateChange={onPlaybackStateChanged}
|
||||
onVideoLoadEnd={() => {
|
||||
setIsVideoLoaded(true);
|
||||
}}
|
||||
onVideoError={(e) => {
|
||||
onVideoProgress={onProgressVlc}
|
||||
onVideoStateChange={onPlaybackStateChangedVlc}
|
||||
onPipStarted={onPipStartedVlc}
|
||||
onVideoLoadEnd={() => setIsVideoLoaded(true)}
|
||||
onVideoError={(e: PlaybackStatePayload) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
@@ -770,16 +999,51 @@ export default function page() {
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
onPipStarted={(e) => {
|
||||
setIsPipMode(e.nativeEvent.pipStarted);
|
||||
progressUpdateInterval={1000}
|
||||
/>
|
||||
) : (
|
||||
<SfPlayerView
|
||||
ref={videoRef as React.RefObject<SfPlayerViewRef>}
|
||||
source={sfVideoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onProgress={onProgressSf}
|
||||
onPlaybackStateChange={onPlaybackStateChangedSf}
|
||||
onPictureInPictureChange={onPictureInPictureChangeSf}
|
||||
onLoad={() => setIsVideoLoaded(true)}
|
||||
onError={(e: { nativeEvent: SfOnErrorEventPayload }) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
onTracksReady={() => {
|
||||
setTracksReady(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!hasPlaybackStarted && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "black",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{isMounted === true && item && !isPipMode && (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
@@ -788,29 +1052,21 @@ export default function page() {
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
startPictureInPicture={startPictureInPicture}
|
||||
play={play}
|
||||
pause={pause}
|
||||
seek={seek}
|
||||
enableTrickplay={true}
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
offline={offline}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setVideoAspectRatio={setVideoAspectRatio}
|
||||
setVideoScaleFactor={setVideoScaleFactor}
|
||||
aspectRatio={aspectRatio}
|
||||
scaleFactor={scaleFactor}
|
||||
setAspectRatio={setAspectRatio}
|
||||
setScaleFactor={setScaleFactor}
|
||||
isVlc
|
||||
isZoomedToFill={isZoomedToFill}
|
||||
onZoomToggle={handleZoomToggle}
|
||||
api={api}
|
||||
downloadedFiles={downloadedFiles}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</VideoProvider>
|
||||
</PlayerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getOrSetDeviceId,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
@@ -344,6 +345,7 @@ function Layout() {
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
@@ -365,6 +367,14 @@ function Layout() {
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/now-playing'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
@@ -393,6 +403,7 @@ function Layout() {
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
|
||||
@@ -262,12 +262,12 @@ const Login: React.FC = () => {
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text: string) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
setCredentials((prev) => ({ ...prev, username: text }))
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.username) {
|
||||
setCredentials({ ...credentials, username: newValue });
|
||||
setCredentials((prev) => ({ ...prev, username: newValue }));
|
||||
}
|
||||
}}
|
||||
value={credentials.username}
|
||||
@@ -286,12 +286,12 @@ const Login: React.FC = () => {
|
||||
<Input
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onChangeText={(text: string) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
setCredentials((prev) => ({ ...prev, password: text }))
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.password) {
|
||||
setCredentials({ ...credentials, password: newValue });
|
||||
setCredentials((prev) => ({ ...prev, password: newValue }));
|
||||
}
|
||||
}}
|
||||
value={credentials.password}
|
||||
@@ -398,8 +398,8 @@ const Login: React.FC = () => {
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{api?.basePath ? (
|
||||
<View className='flex flex-col flex-1 items-center justify-center'>
|
||||
<View className='px-4 -mt-20 w-full'>
|
||||
<View className='flex flex-col flex-1 justify-center'>
|
||||
<View className='px-4 w-full'>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<Text className='text-2xl font-bold -mb-2'>
|
||||
{serverName ? (
|
||||
@@ -415,12 +415,15 @@ const Login: React.FC = () => {
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
setCredentials((prev) => ({ ...prev, username: text }))
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.username) {
|
||||
setCredentials({ ...credentials, username: newValue });
|
||||
setCredentials((prev) => ({
|
||||
...prev,
|
||||
username: newValue,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
value={credentials.username}
|
||||
@@ -437,12 +440,15 @@ const Login: React.FC = () => {
|
||||
<Input
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
setCredentials((prev) => ({ ...prev, password: text }))
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.password) {
|
||||
setCredentials({ ...credentials, password: newValue });
|
||||
setCredentials((prev) => ({
|
||||
...prev,
|
||||
password: newValue,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
value={credentials.password}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"!android",
|
||||
"!Streamyfin.app",
|
||||
"!utils/jellyseerr",
|
||||
"!.expo"
|
||||
"!.expo",
|
||||
"!docs/jellyfin-openapi-stable.json"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
|
||||
411
bun.lock
411
bun.lock
@@ -1,59 +1,60 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "streamyfin",
|
||||
"dependencies": {
|
||||
"@bottom-tabs/react-navigation": "^1.0.2",
|
||||
"@bottom-tabs/react-navigation": "1.1.0",
|
||||
"@expo/metro-runtime": "~6.1.1",
|
||||
"@expo/react-native-action-sheet": "^4.1.1",
|
||||
"@expo/ui": "^0.2.0-beta.4",
|
||||
"@expo/ui": "0.2.0-beta.9",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@gorhom/bottom-sheet": "^5.1.0",
|
||||
"@gorhom/bottom-sheet": "5.2.8",
|
||||
"@jellyfin/sdk": "^0.13.0",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-navigation/material-top-tabs": "^7.2.14",
|
||||
"@react-navigation/material-top-tabs": "7.4.9",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "^54.0.23",
|
||||
"expo-application": "~7.0.5",
|
||||
"expo-asset": "~12.0.6",
|
||||
"expo-background-task": "~1.0.5",
|
||||
"expo-blur": "~15.0.5",
|
||||
"expo-brightness": "~14.0.5",
|
||||
"expo-build-properties": "~1.0.6",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-dev-client": "~6.0.17",
|
||||
"expo-device": "~8.0.5",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.5",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-linear-gradient": "~15.0.5",
|
||||
"expo-linking": "~8.0.6",
|
||||
"expo-localization": "~17.0.5",
|
||||
"expo-notifications": "~0.32.7",
|
||||
"expo-router": "~6.0.14",
|
||||
"expo-screen-orientation": "~9.0.5",
|
||||
"expo-sensors": "~15.0.5",
|
||||
"expo-sharing": "~14.0.5",
|
||||
"expo-splash-screen": "~31.0.7",
|
||||
"expo-status-bar": "~3.0.6",
|
||||
"expo-system-ui": "~6.0.8",
|
||||
"expo-task-manager": "~14.0.8",
|
||||
"expo-web-browser": "~15.0.9",
|
||||
"expo": "~54.0.30",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-asset": "~12.0.12",
|
||||
"expo-background-task": "~1.0.10",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-brightness": "~14.0.8",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-constants": "~18.0.12",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-notifications": "~0.32.15",
|
||||
"expo-router": "~6.0.21",
|
||||
"expo-screen-orientation": "~9.0.8",
|
||||
"expo-sensors": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-task-manager": "~14.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"i18next": "^25.0.0",
|
||||
"jotai": "^2.12.5",
|
||||
"lodash": "^4.17.21",
|
||||
"jotai": "2.16.0",
|
||||
"lodash": "4.17.21",
|
||||
"nativewind": "^2.0.11",
|
||||
"patch-package": "^8.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "16.0.0",
|
||||
"react-native": "npm:react-native-tvos@0.81.5-1",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "^1.0.2",
|
||||
"react-native-bottom-tabs": "1.1.0",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
"react-native-collapsible": "^1.6.2",
|
||||
"react-native-country-flag": "^2.0.2",
|
||||
@@ -72,17 +73,17 @@
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.18.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
||||
"react-native-udp": "^4.1.7",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-video": "6.16.1",
|
||||
"react-native-volume-manager": "^2.0.8",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"sonner-native": "^0.21.0",
|
||||
"sonner-native": "0.21.2",
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zod": "^4.1.3",
|
||||
"zod": "4.1.13",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.5",
|
||||
@@ -317,7 +318,7 @@
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-nUmR8gb6yvrKhtRgzwo/gDimPwnO5a4sCydf8ZS2kHIJhEmSmk+STsusr1LHTuM//wXppBawvSQi2xFXJCdgKQ=="],
|
||||
|
||||
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.0.2", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-OrCw8s2NzFxO1TO5W2vyr7HNvh1Yjy00f72D/0BIPtImc0aj5CRrT9nFRE7YP0FWZb0AY5+0QU9jaoph1rBlSg=="],
|
||||
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.1.0", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-+4YppCodABcSNIgJiq95QUQ+3ClVBG+rLG3WmYI0+/nbxqKbCz6luFBep4KFOj98Iplj1JY2Ki6ix8CcOZVQ/Q=="],
|
||||
|
||||
"@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="],
|
||||
|
||||
@@ -325,47 +326,45 @@
|
||||
|
||||
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
||||
|
||||
"@expo/cli": ["@expo/cli@54.0.16", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devcert": "^1.1.2", "@expo/env": "~2.0.7", "@expo/image-utils": "^0.8.7", "@expo/json-file": "^10.0.7", "@expo/mcp-tunnel": "~0.1.0", "@expo/metro": "~54.1.0", "@expo/metro-config": "~54.0.9", "@expo/osascript": "^2.3.7", "@expo/package-manager": "^1.9.8", "@expo/plist": "^0.4.7", "@expo/prebuild-config": "^54.0.6", "@expo/schema-utils": "^0.1.7", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.81.5", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "expo-server": "^1.0.4", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.4.3", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "build/bin/cli" } }, "sha512-hY/OdRaJMs5WsVPuVSZ+RLH3VObJmL/pv5CGCHEZHN2PxZjSZSdctyKV8UcFBXTF0yIKNAJ9XLs1dlNYXHh4Cw=="],
|
||||
"@expo/cli": ["@expo/cli@54.0.20", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devcert": "^1.2.1", "@expo/env": "~2.0.8", "@expo/image-utils": "^0.8.8", "@expo/json-file": "^10.0.8", "@expo/metro": "~54.2.0", "@expo/metro-config": "~54.0.12", "@expo/osascript": "^2.3.8", "@expo/package-manager": "^1.9.9", "@expo/plist": "^0.4.8", "@expo/prebuild-config": "^54.0.8", "@expo/schema-utils": "^0.1.8", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.81.5", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "expo-server": "^1.0.5", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.5.2", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "build/bin/cli" } }, "sha512-cwsXmhftvS0p9NNYOhXGnicBAZl9puWwRt19Qq5eQ6njLnaj8WvcR+kDZyADtgZxBsZiyVlrKXvnjt43HXywQA=="],
|
||||
|
||||
"@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.5", "", { "dependencies": { "node-forge": "^1.2.1", "nullthrows": "^1.1.1" } }, "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw=="],
|
||||
|
||||
"@expo/config": ["@expo/config@12.0.10", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~54.0.2", "@expo/config-types": "^54.0.8", "@expo/json-file": "^10.0.7", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^10.4.2", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", "sucrase": "3.35.0" } }, "sha512-lJMof5Nqakq1DxGYlghYB/ogSBjmv4Fxn1ovyDmcjlRsQdFCXgu06gEUogkhPtc9wBt9WlTTfqENln5HHyLW6w=="],
|
||||
"@expo/config": ["@expo/config@12.0.13", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~54.0.4", "@expo/config-types": "^54.0.10", "@expo/json-file": "^10.0.8", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", "sucrase": "~3.35.1" } }, "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ=="],
|
||||
|
||||
"@expo/config-plugins": ["@expo/config-plugins@54.0.2", "", { "dependencies": { "@expo/config-types": "^54.0.8", "@expo/json-file": "~10.0.7", "@expo/plist": "^0.4.7", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^10.4.2", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-jD4qxFcURQUVsUFGMcbo63a/AnviK8WUGard+yrdQE3ZrB/aurn68SlApjirQQLEizhjI5Ar2ufqflOBlNpyPg=="],
|
||||
"@expo/config-plugins": ["@expo/config-plugins@54.0.4", "", { "dependencies": { "@expo/config-types": "^54.0.10", "@expo/json-file": "~10.0.8", "@expo/plist": "^0.4.8", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q=="],
|
||||
|
||||
"@expo/config-types": ["@expo/config-types@54.0.8", "", {}, "sha512-lyIn/x/Yz0SgHL7IGWtgTLg6TJWC9vL7489++0hzCHZ4iGjVcfZmPTUfiragZ3HycFFj899qN0jlhl49IHa94A=="],
|
||||
"@expo/config-types": ["@expo/config-types@54.0.10", "", {}, "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA=="],
|
||||
|
||||
"@expo/devcert": ["@expo/devcert@1.2.0", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0", "glob": "^10.4.2" } }, "sha512-Uilcv3xGELD5t/b0eM4cxBFEKQRIivB3v7i+VhWLV/gL98aw810unLKKJbGAxAIhY6Ipyz8ChWibFsKFXYwstA=="],
|
||||
"@expo/devcert": ["@expo/devcert@1.2.1", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0" } }, "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA=="],
|
||||
|
||||
"@expo/devtools": ["@expo/devtools@0.1.7", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-dfIa9qMyXN+0RfU6SN4rKeXZyzKWsnz6xBSDccjL4IRiE+fQ0t84zg0yxgN4t/WK2JU5v6v4fby7W7Crv9gJvA=="],
|
||||
"@expo/devtools": ["@expo/devtools@0.1.8", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ=="],
|
||||
|
||||
"@expo/env": ["@expo/env@2.0.7", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0" } }, "sha512-BNETbLEohk3HQ2LxwwezpG8pq+h7Fs7/vAMP3eAtFT1BCpprLYoBBFZH7gW4aqGfqOcVP4Lc91j014verrYNGg=="],
|
||||
|
||||
"@expo/fingerprint": ["@expo/fingerprint@0.15.3", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^10.4.2", "ignore": "^5.3.1", "minimatch": "^9.0.0", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-8YPJpEYlmV171fi+t+cSLMX1nC5ngY9j2FiN70dHldLpd6Ct6ouGhk96svJ4BQZwsqwII2pokwzrDAwqo4Z0FQ=="],
|
||||
"@expo/fingerprint": ["@expo/fingerprint@0.15.4", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^9.0.0", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng=="],
|
||||
|
||||
"@expo/image-utils": ["@expo/image-utils@0.8.7", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", "resolve-global": "^1.0.0", "semver": "^7.6.0", "temp-dir": "~2.0.0", "unique-string": "~2.0.0" } }, "sha512-SXOww4Wq3RVXLyOaXiCCuQFguCDh8mmaHBv54h/R29wGl4jRY8GEyQEx8SypV/iHt1FbzsU/X3Qbcd9afm2W2w=="],
|
||||
"@expo/image-utils": ["@expo/image-utils@0.8.8", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", "resolve-global": "^1.0.0", "semver": "^7.6.0", "temp-dir": "~2.0.0", "unique-string": "~2.0.0" } }, "sha512-HHHaG4J4nKjTtVa1GG9PCh763xlETScfEyNxxOvfTRr8IKPJckjTyqSLEtdJoFNJ1vqiABEjW7tqGhqGibZLeA=="],
|
||||
|
||||
"@expo/json-file": ["@expo/json-file@10.0.7", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3" } }, "sha512-z2OTC0XNO6riZu98EjdNHC05l51ySeTto6GP7oSQrCvQgG9ARBwD1YvMQaVZ9wU7p/4LzSf1O7tckL3B45fPpw=="],
|
||||
"@expo/json-file": ["@expo/json-file@10.0.8", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3" } }, "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ=="],
|
||||
|
||||
"@expo/mcp-tunnel": ["@expo/mcp-tunnel@0.1.0", "", { "dependencies": { "ws": "^8.18.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.6" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.13.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-rJ6hl0GnIZj9+ssaJvFsC7fwyrmndcGz+RGFzu+0gnlm78X01957yjtHgjcmnQAgL5hWEOR6pkT0ijY5nU5AWw=="],
|
||||
"@expo/metro": ["@expo/metro@54.2.0", "", { "dependencies": { "metro": "0.83.3", "metro-babel-transformer": "0.83.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-config": "0.83.3", "metro-core": "0.83.3", "metro-file-map": "0.83.3", "metro-minify-terser": "0.83.3", "metro-resolver": "0.83.3", "metro-runtime": "0.83.3", "metro-source-map": "0.83.3", "metro-symbolicate": "0.83.3", "metro-transform-plugins": "0.83.3", "metro-transform-worker": "0.83.3" } }, "sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w=="],
|
||||
|
||||
"@expo/metro": ["@expo/metro@54.1.0", "", { "dependencies": { "metro": "0.83.2", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2" } }, "sha512-MgdeRNT/LH0v1wcO0TZp9Qn8zEF0X2ACI0wliPtv5kXVbXWI+yK9GyrstwLAiTXlULKVIg3HVSCCvmLu0M3tnw=="],
|
||||
|
||||
"@expo/metro-config": ["@expo/metro-config@54.0.9", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~12.0.10", "@expo/env": "~2.0.7", "@expo/json-file": "~10.0.7", "@expo/metro": "~54.1.0", "@expo/spawn-async": "^1.7.2", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", "glob": "^10.4.2", "hermes-parser": "^0.29.1", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "minimatch": "^9.0.0", "postcss": "~8.4.32", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-CRI4WgFXrQ2Owyr8q0liEBJveUIF9DcYAKadMRsJV7NxGNBdrIIKzKvqreDfsGiRqivbLsw6UoNb3UE7/SvPfg=="],
|
||||
"@expo/metro-config": ["@expo/metro-config@54.0.12", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~12.0.13", "@expo/env": "~2.0.8", "@expo/json-file": "~10.0.8", "@expo/metro": "~54.2.0", "@expo/spawn-async": "^1.7.2", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", "glob": "^13.0.0", "hermes-parser": "^0.29.1", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "minimatch": "^9.0.0", "postcss": "~8.4.32", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-Xhv1z/ak/cuJWeLxlnWr2u22q2AM/klASbjpP5eE34y91lGWa2NUwrFWoS830MhJ6kuAqtGdoQhwyPa3TES7sA=="],
|
||||
|
||||
"@expo/metro-runtime": ["@expo/metro-runtime@6.1.2", "", { "dependencies": { "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g=="],
|
||||
|
||||
"@expo/osascript": ["@expo/osascript@2.3.7", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "exec-async": "^2.2.0" } }, "sha512-IClSOXxR0YUFxIriUJVqyYki7lLMIHrrzOaP01yxAL1G8pj2DWV5eW1y5jSzIcIfSCNhtGsshGd1tU/AYup5iQ=="],
|
||||
"@expo/osascript": ["@expo/osascript@2.3.8", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "exec-async": "^2.2.0" } }, "sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w=="],
|
||||
|
||||
"@expo/package-manager": ["@expo/package-manager@1.9.8", "", { "dependencies": { "@expo/json-file": "^10.0.7", "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, "sha512-4/I6OWquKXYnzo38pkISHCOCOXxfeEmu4uDoERq1Ei/9Ur/s9y3kLbAamEkitUkDC7gHk1INxRWEfFNzGbmOrA=="],
|
||||
"@expo/package-manager": ["@expo/package-manager@1.9.9", "", { "dependencies": { "@expo/json-file": "^10.0.8", "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, "sha512-Nv5THOwXzPprMJwbnXU01iXSrCp3vJqly9M4EJ2GkKko9Ifer2ucpg7x6OUsE09/lw+npaoUnHMXwkw7gcKxlg=="],
|
||||
|
||||
"@expo/plist": ["@expo/plist@0.4.7", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", "xmlbuilder": "^15.1.1" } }, "sha512-dGxqHPvCZKeRKDU1sJZMmuyVtcASuSYh1LPFVaM1DuffqPL36n6FMEL0iUqq2Tx3xhWk8wCnWl34IKplUjJDdA=="],
|
||||
"@expo/plist": ["@expo/plist@0.4.8", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", "xmlbuilder": "^15.1.1" } }, "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ=="],
|
||||
|
||||
"@expo/prebuild-config": ["@expo/prebuild-config@54.0.6", "", { "dependencies": { "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/config-types": "^54.0.8", "@expo/image-utils": "^0.8.7", "@expo/json-file": "^10.0.7", "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.1", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-xowuMmyPNy+WTNq+YX0m0EFO/Knc68swjThk4dKivgZa8zI1UjvFXOBIOp8RX4ljCXLzwxQJM5oBBTvyn+59ZA=="],
|
||||
"@expo/prebuild-config": ["@expo/prebuild-config@54.0.8", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/config-types": "^54.0.10", "@expo/image-utils": "^0.8.8", "@expo/json-file": "^10.0.8", "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.1", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg=="],
|
||||
|
||||
"@expo/react-native-action-sheet": ["@expo/react-native-action-sheet@4.1.1", "", { "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "hoist-non-react-statics": "^3.3.0" }, "peerDependencies": { "react": ">=18.0.0" } }, "sha512-4KRaba2vhqDRR7ObBj6nrD5uJw8ePoNHdIOMETTpgGTX7StUbrF4j/sfrP1YUyaPEa1P8FXdwG6pB+2WtrJd1A=="],
|
||||
|
||||
"@expo/schema-utils": ["@expo/schema-utils@0.1.7", "", {}, "sha512-jWHoSuwRb5ZczjahrychMJ3GWZu54jK9ulNdh1d4OzAEq672K9E5yOlnlBsfIHWHGzUAT+0CL7Yt1INiXTz68g=="],
|
||||
"@expo/schema-utils": ["@expo/schema-utils@0.1.8", "", {}, "sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A=="],
|
||||
|
||||
"@expo/sdk-runtime-versions": ["@expo/sdk-runtime-versions@1.0.0", "", {}, "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ=="],
|
||||
|
||||
@@ -373,7 +372,7 @@
|
||||
|
||||
"@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="],
|
||||
|
||||
"@expo/ui": ["@expo/ui@0.2.0-canary-20251031-b135dff", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-L/TEKnv/hpQ/Q1sO8lJw0wxdcv88UoA1JShwRSYHLN88UstjxvBNvMqlKGk7SNkTUJtlrttWAundJA4jM2mDPw=="],
|
||||
"@expo/ui": ["@expo/ui@0.2.0-beta.9", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-RaBcp0cMe5GykQogJwRZGy4o4JHDLtrr+HaurDPhwPKqVATsV0rR11ysmFe4QX8XWLP/L3od7NOkXUi5ailvaw=="],
|
||||
|
||||
"@expo/vector-icons": ["@expo/vector-icons@15.0.3", "", { "peerDependencies": { "expo-font": ">=14.0.4", "react": "*", "react-native": "*" } }, "sha512-SBUyYKphmlfUBqxSfDdJ3jAdEVSALS2VUPOUyqn48oZmb2TL/O7t7/PQm5v4NQujYEPLPMTLn9KVw6H7twwbTA=="],
|
||||
|
||||
@@ -381,7 +380,7 @@
|
||||
|
||||
"@expo/xcpretty": ["@expo/xcpretty@4.3.2", "", { "dependencies": { "@babel/code-frame": "7.10.4", "chalk": "^4.1.0", "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw=="],
|
||||
|
||||
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.6", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-vmruJxdiUGDg+ZYcDmS30XDhq/h/+QkINOI5LY/uGjx8cPGwgJW0H6AB902gNTKtccbiKe/rr94EwdmIEz+LAQ=="],
|
||||
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="],
|
||||
|
||||
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
|
||||
|
||||
@@ -391,6 +390,10 @@
|
||||
|
||||
"@ide/backoff": ["@ide/backoff@1.0.0", "", {}, "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g=="],
|
||||
|
||||
"@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
|
||||
|
||||
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||
@@ -527,8 +530,6 @@
|
||||
|
||||
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="],
|
||||
|
||||
"@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.81.5-1", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-v77jJvzH2jzMj3G8pthdaRjiUhmdQ3S/OGiTX45Tn1J+whLaPOEkVRCel9xPHhrTPIEwrOOwGNiAFN/s1hzWZA=="],
|
||||
|
||||
"@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="],
|
||||
|
||||
"@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.81.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.81.5" } }, "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ=="],
|
||||
@@ -549,13 +550,15 @@
|
||||
|
||||
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
|
||||
|
||||
"@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.81.5", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw=="],
|
||||
|
||||
"@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="],
|
||||
|
||||
"@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="],
|
||||
|
||||
"@react-navigation/elements": ["@react-navigation/elements@2.8.1", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-MLmuS5kPAeAFFOylw89WGjgEFBqGj/KBK6ZrFrAOqLnTqEzk52/SO1olb5GB00k6ZUCDZKJOp1BrLXslxE6TgQ=="],
|
||||
"@react-navigation/elements": ["@react-navigation/elements@2.9.2", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-J1GltOAGowNLznEphV/kr4zs0U7mUBO1wVA2CqpkN8ePBsoxrAmsd+T5sEYUCXN9KgTDFvc6IfcDqrGSQngd/g=="],
|
||||
|
||||
"@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.2", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-LB/bCDhdaKsexA5w0otgZEDBysGbiCr2l0hW6z41rJQ0JqAOVybH0cBuFr3Awasv0mQh9iTJNha4VsuUb7Q0Xw=="],
|
||||
"@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.9", "", { "dependencies": { "@react-navigation/elements": "^2.9.2", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-oYpdTfa2D1Tn0HJER9dRCR260agKGgYe+ydSHt3RIsJ9sLg8hU7ntKYWo1FnEC/Nsv1/N1u/tRst7ZpQRjjl4A=="],
|
||||
|
||||
"@react-navigation/native": ["@react-navigation/native@7.1.19", "", { "dependencies": { "@react-navigation/core": "^7.13.0", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-fM7q8di4Q8sp2WUhiUWOe7bEDRyRhbzsKQOd5N2k+lHeCx3UncsRYuw4Q/KN0EovM3wWKqMMmhy/YWuEO04kgw=="],
|
||||
|
||||
@@ -577,9 +580,9 @@
|
||||
|
||||
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.7", "", {}, "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ=="],
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.7", "", { "dependencies": { "@tanstack/query-core": "5.90.7" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ=="],
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
@@ -723,7 +726,7 @@
|
||||
|
||||
"babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.2.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg=="],
|
||||
|
||||
"babel-preset-expo": ["babel-preset-expo@54.0.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.81.5", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.29.1", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4", "resolve-from": "^5.0.0" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo"] }, "sha512-JENWk0bvxW4I1ftveO8GRtX2t2TH6N4Z0TPvIHxroZ/4SswUfyNsUNbbP7Fm4erj3ar/JHGri5kTZ+s3xdjHZw=="],
|
||||
"babel-preset-expo": ["babel-preset-expo@54.0.9", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.81.5", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.29.1", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4", "resolve-from": "^5.0.0" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo"] }, "sha512-8J6hRdgEC2eJobjoft6mKJ294cLxmi3khCUy2JJQp4htOYYkllSLUq6vudWJkTJiIuGdVR4bR6xuz2EvJLWHNg=="],
|
||||
|
||||
"babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="],
|
||||
|
||||
@@ -973,81 +976,81 @@
|
||||
|
||||
"expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="],
|
||||
|
||||
"expo": ["expo@54.0.23", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.16", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devtools": "0.1.7", "@expo/fingerprint": "0.15.3", "@expo/metro": "~54.1.0", "@expo/metro-config": "54.0.9", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.7", "expo-asset": "~12.0.9", "expo-constants": "~18.0.10", "expo-file-system": "~19.0.17", "expo-font": "~14.0.9", "expo-keep-awake": "~15.0.7", "expo-modules-autolinking": "3.0.21", "expo-modules-core": "3.0.25", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-b4uQoiRwQ6nwqsT2709RS15CWYNGF3eJtyr1KyLw9WuMAK7u4jjofkhRiO0+3o1C2NbV+WooyYTOZGubQQMBaQ=="],
|
||||
"expo": ["expo@54.0.30", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.20", "@expo/config": "~12.0.13", "@expo/config-plugins": "~54.0.4", "@expo/devtools": "0.1.8", "@expo/fingerprint": "0.15.4", "@expo/metro": "~54.2.0", "@expo/metro-config": "54.0.12", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.9", "expo-asset": "~12.0.12", "expo-constants": "~18.0.12", "expo-file-system": "~19.0.21", "expo-font": "~14.0.10", "expo-keep-awake": "~15.0.8", "expo-modules-autolinking": "3.0.23", "expo-modules-core": "3.0.29", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-6q+aFfKL0SpT8prfdpR3V8HcN51ov0mCGuwQTzyuk6eeO9rg7a7LWbgPv9rEVXGZEuyULstL8LGNwHqusand7Q=="],
|
||||
|
||||
"expo-application": ["expo-application@7.0.7", "", { "peerDependencies": { "expo": "*" } }, "sha512-Jt1/qqnoDUbZ+bK91+dHaZ1vrPDtRBOltRa681EeedkisqguuEeUx4UHqwVyDK2oHWsK6lO3ojetoA4h8OmNcg=="],
|
||||
"expo-application": ["expo-application@7.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q=="],
|
||||
|
||||
"expo-asset": ["expo-asset@12.0.9", "", { "dependencies": { "@expo/image-utils": "^0.8.7", "expo-constants": "~18.0.9" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-vrdRoyhGhBmd0nJcssTSk1Ypx3Mbn/eXaaBCQVkL0MJ8IOZpAObAjfD5CTy8+8RofcHEQdh3wwZVCs7crvfOeg=="],
|
||||
"expo-asset": ["expo-asset@12.0.12", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.12" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ=="],
|
||||
|
||||
"expo-background-task": ["expo-background-task@1.0.8", "", { "dependencies": { "expo-task-manager": "~14.0.7" }, "peerDependencies": { "expo": "*" } }, "sha512-G6WnljBhO0K9j0ntmytF5rZLtYUpwh8n2+hcgmxM1ISPAVVZSPHZhkF9YjBOKpdPWZxmukBgEwejfcGckb8TQQ=="],
|
||||
"expo-background-task": ["expo-background-task@1.0.10", "", { "dependencies": { "expo-task-manager": "~14.0.9" }, "peerDependencies": { "expo": "*" } }, "sha512-EbPnuf52Ps/RJiaSFwqKGT6TkvMChv7bI0wF42eADbH3J2EMm5y5Qvj0oFmF1CBOwc3mUhqj63o7Pl6OLkGPZQ=="],
|
||||
|
||||
"expo-blur": ["expo-blur@15.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-SugQQbQd+zRPy8z2G5qDD4NqhcD7srBF7fN7O7yq6q7ZFK59VWvpDxtMoUkmSfdxgqONsrBN/rLdk00USADrMg=="],
|
||||
"expo-blur": ["expo-blur@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w=="],
|
||||
|
||||
"expo-brightness": ["expo-brightness@14.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-wccb/NdQEd45UF0lgNEksZt3E8uzlIcxIx1ZqZYWbHyNvcS3LUj5wxB6+ZgKTLeWu4vLQ+oHe+F0QrkC9ojrig=="],
|
||||
"expo-brightness": ["expo-brightness@14.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-WOg3UxzkHFTKBW3XvROlrVRmnJmZLhGBGd1RdzTfrtt2/MdSzvVmCevqWh4bohkeLABh0Yc9YRo1vFgfT73DWw=="],
|
||||
|
||||
"expo-build-properties": ["expo-build-properties@1.0.9", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-2icttCy3OPTk/GWIFt+vwA+0hup53jnmYb7JKRbvNvrrOrz+WblzpeoiaOleI2dYG/vjwpNO8to8qVyKhYJtrQ=="],
|
||||
"expo-build-properties": ["expo-build-properties@1.0.10", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q=="],
|
||||
|
||||
"expo-constants": ["expo-constants@18.0.10", "", { "dependencies": { "@expo/config": "~12.0.10", "@expo/env": "~2.0.7" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw=="],
|
||||
|
||||
"expo-dev-client": ["expo-dev-client@6.0.17", "", { "dependencies": { "expo-dev-launcher": "6.0.17", "expo-dev-menu": "7.0.16", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.8", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-zVilIum3sqXFbhYhPT6TuxR3ddH/IfHL82FiOTqJUiYaTQqun1I6ogSvU1djhY1eXUYhfYIBieQNWMVjXPxMvw=="],
|
||||
"expo-dev-client": ["expo-dev-client@6.0.20", "", { "dependencies": { "expo-dev-launcher": "6.0.20", "expo-dev-menu": "7.0.18", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.10", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA=="],
|
||||
|
||||
"expo-dev-launcher": ["expo-dev-launcher@6.0.17", "", { "dependencies": { "expo-dev-menu": "7.0.16", "expo-manifests": "~1.0.8" }, "peerDependencies": { "expo": "*" } }, "sha512-riLxFXaw6Nvgb27TiQtUvoHkW/zTz0aO7M+qxDBBaEbJMJSFl51KSwOJJBTItVQIE9f9jB8x5L1CfLw81/McZw=="],
|
||||
"expo-dev-launcher": ["expo-dev-launcher@6.0.20", "", { "dependencies": { "ajv": "^8.11.0", "expo-dev-menu": "7.0.18", "expo-manifests": "~1.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA=="],
|
||||
|
||||
"expo-dev-menu": ["expo-dev-menu@7.0.16", "", { "dependencies": { "expo-dev-menu-interface": "2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-/kjTjk5tcZV0ixYnV3JyzPXKlMimpBNYaDo4XxBbRFIkTf/vmb/9e1BTR2nALnoa/D3MRwtR43gZYT+W/wfKXw=="],
|
||||
"expo-dev-menu": ["expo-dev-menu@7.0.18", "", { "dependencies": { "expo-dev-menu-interface": "2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA=="],
|
||||
|
||||
"expo-dev-menu-interface": ["expo-dev-menu-interface@2.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw=="],
|
||||
|
||||
"expo-device": ["expo-device@8.0.9", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-XqRpaljDNAYZGZzMpC+b9KZfzfydtkwx3pJAp6ODDH+O/5wjAw+mLc5wQMGJCx8/aqVmMsAokec7iebxDPFZDA=="],
|
||||
"expo-device": ["expo-device@8.0.10", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA=="],
|
||||
|
||||
"expo-doctor": ["expo-doctor@1.17.11", "", { "bin": { "expo-doctor": "build/index.js" } }, "sha512-4eYZPJm4op2aRQWvd6RA6dZt1mVQQe79n7iqqFi6P927K8w2ld8kZ2D7m/4ahjj9/HBW9NS98m4qGomKJFDuPg=="],
|
||||
|
||||
"expo-file-system": ["expo-file-system@19.0.17", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-WwaS01SUFrxBnExn87pg0sCTJjZpf2KAOzfImG0o8yhkU7fbYpihpl/oocXBEsNbj58a8hVt1Y4CVV5c1tzu/g=="],
|
||||
"expo-file-system": ["expo-file-system@19.0.21", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg=="],
|
||||
|
||||
"expo-font": ["expo-font@14.0.9", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg=="],
|
||||
"expo-font": ["expo-font@14.0.10", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q=="],
|
||||
|
||||
"expo-haptics": ["expo-haptics@15.0.7", "", { "peerDependencies": { "expo": "*" } }, "sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ=="],
|
||||
"expo-haptics": ["expo-haptics@15.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g=="],
|
||||
|
||||
"expo-image": ["expo-image@3.0.10", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-i4qNCEf9Ur7vDqdfDdFfWnNCAF2efDTdahuDy9iELPS2nzMKBLeeGA2KxYEPuRylGCS96Rwm+SOZJu6INc2ADQ=="],
|
||||
"expo-image": ["expo-image@3.0.11", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA=="],
|
||||
|
||||
"expo-json-utils": ["expo-json-utils@0.15.0", "", {}, "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ=="],
|
||||
|
||||
"expo-keep-awake": ["expo-keep-awake@15.0.7", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CgBNcWVPnrIVII5G54QDqoE125l+zmqR4HR8q+MQaCfHet+dYpS5vX5zii/RMayzGN4jPgA4XYIQ28ePKFjHoA=="],
|
||||
"expo-keep-awake": ["expo-keep-awake@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ=="],
|
||||
|
||||
"expo-linear-gradient": ["expo-linear-gradient@15.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-yF+y+9Shpr/OQFfy/wglB/0bykFMbwHBTuMRa5Of/r2P1wbkcacx8rg0JsUWkXH/rn2i2iWdubyqlxSJa3ggZA=="],
|
||||
"expo-linear-gradient": ["expo-linear-gradient@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw=="],
|
||||
|
||||
"expo-linking": ["expo-linking@8.0.8", "", { "dependencies": { "expo-constants": "~18.0.8", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg=="],
|
||||
"expo-linking": ["expo-linking@8.0.11", "", { "dependencies": { "expo-constants": "~18.0.12", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA=="],
|
||||
|
||||
"expo-localization": ["expo-localization@17.0.7", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-ACg1B0tJLNa+f8mZfAaNrMyNzrrzHAARVH1sHHvh+LolKdQpgSKX69Uroz1Llv4C71furpwBklVStbNcEwVVVA=="],
|
||||
"expo-localization": ["expo-localization@17.0.8", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g=="],
|
||||
|
||||
"expo-manifests": ["expo-manifests@1.0.8", "", { "dependencies": { "@expo/config": "~12.0.8", "expo-json-utils": "~0.15.0" }, "peerDependencies": { "expo": "*" } }, "sha512-nA5PwU2uiUd+2nkDWf9e71AuFAtbrb330g/ecvuu52bmaXtN8J8oiilc9BDvAX0gg2fbtOaZdEdjBYopt1jdlQ=="],
|
||||
"expo-manifests": ["expo-manifests@1.0.10", "", { "dependencies": { "@expo/config": "~12.0.11", "expo-json-utils": "~0.15.0" }, "peerDependencies": { "expo": "*" } }, "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ=="],
|
||||
|
||||
"expo-modules-autolinking": ["expo-modules-autolinking@3.0.21", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-pOtPDLln3Ju8DW1zRW4OwZ702YqZ8g+kM/tEY1sWfv22kWUtxkvK+ytRDRpRdnKEnC28okbhWqeMnmVkSFzP6Q=="],
|
||||
"expo-modules-autolinking": ["expo-modules-autolinking@3.0.23", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-YZnaE0G+52xftjH5nsIRaWsoVBY38SQCECclpdgLisdbRY/6Mzo7ndokjauOv3mpFmzMZACHyJNu1YSAffQwTg=="],
|
||||
|
||||
"expo-modules-core": ["expo-modules-core@3.0.25", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0P8PT8UV6c5/+p8zeVM/FXvBgn/ErtGcMaasqUgbzzBUg94ktbkIrij9t9reGCrir03BYt/Bcpv+EQtYC8JOug=="],
|
||||
"expo-modules-core": ["expo-modules-core@3.0.29", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q=="],
|
||||
|
||||
"expo-notifications": ["expo-notifications@0.32.12", "", { "dependencies": { "@expo/image-utils": "^0.8.7", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", "expo-application": "~7.0.7", "expo-constants": "~18.0.9" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-FVJ5W4rOpKvmrLJ1Sd5pxiVTV4a7ApgTlKro+E5X8M2TBbXmEVOjs09klzdalXTjlzmU/Gu8aRw9xr7Ea/gZdw=="],
|
||||
"expo-notifications": ["expo-notifications@0.32.15", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", "expo-application": "~7.0.8", "expo-constants": "~18.0.12" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-gnJcauheC2S0Wl0RuJaFkaBRVzCG011j5hlG0TEbsuOCPBuB/F30YEk8yurK8Psv+zHkVfeiJ5AC+nL0LWk0WA=="],
|
||||
|
||||
"expo-router": ["expo-router@6.0.14", "", { "dependencies": { "@expo/metro-runtime": "^6.1.2", "@expo/schema-utils": "^0.1.7", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/native": "^7.1.8", "@react-navigation/native-stack": "^7.3.16", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-server": "^1.0.3", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@react-navigation/drawer": "^7.5.0", "@testing-library/react-native": ">= 12.0.0", "expo": "*", "expo-constants": "^18.0.10", "expo-linking": "^8.0.8", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": ">= 19.0.0" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-vizLO4SgnMEL+PPs2dXr+etEOuksjue7yUQBCtfCEdqoDkQlB0r35zI7rS34Wt53sxKWSlM2p+038qQEpxtiFw=="],
|
||||
"expo-router": ["expo-router@6.0.21", "", { "dependencies": { "@expo/metro-runtime": "^6.1.2", "@expo/schema-utils": "^0.1.8", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/native": "^7.1.8", "@react-navigation/native-stack": "^7.3.16", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-server": "^1.0.5", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@react-navigation/drawer": "^7.5.0", "@testing-library/react-native": ">= 12.0.0", "expo": "*", "expo-constants": "^18.0.12", "expo-linking": "^8.0.11", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": "~19.0.3 || ~19.1.4 || ~19.2.3" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-wjTUjrnWj6gRYjaYl1kYfcRnNE4ZAQ0kz0+sQf6/mzBd/OU6pnOdD7WrdAW3pTTpm52Q8sMoeX98tNQEddg2uA=="],
|
||||
|
||||
"expo-screen-orientation": ["expo-screen-orientation@9.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-UH/XlB9eMw+I2cyHSkXhAHRAPk83WyA3k5bst7GLu14wRuWiTch9fb6I7qEJK5CN6+XelcWxlBJymys6Fr/FKA=="],
|
||||
"expo-screen-orientation": ["expo-screen-orientation@9.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-qRoPi3E893o3vQHT4h1NKo51+7g2hjRSbDeg1fsSo/u2pOW5s6FCeoacLvD+xofOP33cH2MkE4ua54aWWO7Icw=="],
|
||||
|
||||
"expo-sensors": ["expo-sensors@15.0.7", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-TGUxRx/Ss7KGgfWo453YF64ENucw6oYryPiu/8I3ZZuf114xQPRxAbsZohPLaVUUGuaUyWbDsb0eRsmuKUzBnQ=="],
|
||||
"expo-sensors": ["expo-sensors@15.0.8", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-ttibOSCYjFAMIfjV+vVukO1v7GKlbcPRfxcRqbTaSMGneewDwVSXbGFImY530fj1BR3mWq4n9jHnuDp8tAEY9g=="],
|
||||
|
||||
"expo-server": ["expo-server@1.0.4", "", {}, "sha512-IN06r3oPxFh3plSXdvBL7dx0x6k+0/g0bgxJlNISs6qL5Z+gyPuWS750dpTzOeu37KyBG0RcyO9cXUKzjYgd4A=="],
|
||||
"expo-server": ["expo-server@1.0.5", "", {}, "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA=="],
|
||||
|
||||
"expo-sharing": ["expo-sharing@14.0.7", "", { "peerDependencies": { "expo": "*" } }, "sha512-t/5tR8ZJNH6tMkHXlF7453UafNIfrpfTG+THN9EMLC4Wsi4bJuESPm3NdmWDg2D4LDALJI/LQo0iEnLAd5Sp4g=="],
|
||||
"expo-sharing": ["expo-sharing@14.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q=="],
|
||||
|
||||
"expo-splash-screen": ["expo-splash-screen@31.0.10", "", { "dependencies": { "@expo/prebuild-config": "^54.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-i6g9IK798mae4yvflstQ1HkgahIJ6exzTCTw4vEdxV0J2SwiW3Tj+CwRjf0te7Zsb+7dDQhBTmGZwdv00VER2A=="],
|
||||
"expo-splash-screen": ["expo-splash-screen@31.0.13", "", { "dependencies": { "@expo/prebuild-config": "^54.0.8" }, "peerDependencies": { "expo": "*" } }, "sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA=="],
|
||||
|
||||
"expo-status-bar": ["expo-status-bar@3.0.8", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-L248XKPhum7tvREoS1VfE0H6dPCaGtoUWzRsUv7hGKdiB4cus33Rc0sxkWkoQ77wE8stlnUlL5lvmT0oqZ3ZBw=="],
|
||||
"expo-status-bar": ["expo-status-bar@3.0.9", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw=="],
|
||||
|
||||
"expo-system-ui": ["expo-system-ui@6.0.8", "", { "dependencies": { "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-DzJYqG2fibBSLzPDL4BybGCiilYOtnI1OWhcYFwoM4k0pnEzMBt1Vj8Z67bXglDDuz2HCQPGNtB3tQft5saKqQ=="],
|
||||
"expo-system-ui": ["expo-system-ui@6.0.9", "", { "dependencies": { "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-eQTYGzw1V4RYiYHL9xDLYID3Wsec2aZS+ypEssmF64D38aDrqbDgz1a2MSlHLQp2jHXSs3FvojhZ9FVela1Zcg=="],
|
||||
|
||||
"expo-task-manager": ["expo-task-manager@14.0.8", "", { "dependencies": { "unimodules-app-loader": "~6.0.7" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-HxhyvmulM8px+LQvqIKS85KVx2UodZf5RO+FE2ltpC4mQ5IFkX/ESqiK0grzDa4pVFLyxvs8LjuUKsfB5c39PQ=="],
|
||||
|
||||
"expo-updates-interface": ["expo-updates-interface@2.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg=="],
|
||||
|
||||
"expo-web-browser": ["expo-web-browser@15.0.9", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Dj8kNFO+oXsxqCDNlUT/GhOrJnm10kAElH++3RplLydogFm5jTzXYWDEeNIDmV+F+BzGYs+sIhxiBf7RyaxXZg=="],
|
||||
"expo-web-browser": ["expo-web-browser@15.0.10", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg=="],
|
||||
|
||||
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
||||
|
||||
@@ -1071,6 +1074,8 @@
|
||||
|
||||
"fbjs-css-vars": ["fbjs-css-vars@1.0.2", "", {}, "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
@@ -1271,7 +1276,7 @@
|
||||
|
||||
"joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA=="],
|
||||
|
||||
"jotai": ["jotai@2.15.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-yHT1HAZ3ba2Q8wgaUQ+xfBzEtcS8ie687I8XVCBinfg4bNniyqLIN+utPXWKQE93LMF5fPbQSVRZqgpcN5yd6Q=="],
|
||||
"jotai": ["jotai@2.16.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-NmkwPBet0SHQ28GBfEb10sqnbVOYyn6DL4iazZgGRDUKxSWL0iqcm+IK4TqTSFC2ixGk+XX2e46Wbv364a3cKg=="],
|
||||
|
||||
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
|
||||
|
||||
@@ -1373,23 +1378,23 @@
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"metro": ["metro@0.83.2", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-symbolicate": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-HQgs9H1FyVbRptNSMy/ImchTTE5vS2MSqLoOo7hbDoBq6hPPZokwJvBMwrYSxdjQZmLXz2JFZtdvS+ZfgTc9yw=="],
|
||||
"metro": ["metro@0.83.3", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-config": "0.83.3", "metro-core": "0.83.3", "metro-file-map": "0.83.3", "metro-resolver": "0.83.3", "metro-runtime": "0.83.3", "metro-source-map": "0.83.3", "metro-symbolicate": "0.83.3", "metro-transform-plugins": "0.83.3", "metro-transform-worker": "0.83.3", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q=="],
|
||||
|
||||
"metro-babel-transformer": ["metro-babel-transformer@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.32.0", "nullthrows": "^1.1.1" } }, "sha512-rirY1QMFlA1uxH3ZiNauBninwTioOgwChnRdDcbB4tgRZ+bGX9DiXoh9QdpppiaVKXdJsII932OwWXGGV4+Nlw=="],
|
||||
"metro-babel-transformer": ["metro-babel-transformer@0.83.3", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.32.0", "nullthrows": "^1.1.1" } }, "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g=="],
|
||||
|
||||
"metro-cache": ["metro-cache@0.83.2", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.2" } }, "sha512-Z43IodutUZeIS7OTH+yQFjc59QlFJ6s5OvM8p2AP9alr0+F8UKr8ADzFzoGKoHefZSKGa4bJx7MZJLF6GwPDHQ=="],
|
||||
"metro-cache": ["metro-cache@0.83.3", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.3" } }, "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q=="],
|
||||
|
||||
"metro-cache-key": ["metro-cache-key@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-3EMG/GkGKYoTaf5RqguGLSWRqGTwO7NQ0qXKmNBjr0y6qD9s3VBXYlwB+MszGtmOKsqE9q3FPrE5Nd9Ipv7rZw=="],
|
||||
"metro-cache-key": ["metro-cache-key@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw=="],
|
||||
|
||||
"metro-config": ["metro-config@0.83.2", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.2", "metro-cache": "0.83.2", "metro-core": "0.83.2", "metro-runtime": "0.83.2", "yaml": "^2.6.1" } }, "sha512-1FjCcdBe3e3D08gSSiU9u3Vtxd7alGH3x/DNFqWDFf5NouX4kLgbVloDDClr1UrLz62c0fHh2Vfr9ecmrOZp+g=="],
|
||||
"metro-config": ["metro-config@0.83.3", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.3", "metro-cache": "0.83.3", "metro-core": "0.83.3", "metro-runtime": "0.83.3", "yaml": "^2.6.1" } }, "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA=="],
|
||||
|
||||
"metro-core": ["metro-core@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.2" } }, "sha512-8DRb0O82Br0IW77cNgKMLYWUkx48lWxUkvNUxVISyMkcNwE/9ywf1MYQUE88HaKwSrqne6kFgCSA/UWZoUT0Iw=="],
|
||||
"metro-core": ["metro-core@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.3" } }, "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw=="],
|
||||
|
||||
"metro-file-map": ["metro-file-map@0.83.2", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-cMSWnEqZrp/dzZIEd7DEDdk72PXz6w5NOKriJoDN9p1TDQ5nAYrY2lHi8d6mwbcGLoSlWmpPyny9HZYFfPWcGQ=="],
|
||||
"metro-file-map": ["metro-file-map@0.83.3", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA=="],
|
||||
|
||||
"metro-minify-terser": ["metro-minify-terser@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-zvIxnh7U0JQ7vT4quasKsijId3dOAWgq+ip2jF/8TMrPUqQabGrs04L2dd0haQJ+PA+d4VvK/bPOY8X/vL2PWw=="],
|
||||
"metro-minify-terser": ["metro-minify-terser@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ=="],
|
||||
|
||||
"metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="],
|
||||
"metro-resolver": ["metro-resolver@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ=="],
|
||||
|
||||
"metro-runtime": ["metro-runtime@0.83.3", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw=="],
|
||||
|
||||
@@ -1397,9 +1402,9 @@
|
||||
|
||||
"metro-symbolicate": ["metro-symbolicate@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.3", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw=="],
|
||||
|
||||
"metro-transform-plugins": ["metro-transform-plugins@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-5WlW25WKPkiJk2yA9d8bMuZrgW7vfA4f4MBb9ZeHbTB3eIAoNN8vS8NENgG/X/90vpTB06X66OBvxhT3nHwP6A=="],
|
||||
"metro-transform-plugins": ["metro-transform-plugins@0.83.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A=="],
|
||||
|
||||
"metro-transform-worker": ["metro-transform-worker@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.83.2", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-minify-terser": "0.83.2", "metro-source-map": "0.83.2", "metro-transform-plugins": "0.83.2", "nullthrows": "^1.1.1" } }, "sha512-G5DsIg+cMZ2KNfrdLnWMvtppb3+Rp1GMyj7Bvd9GgYc/8gRmvq1XVEF9XuO87Shhb03kFhGqMTgZerz3hZ1v4Q=="],
|
||||
"metro-transform-worker": ["metro-transform-worker@0.83.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.83.3", "metro-babel-transformer": "0.83.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-minify-terser": "0.83.3", "metro-source-map": "0.83.3", "metro-transform-plugins": "0.83.3", "nullthrows": "^1.1.1" } }, "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
@@ -1517,7 +1522,7 @@
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
"path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
|
||||
|
||||
"peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="],
|
||||
|
||||
@@ -1609,11 +1614,11 @@
|
||||
|
||||
"react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="],
|
||||
|
||||
"react-native": ["react-native-tvos@0.81.5-1", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native-tvos/virtualized-lists": "0.81.5-1", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-jEZ5S8Urjaxkb/pQsfxXslTtKGfeBdaXwEObTyAF3PvCT0wYKD4NbftVJC5Iid9/jKeoBfWTuAOTFfaivqx7IA=="],
|
||||
"react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "@react-native/virtualized-lists": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="],
|
||||
|
||||
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
|
||||
|
||||
"react-native-bottom-tabs": ["react-native-bottom-tabs@1.0.2", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-eWNuTpJVefKRaROda4ZeWHvW1cUEb0mw8L7FyLEcPPsd7Tp3rfLRrhptl/O/3mAki9gvpzYE8ASE3GwUrjfp+Q=="],
|
||||
"react-native-bottom-tabs": ["react-native-bottom-tabs@1.1.0", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Uu1gvM3i1Hb4DjVvR/38J1QVQEs0RkPc7K6yon99HgvRWWOyLs7kjPDhUswtb8ije4pKW712skIXWJ0lgKzbyQ=="],
|
||||
|
||||
"react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="],
|
||||
|
||||
@@ -1655,14 +1660,14 @@
|
||||
|
||||
"react-native-tab-view": ["react-native-tab-view@4.2.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-TUbh7Yr0tE/99t1pJQLbQ+4/Px67xkT7/r3AhfV+93Q3WoUira0Lx7yuKUP2C118doqxub8NCLERwcqsHr29nQ=="],
|
||||
|
||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#003afd0", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-003afd0"],
|
||||
|
||||
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
|
||||
|
||||
"react-native-url-polyfill": ["react-native-url-polyfill@2.0.0", "", { "dependencies": { "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "react-native": "*" } }, "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA=="],
|
||||
|
||||
"react-native-uuid": ["react-native-uuid@2.0.3", "", {}, "sha512-f/YfIS2f5UB+gut7t/9BKGSCYbRA9/74A5R1MDp+FLYsuS+OSWoiM/D8Jko6OJB6Jcu3v6ONuddvZKHdIGpeiw=="],
|
||||
|
||||
"react-native-video": ["react-native-video@6.16.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+G6tVVGbwFqNTyPivqb+PhQzWr5OudDQ1dgvBNyBRAgcS8rOcbwuS6oX+m8cxOsXHn1UT9ofQnjQEwkGOsvomg=="],
|
||||
|
||||
"react-native-volume-manager": ["react-native-volume-manager@2.0.8", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-aZM47/mYkdQ4CbXpKYO6Ajiczv7fxbQXZ9c0H8gRuQUaS3OCz/MZABer6o9aDWq0KMNsQ7q7GVFLRPnSSeeMmw=="],
|
||||
|
||||
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
|
||||
@@ -1789,7 +1794,7 @@
|
||||
|
||||
"slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
|
||||
|
||||
"sonner-native": ["sonner-native@0.21.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.10.1", "react-native-safe-area-context": ">=4.10.5", "react-native-screens": ">=3.31.1", "react-native-svg": ">=15.6.0" } }, "sha512-00RSmfVBd/XfQdRh7sqgFUjftx09HRgEMnZei4CVKcRKeqRcq9DXn5o1nJhz3aA4Cyf5k2+0kK4spdWtAtNqSA=="],
|
||||
"sonner-native": ["sonner-native@0.21.2", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.10.1", "react-native-safe-area-context": ">=4.10.5", "react-native-screens": ">=3.31.1", "react-native-svg": ">=15.6.0" } }, "sha512-LnGPmfgzrNIwcc+FvcLJqx8aH1dEHePRzvNR8aIR4kl9spySRkXK160GmQIazjfm6mSMlPqZwRa5eycvrzg/eQ=="],
|
||||
|
||||
"source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
|
||||
|
||||
@@ -1867,6 +1872,8 @@
|
||||
|
||||
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
|
||||
|
||||
"tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="],
|
||||
@@ -1997,9 +2004,7 @@
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="],
|
||||
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
|
||||
|
||||
"@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
|
||||
|
||||
@@ -2011,15 +2016,17 @@
|
||||
|
||||
"@babel/plugin-transform-runtime/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
|
||||
|
||||
"@expo/cli/@expo/env": ["@expo/env@2.0.8", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0" } }, "sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA=="],
|
||||
|
||||
"@expo/cli/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
|
||||
|
||||
"@expo/cli/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
|
||||
"@expo/cli/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
|
||||
|
||||
"@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
|
||||
|
||||
"@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
|
||||
|
||||
"@expo/cli/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
"@expo/cli/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
@@ -2029,53 +2036,47 @@
|
||||
|
||||
"@expo/config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
|
||||
|
||||
"@expo/config/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
|
||||
"@expo/config/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
|
||||
|
||||
"@expo/config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
"@expo/config/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@expo/config/sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||
|
||||
"@expo/config-plugins/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
|
||||
|
||||
"@expo/config-plugins/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
|
||||
"@expo/config-plugins/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
|
||||
|
||||
"@expo/config-plugins/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
"@expo/config-plugins/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||
|
||||
"@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"@expo/devcert/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
|
||||
|
||||
"@expo/env/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
|
||||
|
||||
"@expo/fingerprint/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
|
||||
|
||||
"@expo/fingerprint/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
|
||||
"@expo/fingerprint/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
|
||||
|
||||
"@expo/fingerprint/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
"@expo/fingerprint/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@expo/image-utils/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
|
||||
|
||||
"@expo/image-utils/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
"@expo/image-utils/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@expo/json-file/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
|
||||
|
||||
"@expo/mcp-tunnel/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"@expo/mcp-tunnel/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@expo/metro/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
|
||||
|
||||
"@expo/metro/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
|
||||
"@expo/metro-config/@expo/env": ["@expo/env@2.0.8", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0" } }, "sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA=="],
|
||||
|
||||
"@expo/metro-config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
|
||||
|
||||
"@expo/metro-config/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
|
||||
"@expo/metro-config/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
|
||||
|
||||
"@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
|
||||
|
||||
"@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
|
||||
|
||||
"@expo/prebuild-config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
"@expo/prebuild-config/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@expo/xcpretty/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
|
||||
|
||||
@@ -2109,14 +2110,24 @@
|
||||
|
||||
"@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro": ["metro@0.83.2", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-symbolicate": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-HQgs9H1FyVbRptNSMy/ImchTTE5vS2MSqLoOo7hbDoBq6hPPZokwJvBMwrYSxdjQZmLXz2JFZtdvS+ZfgTc9yw=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro-config": ["metro-config@0.83.2", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.2", "metro-cache": "0.83.2", "metro-core": "0.83.2", "metro-runtime": "0.83.2", "yaml": "^2.6.1" } }, "sha512-1FjCcdBe3e3D08gSSiU9u3Vtxd7alGH3x/DNFqWDFf5NouX4kLgbVloDDClr1UrLz62c0fHh2Vfr9ecmrOZp+g=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro-core": ["metro-core@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.2" } }, "sha512-8DRb0O82Br0IW77cNgKMLYWUkx48lWxUkvNUxVISyMkcNwE/9ywf1MYQUE88HaKwSrqne6kFgCSA/UWZoUT0Iw=="],
|
||||
|
||||
"@react-native/community-cli-plugin/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@react-navigation/bottom-tabs/@react-navigation/elements": ["@react-navigation/elements@2.8.1", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-MLmuS5kPAeAFFOylw89WGjgEFBqGj/KBK6ZrFrAOqLnTqEzk52/SO1olb5GB00k6ZUCDZKJOp1BrLXslxE6TgQ=="],
|
||||
|
||||
"@react-navigation/bottom-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
|
||||
"@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
|
||||
"@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
|
||||
"@react-navigation/native-stack/@react-navigation/elements": ["@react-navigation/elements@2.8.1", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-MLmuS5kPAeAFFOylw89WGjgEFBqGj/KBK6ZrFrAOqLnTqEzk52/SO1olb5GB00k6ZUCDZKJOp1BrLXslxE6TgQ=="],
|
||||
|
||||
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
|
||||
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
@@ -2151,7 +2162,11 @@
|
||||
|
||||
"error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
||||
|
||||
"expo-build-properties/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
"expo-build-properties/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"expo-constants/@expo/config": ["@expo/config@12.0.10", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~54.0.2", "@expo/config-types": "^54.0.8", "@expo/json-file": "^10.0.7", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^10.4.2", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", "sucrase": "3.35.0" } }, "sha512-lJMof5Nqakq1DxGYlghYB/ogSBjmv4Fxn1ovyDmcjlRsQdFCXgu06gEUogkhPtc9wBt9WlTTfqENln5HHyLW6w=="],
|
||||
|
||||
"expo-manifests/@expo/config": ["@expo/config@12.0.11", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~54.0.3", "@expo/config-types": "^54.0.9", "@expo/json-file": "^10.0.7", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", "sucrase": "~3.35.1" } }, "sha512-bGKNCbHirwgFlcOJHXpsAStQvM0nU3cmiobK0o07UkTfcUxl9q9lOQQh2eoMGqpm6Vs1IcwBpYye6thC3Nri/w=="],
|
||||
|
||||
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
|
||||
|
||||
@@ -2201,20 +2216,10 @@
|
||||
|
||||
"metro/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
|
||||
|
||||
"metro/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
|
||||
|
||||
"metro/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
|
||||
|
||||
"metro/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
|
||||
|
||||
"metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
||||
|
||||
"metro-babel-transformer/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
|
||||
|
||||
"metro-config/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
|
||||
|
||||
"metro-transform-worker/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
|
||||
|
||||
"nativewind/@babel/types": ["@babel/types@7.19.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.18.10", "@babel/helper-validator-identifier": "^7.18.6", "to-fast-properties": "^2.0.0" } }, "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA=="],
|
||||
|
||||
"nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
@@ -2227,7 +2232,7 @@
|
||||
|
||||
"patch-package/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
|
||||
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
"path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
|
||||
|
||||
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||
|
||||
@@ -2287,6 +2292,8 @@
|
||||
|
||||
"test-exclude/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
@@ -2303,6 +2310,8 @@
|
||||
|
||||
"@babel/highlight/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
|
||||
|
||||
"@expo/cli/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||
|
||||
"@expo/cli/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
|
||||
|
||||
"@expo/cli/ora/cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="],
|
||||
@@ -2311,9 +2320,15 @@
|
||||
|
||||
"@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
|
||||
|
||||
"@expo/metro/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
|
||||
"@expo/config-plugins/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||
|
||||
"@expo/metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
|
||||
"@expo/config/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||
|
||||
"@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"@expo/fingerprint/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||
|
||||
"@expo/metro-config/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||
|
||||
"@expo/package-manager/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
|
||||
|
||||
@@ -2335,6 +2350,38 @@
|
||||
|
||||
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/metro-babel-transformer": ["metro-babel-transformer@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.32.0", "nullthrows": "^1.1.1" } }, "sha512-rirY1QMFlA1uxH3ZiNauBninwTioOgwChnRdDcbB4tgRZ+bGX9DiXoh9QdpppiaVKXdJsII932OwWXGGV4+Nlw=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/metro-cache": ["metro-cache@0.83.2", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.2" } }, "sha512-Z43IodutUZeIS7OTH+yQFjc59QlFJ6s5OvM8p2AP9alr0+F8UKr8ADzFzoGKoHefZSKGa4bJx7MZJLF6GwPDHQ=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/metro-cache-key": ["metro-cache-key@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-3EMG/GkGKYoTaf5RqguGLSWRqGTwO7NQ0qXKmNBjr0y6qD9s3VBXYlwB+MszGtmOKsqE9q3FPrE5Nd9Ipv7rZw=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/metro-file-map": ["metro-file-map@0.83.2", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-cMSWnEqZrp/dzZIEd7DEDdk72PXz6w5NOKriJoDN9p1TDQ5nAYrY2lHi8d6mwbcGLoSlWmpPyny9HZYFfPWcGQ=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/metro-transform-plugins": ["metro-transform-plugins@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-5WlW25WKPkiJk2yA9d8bMuZrgW7vfA4f4MBb9ZeHbTB3eIAoNN8vS8NENgG/X/90vpTB06X66OBvxhT3nHwP6A=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/metro-transform-worker": ["metro-transform-worker@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.83.2", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-minify-terser": "0.83.2", "metro-source-map": "0.83.2", "metro-transform-plugins": "0.83.2", "nullthrows": "^1.1.1" } }, "sha512-G5DsIg+cMZ2KNfrdLnWMvtppb3+Rp1GMyj7Bvd9GgYc/8gRmvq1XVEF9XuO87Shhb03kFhGqMTgZerz3hZ1v4Q=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro-config/metro-cache": ["metro-cache@0.83.2", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.2" } }, "sha512-Z43IodutUZeIS7OTH+yQFjc59QlFJ6s5OvM8p2AP9alr0+F8UKr8ADzFzoGKoHefZSKGa4bJx7MZJLF6GwPDHQ=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro-config/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="],
|
||||
|
||||
"@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"@react-navigation/bottom-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||
@@ -2367,6 +2414,34 @@
|
||||
|
||||
"connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"expo-constants/@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
|
||||
|
||||
"expo-constants/@expo/config/@expo/config-plugins": ["@expo/config-plugins@54.0.2", "", { "dependencies": { "@expo/config-types": "^54.0.8", "@expo/json-file": "~10.0.7", "@expo/plist": "^0.4.7", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^10.4.2", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-jD4qxFcURQUVsUFGMcbo63a/AnviK8WUGard+yrdQE3ZrB/aurn68SlApjirQQLEizhjI5Ar2ufqflOBlNpyPg=="],
|
||||
|
||||
"expo-constants/@expo/config/@expo/config-types": ["@expo/config-types@54.0.8", "", {}, "sha512-lyIn/x/Yz0SgHL7IGWtgTLg6TJWC9vL7489++0hzCHZ4iGjVcfZmPTUfiragZ3HycFFj899qN0jlhl49IHa94A=="],
|
||||
|
||||
"expo-constants/@expo/config/@expo/json-file": ["@expo/json-file@10.0.7", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3" } }, "sha512-z2OTC0XNO6riZu98EjdNHC05l51ySeTto6GP7oSQrCvQgG9ARBwD1YvMQaVZ9wU7p/4LzSf1O7tckL3B45fPpw=="],
|
||||
|
||||
"expo-constants/@expo/config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
|
||||
|
||||
"expo-constants/@expo/config/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
|
||||
|
||||
"expo-constants/@expo/config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
|
||||
"expo-manifests/@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
|
||||
|
||||
"expo-manifests/@expo/config/@expo/config-plugins": ["@expo/config-plugins@54.0.3", "", { "dependencies": { "@expo/config-types": "^54.0.9", "@expo/json-file": "~10.0.7", "@expo/plist": "^0.4.7", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-tBIUZIxLQfCu5jmqTO+UOeeDUGIB0BbK6xTMkPRObAXRQeTLPPfokZRCo818d2owd+Bcmq1wBaDz0VY3g+glfw=="],
|
||||
|
||||
"expo-manifests/@expo/config/@expo/config-types": ["@expo/config-types@54.0.9", "", {}, "sha512-Llf4jwcrAnrxgE5WCdAOxtMf8FGwS4Sk0SSgI0NnIaSyCnmOCAm80GPFvsK778Oj19Ub4tSyzdqufPyeQPksWw=="],
|
||||
|
||||
"expo-manifests/@expo/config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
|
||||
|
||||
"expo-manifests/@expo/config/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
|
||||
|
||||
"expo-manifests/@expo/config/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"expo-manifests/@expo/config/sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||
|
||||
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
@@ -2387,14 +2462,8 @@
|
||||
|
||||
"metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="],
|
||||
|
||||
"metro-transform-worker/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
|
||||
|
||||
"metro-transform-worker/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
|
||||
|
||||
"metro/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="],
|
||||
|
||||
"metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
|
||||
|
||||
"node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"patch-package/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||
@@ -2411,6 +2480,8 @@
|
||||
|
||||
"serve-static/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"sucrase/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
||||
|
||||
"test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
@@ -2445,6 +2516,12 @@
|
||||
|
||||
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
|
||||
|
||||
"@react-native/community-cli-plugin/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-zvIxnh7U0JQ7vT4quasKsijId3dOAWgq+ip2jF/8TMrPUqQabGrs04L2dd0haQJ+PA+d4VvK/bPOY8X/vL2PWw=="],
|
||||
|
||||
"@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
@@ -2465,6 +2542,18 @@
|
||||
|
||||
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"expo-constants/@expo/config/@expo/config-plugins/@expo/plist": ["@expo/plist@0.4.7", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", "xmlbuilder": "^15.1.1" } }, "sha512-dGxqHPvCZKeRKDU1sJZMmuyVtcASuSYh1LPFVaM1DuffqPL36n6FMEL0iUqq2Tx3xhWk8wCnWl34IKplUjJDdA=="],
|
||||
|
||||
"expo-constants/@expo/config/@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||
|
||||
"expo-constants/@expo/config/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"expo-manifests/@expo/config/@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||
|
||||
"expo-manifests/@expo/config/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||
|
||||
"expo-manifests/@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
@@ -2477,6 +2566,8 @@
|
||||
|
||||
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||
|
||||
"@expo/cli/ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||
@@ -2495,6 +2586,8 @@
|
||||
|
||||
"ansi-fragments/slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||
|
||||
"expo-constants/@expo/config/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||
|
||||
43
components/AddToWatchlist.tsx
Normal file
43
components/AddToWatchlist.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import {
|
||||
WatchlistSheet,
|
||||
type WatchlistSheetRef,
|
||||
} from "@/components/watchlists/WatchlistSheet";
|
||||
import {
|
||||
useItemInWatchlists,
|
||||
useStreamystatsEnabled,
|
||||
} from "@/hooks/useWatchlists";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const AddToWatchlist: FC<Props> = ({ item, ...props }) => {
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
const sheetRef = useRef<WatchlistSheetRef>(null);
|
||||
|
||||
const { data: watchlistsContainingItem } = useItemInWatchlists(item.Id);
|
||||
const isInAnyWatchlist = (watchlistsContainingItem?.length ?? 0) > 0;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
sheetRef.current?.open(item);
|
||||
}, [item]);
|
||||
|
||||
// Don't render if Streamystats is not enabled
|
||||
if (!streamystatsEnabled) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isInAnyWatchlist ? "list" : "list-outline"}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
<WatchlistSheet ref={sheetRef} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -109,7 +109,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
/**
|
||||
@@ -16,7 +16,13 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
* after BottomSheetModalProvider.
|
||||
*/
|
||||
export const GlobalModal = () => {
|
||||
const { hideModal, modalState, modalRef } = useGlobalModal();
|
||||
const { hideModal, modalState, modalRef, isVisible } = useGlobalModal();
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && modalState.content) {
|
||||
modalRef.current?.present();
|
||||
}
|
||||
}, [isVisible, modalState.content, modalRef]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { type Bitrate } from "@/components/BitrateSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
|
||||
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
@@ -18,7 +19,6 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
@@ -29,9 +29,9 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
import { AddToWatchlist } from "./AddToWatchlist";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
@@ -67,18 +67,23 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
// Use itemWithSources for play settings since it has MediaSources data
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(item!, settings);
|
||||
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||
[api, item],
|
||||
);
|
||||
|
||||
const onLogoLoad = React.useCallback(() => {
|
||||
setLoadingLogo(false);
|
||||
}, []);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
@@ -87,7 +92,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
@@ -117,6 +122,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
<AddToWatchlist item={item} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -134,6 +140,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
<AddToWatchlist item={item} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -162,7 +169,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||
className='flex-1'
|
||||
headerHeight={headerHeight}
|
||||
headerImage={
|
||||
<View style={[{ flex: 1 }]}>
|
||||
@@ -189,8 +196,8 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
width: "100%",
|
||||
}}
|
||||
contentFit='contain'
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoLoad}
|
||||
/>
|
||||
) : (
|
||||
<View />
|
||||
@@ -238,25 +245,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{item.Type === "Episode" && !isOffline && (
|
||||
<CurrentSeries item={item} className='mb-4' />
|
||||
<CurrentSeries item={item} className='mb-2' />
|
||||
)}
|
||||
|
||||
{!isOffline && (
|
||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||
)}
|
||||
|
||||
{item.People && item.People.length > 0 && !isOffline && (
|
||||
<View className='mb-4'>
|
||||
{item.People.slice(0, 3).map((person, idx) => (
|
||||
<MoreMoviesWithActor
|
||||
currentItem={item}
|
||||
key={idx}
|
||||
actorId={person.Id!}
|
||||
className='mb-4'
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<ItemPeopleSections item={item} isOffline={isOffline} />
|
||||
|
||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||
</>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||
@@ -10,16 +11,18 @@ import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
actorId: string;
|
||||
actorName?: string | null;
|
||||
currentItem: BaseItemDto;
|
||||
}
|
||||
|
||||
export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
actorId,
|
||||
actorName,
|
||||
currentItem,
|
||||
...props
|
||||
}) => {
|
||||
@@ -27,19 +30,6 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: actor } = useQuery({
|
||||
queryKey: ["actor", actorId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return null;
|
||||
return await getUserItemData({
|
||||
api,
|
||||
userId: user.Id,
|
||||
itemId: actorId,
|
||||
});
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!actorId,
|
||||
});
|
||||
|
||||
const { data: items, isLoading } = useQuery({
|
||||
queryKey: ["actor", "movies", actorId, currentItem.Id],
|
||||
queryFn: async () => {
|
||||
@@ -72,20 +62,10 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
enabled: !!api && !!user?.Id && !!actorId,
|
||||
});
|
||||
|
||||
if (items?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className='text-lg font-bold mb-2 px-4'>
|
||||
{t("item_card.more_with", { name: actor?.Name })}
|
||||
</Text>
|
||||
<HorizontalScroll
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
height={247}
|
||||
renderItem={(item: BaseItemDto, idx: number) => (
|
||||
const renderItem = useCallback(
|
||||
(item: BaseItemDto, idx: number) => (
|
||||
<TouchableItemRouter
|
||||
key={idx}
|
||||
key={item.Id ?? idx}
|
||||
item={item}
|
||||
className='flex flex-col w-28'
|
||||
>
|
||||
@@ -94,7 +74,22 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (items?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className='text-lg font-bold mb-2 px-4'>
|
||||
{t("item_card.more_with", { name: actorName ?? "" })}
|
||||
</Text>
|
||||
<HorizontalScroll
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -54,9 +54,7 @@ const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
|
||||
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
||||
>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
|
||||
value ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -73,9 +71,7 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
disabled={option.disabled}
|
||||
className={`px-4 py-3 flex flex-row items-center justify-between ${
|
||||
option.disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
className={`px-4 py-3 flex flex-row items-center justify-between ${option.disabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<Text className='flex-1 text-white'>{option.label}</Text>
|
||||
{isToggle ? (
|
||||
@@ -219,11 +215,7 @@ const PlatformDropdownComponent = ({
|
||||
return (
|
||||
<Host style={expoUIConfig?.hostStyle}>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<View className=''>
|
||||
{trigger || <Button variant='bordered'>Show Menu</Button>}
|
||||
</View>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Trigger>{trigger}</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
{groups.flatMap((group, groupIndex) => {
|
||||
// Check if this group has radio options
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -280,7 +280,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
]);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
console.log("onPress");
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
@@ -474,52 +473,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
),
|
||||
}));
|
||||
|
||||
// if (Platform.OS === "ios")
|
||||
// return (
|
||||
// <Host
|
||||
// style={{
|
||||
// height: 50,
|
||||
// flex: 1,
|
||||
// flexShrink: 0,
|
||||
// }}
|
||||
// >
|
||||
// <Button
|
||||
// variant='glassProminent'
|
||||
// onPress={onPress}
|
||||
// color={effectiveColors.primary}
|
||||
// modifiers={[fixedSize()]}
|
||||
// >
|
||||
// <View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
|
||||
// <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
// {runtimeTicksToMinutes(
|
||||
// (item?.RunTimeTicks || 0) -
|
||||
// (item?.UserData?.PlaybackPositionTicks || 0),
|
||||
// )}
|
||||
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
||||
// </Animated.Text>
|
||||
// <Animated.Text style={animatedTextStyle}>
|
||||
// <Ionicons name='play-circle' size={24} />
|
||||
// </Animated.Text>
|
||||
// {client && (
|
||||
// <Animated.Text style={animatedTextStyle}>
|
||||
// <Feather name='cast' size={22} />
|
||||
// <CastButton tintColor='transparent' />
|
||||
// </Animated.Text>
|
||||
// )}
|
||||
// {!client && settings?.openInVLC && (
|
||||
// <Animated.Text style={animatedTextStyle}>
|
||||
// <MaterialCommunityIcons
|
||||
// name='vlc'
|
||||
// size={18}
|
||||
// color={animatedTextStyle.color}
|
||||
// />
|
||||
// </Animated.Text>
|
||||
// )}
|
||||
// </View>
|
||||
// </Button>
|
||||
// </Host>
|
||||
// );
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
@@ -569,15 +522,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<CastButton tintColor='transparent' />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name='vlc'
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -17,7 +17,6 @@ import Animated, {
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import type { Button } from "./Button";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
@@ -50,7 +49,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const startColor = useSharedValue(effectiveColors);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const { settings } = useSettings();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
@@ -61,7 +59,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
const onPress = () => {
|
||||
console.log("onpress");
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
@@ -207,15 +204,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name='play-circle' size={24} />
|
||||
</Animated.Text>
|
||||
{settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name='vlc'
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
@@ -14,14 +15,16 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||
const toggle = useMarkAsPlayed(items);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
void toggle(!allPlayed);
|
||||
}, [allPlayed, toggle]);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
color={allPlayed ? "purple" : "white"}
|
||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={async () => {
|
||||
await toggle(!allPlayed);
|
||||
}}
|
||||
onPress={handlePress}
|
||||
size={props.size}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { HorizontalScroll } from "./common/HorizontalScroll";
|
||||
import { Text } from "./common/Text";
|
||||
@@ -53,7 +54,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
||||
<HorizontalScroll
|
||||
data={movies}
|
||||
loading={isLoading}
|
||||
height={247}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
noItemsText={t("item_card.no_similar_items_found")}
|
||||
renderItem={(item: BaseItemDto, idx: number) => (
|
||||
<TouchableItemRouter
|
||||
|
||||
@@ -282,7 +282,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
if (currentItem) {
|
||||
setSelectedOptions({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ export const HorizontalScroll = <T,>(
|
||||
|
||||
if (!data || loading) {
|
||||
return (
|
||||
<View className='px-4 mb-2'>
|
||||
<View className='px-4'>
|
||||
<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>
|
||||
|
||||
42
components/common/SectionHeader.tsx
Normal file
42
components/common/SectionHeader.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { Text } from "./Text";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
actionLabel?: string;
|
||||
actionDisabled?: boolean;
|
||||
onPressAction?: () => void;
|
||||
};
|
||||
|
||||
export const SectionHeader: React.FC<Props> = ({
|
||||
title,
|
||||
actionLabel,
|
||||
actionDisabled = false,
|
||||
onPressAction,
|
||||
}) => {
|
||||
const shouldShowAction = Boolean(actionLabel) && Boolean(onPressAction);
|
||||
|
||||
return (
|
||||
<View className='px-4 flex flex-row items-center justify-between mb-2'>
|
||||
<Text className='text-lg font-bold text-neutral-100'>{title}</Text>
|
||||
{shouldShowAction && (
|
||||
<TouchableOpacity
|
||||
onPress={onPressAction}
|
||||
disabled={actionDisabled}
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={actionLabel}
|
||||
className='py-1 pl-3'
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: actionDisabled ? "rgba(255,255,255,0.4)" : Colors.primary,
|
||||
}}
|
||||
>
|
||||
{actionLabel}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,10 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||
return `/(auth)/(tabs)/${from}/livetv`;
|
||||
}
|
||||
|
||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||
return `/(auth)/(tabs)/(libraries)/music/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "Series") {
|
||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
||||
}
|
||||
@@ -50,6 +54,13 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
||||
};
|
||||
}
|
||||
|
||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||
return {
|
||||
pathname: "/music/[libraryId]" as const,
|
||||
params: { libraryId: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "Series") {
|
||||
return {
|
||||
pathname: "/series/[id]" as const,
|
||||
@@ -99,6 +110,25 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
// For offline mode, we still need to use query params
|
||||
if (isOffline) {
|
||||
const url = `${itemRouter(item, from)}&offline=true`;
|
||||
router.push(url as any);
|
||||
return;
|
||||
}
|
||||
|
||||
// Force music libraries to navigate via the explicit string route.
|
||||
// This avoids losing the dynamic [libraryId] param when going through a nested navigator.
|
||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||
router.push(itemRouter(item, from) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const navigation = getItemNavigation(item, from);
|
||||
router.push(navigation as any);
|
||||
}, [from, isOffline, item, router]);
|
||||
|
||||
const showActionSheet = useCallback(() => {
|
||||
if (
|
||||
!(
|
||||
@@ -108,13 +138,14 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
)
|
||||
)
|
||||
return;
|
||||
const options = [
|
||||
|
||||
const options: string[] = [
|
||||
"Mark as Played",
|
||||
"Mark as Not Played",
|
||||
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||
"Cancel",
|
||||
];
|
||||
const cancelButtonIndex = 3;
|
||||
const cancelButtonIndex = options.length - 1;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
@@ -131,28 +162,24 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);
|
||||
}, [
|
||||
showActionSheetWithOptions,
|
||||
isFavorite,
|
||||
markAsPlayedStatus,
|
||||
toggleFavorite,
|
||||
]);
|
||||
|
||||
if (
|
||||
from === "(home)" ||
|
||||
from === "(search)" ||
|
||||
from === "(libraries)" ||
|
||||
from === "(favorites)"
|
||||
from === "(favorites)" ||
|
||||
from === "(watchlists)"
|
||||
)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onLongPress={showActionSheet}
|
||||
onPress={() => {
|
||||
if (isOffline) {
|
||||
// For offline mode, we still need to use query params
|
||||
const url = `${itemRouter(item, from)}&offline=true`;
|
||||
router.push(url as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const navigation = getItemNavigation(item, from);
|
||||
router.push(navigation as any);
|
||||
}}
|
||||
onPress={handlePress}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAtom } from "jotai";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import {
|
||||
filterByAtom,
|
||||
genreFilterAtom,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
@@ -13,11 +14,13 @@ export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom);
|
||||
|
||||
if (
|
||||
selectedGenres.length === 0 &&
|
||||
selectedTags.length === 0 &&
|
||||
selectedYears.length === 0
|
||||
selectedYears.length === 0 &&
|
||||
selectedFilters.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -28,6 +31,7 @@ export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
||||
setSelectedGenres([]);
|
||||
setSelectedTags([]);
|
||||
setSelectedYears([]);
|
||||
setSelectedFilters([]);
|
||||
}}
|
||||
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
|
||||
{...props}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -22,8 +23,10 @@ type FavoriteTypes =
|
||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||
|
||||
export const Favorites = () => {
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const pageSize = 20;
|
||||
const [emptyState, setEmptyState] = useState<EmptyState>({
|
||||
Series: false,
|
||||
Movie: false,
|
||||
@@ -91,35 +94,77 @@ export const Favorites = () => {
|
||||
|
||||
const fetchFavoriteSeries = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Series", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
fetchFavoritesByType("Series", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
const fetchFavoriteMovies = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Movie", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
fetchFavoritesByType("Movie", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
const fetchFavoriteEpisodes = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Episode", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
fetchFavoritesByType("Episode", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
const fetchFavoriteVideos = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Video", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
fetchFavoritesByType("Video", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
const fetchFavoriteBoxsets = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("BoxSet", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
fetchFavoritesByType("BoxSet", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
const fetchFavoritePlaylists = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Playlist", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
fetchFavoritesByType("Playlist", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
|
||||
const handleSeeAllSeries = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Series", title: t("favorites.series") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllMovies = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Movie", title: t("favorites.movies") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllEpisodes = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Episode", title: t("favorites.episodes") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllVideos = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Video", title: t("favorites.videos") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllBoxsets = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "BoxSet", title: t("favorites.boxsets") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllPlaylists = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Playlist", title: t("favorites.playlists") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<View className='flex flex-co gap-y-4'>
|
||||
{areAllEmpty() && (
|
||||
@@ -143,6 +188,8 @@ export const Favorites = () => {
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllSeries}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
@@ -150,30 +197,40 @@ export const Favorites = () => {
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllMovies}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllEpisodes}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllVideos}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllBoxsets}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllPlaylists}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
|
||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -45,12 +47,14 @@ type InfiniteScrollingCollectionListSection = {
|
||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
pageSize?: number;
|
||||
priority?: 1 | 2; // 1 = high priority (loads first), 2 = low priority
|
||||
};
|
||||
|
||||
type MediaListSectionType = {
|
||||
type: "MediaListSection";
|
||||
queryKey: (string | undefined)[];
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
priority?: 1 | 2;
|
||||
};
|
||||
|
||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||
@@ -74,7 +78,7 @@ export const Home = () => {
|
||||
retryCheck,
|
||||
} = useNetworkStatus();
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !prevIsConnected.current) {
|
||||
@@ -172,6 +176,7 @@ export const Home = () => {
|
||||
|
||||
const refetch = async () => {
|
||||
setLoading(true);
|
||||
setLoadedSections(new Set());
|
||||
await refreshStreamyfinPluginSettings();
|
||||
await invalidateCache();
|
||||
setLoading(false);
|
||||
@@ -194,10 +199,10 @@ export const Home = () => {
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 100, // Fetch a larger set for pagination
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||
limit: 10,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes,
|
||||
parentId,
|
||||
})
|
||||
@@ -236,7 +241,68 @@ export const Home = () => {
|
||||
);
|
||||
});
|
||||
|
||||
const ss: Section[] = [
|
||||
// Helper to sort items by most recent activity
|
||||
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||
return items.sort((a, b) => {
|
||||
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
||||
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to deduplicate items by ID
|
||||
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
if (!item.Id || seen.has(item.Id)) return false;
|
||||
seen.add(item.Id);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// Build the first sections based on merge setting
|
||||
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
||||
? [
|
||||
{
|
||||
title: t("home.continue_and_next_up"),
|
||||
queryKey: ["home", "continueAndNextUp"],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
// Fetch both in parallel
|
||||
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||
getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
startIndex: 0,
|
||||
limit: 20,
|
||||
}),
|
||||
getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
startIndex: 0,
|
||||
limit: 20,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const resumeItems = resumeResponse.data.Items || [];
|
||||
const nextUpItems = nextUpResponse.data.Items || [];
|
||||
|
||||
// Combine, sort by recent activity, deduplicate
|
||||
const combined = [...resumeItems, ...nextUpItems];
|
||||
const sorted = sortByRecentActivity(combined);
|
||||
const deduplicated = deduplicateById(sorted);
|
||||
|
||||
// Paginate client-side
|
||||
return deduplicated.slice(pageParam, pageParam + 10);
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
priority: 1,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
@@ -244,9 +310,8 @@ export const Home = () => {
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
})
|
||||
@@ -254,6 +319,7 @@ export const Home = () => {
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
@@ -262,22 +328,29 @@ export const Home = () => {
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
priority: 1,
|
||||
},
|
||||
...latestMediaViews,
|
||||
];
|
||||
|
||||
const ss: Section[] = [
|
||||
...firstSections,
|
||||
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
|
||||
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
|
||||
...(!settings?.streamyStatsMovieRecommendations
|
||||
? [
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
@@ -287,13 +360,24 @@ export const Home = () => {
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
type: "InfiniteScrollingCollectionList" as const,
|
||||
orientation: "vertical" as const,
|
||||
pageSize: 10,
|
||||
priority: 2 as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return ss;
|
||||
}, [api, user?.Id, collections, t, createCollectionConfig]);
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
collections,
|
||||
t,
|
||||
createCollectionConfig,
|
||||
settings?.streamyStatsMovieRecommendations,
|
||||
settings.mergeNextUpAndContinueWatching,
|
||||
]);
|
||||
|
||||
const customSections = useMemo(() => {
|
||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||
@@ -322,10 +406,9 @@ export const Home = () => {
|
||||
if (section.nextUp) {
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: section.nextUp?.limit || pageSize,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: section.nextUp?.enableResumable,
|
||||
enableRewatching: section.nextUp?.enableRewatching,
|
||||
});
|
||||
@@ -338,7 +421,7 @@ export const Home = () => {
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: section.latest?.includeItemTypes,
|
||||
limit: section.latest?.limit || 100, // Fetch larger set
|
||||
limit: section.latest?.limit || 10,
|
||||
isPlayed: section.latest?.isPlayed,
|
||||
groupItems: section.latest?.groupItems,
|
||||
})
|
||||
@@ -367,6 +450,8 @@ export const Home = () => {
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: section?.orientation || "vertical",
|
||||
pageSize,
|
||||
// First 2 custom sections are high priority
|
||||
priority: index < 2 ? 1 : 2,
|
||||
});
|
||||
});
|
||||
return ss;
|
||||
@@ -374,6 +459,25 @@ export const Home = () => {
|
||||
|
||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||
|
||||
// Get all high priority section keys and check if all have loaded
|
||||
const highPrioritySectionKeys = useMemo(() => {
|
||||
return sections
|
||||
.filter((s) => s.priority === 1)
|
||||
.map((s) => s.queryKey.join("-"));
|
||||
}, [sections]);
|
||||
|
||||
const allHighPriorityLoaded = useMemo(() => {
|
||||
return highPrioritySectionKeys.every((key) => loadedSections.has(key));
|
||||
}, [highPrioritySectionKeys, loadedSections]);
|
||||
|
||||
const markSectionLoaded = useCallback(
|
||||
(queryKey: (string | undefined | null)[]) => {
|
||||
const key = queryKey.join("-");
|
||||
setLoadedSections((prev) => new Set(prev).add(key));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (!isConnected || serverConnected !== true) {
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
@@ -451,10 +555,6 @@ export const Home = () => {
|
||||
ref={scrollRef}
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
onScroll={(event) => {
|
||||
setScrollY(event.nativeEvent.contentOffset.y - 500);
|
||||
}}
|
||||
scrollEventThrottle={16}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loading}
|
||||
@@ -474,28 +574,77 @@ export const Home = () => {
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
{sections.map((section, index) => {
|
||||
// Render Streamystats sections after Continue Watching and Next Up
|
||||
// When merged, they appear after index 0; otherwise after index 1
|
||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
||||
? 0
|
||||
: 1;
|
||||
const hasStreamystatsContent =
|
||||
settings.streamyStatsMovieRecommendations ||
|
||||
settings.streamyStatsSeriesRecommendations ||
|
||||
settings.streamyStatsPromotedWatchlists;
|
||||
const streamystatsSections =
|
||||
index === streamystatsIndex && hasStreamystatsContent ? (
|
||||
<View
|
||||
key='streamystats-sections'
|
||||
className='flex flex-col space-y-4'
|
||||
>
|
||||
{settings.streamyStatsMovieRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_movies",
|
||||
)}
|
||||
type='Movie'
|
||||
enabled={allHighPriorityLoaded}
|
||||
/>
|
||||
)}
|
||||
{settings.streamyStatsSeriesRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_series",
|
||||
)}
|
||||
type='Series'
|
||||
enabled={allHighPriorityLoaded}
|
||||
/>
|
||||
)}
|
||||
{settings.streamyStatsPromotedWatchlists && (
|
||||
<StreamystatsPromotedWatchlists
|
||||
enabled={allHighPriorityLoaded}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
) : null;
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
const isHighPriority = section.priority === 1;
|
||||
return (
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<InfiniteScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
enabled={isHighPriority || allHighPriorityLoaded}
|
||||
onLoaded={
|
||||
isHighPriority
|
||||
? () => markSectionLoaded(section.queryKey)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
scrollY={scrollY}
|
||||
enableLazyLoading={true}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -30,6 +30,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
|
||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -241,7 +243,69 @@ export const HomeWithCarousel = () => {
|
||||
);
|
||||
});
|
||||
|
||||
const ss: Section[] = [
|
||||
// Helper to sort items by most recent activity
|
||||
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||
return items.sort((a, b) => {
|
||||
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
||||
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to deduplicate items by ID
|
||||
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
if (!item.Id || seen.has(item.Id)) return false;
|
||||
seen.add(item.Id);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// Build the first sections based on merge setting
|
||||
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
||||
? [
|
||||
{
|
||||
title: t("home.continue_and_next_up"),
|
||||
queryKey: ["home", "continueAndNextUp"],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
// Fetch both in parallel
|
||||
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||
getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
startIndex: 0,
|
||||
limit: 20,
|
||||
}),
|
||||
getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: 0,
|
||||
limit: 20,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const resumeItems = resumeResponse.data.Items || [];
|
||||
const nextUpItems = nextUpResponse.data.Items || [];
|
||||
|
||||
// Combine, sort by recent activity, deduplicate
|
||||
const combined = [...resumeItems, ...nextUpItems];
|
||||
const sorted = sortByRecentActivity(combined);
|
||||
const deduplicated = deduplicateById(sorted);
|
||||
|
||||
// Paginate client-side
|
||||
return deduplicated.slice(pageParam, pageParam + 10);
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
@@ -278,11 +342,18 @@ export const HomeWithCarousel = () => {
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
];
|
||||
|
||||
const ss: Section[] = [
|
||||
...firstSections,
|
||||
...latestMediaViews,
|
||||
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
|
||||
...(!settings?.streamyStatsMovieRecommendations
|
||||
? [
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
@@ -292,13 +363,23 @@ export const HomeWithCarousel = () => {
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
type: "InfiniteScrollingCollectionList" as const,
|
||||
orientation: "vertical" as const,
|
||||
pageSize: 10,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return ss;
|
||||
}, [api, user?.Id, collections, t, createCollectionConfig]);
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
collections,
|
||||
t,
|
||||
createCollectionConfig,
|
||||
settings?.streamyStatsMovieRecommendations,
|
||||
settings.mergeNextUpAndContinueWatching,
|
||||
]);
|
||||
|
||||
const customSections = useMemo(() => {
|
||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||
@@ -477,10 +558,44 @@ export const HomeWithCarousel = () => {
|
||||
>
|
||||
<View className='flex flex-col space-y-4'>
|
||||
{sections.map((section, index) => {
|
||||
// Render Streamystats sections after Continue Watching and Next Up
|
||||
// When merged, they appear after index 0; otherwise after index 1
|
||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
||||
? 0
|
||||
: 1;
|
||||
const hasStreamystatsContent =
|
||||
settings.streamyStatsMovieRecommendations ||
|
||||
settings.streamyStatsSeriesRecommendations ||
|
||||
settings.streamyStatsPromotedWatchlists;
|
||||
const streamystatsSections =
|
||||
index === streamystatsIndex && hasStreamystatsContent ? (
|
||||
<>
|
||||
{settings.streamyStatsMovieRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_movies",
|
||||
)}
|
||||
type='Movie'
|
||||
/>
|
||||
)}
|
||||
{settings.streamyStatsSeriesRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_series",
|
||||
)}
|
||||
type='Series'
|
||||
/>
|
||||
)}
|
||||
{settings.streamyStatsPromotedWatchlists && (
|
||||
<StreamystatsPromotedWatchlists />
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
return (
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<InfiniteScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
@@ -488,17 +603,21 @@ export const HomeWithCarousel = () => {
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
scrollY={scrollY}
|
||||
enableLazyLoading={true}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
View,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { Colors } from "../../constants/Colors";
|
||||
@@ -28,6 +29,9 @@ interface Props extends ViewProps {
|
||||
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
|
||||
hideIfEmpty?: boolean;
|
||||
pageSize?: number;
|
||||
onPressSeeAll?: () => void;
|
||||
enabled?: boolean;
|
||||
onLoaded?: () => void;
|
||||
}
|
||||
|
||||
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
@@ -38,32 +42,67 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
pageSize = 10,
|
||||
onPressSeeAll,
|
||||
enabled = true,
|
||||
onLoaded,
|
||||
...props
|
||||
}) => {
|
||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
const effectivePageSize = Math.max(1, pageSize);
|
||||
const hasCalledOnLoaded = useRef(false);
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isSuccess,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn: ({ pageParam = 0, ...context }) =>
|
||||
queryFn({ ...context, queryKey, pageParam }),
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
// If the last page has fewer items than pageSize, we've reached the end
|
||||
if (lastPage.length < pageSize) {
|
||||
if (lastPage.length < effectivePageSize) {
|
||||
return undefined;
|
||||
}
|
||||
// Otherwise, return the next start index
|
||||
return allPages.length * pageSize;
|
||||
// Otherwise, return the next start index based on how many items we already loaded.
|
||||
// This avoids overlaps if the server/page size differs from our configured page size.
|
||||
return allPages.reduce((acc, page) => acc + page.length, 0);
|
||||
},
|
||||
initialPageParam: 0,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
enabled,
|
||||
});
|
||||
|
||||
// Notify parent when data has loaded
|
||||
useEffect(() => {
|
||||
if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
|
||||
hasCalledOnLoaded.current = true;
|
||||
onLoaded();
|
||||
}
|
||||
}, [isSuccess, onLoaded]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Flatten all pages into a single array
|
||||
const allItems = data?.pages.flat() || [];
|
||||
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
||||
const allItems = useMemo(() => {
|
||||
const items = data?.pages.flat() ?? [];
|
||||
const seen = new Set<string>();
|
||||
const deduped: BaseItemDto[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const id = item.Id;
|
||||
if (!id) continue;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push(item);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}, [data]);
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
|
||||
@@ -90,9 +129,12 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||
{title}
|
||||
</Text>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
actionLabel={t("common.seeAll", { defaultValue: "See all" })}
|
||||
actionDisabled={isLoading}
|
||||
onPressAction={onPressSeeAll}
|
||||
/>
|
||||
{isLoading === false && allItems.length === 0 && (
|
||||
<View className='px-4'>
|
||||
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
||||
|
||||
245
components/home/StreamystatsPromotedWatchlists.tsx
Normal file
245
components/home/StreamystatsPromotedWatchlists.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
PublicSystemInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, View, type ViewProps } from "react-native";
|
||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import SeriesPoster from "../posters/SeriesPoster";
|
||||
|
||||
const ITEM_WIDTH = 120; // w-28 (112px) + mr-2 (8px)
|
||||
|
||||
interface WatchlistSectionProps extends ViewProps {
|
||||
watchlist: StreamystatsWatchlist;
|
||||
jellyfinServerId: string;
|
||||
}
|
||||
|
||||
const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||
watchlist,
|
||||
jellyfinServerId,
|
||||
...props
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
|
||||
const { data: items, isLoading } = useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"watchlist",
|
||||
watchlist.id,
|
||||
jellyfinServerId,
|
||||
settings?.streamyStatsServerUrl,
|
||||
],
|
||||
queryFn: async (): Promise<BaseItemDto[]> => {
|
||||
if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const watchlistDetail = await streamystatsApi.getWatchlistItemIds({
|
||||
watchlistId: watchlist.id,
|
||||
jellyfinServerId,
|
||||
});
|
||||
|
||||
const itemIds = watchlistDetail.data?.items;
|
||||
if (!itemIds?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
ids: itemIds,
|
||||
fields: ["PrimaryImageAspectRatio", "Genres"],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled:
|
||||
Boolean(settings?.streamyStatsServerUrl) &&
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
return items?.map((_, index) => index * ITEM_WIDTH) ?? [];
|
||||
}, [items]);
|
||||
|
||||
if (!isLoading && (!items || items.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<SectionHeader title={watchlist.name} />
|
||||
{isLoading ? (
|
||||
<View className='flex flex-row gap-2 px-4'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<View className='w-28' key={i}>
|
||||
<View className='bg-neutral-900 aspect-[2/3] w-full rounded-md mb-1' />
|
||||
<View className='rounded-md overflow-hidden mb-1 self-start'>
|
||||
<Text
|
||||
className='text-neutral-900 bg-neutral-900 rounded-md'
|
||||
numberOfLines={1}
|
||||
>
|
||||
Loading...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate='fast'
|
||||
>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{items?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='mr-2 w-28'
|
||||
>
|
||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface StreamystatsPromotedWatchlistsProps extends ViewProps {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const StreamystatsPromotedWatchlists: React.FC<
|
||||
StreamystatsPromotedWatchlistsProps
|
||||
> = ({ enabled = true, ...props }) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
|
||||
const streamyStatsEnabled = useMemo(() => {
|
||||
return Boolean(settings?.streamyStatsServerUrl);
|
||||
}, [settings?.streamyStatsServerUrl]);
|
||||
|
||||
// Fetch server info to get the Jellyfin server ID
|
||||
const { data: serverInfo } = useQuery({
|
||||
queryKey: ["jellyfin", "serverInfo"],
|
||||
queryFn: async (): Promise<PublicSystemInfo | null> => {
|
||||
if (!api) return null;
|
||||
const response = await getSystemApi(api).getPublicSystemInfo();
|
||||
return response.data;
|
||||
},
|
||||
enabled: enabled && Boolean(api) && streamyStatsEnabled,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const jellyfinServerId = serverInfo?.Id;
|
||||
|
||||
const {
|
||||
data: watchlists,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"promotedWatchlists",
|
||||
jellyfinServerId,
|
||||
settings?.streamyStatsServerUrl,
|
||||
],
|
||||
queryFn: async (): Promise<StreamystatsWatchlist[]> => {
|
||||
if (
|
||||
!settings?.streamyStatsServerUrl ||
|
||||
!api?.accessToken ||
|
||||
!jellyfinServerId
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const response = await streamystatsApi.getPromotedWatchlists({
|
||||
jellyfinServerId,
|
||||
includePreview: false,
|
||||
});
|
||||
|
||||
return response.data || [];
|
||||
},
|
||||
enabled:
|
||||
enabled &&
|
||||
streamyStatsEnabled &&
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(jellyfinServerId) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
if (!streamyStatsEnabled) return null;
|
||||
if (isError) return null;
|
||||
if (!isLoading && (!watchlists || watchlists.length === 0)) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View {...props}>
|
||||
<View className='h-4 w-32 bg-neutral-900 rounded ml-4 mb-2' />
|
||||
<View className='flex flex-row gap-2 px-4'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<View className='w-28' key={i}>
|
||||
<View className='bg-neutral-900 aspect-[2/3] w-full rounded-md mb-1' />
|
||||
<View className='rounded-md overflow-hidden mb-1 self-start'>
|
||||
<Text
|
||||
className='text-neutral-900 bg-neutral-900 rounded-md'
|
||||
numberOfLines={1}
|
||||
>
|
||||
Loading...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{watchlists?.map((watchlist) => (
|
||||
<WatchlistSection
|
||||
key={watchlist.id}
|
||||
watchlist={watchlist}
|
||||
jellyfinServerId={jellyfinServerId!}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
197
components/home/StreamystatsRecommendations.tsx
Normal file
197
components/home/StreamystatsRecommendations.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
PublicSystemInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, View, type ViewProps } from "react-native";
|
||||
|
||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import SeriesPoster from "../posters/SeriesPoster";
|
||||
|
||||
const ITEM_WIDTH = 120; // w-28 (112px) + mr-2 (8px)
|
||||
|
||||
interface Props extends ViewProps {
|
||||
title: string;
|
||||
type: "Movie" | "Series";
|
||||
limit?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
title,
|
||||
type,
|
||||
limit = 20,
|
||||
enabled = true,
|
||||
...props
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
|
||||
const streamyStatsEnabled = useMemo(() => {
|
||||
return Boolean(settings?.streamyStatsServerUrl);
|
||||
}, [settings?.streamyStatsServerUrl]);
|
||||
|
||||
// Fetch server info to get the Jellyfin server ID
|
||||
const { data: serverInfo } = useQuery({
|
||||
queryKey: ["jellyfin", "serverInfo"],
|
||||
queryFn: async (): Promise<PublicSystemInfo | null> => {
|
||||
if (!api) return null;
|
||||
const response = await getSystemApi(api).getPublicSystemInfo();
|
||||
return response.data;
|
||||
},
|
||||
enabled: enabled && Boolean(api) && streamyStatsEnabled,
|
||||
staleTime: 60 * 60 * 1000, // 1 hour - server info rarely changes
|
||||
});
|
||||
|
||||
const jellyfinServerId = serverInfo?.Id;
|
||||
|
||||
const {
|
||||
data: recommendationIds,
|
||||
isLoading: isLoadingRecommendations,
|
||||
isError: isRecommendationsError,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"recommendations",
|
||||
type,
|
||||
jellyfinServerId,
|
||||
settings?.streamyStatsServerUrl,
|
||||
],
|
||||
queryFn: async (): Promise<string[]> => {
|
||||
if (
|
||||
!settings?.streamyStatsServerUrl ||
|
||||
!api?.accessToken ||
|
||||
!jellyfinServerId
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamyStatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const response = await streamyStatsApi.getRecommendationIds(
|
||||
jellyfinServerId,
|
||||
type,
|
||||
limit,
|
||||
);
|
||||
|
||||
const data = response as StreamystatsRecommendationsIdsResponse;
|
||||
|
||||
if (type === "Movie") {
|
||||
return data.data.movies || [];
|
||||
}
|
||||
return data.data.series || [];
|
||||
},
|
||||
enabled:
|
||||
enabled &&
|
||||
streamyStatsEnabled &&
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(jellyfinServerId) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const {
|
||||
data: items,
|
||||
isLoading: isLoadingItems,
|
||||
isError: isItemsError,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"recommendations",
|
||||
"items",
|
||||
type,
|
||||
recommendationIds,
|
||||
],
|
||||
queryFn: async (): Promise<BaseItemDto[]> => {
|
||||
if (!api || !user?.Id || !recommendationIds?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
ids: recommendationIds,
|
||||
fields: ["PrimaryImageAspectRatio", "Genres"],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled:
|
||||
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const isLoading = isLoadingRecommendations || isLoadingItems;
|
||||
const isError = isRecommendationsError || isItemsError;
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
return items?.map((_, index) => index * ITEM_WIDTH) ?? [];
|
||||
}, [items]);
|
||||
|
||||
if (!streamyStatsEnabled) return null;
|
||||
if (isError) return null;
|
||||
if (!isLoading && (!items || items.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<SectionHeader title={title} />
|
||||
{isLoading ? (
|
||||
<View className='flex flex-row gap-2 px-4'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<View className='w-28' key={i}>
|
||||
<View className='bg-neutral-900 aspect-[2/3] w-full rounded-md mb-1' />
|
||||
<View className='rounded-md overflow-hidden mb-1 self-start'>
|
||||
<Text
|
||||
className='text-neutral-900 bg-neutral-900 rounded-md'
|
||||
numberOfLines={1}
|
||||
>
|
||||
Loading title...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate='fast'
|
||||
>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{items?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='mr-2 w-28'
|
||||
>
|
||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
80
components/item/ItemPeopleSections.tsx
Normal file
80
components/item/ItemPeopleSections.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemPerson,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { InteractionManager, View, type ViewProps } from "react-native";
|
||||
import { MoreMoviesWithActor } from "@/components/MoreMoviesWithActor";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { useItemPeopleQuery } from "@/hooks/useItemPeopleQuery";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
}
|
||||
|
||||
export const ItemPeopleSections: React.FC<Props> = ({
|
||||
item,
|
||||
isOffline,
|
||||
...props
|
||||
}) => {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOffline) return;
|
||||
const task = InteractionManager.runAfterInteractions(() =>
|
||||
setEnabled(true),
|
||||
);
|
||||
return () => task.cancel();
|
||||
}, [isOffline]);
|
||||
|
||||
const { data, isLoading } = useItemPeopleQuery(
|
||||
item.Id,
|
||||
enabled && !isOffline,
|
||||
);
|
||||
|
||||
const people = useMemo(() => (Array.isArray(data) ? data : []), [data]);
|
||||
|
||||
const itemWithPeople = useMemo(() => {
|
||||
return { ...item, People: people } as BaseItemDto;
|
||||
}, [item, people]);
|
||||
|
||||
const topPeople = useMemo(() => people.slice(0, 3), [people]);
|
||||
|
||||
const renderActorSection = useCallback(
|
||||
(person: BaseItemPerson, idx: number, total: number) => {
|
||||
if (!person.Id) return null;
|
||||
|
||||
const spacingClassName = idx === total - 1 ? undefined : "mb-2";
|
||||
|
||||
return (
|
||||
<MoreMoviesWithActor
|
||||
key={person.Id}
|
||||
currentItem={item}
|
||||
actorId={person.Id}
|
||||
actorName={person.Name}
|
||||
className={spacingClassName}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[item],
|
||||
);
|
||||
|
||||
if (isOffline || !enabled) return null;
|
||||
|
||||
const shouldSpaceCastAndCrew = topPeople.length > 0;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<CastAndCrew
|
||||
item={itemWithPeople}
|
||||
loading={isLoading}
|
||||
className={shouldSpaceCastAndCrew ? "mb-2" : undefined}
|
||||
/>
|
||||
{topPeople.map((person, idx) =>
|
||||
renderActorSection(person, idx, topPeople.length),
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { Text } from "../common/Text";
|
||||
interface Props extends ViewProps {
|
||||
title?: string | null | undefined;
|
||||
subtitle?: string | null | undefined;
|
||||
subtitleColor?: "default" | "red";
|
||||
value?: string | null | undefined;
|
||||
children?: ReactNode;
|
||||
iconAfter?: ReactNode;
|
||||
@@ -14,6 +15,7 @@ interface Props extends ViewProps {
|
||||
textColor?: "default" | "blue" | "red";
|
||||
onPress?: () => void;
|
||||
disabled?: boolean;
|
||||
disabledByAdmin?: boolean;
|
||||
}
|
||||
|
||||
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
@@ -27,21 +29,23 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
textColor = "default",
|
||||
onPress,
|
||||
disabled = false,
|
||||
disabledByAdmin = false,
|
||||
...viewProps
|
||||
}) => {
|
||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||
const isDisabled = disabled || disabledByAdmin;
|
||||
if (onPress)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={disabled}
|
||||
disabled={isDisabled}
|
||||
onPress={onPress}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
<ListItemContent
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
subtitle={effectiveSubtitle}
|
||||
subtitleColor={disabledByAdmin ? "red" : undefined}
|
||||
value={value}
|
||||
icon={icon}
|
||||
textColor={textColor}
|
||||
@@ -54,14 +58,13 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
);
|
||||
return (
|
||||
<View
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
{...viewProps}
|
||||
>
|
||||
<ListItemContent
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
subtitle={effectiveSubtitle}
|
||||
subtitleColor={disabledByAdmin ? "red" : undefined}
|
||||
value={value}
|
||||
icon={icon}
|
||||
textColor={textColor}
|
||||
@@ -77,6 +80,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
const ListItemContent = ({
|
||||
title,
|
||||
subtitle,
|
||||
subtitleColor,
|
||||
textColor,
|
||||
icon,
|
||||
value,
|
||||
@@ -107,7 +111,7 @@ const ListItemContent = ({
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
className='text-[#9899A1] text-[12px] mt-0.5'
|
||||
className={`text-[12px] mt-0.5 ${subtitleColor === "red" ? "text-red-600" : "text-[#9899A1]"}`}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{subtitle}
|
||||
|
||||
236
components/music/MiniPlayerBar.tsx
Normal file
236
components/music/MiniPlayerBar.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
|
||||
const HORIZONTAL_MARGIN = Platform.OS === "android" ? 8 : 16;
|
||||
const BOTTOM_TAB_HEIGHT = Platform.OS === "android" ? 56 : 52;
|
||||
const BAR_HEIGHT = Platform.OS === "android" ? 58 : 50;
|
||||
|
||||
export const MiniPlayerBar: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { currentTrack, isPlaying, progress, duration, togglePlayPause, next } =
|
||||
useMusicPlayer();
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (!api || !currentTrack) return null;
|
||||
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
||||
if (albumId) {
|
||||
return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`;
|
||||
}
|
||||
return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=100&maxWidth=100`;
|
||||
}, [api, currentTrack]);
|
||||
|
||||
const _progressPercentage = useMemo(() => {
|
||||
if (!duration || duration === 0) return 0;
|
||||
return (progress / duration) * 100;
|
||||
}, [progress, duration]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
router.push("/(auth)/now-playing");
|
||||
}, [router]);
|
||||
|
||||
const handlePlayPause = useCallback(
|
||||
(e: any) => {
|
||||
e.stopPropagation();
|
||||
togglePlayPause();
|
||||
},
|
||||
[togglePlayPause],
|
||||
);
|
||||
|
||||
const handleNext = useCallback(
|
||||
(e: any) => {
|
||||
e.stopPropagation();
|
||||
next();
|
||||
},
|
||||
[next],
|
||||
);
|
||||
|
||||
if (!currentTrack) return null;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* Album art */}
|
||||
<View style={styles.albumArt}>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={styles.albumImage}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.albumPlaceholder}>
|
||||
<Ionicons name='musical-note' size={20} color='#888' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Track info */}
|
||||
<View style={styles.trackInfo}>
|
||||
<Text numberOfLines={1} style={styles.trackTitle}>
|
||||
{currentTrack.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} style={styles.artistName}>
|
||||
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Controls */}
|
||||
<View style={styles.controls}>
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayPause}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
style={styles.controlButton}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={26}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleNext}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
style={styles.controlButton}
|
||||
>
|
||||
<Ionicons name='play-forward' size={22} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Progress bar at bottom */}
|
||||
{/* <View style={styles.progressContainer}>
|
||||
<View
|
||||
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
|
||||
/>
|
||||
</View> */}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
bottom:
|
||||
BOTTOM_TAB_HEIGHT +
|
||||
insets.bottom +
|
||||
(Platform.OS === "android" ? 32 : 4),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.9}
|
||||
style={styles.touchable}
|
||||
>
|
||||
{Platform.OS === "ios" ? (
|
||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||
{content}
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={styles.androidContainer}>{content}</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: "absolute",
|
||||
left: HORIZONTAL_MARGIN,
|
||||
right: HORIZONTAL_MARGIN,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
touchable: {
|
||||
borderRadius: 50,
|
||||
overflow: "hidden",
|
||||
},
|
||||
blurContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingRight: 10,
|
||||
paddingLeft: 20,
|
||||
paddingVertical: 0,
|
||||
height: BAR_HEIGHT,
|
||||
backgroundColor: "rgba(40, 40, 40, 0.5)",
|
||||
},
|
||||
androidContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
height: BAR_HEIGHT,
|
||||
backgroundColor: "rgba(28, 28, 30, 0.97)",
|
||||
borderRadius: 14,
|
||||
borderWidth: 0.5,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
albumArt: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#333",
|
||||
},
|
||||
albumImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
albumPlaceholder: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#2a2a2a",
|
||||
},
|
||||
trackInfo: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
marginRight: 8,
|
||||
justifyContent: "center",
|
||||
},
|
||||
trackTitle: {
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
artistName: {
|
||||
color: "rgba(255, 255, 255, 0.6)",
|
||||
fontSize: 12,
|
||||
},
|
||||
controls: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
controlButton: {
|
||||
padding: 8,
|
||||
},
|
||||
progressContainer: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 10,
|
||||
right: 10,
|
||||
height: 3,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
progressFill: {
|
||||
height: "100%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
});
|
||||
68
components/music/MusicAlbumCard.tsx
Normal file
68
components/music/MusicAlbumCard.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface Props {
|
||||
album: BaseItemDto;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const MusicAlbumCard: React.FC<Props> = ({ album, width = 150 }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const router = useRouter();
|
||||
|
||||
const imageUrl = useMemo(
|
||||
() => getPrimaryImageUrl({ api, item: album }),
|
||||
[api, album],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/music/album/[albumId]",
|
||||
params: { albumId: album.Id! },
|
||||
});
|
||||
}, [router, album.Id]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
style={{ width }}
|
||||
className='flex flex-col'
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width,
|
||||
height: width,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Text className='text-4xl'>🎵</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text numberOfLines={1} className='text-white text-sm font-medium mt-2'>
|
||||
{album.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-neutral-400 text-xs'>
|
||||
{album.AlbumArtist || album.Artists?.join(", ")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
68
components/music/MusicArtistCard.tsx
Normal file
68
components/music/MusicArtistCard.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface Props {
|
||||
artist: BaseItemDto;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const MusicArtistCard: React.FC<Props> = ({ artist, size = 100 }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const router = useRouter();
|
||||
|
||||
const imageUrl = useMemo(
|
||||
() => getPrimaryImageUrl({ api, item: artist }),
|
||||
[api, artist],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/music/artist/[artistId]",
|
||||
params: { artistId: artist.Id! },
|
||||
});
|
||||
}, [router, artist.Id]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
style={{ width: size }}
|
||||
className='flex flex-col items-center'
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Text className='text-3xl'>👤</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className='text-white text-xs font-medium mt-2 text-center'
|
||||
>
|
||||
{artist.Name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
83
components/music/MusicPlaybackEngine.tsx
Normal file
83
components/music/MusicPlaybackEngine.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import TrackPlayer, {
|
||||
Event,
|
||||
type PlaybackActiveTrackChangedEvent,
|
||||
State,
|
||||
useActiveTrack,
|
||||
usePlaybackState,
|
||||
useProgress,
|
||||
} from "react-native-track-player";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
|
||||
export const MusicPlaybackEngine: React.FC = () => {
|
||||
const { position, duration } = useProgress(1000);
|
||||
const playbackState = usePlaybackState();
|
||||
const activeTrack = useActiveTrack();
|
||||
const {
|
||||
setProgress,
|
||||
setDuration,
|
||||
setIsPlaying,
|
||||
reportProgress,
|
||||
onTrackEnd,
|
||||
syncFromTrackPlayer,
|
||||
} = useMusicPlayer();
|
||||
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
|
||||
// Sync progress from TrackPlayer to our state
|
||||
useEffect(() => {
|
||||
if (position > 0) {
|
||||
setProgress(position);
|
||||
}
|
||||
}, [position, setProgress]);
|
||||
|
||||
// Sync duration from TrackPlayer to our state
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
setDuration(duration);
|
||||
}
|
||||
}, [duration, setDuration]);
|
||||
|
||||
// Sync playback state from TrackPlayer to our state
|
||||
useEffect(() => {
|
||||
const isPlaying = playbackState.state === State.Playing;
|
||||
setIsPlaying(isPlaying);
|
||||
}, [playbackState.state, setIsPlaying]);
|
||||
|
||||
// Sync active track changes
|
||||
useEffect(() => {
|
||||
if (activeTrack) {
|
||||
syncFromTrackPlayer();
|
||||
}
|
||||
}, [activeTrack?.id, syncFromTrackPlayer]);
|
||||
|
||||
// Report progress every ~10 seconds
|
||||
useEffect(() => {
|
||||
if (
|
||||
Math.floor(position) - Math.floor(lastReportedProgressRef.current) >=
|
||||
10
|
||||
) {
|
||||
lastReportedProgressRef.current = position;
|
||||
reportProgress();
|
||||
}
|
||||
}, [position, reportProgress]);
|
||||
|
||||
// Listen for track end
|
||||
useEffect(() => {
|
||||
const subscription =
|
||||
TrackPlayer.addEventListener<PlaybackActiveTrackChangedEvent>(
|
||||
Event.PlaybackActiveTrackChanged,
|
||||
async (event) => {
|
||||
// If there's no next track and the previous track ended, call onTrackEnd
|
||||
if (event.lastTrack && !event.track) {
|
||||
onTrackEnd();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [onTrackEnd]);
|
||||
|
||||
// No visual component needed - TrackPlayer is headless
|
||||
return null;
|
||||
};
|
||||
71
components/music/MusicPlaylistCard.tsx
Normal file
71
components/music/MusicPlaylistCard.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface Props {
|
||||
playlist: BaseItemDto;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const MusicPlaylistCard: React.FC<Props> = ({
|
||||
playlist,
|
||||
width = 150,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const router = useRouter();
|
||||
|
||||
const imageUrl = useMemo(
|
||||
() => getPrimaryImageUrl({ api, item: playlist }),
|
||||
[api, playlist],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/music/playlist/[playlistId]",
|
||||
params: { playlistId: playlist.Id! },
|
||||
});
|
||||
}, [router, playlist.Id]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
style={{ width }}
|
||||
className='flex flex-col'
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width,
|
||||
height: width,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Text className='text-4xl'>🎶</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text numberOfLines={1} className='text-white text-sm font-medium mt-2'>
|
||||
{playlist.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-neutral-400 text-xs'>
|
||||
{playlist.ChildCount} tracks
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
130
components/music/MusicTrackItem.tsx
Normal file
130
components/music/MusicTrackItem.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { formatDuration } from "@/utils/time";
|
||||
|
||||
interface Props {
|
||||
track: BaseItemDto;
|
||||
index?: number;
|
||||
queue?: BaseItemDto[];
|
||||
showArtwork?: boolean;
|
||||
}
|
||||
|
||||
export const MusicTrackItem: React.FC<Props> = ({
|
||||
track,
|
||||
index,
|
||||
queue,
|
||||
showArtwork = true,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const { playTrack, playNext, addToQueue, currentTrack, isPlaying } =
|
||||
useMusicPlayer();
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
const albumId = track.AlbumId || track.ParentId;
|
||||
if (albumId) {
|
||||
return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`;
|
||||
}
|
||||
return getPrimaryImageUrl({ api, item: track });
|
||||
}, [api, track]);
|
||||
|
||||
const isCurrentTrack = currentTrack?.Id === track.Id;
|
||||
|
||||
const duration = useMemo(() => {
|
||||
if (!track.RunTimeTicks) return "";
|
||||
return formatDuration(track.RunTimeTicks);
|
||||
}, [track.RunTimeTicks]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
playTrack(track, queue);
|
||||
}, [playTrack, track, queue]);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
const options = ["Play Next", "Add to Queue", "Cancel"];
|
||||
const cancelButtonIndex = 2;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
title: track.Name ?? undefined,
|
||||
message: (track.Artists?.join(", ") || track.AlbumArtist) ?? undefined,
|
||||
},
|
||||
(selectedIndex) => {
|
||||
if (selectedIndex === 0) {
|
||||
playNext(track);
|
||||
} else if (selectedIndex === 1) {
|
||||
addToQueue(track);
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [showActionSheetWithOptions, track, playNext, addToQueue]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={300}
|
||||
className={`flex flex-row items-center py-3 ${isCurrentTrack ? "bg-purple-900/20" : ""}`}
|
||||
>
|
||||
{index !== undefined && (
|
||||
<View className='w-8 items-center'>
|
||||
{isCurrentTrack && isPlaying ? (
|
||||
<Ionicons name='musical-note' size={16} color='#9334E9' />
|
||||
) : (
|
||||
<Text className='text-neutral-500 text-sm'>{index}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showArtwork && (
|
||||
<View
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Ionicons name='musical-note' size={20} color='#737373' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='flex-1 mr-4'>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={`text-sm ${isCurrentTrack ? "text-purple-400 font-medium" : "text-white"}`}
|
||||
>
|
||||
{track.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-neutral-400 text-xs mt-0.5'>
|
||||
{track.Artists?.join(", ") || track.AlbumArtist}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className='text-neutral-500 text-xs'>{duration}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
6
components/music/index.ts
Normal file
6
components/music/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./MiniPlayerBar";
|
||||
export * from "./MusicAlbumCard";
|
||||
export * from "./MusicArtistCard";
|
||||
export * from "./MusicPlaybackEngine";
|
||||
export * from "./MusicPlaylistCard";
|
||||
export * from "./MusicTrackItem";
|
||||
@@ -8,6 +8,7 @@ import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||
@@ -50,7 +51,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
<HorizontalScroll
|
||||
loading={loading}
|
||||
keyExtractor={(i, _idx) => i.Id?.toString() || ""}
|
||||
height={247}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
data={destinctPeople}
|
||||
renderItem={(i) => (
|
||||
<TouchableOpacity
|
||||
@@ -65,8 +66,12 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
className='flex flex-col w-28'
|
||||
>
|
||||
<Poster id={i.Id} url={getPrimaryImageUrl({ api, item: i })} />
|
||||
<Text className='mt-2'>{i.Name}</Text>
|
||||
<Text className='text-xs opacity-50'>{i.Role}</Text>
|
||||
<Text className='mt-2' numberOfLines={1}>
|
||||
{i.Name}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-50' numberOfLines={1}>
|
||||
{i.Role}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||
@@ -25,7 +26,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
</Text>
|
||||
<HorizontalScroll
|
||||
data={[item]}
|
||||
height={247}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
renderItem={(item, _index) => (
|
||||
<TouchableOpacity
|
||||
key={item?.Id}
|
||||
@@ -38,7 +39,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
id={item?.Id}
|
||||
url={getPrimaryImageUrlById({ api, id: item?.ParentId })}
|
||||
/>
|
||||
<Text>{item?.SeriesName}</Text>
|
||||
<Text numberOfLines={1}>{item?.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -50,6 +50,16 @@ export const AppearanceSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
||||
>
|
||||
<Switch
|
||||
value={settings.mergeNextUpAndContinueWatching}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
onPress={() =>
|
||||
router.push("/settings/appearance/hide-libraries/page")
|
||||
|
||||
@@ -11,12 +11,12 @@ const DisabledSetting: React.FC<
|
||||
}}
|
||||
>
|
||||
<View {...props}>
|
||||
{children}
|
||||
{disabled && showText && (
|
||||
<Text className='text-center text-red-700 my-4'>
|
||||
{text ?? "Currently disabled by admin."}
|
||||
<Text className='text-xs text-red-600 px-4 mt-1'>
|
||||
{text ?? "Disabled by admin"}
|
||||
</Text>
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
40
components/settings/KSPlayerSettings.tsx
Normal file
40
components/settings/KSPlayerSettings.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, Switch } from "react-native";
|
||||
import { setHardwareDecode } from "@/modules/sf-player";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const KSPlayerSettings: React.FC = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleHardwareDecodeChange = useCallback(
|
||||
(value: boolean) => {
|
||||
updateSettings({ ksHardwareDecode: value });
|
||||
setHardwareDecode(value);
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
if (Platform.OS !== "ios" || !settings) return null;
|
||||
|
||||
return (
|
||||
<ListGroup
|
||||
title={t("home.settings.subtitles.ksplayer_title")}
|
||||
className='mt-4'
|
||||
>
|
||||
<ListItem
|
||||
title={t("home.settings.subtitles.hardware_decode")}
|
||||
subtitle={t("home.settings.subtitles.hardware_decode_description")}
|
||||
>
|
||||
<Switch
|
||||
value={settings.ksHardwareDecode}
|
||||
onValueChange={handleHardwareDecodeChange}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
33
components/settings/KefinTweaks.tsx
Normal file
33
components/settings/KefinTweaks.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, Text, View } from "react-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export const KefinTweaksSettings = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isEnabled = settings?.useKefinTweaks ?? false;
|
||||
|
||||
return (
|
||||
<View className=''>
|
||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||
<Text className='text-xs text-red-600 mb-2'>
|
||||
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||
</Text>
|
||||
|
||||
<View className='flex flex-row items-center justify-between mt-2'>
|
||||
<Text className='text-white'>
|
||||
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
|
||||
</Text>
|
||||
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||
trackColor={{ false: "#555", true: "purple" }}
|
||||
thumbColor={isEnabled ? "#fff" : "#ccc"}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { VideoPlayerSettings } from "./VideoPlayerSettings";
|
||||
|
||||
export const PlaybackControlsSettings: React.FC = () => {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
@@ -190,6 +191,8 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
<VideoPlayerSettings />
|
||||
</DisabledSetting>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,16 @@ export const PluginSettings = () => {
|
||||
title='Marlin Search'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
||||
title='Streamystats'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||
title='KefinTweaks'
|
||||
showArrow
|
||||
/>
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,12 +5,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import {
|
||||
OUTLINE_THICKNESS,
|
||||
type OutlineThickness,
|
||||
VLC_COLORS,
|
||||
type VLCColor,
|
||||
} from "@/constants/SubtitleConstants";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
@@ -92,84 +86,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
];
|
||||
}, [settings?.subtitleMode, t, updateSettings]);
|
||||
|
||||
const textColorOptionGroups = useMemo(() => {
|
||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||
const options = colors.map((color) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.colors.${color}`),
|
||||
value: color,
|
||||
selected: (settings?.vlcTextColor || "White") === color,
|
||||
onPress: () => updateSettings({ vlcTextColor: color }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcTextColor, t, updateSettings]);
|
||||
|
||||
const backgroundColorOptionGroups = useMemo(() => {
|
||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||
const options = colors.map((color) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.colors.${color}`),
|
||||
value: color,
|
||||
selected: (settings?.vlcBackgroundColor || "Black") === color,
|
||||
onPress: () => updateSettings({ vlcBackgroundColor: color }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcBackgroundColor, t, updateSettings]);
|
||||
|
||||
const outlineColorOptionGroups = useMemo(() => {
|
||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||
const options = colors.map((color) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.colors.${color}`),
|
||||
value: color,
|
||||
selected: (settings?.vlcOutlineColor || "Black") === color,
|
||||
onPress: () => updateSettings({ vlcOutlineColor: color }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcOutlineColor, t, updateSettings]);
|
||||
|
||||
const outlineThicknessOptionGroups = useMemo(() => {
|
||||
const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[];
|
||||
const options = thicknesses.map((thickness) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.thickness.${thickness}`),
|
||||
value: thickness,
|
||||
selected: (settings?.vlcOutlineThickness || "Normal") === thickness,
|
||||
onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcOutlineThickness, t, updateSettings]);
|
||||
|
||||
const backgroundOpacityOptionGroups = useMemo(() => {
|
||||
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
const options = opacities.map((opacity) => ({
|
||||
type: "radio" as const,
|
||||
label: `${Math.round((opacity / 255) * 100)}%`,
|
||||
value: opacity,
|
||||
selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity,
|
||||
onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcBackgroundOpacity, updateSettings]);
|
||||
|
||||
const outlineOpacityOptionGroups = useMemo(() => {
|
||||
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
const options = opacities.map((opacity) => ({
|
||||
type: "radio" as const,
|
||||
label: `${Math.round((opacity / 255) * 100)}%`,
|
||||
value: opacity,
|
||||
selected: (settings?.vlcOutlineOpacity ?? 255) === opacity,
|
||||
onPress: () => updateSettings({ vlcOutlineOpacity: opacity }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcOutlineOpacity, updateSettings]);
|
||||
|
||||
if (isTv) return null;
|
||||
if (!settings) return null;
|
||||
|
||||
@@ -244,130 +160,14 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
disabled={pluginSettings?.subtitleSize?.locked}
|
||||
>
|
||||
<Stepper
|
||||
value={settings.subtitleSize}
|
||||
value={settings.subtitleSize / 100}
|
||||
disabled={pluginSettings?.subtitleSize?.locked}
|
||||
step={5}
|
||||
min={0}
|
||||
max={120}
|
||||
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.text_color")}>
|
||||
<PlatformDropdown
|
||||
groups={textColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
step={0.1}
|
||||
min={0.3}
|
||||
max={1.5}
|
||||
onUpdate={(value) =>
|
||||
updateSettings({ subtitleSize: Math.round(value * 100) })
|
||||
}
|
||||
title={t("home.settings.subtitles.text_color")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.background_color")}>
|
||||
<PlatformDropdown
|
||||
groups={backgroundColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.background_color")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_color")}>
|
||||
<PlatformDropdown
|
||||
groups={outlineColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.outline_color")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
|
||||
<PlatformDropdown
|
||||
groups={outlineThicknessOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.outline_thickness")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
||||
<PlatformDropdown
|
||||
groups={backgroundOpacityOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.background_opacity")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
|
||||
<PlatformDropdown
|
||||
groups={outlineOpacityOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.outline_opacity")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.bold_text")}>
|
||||
<Switch
|
||||
value={settings?.vlcIsBold ?? false}
|
||||
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
93
components/settings/VideoPlayerSettings.tsx
Normal file
93
components/settings/VideoPlayerSettings.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, Switch, View } from "react-native";
|
||||
import { setHardwareDecode } from "@/modules/sf-player";
|
||||
import { useSettings, VideoPlayerIOS } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
|
||||
export const VideoPlayerSettings: React.FC = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleHardwareDecodeChange = useCallback(
|
||||
(value: boolean) => {
|
||||
updateSettings({ ksHardwareDecode: value });
|
||||
setHardwareDecode(value);
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const videoPlayerOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: [
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("home.settings.video_player.ksplayer"),
|
||||
value: VideoPlayerIOS.KSPlayer,
|
||||
selected: settings?.videoPlayerIOS === VideoPlayerIOS.KSPlayer,
|
||||
onPress: () =>
|
||||
updateSettings({ videoPlayerIOS: VideoPlayerIOS.KSPlayer }),
|
||||
},
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("home.settings.video_player.vlc"),
|
||||
value: VideoPlayerIOS.VLC,
|
||||
selected: settings?.videoPlayerIOS === VideoPlayerIOS.VLC,
|
||||
onPress: () =>
|
||||
updateSettings({ videoPlayerIOS: VideoPlayerIOS.VLC }),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[settings?.videoPlayerIOS, t, updateSettings],
|
||||
);
|
||||
|
||||
const getPlayerLabel = useCallback(() => {
|
||||
switch (settings?.videoPlayerIOS) {
|
||||
case VideoPlayerIOS.VLC:
|
||||
return t("home.settings.video_player.vlc");
|
||||
default:
|
||||
return t("home.settings.video_player.ksplayer");
|
||||
}
|
||||
}, [settings?.videoPlayerIOS, t]);
|
||||
|
||||
if (Platform.OS !== "ios" || !settings) return null;
|
||||
|
||||
return (
|
||||
<ListGroup title={t("home.settings.video_player.title")} className='mt-4'>
|
||||
<ListItem
|
||||
title={t("home.settings.video_player.video_player")}
|
||||
subtitle={t("home.settings.video_player.video_player_description")}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={videoPlayerOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{getPlayerLabel()}</Text>
|
||||
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.video_player.video_player")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{settings.videoPlayerIOS === VideoPlayerIOS.KSPlayer && (
|
||||
<ListItem
|
||||
title={t("home.settings.subtitles.hardware_decode")}
|
||||
subtitle={t("home.settings.subtitles.hardware_decode_description")}
|
||||
>
|
||||
<Switch
|
||||
value={settings.ksHardwareDecode}
|
||||
onValueChange={handleHardwareDecodeChange}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,6 @@ interface BottomControlsProps {
|
||||
showRemoteBubble: boolean;
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
isVlc: boolean;
|
||||
showSkipButton: boolean;
|
||||
showSkipCreditButton: boolean;
|
||||
hasContentAfterCredits: boolean;
|
||||
@@ -67,7 +66,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
showRemoteBubble,
|
||||
currentTime,
|
||||
remainingTime,
|
||||
isVlc,
|
||||
showSkipButton,
|
||||
showSkipCreditButton,
|
||||
hasContentAfterCredits,
|
||||
@@ -157,7 +155,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
? false
|
||||
: // Show during credits if no content after, OR near end of video
|
||||
(showSkipCreditButton && !hasContentAfterCredits) ||
|
||||
(isVlc ? remainingTime < 10000 : remainingTime < 10)
|
||||
remainingTime < 10000
|
||||
}
|
||||
onFinish={handleNextEpisodeAutoPlay}
|
||||
onPress={handleNextEpisodeManual}
|
||||
@@ -215,7 +213,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
<TimeDisplay
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
isVlc={isVlc}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -4,16 +4,8 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import {
|
||||
type Dispatch,
|
||||
type FC,
|
||||
type MutableRefObject,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
type SharedValue,
|
||||
@@ -28,7 +20,6 @@ import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
@@ -36,7 +27,6 @@ import { ticksToMs } from "@/utils/time";
|
||||
import { BottomControls } from "./BottomControls";
|
||||
import { CenterControls } from "./CenterControls";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
import { ControlProvider } from "./contexts/ControlContext";
|
||||
import { EpisodeList } from "./EpisodeList";
|
||||
import { GestureOverlay } from "./GestureOverlay";
|
||||
import { HeaderControls } from "./HeaderControls";
|
||||
@@ -44,42 +34,30 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||
import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
||||
import { useVideoSlider } from "./hooks/useVideoSlider";
|
||||
import { useVideoTime } from "./hooks/useVideoTime";
|
||||
import { type ScaleFactor } from "./ScaleFactorSelector";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
|
||||
isPlaying: boolean;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
cacheProgress: SharedValue<number>;
|
||||
progress: SharedValue<number>;
|
||||
isBuffering: boolean;
|
||||
showControls: boolean;
|
||||
|
||||
enableTrickplay?: boolean;
|
||||
togglePlay: () => void;
|
||||
setShowControls: (shown: boolean) => void;
|
||||
offline?: boolean;
|
||||
isVideoLoaded?: boolean;
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
seek: (ticks: number) => void;
|
||||
startPictureInPicture?: () => Promise<void>;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
setSubtitleURL?: (url: string, customName: string) => void;
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
setAudioTrack?: (index: number) => void;
|
||||
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
|
||||
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
|
||||
aspectRatio?: AspectRatio;
|
||||
scaleFactor?: ScaleFactor;
|
||||
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
||||
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
|
||||
isVlc?: boolean;
|
||||
isZoomedToFill?: boolean;
|
||||
onZoomToggle?: () => void;
|
||||
api?: Api | null;
|
||||
downloadedFiles?: DownloadedItem[];
|
||||
}
|
||||
@@ -99,20 +77,11 @@ export const Controls: FC<Props> = ({
|
||||
showControls,
|
||||
setShowControls,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
getAudioTracks,
|
||||
getSubtitleTracks,
|
||||
setSubtitleURL,
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
setVideoAspectRatio,
|
||||
setVideoScaleFactor,
|
||||
aspectRatio = "default",
|
||||
scaleFactor = 1.0,
|
||||
setAspectRatio,
|
||||
setScaleFactor,
|
||||
isZoomedToFill = false,
|
||||
onZoomToggle,
|
||||
offline = false,
|
||||
isVlc = false,
|
||||
api = null,
|
||||
downloadedFiles = undefined,
|
||||
}) => {
|
||||
@@ -194,17 +163,13 @@ export const Controls: FC<Props> = ({
|
||||
zIndex: 10,
|
||||
}));
|
||||
|
||||
// Initialize progress values
|
||||
// Initialize progress values - MPV uses milliseconds
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
progress.value = isVlc
|
||||
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
|
||||
: item?.UserData?.PlaybackPositionTicks || 0;
|
||||
max.value = isVlc
|
||||
? ticksToMs(item.RunTimeTicks || 0)
|
||||
: item.RunTimeTicks || 0;
|
||||
progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks);
|
||||
max.value = ticksToMs(item.RunTimeTicks || 0);
|
||||
}
|
||||
}, [item, isVlc, progress, max]);
|
||||
}, [item, progress, max]);
|
||||
|
||||
// Navigation hooks
|
||||
const {
|
||||
@@ -215,7 +180,6 @@ export const Controls: FC<Props> = ({
|
||||
} = useVideoNavigation({
|
||||
progress,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
seek,
|
||||
play,
|
||||
});
|
||||
@@ -225,7 +189,6 @@ export const Controls: FC<Props> = ({
|
||||
progress,
|
||||
max,
|
||||
isSeeking,
|
||||
isVlc,
|
||||
});
|
||||
|
||||
const toggleControls = useCallback(() => {
|
||||
@@ -248,7 +211,6 @@ export const Controls: FC<Props> = ({
|
||||
progress,
|
||||
min,
|
||||
max,
|
||||
isVlc,
|
||||
showControls,
|
||||
isPlaying,
|
||||
seek,
|
||||
@@ -273,7 +235,6 @@ export const Controls: FC<Props> = ({
|
||||
progress,
|
||||
isSeeking,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
seek,
|
||||
play,
|
||||
pause,
|
||||
@@ -302,9 +263,8 @@ export const Controls: FC<Props> = ({
|
||||
: current.actual;
|
||||
} else {
|
||||
// When not scrubbing, only update if progress changed significantly (1 second)
|
||||
const progressUnit = isVlc
|
||||
? CONTROLS_CONSTANTS.PROGRESS_UNIT_MS
|
||||
: CONTROLS_CONSTANTS.PROGRESS_UNIT_TICKS;
|
||||
// MPV uses milliseconds
|
||||
const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS;
|
||||
const progressDiff = Math.abs(current.actual - effectiveProgress.value);
|
||||
if (progressDiff >= progressUnit) {
|
||||
effectiveProgress.value = current.actual;
|
||||
@@ -325,7 +285,6 @@ export const Controls: FC<Props> = ({
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc,
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
@@ -337,7 +296,6 @@ export const Controls: FC<Props> = ({
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc,
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
@@ -361,12 +319,10 @@ export const Controls: FC<Props> = ({
|
||||
mediaSource: newMediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(
|
||||
item,
|
||||
settings,
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined,
|
||||
);
|
||||
} = getDefaultPlaySettings(item, settings, {
|
||||
indexes: previousIndexes,
|
||||
source: mediaSource ?? undefined,
|
||||
});
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
...(offline && { offline: "true" }),
|
||||
@@ -379,8 +335,6 @@ export const Controls: FC<Props> = ({
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
console.log("queryParams", queryParams);
|
||||
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
},
|
||||
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
||||
@@ -471,6 +425,7 @@ export const Controls: FC<Props> = ({
|
||||
episodeView,
|
||||
onHideControls: hideControls,
|
||||
timeout: CONTROLS_CONSTANTS.TIMEOUT,
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
const switchOnEpisodeMode = useCallback(() => {
|
||||
@@ -481,11 +436,7 @@ export const Controls: FC<Props> = ({
|
||||
}, [isPlaying, togglePlay]);
|
||||
|
||||
return (
|
||||
<ControlProvider
|
||||
item={item}
|
||||
mediaSource={mediaSource}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
>
|
||||
<View style={styles.controlsContainer} pointerEvents='box-none'>
|
||||
{episodeView ? (
|
||||
<EpisodeList
|
||||
item={item}
|
||||
@@ -517,17 +468,10 @@ export const Controls: FC<Props> = ({
|
||||
goToNextItem={goToNextItem}
|
||||
previousItem={previousItem}
|
||||
nextItem={nextItem}
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
aspectRatio={aspectRatio}
|
||||
scaleFactor={scaleFactor}
|
||||
setAspectRatio={setAspectRatio}
|
||||
setScaleFactor={setScaleFactor}
|
||||
setVideoAspectRatio={setVideoAspectRatio}
|
||||
setVideoScaleFactor={setVideoScaleFactor}
|
||||
isZoomedToFill={isZoomedToFill}
|
||||
onZoomToggle={onZoomToggle}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
@@ -556,7 +500,6 @@ export const Controls: FC<Props> = ({
|
||||
showRemoteBubble={showRemoteBubble}
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
isVlc={isVlc}
|
||||
showSkipButton={showSkipButton}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
@@ -585,6 +528,16 @@ export const Controls: FC<Props> = ({
|
||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
|
||||
)}
|
||||
</ControlProvider>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
controlsContainer: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { type Dispatch, type FC, type SetStateAction } from "react";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
@@ -13,15 +13,11 @@ import {
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ICON_SIZES } from "./constants";
|
||||
import { VideoProvider } from "./contexts/VideoContext";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
|
||||
import {
|
||||
type AspectRatio,
|
||||
AspectRatioSelector,
|
||||
} from "./VideoScalingModeSelector";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
import { ZoomToggle } from "./ZoomToggle";
|
||||
|
||||
interface HeaderControlsProps {
|
||||
item: BaseItemDto;
|
||||
@@ -34,17 +30,10 @@ interface HeaderControlsProps {
|
||||
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
|
||||
previousItem?: BaseItemDto | null;
|
||||
nextItem?: BaseItemDto | null;
|
||||
getAudioTracks?: (() => Promise<any[] | null>) | (() => any[]);
|
||||
getSubtitleTracks?: (() => Promise<any[] | null>) | (() => any[]);
|
||||
setAudioTrack?: (index: number) => void;
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
setSubtitleURL?: (url: string, customName: string) => void;
|
||||
aspectRatio?: AspectRatio;
|
||||
scaleFactor?: ScaleFactor;
|
||||
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
||||
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
|
||||
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
|
||||
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
|
||||
isZoomedToFill?: boolean;
|
||||
onZoomToggle?: () => void;
|
||||
}
|
||||
|
||||
export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
@@ -58,39 +47,17 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
goToNextItem,
|
||||
previousItem,
|
||||
nextItem,
|
||||
getAudioTracks,
|
||||
getSubtitleTracks,
|
||||
setAudioTrack,
|
||||
setSubtitleTrack,
|
||||
setSubtitleURL,
|
||||
aspectRatio = "default",
|
||||
scaleFactor = 1.0,
|
||||
setAspectRatio,
|
||||
setScaleFactor,
|
||||
setVideoAspectRatio,
|
||||
setVideoScaleFactor,
|
||||
aspectRatio: _aspectRatio = "default",
|
||||
setVideoAspectRatio: _setVideoAspectRatio,
|
||||
isZoomedToFill = false,
|
||||
onZoomToggle,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const { width: _screenWidth } = useWindowDimensions();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const handleAspectRatioChange = async (newRatio: AspectRatio) => {
|
||||
if (!setAspectRatio || !setVideoAspectRatio) return;
|
||||
|
||||
setAspectRatio(newRatio);
|
||||
const aspectRatioString = newRatio === "default" ? null : newRatio;
|
||||
await setVideoAspectRatio(aspectRatioString);
|
||||
};
|
||||
|
||||
const handleScaleFactorChange = async (newScale: ScaleFactor) => {
|
||||
if (!setScaleFactor || !setVideoScaleFactor) return;
|
||||
|
||||
setScaleFactor(newScale);
|
||||
await setVideoScaleFactor(newScale);
|
||||
};
|
||||
|
||||
const onClose = async () => {
|
||||
lightHapticFeedback();
|
||||
router.back();
|
||||
@@ -102,35 +69,23 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
{
|
||||
position: "absolute",
|
||||
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
||||
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
||||
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
||||
width: settings?.safeAreaInControlsEnabled
|
||||
? screenWidth - insets.left - insets.right
|
||||
: screenWidth,
|
||||
},
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={"flex flex-row w-full pt-2"}
|
||||
className='flex flex-row justify-between'
|
||||
>
|
||||
<View className='mr-auto' pointerEvents='box-none'>
|
||||
<View className='mr-auto p-2' pointerEvents='box-none'>
|
||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
<View pointerEvents='auto'>
|
||||
<DropdownView />
|
||||
</View>
|
||||
</VideoProvider>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV &&
|
||||
(settings.defaultPlayer === VideoPlayer.VLC_4 ||
|
||||
Platform.OS === "android") && (
|
||||
{!Platform.isTV && startPictureInPicture && (
|
||||
<TouchableOpacity
|
||||
onPress={startPictureInPicture}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
@@ -174,15 +129,20 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<AspectRatioSelector
|
||||
{/*<AspectRatioSelector
|
||||
currentRatio={aspectRatio}
|
||||
onRatioChange={handleAspectRatioChange}
|
||||
onRatioChange={async (newRatio) => {
|
||||
if (setVideoAspectRatio) {
|
||||
const aspectRatioString = newRatio === "default" ? null : newRatio;
|
||||
await setVideoAspectRatio(aspectRatioString);
|
||||
}
|
||||
}}
|
||||
disabled={!setVideoAspectRatio}
|
||||
/>
|
||||
<ScaleFactorSelector
|
||||
currentScale={scaleFactor}
|
||||
onScaleChange={handleScaleFactorChange}
|
||||
disabled={!setVideoScaleFactor}
|
||||
/>*/}
|
||||
<ZoomToggle
|
||||
isZoomedToFill={isZoomedToFill}
|
||||
onToggle={onZoomToggle ?? (() => {})}
|
||||
disabled={!onZoomToggle}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useMemo } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
export type ScaleFactor =
|
||||
| 1.0
|
||||
| 1.1
|
||||
| 1.2
|
||||
| 1.3
|
||||
| 1.4
|
||||
| 1.5
|
||||
| 1.6
|
||||
| 1.7
|
||||
| 1.8
|
||||
| 1.9
|
||||
| 2.0;
|
||||
|
||||
interface ScaleFactorSelectorProps {
|
||||
currentScale: ScaleFactor;
|
||||
onScaleChange: (scale: ScaleFactor) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ScaleFactorOption {
|
||||
id: ScaleFactor;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SCALE_FACTOR_OPTIONS: ScaleFactorOption[] = [
|
||||
{
|
||||
id: 1.0,
|
||||
label: "1.0x",
|
||||
description: "Original size",
|
||||
},
|
||||
{
|
||||
id: 1.1,
|
||||
label: "1.1x",
|
||||
description: "10% larger",
|
||||
},
|
||||
{
|
||||
id: 1.2,
|
||||
label: "1.2x",
|
||||
description: "20% larger",
|
||||
},
|
||||
{
|
||||
id: 1.3,
|
||||
label: "1.3x",
|
||||
description: "30% larger",
|
||||
},
|
||||
{
|
||||
id: 1.4,
|
||||
label: "1.4x",
|
||||
description: "40% larger",
|
||||
},
|
||||
{
|
||||
id: 1.5,
|
||||
label: "1.5x",
|
||||
description: "50% larger",
|
||||
},
|
||||
{
|
||||
id: 1.6,
|
||||
label: "1.6x",
|
||||
description: "60% larger",
|
||||
},
|
||||
{
|
||||
id: 1.7,
|
||||
label: "1.7x",
|
||||
description: "70% larger",
|
||||
},
|
||||
{
|
||||
id: 1.8,
|
||||
label: "1.8x",
|
||||
description: "80% larger",
|
||||
},
|
||||
{
|
||||
id: 1.9,
|
||||
label: "1.9x",
|
||||
description: "90% larger",
|
||||
},
|
||||
{
|
||||
id: 2.0,
|
||||
label: "2.0x",
|
||||
description: "Double size",
|
||||
},
|
||||
];
|
||||
|
||||
export const ScaleFactorSelector: React.FC<ScaleFactorSelectorProps> = ({
|
||||
currentScale,
|
||||
onScaleChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const handleScaleSelect = (scale: ScaleFactor) => {
|
||||
onScaleChange(scale);
|
||||
lightHapticFeedback();
|
||||
};
|
||||
|
||||
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||
return [
|
||||
{
|
||||
options: SCALE_FACTOR_OPTIONS.map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
selected: option.id === currentScale,
|
||||
onPress: () => handleScaleSelect(option.id),
|
||||
disabled,
|
||||
})),
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentScale, disabled]);
|
||||
|
||||
const trigger = useMemo(
|
||||
() => (
|
||||
<View
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||
>
|
||||
<Ionicons name='search-outline' size={24} color='white' />
|
||||
</View>
|
||||
),
|
||||
[disabled],
|
||||
);
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title='Scale Factor'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -6,18 +6,20 @@ import { formatTimeString } from "@/utils/time";
|
||||
interface TimeDisplayProps {
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
isVlc: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays current time and remaining time.
|
||||
* MPV player uses milliseconds for time values.
|
||||
*/
|
||||
export const TimeDisplay: FC<TimeDisplayProps> = ({
|
||||
currentTime,
|
||||
remainingTime,
|
||||
isVlc,
|
||||
}) => {
|
||||
const getFinishTime = () => {
|
||||
const now = new Date();
|
||||
const remainingMs = isVlc ? remainingTime : remainingTime * 1000;
|
||||
const finishTime = new Date(now.getTime() + remainingMs);
|
||||
// remainingTime is in ms
|
||||
const finishTime = new Date(now.getTime() + remainingTime);
|
||||
return finishTime.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
@@ -28,11 +30,11 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
||||
return (
|
||||
<View className='flex flex-row items-center justify-between mt-2'>
|
||||
<Text className='text-[12px] text-neutral-400'>
|
||||
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
||||
{formatTimeString(currentTime, "ms")}
|
||||
</Text>
|
||||
<View className='flex flex-col items-end'>
|
||||
<Text className='text-[12px] text-neutral-400'>
|
||||
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
||||
-{formatTimeString(remainingTime, "ms")}
|
||||
</Text>
|
||||
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
||||
ends at {getFinishTime()}
|
||||
|
||||
44
components/video-player/controls/ZoomToggle.tsx
Normal file
44
components/video-player/controls/ZoomToggle.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { ICON_SIZES } from "./constants";
|
||||
|
||||
interface ZoomToggleProps {
|
||||
isZoomedToFill: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ZoomToggle: React.FC<ZoomToggleProps> = ({
|
||||
isZoomedToFill,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const handlePress = () => {
|
||||
if (disabled) return;
|
||||
lightHapticFeedback();
|
||||
onToggle();
|
||||
};
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
disabled={disabled}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
>
|
||||
<View style={{ opacity: disabled ? 0.5 : 1 }}>
|
||||
<Ionicons
|
||||
name={isZoomedToFill ? "contract-outline" : "expand-outline"}
|
||||
size={ICON_SIZES.HEADER}
|
||||
color='white'
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import type React from "react";
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
|
||||
interface ControlContextProps {
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean | undefined;
|
||||
}
|
||||
|
||||
const ControlContext = createContext<ControlContextProps | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface ControlProviderProps {
|
||||
children: ReactNode;
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean | undefined;
|
||||
}
|
||||
|
||||
export const ControlProvider: React.FC<ControlProviderProps> = ({
|
||||
children,
|
||||
item,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
}) => {
|
||||
return (
|
||||
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
|
||||
{children}
|
||||
</ControlContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useControlContext = () => {
|
||||
const context = useContext(ControlContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useControlContext must be used within a ControlProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
115
components/video-player/controls/contexts/PlayerContext.tsx
Normal file
115
components/video-player/controls/contexts/PlayerContext.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import React, {
|
||||
createContext,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import type { SfPlayerViewRef, VlcPlayerViewRef } from "@/modules";
|
||||
|
||||
// Union type for both player refs
|
||||
type PlayerRef = SfPlayerViewRef | VlcPlayerViewRef;
|
||||
|
||||
interface PlayerContextProps {
|
||||
playerRef: MutableRefObject<PlayerRef | null>;
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean;
|
||||
tracksReady: boolean;
|
||||
}
|
||||
|
||||
const PlayerContext = createContext<PlayerContextProps | undefined>(undefined);
|
||||
|
||||
interface PlayerProviderProps {
|
||||
children: ReactNode;
|
||||
playerRef: MutableRefObject<PlayerRef | null>;
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean;
|
||||
tracksReady: boolean;
|
||||
}
|
||||
|
||||
export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
children,
|
||||
playerRef,
|
||||
item,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
tracksReady,
|
||||
}) => {
|
||||
const value = useMemo(
|
||||
() => ({ playerRef, item, mediaSource, isVideoLoaded, tracksReady }),
|
||||
[playerRef, item, mediaSource, isVideoLoaded, tracksReady],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Core context hook
|
||||
export const usePlayerContext = () => {
|
||||
const context = useContext(PlayerContext);
|
||||
if (!context)
|
||||
throw new Error("usePlayerContext must be used within PlayerProvider");
|
||||
return context;
|
||||
};
|
||||
|
||||
// Player controls hook - supports both SfPlayer (iOS) and VlcPlayer (Android)
|
||||
export const usePlayerControls = () => {
|
||||
const { playerRef } = usePlayerContext();
|
||||
|
||||
// Helper to get SfPlayer-specific ref (for iOS-only features)
|
||||
const getSfRef = () => playerRef.current as SfPlayerViewRef | null;
|
||||
|
||||
return {
|
||||
// Subtitle controls (both players support these, but with different interfaces)
|
||||
getSubtitleTracks: async () => {
|
||||
return playerRef.current?.getSubtitleTracks?.() ?? null;
|
||||
},
|
||||
setSubtitleTrack: (trackId: number) => {
|
||||
playerRef.current?.setSubtitleTrack?.(trackId);
|
||||
},
|
||||
// iOS only (SfPlayer)
|
||||
disableSubtitles: () => {
|
||||
getSfRef()?.disableSubtitles?.();
|
||||
},
|
||||
addSubtitleFile: (url: string, select = true) => {
|
||||
getSfRef()?.addSubtitleFile?.(url, select);
|
||||
},
|
||||
|
||||
// Audio controls (both players)
|
||||
getAudioTracks: async () => {
|
||||
return playerRef.current?.getAudioTracks?.() ?? null;
|
||||
},
|
||||
setAudioTrack: (trackId: number) => {
|
||||
playerRef.current?.setAudioTrack?.(trackId);
|
||||
},
|
||||
|
||||
// Playback controls (both players)
|
||||
play: () => playerRef.current?.play?.(),
|
||||
pause: () => playerRef.current?.pause?.(),
|
||||
seekTo: (position: number) => playerRef.current?.seekTo?.(position),
|
||||
// iOS only (SfPlayer)
|
||||
seekBy: (offset: number) => getSfRef()?.seekBy?.(offset),
|
||||
setSpeed: (speed: number) => getSfRef()?.setSpeed?.(speed),
|
||||
|
||||
// Subtitle positioning - iOS only (SfPlayer)
|
||||
setSubtitleScale: (scale: number) => getSfRef()?.setSubtitleScale?.(scale),
|
||||
setSubtitlePosition: (position: number) =>
|
||||
getSfRef()?.setSubtitlePosition?.(position),
|
||||
setSubtitleMarginY: (margin: number) =>
|
||||
getSfRef()?.setSubtitleMarginY?.(margin),
|
||||
setSubtitleFontSize: (size: number) =>
|
||||
getSfRef()?.setSubtitleFontSize?.(size),
|
||||
|
||||
// PiP (both players)
|
||||
startPictureInPicture: () => playerRef.current?.startPictureInPicture?.(),
|
||||
// iOS only (SfPlayer)
|
||||
stopPictureInPicture: () => getSfRef()?.stopPictureInPicture?.(),
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,51 @@
|
||||
/**
|
||||
* VideoContext.tsx
|
||||
*
|
||||
* Manages subtitle and audio track state for the video player UI.
|
||||
*
|
||||
* ============================================================================
|
||||
* ARCHITECTURE
|
||||
* ============================================================================
|
||||
*
|
||||
* - Jellyfin is source of truth for subtitle list (embedded + external)
|
||||
* - KSPlayer only knows about:
|
||||
* - Embedded subs it finds in the video stream
|
||||
* - External subs we explicitly add via addSubtitleFile()
|
||||
* - UI shows Jellyfin's complete list
|
||||
* - On selection: either select embedded track or load external URL
|
||||
*
|
||||
* ============================================================================
|
||||
* INDEX TYPES
|
||||
* ============================================================================
|
||||
*
|
||||
* 1. SERVER INDEX (sub.Index / track.index)
|
||||
* - Jellyfin's server-side stream index
|
||||
* - Used to report playback state to Jellyfin server
|
||||
* - Value of -1 means disabled/none
|
||||
*
|
||||
* 2. MPV INDEX (track.mpvIndex)
|
||||
* - KSPlayer's internal track ID
|
||||
* - KSPlayer orders tracks as: [all embedded, then all external]
|
||||
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
|
||||
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
|
||||
*
|
||||
* ============================================================================
|
||||
* SUBTITLE HANDLING
|
||||
* ============================================================================
|
||||
*
|
||||
* Embedded (DeliveryMethod.Embed):
|
||||
* - Already in KSPlayer's track list
|
||||
* - Select via setSubtitleTrack(mpvId)
|
||||
*
|
||||
* External (DeliveryMethod.External):
|
||||
* - Loaded into KSPlayer's srtControl on video start
|
||||
* - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
|
||||
*
|
||||
* Image-based during transcoding:
|
||||
* - Burned into video by Jellyfin, not in KSPlayer
|
||||
* - Requires replacePlayer() to change
|
||||
*/
|
||||
|
||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
@@ -9,52 +57,26 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
||||
import type { SfAudioTrack } from "@/modules";
|
||||
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
||||
import type { Track } from "../types";
|
||||
import { useControlContext } from "./ControlContext";
|
||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||
|
||||
interface VideoContextProps {
|
||||
audioTracks: Track[] | null;
|
||||
subtitleTracks: Track[] | null;
|
||||
setAudioTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||
audioTracks: Track[] | null;
|
||||
}
|
||||
|
||||
const VideoContext = createContext<VideoContextProps | undefined>(undefined);
|
||||
|
||||
interface VideoProviderProps {
|
||||
children: ReactNode;
|
||||
getAudioTracks:
|
||||
| (() => Promise<TrackInfo[] | null>)
|
||||
| (() => TrackInfo[])
|
||||
| undefined;
|
||||
getSubtitleTracks:
|
||||
| (() => Promise<TrackInfo[] | null>)
|
||||
| (() => TrackInfo[])
|
||||
| undefined;
|
||||
setAudioTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||
}
|
||||
|
||||
export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
getSubtitleTracks,
|
||||
getAudioTracks,
|
||||
setSubtitleTrack,
|
||||
setSubtitleURL,
|
||||
setAudioTrack,
|
||||
}) => {
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
|
||||
const ControlContext = useControlContext();
|
||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||
const mediaSource = ControlContext?.mediaSource;
|
||||
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||
const { tracksReady, mediaSource } = usePlayerContext();
|
||||
const playerControls = usePlayerControls();
|
||||
|
||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
@@ -66,185 +88,189 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
playbackPosition: string;
|
||||
}>();
|
||||
|
||||
const onTextBasedSubtitle = useMemo(() => {
|
||||
return (
|
||||
allSubs.find(
|
||||
(s) =>
|
||||
s.Index?.toString() === subtitleIndex &&
|
||||
(s.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
s.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
s.DeliveryMethod === SubtitleDeliveryMethod.External),
|
||||
) || subtitleIndex === "-1"
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||
const allAudio =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||
|
||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||
|
||||
/**
|
||||
* Check if the currently selected subtitle is image-based.
|
||||
* Used to determine if we need to refresh the player when changing subs.
|
||||
*/
|
||||
const isCurrentSubImageBased = useMemo(() => {
|
||||
if (subtitleIndex === "-1") return false;
|
||||
const currentSub = allSubs.find(
|
||||
(s) => s.Index?.toString() === subtitleIndex,
|
||||
);
|
||||
return currentSub ? isImageBasedSubtitle(currentSub) : false;
|
||||
}, [allSubs, subtitleIndex]);
|
||||
|
||||
const setPlayerParams = ({
|
||||
chosenAudioIndex = audioIndex,
|
||||
chosenSubtitleIndex = subtitleIndex,
|
||||
}: {
|
||||
chosenAudioIndex?: string;
|
||||
chosenSubtitleIndex?: string;
|
||||
/**
|
||||
* Refresh the player with new parameters.
|
||||
* This triggers Jellyfin to re-process the stream (e.g., burn in image subs).
|
||||
*/
|
||||
const replacePlayer = (params: {
|
||||
audioIndex?: string;
|
||||
subtitleIndex?: string;
|
||||
}) => {
|
||||
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId ?? "",
|
||||
audioIndex: chosenAudioIndex,
|
||||
subtitleIndex: chosenSubtitleIndex,
|
||||
audioIndex: params.audioIndex ?? audioIndex,
|
||||
subtitleIndex: params.subtitleIndex ?? subtitleIndex,
|
||||
mediaSourceId: mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue,
|
||||
playbackPosition: playbackPosition,
|
||||
}).toString();
|
||||
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
};
|
||||
|
||||
const setTrackParams = (
|
||||
type: "audio" | "subtitle",
|
||||
index: number,
|
||||
serverIndex: number,
|
||||
) => {
|
||||
const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack;
|
||||
const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex";
|
||||
|
||||
// If we're transcoding and we're going from a image based subtitle
|
||||
// to a text based subtitle, we need to change the player params.
|
||||
|
||||
const shouldChangePlayerParams =
|
||||
type === "subtitle" &&
|
||||
mediaSource?.TranscodingUrl &&
|
||||
!onTextBasedSubtitle;
|
||||
|
||||
console.log("Set player params", index, serverIndex);
|
||||
if (shouldChangePlayerParams) {
|
||||
setPlayerParams({
|
||||
chosenSubtitleIndex: serverIndex.toString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setTrack?.(serverIndex);
|
||||
router.setParams({
|
||||
[paramKey]: serverIndex.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch tracks when ready
|
||||
useEffect(() => {
|
||||
if (!tracksReady) return;
|
||||
|
||||
const fetchTracks = async () => {
|
||||
if (getSubtitleTracks) {
|
||||
let subtitleData: TrackInfo[] | null = null;
|
||||
try {
|
||||
subtitleData = await getSubtitleTracks();
|
||||
} catch (error) {
|
||||
console.log("[VideoContext] Failed to get subtitle tracks:", error);
|
||||
return;
|
||||
}
|
||||
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
|
||||
if (
|
||||
mediaSource?.TranscodingUrl &&
|
||||
subtitleData &&
|
||||
subtitleData.length > 1
|
||||
) {
|
||||
subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()];
|
||||
}
|
||||
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
||||
const playerAudio = (audioData as SfAudioTrack[]) ?? [];
|
||||
|
||||
let embedSubIndex = 1;
|
||||
const processedSubs: Track[] = allSubs?.map((sub) => {
|
||||
/** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */
|
||||
const shouldIncrement =
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||
/** The index of subtitle inside VLC Player Itself */
|
||||
const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1;
|
||||
if (shouldIncrement) embedSubIndex++;
|
||||
return {
|
||||
name: sub.DisplayTitle || "Undefined Subtitle",
|
||||
index: sub.Index ?? -1,
|
||||
setTrack: () =>
|
||||
shouldIncrement
|
||||
? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1)
|
||||
: setPlayerParams({
|
||||
chosenSubtitleIndex: sub.Index?.toString(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Step 3: Restore the original order
|
||||
const subtitles: Track[] = processedSubs.sort(
|
||||
(a, b) => a.index - b.index,
|
||||
// Separate embedded vs external subtitles from Jellyfin's list
|
||||
// KSPlayer orders tracks as: [all embedded, then all external]
|
||||
const embeddedSubs = allSubs.filter(
|
||||
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed,
|
||||
);
|
||||
const externalSubs = allSubs.filter(
|
||||
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.External,
|
||||
);
|
||||
|
||||
// Add a "Disable Subtitles" option
|
||||
subtitles.unshift({
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
setTrack: () =>
|
||||
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
|
||||
? setTrackParams("subtitle", -1, -1)
|
||||
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
|
||||
// Count embedded subs that will be in KSPlayer
|
||||
// (excludes image-based subs during transcoding as they're burned in)
|
||||
const embeddedInPlayer = embeddedSubs.filter(
|
||||
(s) => !isTranscoding || !isImageBasedSubtitle(s),
|
||||
);
|
||||
|
||||
const subs: Track[] = [];
|
||||
|
||||
// Process all Jellyfin subtitles
|
||||
for (const sub of allSubs) {
|
||||
const isEmbedded = sub.DeliveryMethod === SubtitleDeliveryMethod.Embed;
|
||||
const isExternal =
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||
|
||||
// For image-based subs during transcoding, need to refresh player
|
||||
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: -1,
|
||||
setTrack: () => {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
},
|
||||
});
|
||||
setSubtitleTracks(subtitles);
|
||||
continue;
|
||||
}
|
||||
if (getAudioTracks) {
|
||||
let audioData: TrackInfo[] | null = null;
|
||||
try {
|
||||
audioData = await getAudioTracks();
|
||||
} catch (error) {
|
||||
console.log("[VideoContext] Failed to get audio tracks:", error);
|
||||
|
||||
// Calculate KSPlayer track ID based on type
|
||||
// KSPlayer IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
|
||||
let mpvId = -1;
|
||||
|
||||
if (isEmbedded) {
|
||||
// Find position among embedded subs that are in player
|
||||
const embeddedPosition = embeddedInPlayer.findIndex(
|
||||
(s) => s.Index === sub.Index,
|
||||
);
|
||||
if (embeddedPosition !== -1) {
|
||||
mpvId = embeddedPosition + 1; // 1-based ID
|
||||
}
|
||||
} else if (isExternal) {
|
||||
// Find position among external subs, offset by embedded count
|
||||
const externalPosition = externalSubs.findIndex(
|
||||
(s) => s.Index === sub.Index,
|
||||
);
|
||||
if (externalPosition !== -1) {
|
||||
mpvId = embeddedInPlayer.length + externalPosition + 1;
|
||||
}
|
||||
}
|
||||
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: mpvId,
|
||||
setTrack: () => {
|
||||
// Transcoding + switching to/from image-based sub
|
||||
if (
|
||||
isTranscoding &&
|
||||
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
|
||||
) {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
return;
|
||||
}
|
||||
const allAudio =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
||||
if (!mediaSource?.TranscodingUrl) {
|
||||
const vlcIndex = audioData?.at(idx + 1)?.index ?? -1;
|
||||
return {
|
||||
name: audio.DisplayTitle ?? "Undefined Audio",
|
||||
index: audio.Index ?? -1,
|
||||
setTrack: () =>
|
||||
setTrackParams("audio", vlcIndex, audio.Index ?? -1),
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: audio.DisplayTitle ?? "Undefined Audio",
|
||||
index: audio.Index ?? -1,
|
||||
setTrack: () =>
|
||||
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
|
||||
};
|
||||
});
|
||||
|
||||
// Add a "Disable Audio" option if its not transcoding.
|
||||
if (!mediaSource?.TranscodingUrl) {
|
||||
audioTracks.unshift({
|
||||
// Direct switch in player
|
||||
if (mpvId !== -1) {
|
||||
playerControls.setSubtitleTrack(mpvId);
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback - refresh player
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add "Disable" option at the beginning
|
||||
subs.unshift({
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
setTrack: () => setTrackParams("audio", -1, -1),
|
||||
mpvIndex: -1,
|
||||
setTrack: () => {
|
||||
if (isTranscoding && isCurrentSubImageBased) {
|
||||
replacePlayer({ subtitleIndex: "-1" });
|
||||
} else {
|
||||
playerControls.setSubtitleTrack(-1);
|
||||
router.setParams({ subtitleIndex: "-1" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Process audio tracks
|
||||
const audio: Track[] = allAudio.map((a, idx) => {
|
||||
const playerTrack = playerAudio[idx];
|
||||
const mpvId = playerTrack?.id ?? idx + 1;
|
||||
|
||||
return {
|
||||
name: a.DisplayTitle || "Unknown",
|
||||
index: a.Index ?? -1,
|
||||
mpvIndex: mpvId,
|
||||
setTrack: () => {
|
||||
if (isTranscoding) {
|
||||
replacePlayer({ audioIndex: String(a.Index) });
|
||||
return;
|
||||
}
|
||||
setAudioTracks(audioTracks);
|
||||
}
|
||||
playerControls.setAudioTrack(mpvId);
|
||||
router.setParams({ audioIndex: String(a.Index) });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||
setAudioTracks(audio);
|
||||
};
|
||||
|
||||
fetchTracks();
|
||||
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
|
||||
}, [tracksReady, mediaSource]);
|
||||
|
||||
return (
|
||||
<VideoContext.Provider
|
||||
value={{
|
||||
audioTracks,
|
||||
subtitleTracks,
|
||||
setSubtitleTrack,
|
||||
setSubtitleURL,
|
||||
setAudioTrack,
|
||||
}}
|
||||
>
|
||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||
{children}
|
||||
</VideoContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useVideoContext = () => {
|
||||
const context = useContext(VideoContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useVideoContext must be used within a VideoProvider");
|
||||
}
|
||||
return context;
|
||||
const ctx = useContext(VideoContext);
|
||||
if (!ctx)
|
||||
throw new Error("useVideoContext must be used within VideoProvider");
|
||||
return ctx;
|
||||
};
|
||||
|
||||
@@ -7,17 +7,26 @@ import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import { useControlContext } from "../contexts/ControlContext";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { usePlayerContext } from "../contexts/PlayerContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
|
||||
// Subtitle size presets (stored as scale * 100, so 1.0 = 100)
|
||||
const SUBTITLE_SIZE_PRESETS = [
|
||||
{ label: "0.5", value: 50 },
|
||||
{ label: "0.6", value: 60 },
|
||||
{ label: "0.7", value: 70 },
|
||||
{ label: "0.8", value: 80 },
|
||||
{ label: "0.9", value: 90 },
|
||||
{ label: "1.0", value: 100 },
|
||||
{ label: "1.1", value: 110 },
|
||||
{ label: "1.2", value: 120 },
|
||||
] as const;
|
||||
|
||||
const DropdownView = () => {
|
||||
const videoContext = useVideoContext();
|
||||
const { subtitleTracks, audioTracks } = videoContext;
|
||||
const ControlContext = useControlContext();
|
||||
const [item, mediaSource] = [
|
||||
ControlContext?.item,
|
||||
ControlContext?.mediaSource,
|
||||
];
|
||||
const { subtitleTracks, audioTracks } = useVideoContext();
|
||||
const { item, mediaSource } = usePlayerContext();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
||||
@@ -100,6 +109,18 @@ const DropdownView = () => {
|
||||
onPress: () => sub.setTrack(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Subtitle Size Section
|
||||
groups.push({
|
||||
title: "Subtitle Size",
|
||||
options: SUBTITLE_SIZE_PRESETS.map((preset) => ({
|
||||
type: "radio" as const,
|
||||
label: preset.label,
|
||||
value: preset.value.toString(),
|
||||
selected: settings.subtitleSize === preset.value,
|
||||
onPress: () => updateSettings({ subtitleSize: preset.value }),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Audio Section
|
||||
@@ -126,6 +147,8 @@ const DropdownView = () => {
|
||||
audioTracksKey,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
settings.subtitleSize,
|
||||
updateSettings,
|
||||
// Note: subtitleTracks and audioTracks are intentionally excluded
|
||||
// because we use subtitleTracksKey and audioTracksKey for stability
|
||||
]);
|
||||
@@ -148,6 +171,7 @@ const DropdownView = () => {
|
||||
title='Playback Options'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
expoUIConfig={{}}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
|
||||
@@ -22,7 +22,6 @@ interface UseRemoteControlProps {
|
||||
progress: SharedValue<number>;
|
||||
min: SharedValue<number>;
|
||||
max: SharedValue<number>;
|
||||
isVlc: boolean;
|
||||
showControls: boolean;
|
||||
isPlaying: boolean;
|
||||
seek: (value: number) => void;
|
||||
@@ -34,11 +33,14 @@ interface UseRemoteControlProps {
|
||||
handleSeekBackward: (seconds: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage TV remote control interactions.
|
||||
* MPV player uses milliseconds for time values.
|
||||
*/
|
||||
export function useRemoteControl({
|
||||
progress,
|
||||
min,
|
||||
max,
|
||||
isVlc,
|
||||
showControls,
|
||||
isPlaying,
|
||||
seek,
|
||||
@@ -61,21 +63,18 @@ export function useRemoteControl({
|
||||
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const SCRUB_INTERVAL = isVlc
|
||||
? CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS
|
||||
: CONTROLS_CONSTANTS.SCRUB_INTERVAL_TICKS;
|
||||
// MPV uses ms
|
||||
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
|
||||
|
||||
const updateTime = useCallback(
|
||||
(progressValue: number) => {
|
||||
const progressInTicks = isVlc ? msToTicks(progressValue) : progressValue;
|
||||
const updateTime = useCallback((progressValue: number) => {
|
||||
// Convert ms to ticks for calculation
|
||||
const progressInTicks = msToTicks(progressValue);
|
||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTime({ hours, minutes, seconds });
|
||||
},
|
||||
[isVlc],
|
||||
);
|
||||
}, []);
|
||||
|
||||
// TV remote control handling (no-op on non-TV platforms)
|
||||
useTVEventHandler((evt) => {
|
||||
@@ -102,7 +101,8 @@ export function useRemoteControl({
|
||||
Math.min(max.value, base + direction * SCRUB_INTERVAL),
|
||||
);
|
||||
remoteScrubProgress.value = updated;
|
||||
const progressInTicks = isVlc ? msToTicks(updated) : updated;
|
||||
// Convert ms to ticks for trickplay
|
||||
const progressInTicks = msToTicks(updated);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
updateTime(updated);
|
||||
break;
|
||||
@@ -111,9 +111,8 @@ export function useRemoteControl({
|
||||
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
|
||||
progress.value = remoteScrubProgress.value;
|
||||
|
||||
const seekTarget = isVlc
|
||||
? Math.max(0, remoteScrubProgress.value)
|
||||
: Math.max(0, ticksToSeconds(remoteScrubProgress.value));
|
||||
// MPV uses ms, seek expects ms
|
||||
const seekTarget = Math.max(0, remoteScrubProgress.value);
|
||||
|
||||
seek(seekTarget);
|
||||
if (isPlaying) play();
|
||||
|
||||
@@ -3,20 +3,22 @@ import type { SharedValue } from "react-native-reanimated";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { secondsToMs, ticksToSeconds } from "@/utils/time";
|
||||
import { secondsToMs } from "@/utils/time";
|
||||
|
||||
interface UseVideoNavigationProps {
|
||||
progress: SharedValue<number>;
|
||||
isPlaying: boolean;
|
||||
isVlc: boolean;
|
||||
seek: (value: number) => void;
|
||||
play: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage video navigation (seeking forward/backward).
|
||||
* MPV player uses milliseconds for time values.
|
||||
*/
|
||||
export function useVideoNavigation({
|
||||
progress,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
seek,
|
||||
play,
|
||||
}: UseVideoNavigationProps) {
|
||||
@@ -30,16 +32,15 @@ export function useVideoNavigation({
|
||||
try {
|
||||
const curr = progress.value;
|
||||
if (curr !== undefined) {
|
||||
const newTime = isVlc
|
||||
? Math.max(0, curr - secondsToMs(seconds))
|
||||
: Math.max(0, ticksToSeconds(curr) - seconds);
|
||||
// MPV uses ms
|
||||
const newTime = Math.max(0, curr - secondsToMs(seconds));
|
||||
seek(newTime);
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||
}
|
||||
},
|
||||
[isPlaying, isVlc, seek, progress],
|
||||
[isPlaying, seek, progress],
|
||||
);
|
||||
|
||||
const handleSeekForward = useCallback(
|
||||
@@ -48,16 +49,15 @@ export function useVideoNavigation({
|
||||
try {
|
||||
const curr = progress.value;
|
||||
if (curr !== undefined) {
|
||||
const newTime = isVlc
|
||||
? curr + secondsToMs(seconds)
|
||||
: ticksToSeconds(curr) + seconds;
|
||||
// MPV uses ms
|
||||
const newTime = curr + secondsToMs(seconds);
|
||||
seek(Math.max(0, newTime));
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||
}
|
||||
},
|
||||
[isPlaying, isVlc, seek, progress],
|
||||
[isPlaying, seek, progress],
|
||||
);
|
||||
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
@@ -69,9 +69,11 @@ export function useVideoNavigation({
|
||||
try {
|
||||
const curr = progress.value;
|
||||
if (curr !== undefined) {
|
||||
const newTime = isVlc
|
||||
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
|
||||
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
|
||||
// MPV uses ms
|
||||
const newTime = Math.max(
|
||||
0,
|
||||
curr - secondsToMs(settings.rewindSkipTime),
|
||||
);
|
||||
seek(newTime);
|
||||
if (wasPlayingRef.current) {
|
||||
play();
|
||||
@@ -80,7 +82,7 @@ export function useVideoNavigation({
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||
}
|
||||
}, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]);
|
||||
}, [settings, isPlaying, play, seek, progress, lightHapticFeedback]);
|
||||
|
||||
const handleSkipForward = useCallback(async () => {
|
||||
if (!settings?.forwardSkipTime) {
|
||||
@@ -91,9 +93,8 @@ export function useVideoNavigation({
|
||||
try {
|
||||
const curr = progress.value;
|
||||
if (curr !== undefined) {
|
||||
const newTime = isVlc
|
||||
? curr + secondsToMs(settings.forwardSkipTime)
|
||||
: ticksToSeconds(curr) + settings.forwardSkipTime;
|
||||
// MPV uses ms
|
||||
const newTime = curr + secondsToMs(settings.forwardSkipTime);
|
||||
seek(Math.max(0, newTime));
|
||||
if (wasPlayingRef.current) {
|
||||
play();
|
||||
@@ -102,7 +103,7 @@ export function useVideoNavigation({
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||
}
|
||||
}, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]);
|
||||
}, [settings, isPlaying, play, seek, progress, lightHapticFeedback]);
|
||||
|
||||
return {
|
||||
handleSeekBackward,
|
||||
|
||||
@@ -8,7 +8,6 @@ interface UseVideoSliderProps {
|
||||
progress: SharedValue<number>;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
isPlaying: boolean;
|
||||
isVlc: boolean;
|
||||
seek: (value: number) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
@@ -16,11 +15,14 @@ interface UseVideoSliderProps {
|
||||
showControls: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage video slider interactions.
|
||||
* MPV player uses milliseconds for time values.
|
||||
*/
|
||||
export function useVideoSlider({
|
||||
progress,
|
||||
isSeeking,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
seek,
|
||||
play,
|
||||
pause,
|
||||
@@ -62,21 +64,20 @@ export function useVideoSlider({
|
||||
setIsSliding(false);
|
||||
isSeeking.value = false;
|
||||
progress.value = value;
|
||||
const seekValue = Math.max(
|
||||
0,
|
||||
Math.floor(isVlc ? value : ticksToSeconds(value)),
|
||||
);
|
||||
// MPV uses ms, seek expects ms
|
||||
const seekValue = Math.max(0, Math.floor(value));
|
||||
seek(seekValue);
|
||||
if (wasPlayingRef.current) {
|
||||
play();
|
||||
}
|
||||
},
|
||||
[isVlc, seek, play, progress, isSeeking],
|
||||
[seek, play, progress, isSeeking],
|
||||
);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
debounce((value: number) => {
|
||||
const progressInTicks = isVlc ? msToTicks(value) : value;
|
||||
// Convert ms to ticks for trickplay
|
||||
const progressInTicks = msToTicks(value);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
@@ -84,7 +85,7 @@ export function useVideoSlider({
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTime({ hours, minutes, seconds });
|
||||
}, CONTROLS_CONSTANTS.SLIDER_DEBOUNCE_MS),
|
||||
[isVlc, calculateTrickplayUrl],
|
||||
[calculateTrickplayUrl],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,21 +4,18 @@ import {
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
} from "react-native-reanimated";
|
||||
import { ticksToSeconds } from "@/utils/time";
|
||||
|
||||
interface UseVideoTimeProps {
|
||||
progress: SharedValue<number>;
|
||||
max: SharedValue<number>;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
isVlc: boolean;
|
||||
}
|
||||
|
||||
export function useVideoTime({
|
||||
progress,
|
||||
max,
|
||||
isSeeking,
|
||||
isVlc,
|
||||
}: UseVideoTimeProps) {
|
||||
/**
|
||||
* Hook to manage video time display.
|
||||
* MPV player uses milliseconds for time values.
|
||||
*/
|
||||
export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
||||
|
||||
@@ -27,19 +24,16 @@ export function useVideoTime({
|
||||
|
||||
const updateTimes = useCallback(
|
||||
(currentProgress: number, maxValue: number) => {
|
||||
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
|
||||
const remaining = isVlc
|
||||
? maxValue - currentProgress
|
||||
: ticksToSeconds(maxValue - currentProgress);
|
||||
// MPV uses milliseconds
|
||||
const current = currentProgress;
|
||||
const remaining = maxValue - currentProgress;
|
||||
|
||||
// Only update state if the displayed time actually changed (avoid sub-second updates)
|
||||
const currentSeconds = Math.floor(current / (isVlc ? 1000 : 1));
|
||||
const remainingSeconds = Math.floor(remaining / (isVlc ? 1000 : 1));
|
||||
const lastCurrentSeconds = Math.floor(
|
||||
lastCurrentTimeRef.current / (isVlc ? 1000 : 1),
|
||||
);
|
||||
const currentSeconds = Math.floor(current / 1000);
|
||||
const remainingSeconds = Math.floor(remaining / 1000);
|
||||
const lastCurrentSeconds = Math.floor(lastCurrentTimeRef.current / 1000);
|
||||
const lastRemainingSeconds = Math.floor(
|
||||
lastRemainingTimeRef.current / (isVlc ? 1000 : 1),
|
||||
lastRemainingTimeRef.current / 1000,
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -52,7 +46,7 @@ export function useVideoTime({
|
||||
lastRemainingTimeRef.current = remaining;
|
||||
}
|
||||
},
|
||||
[isVlc],
|
||||
[],
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user