mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-16 17:33:00 +01:00
Compare commits
2 Commits
fix/ui-hea
...
jellyfin-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b39eb35795 | ||
|
|
fbeb025d73 |
@@ -73,6 +73,7 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
@@ -157,6 +158,7 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { File, Paths } from "expo-file-system";
|
import { File, Paths } from "expo-file-system";
|
||||||
import { requireOptionalNativeModule } from "expo-modules-core";
|
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import type * as SharingType from "expo-sharing";
|
import type * as SharingType from "expo-sharing";
|
||||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
@@ -8,7 +6,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import Collapsible from "react-native-collapsible";
|
import Collapsible from "react-native-collapsible";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -75,25 +72,6 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}, [filteredLogs, Sharing]);
|
}, [filteredLogs, Sharing]);
|
||||||
|
|
||||||
const copyLog = useCallback(
|
|
||||||
async (log: NonNullable<typeof logs>[number]) => {
|
|
||||||
// Skip on builds that don't ship the expo-clipboard native module
|
|
||||||
// (probe returns null instead of throwing); same guard as Quick Connect.
|
|
||||||
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
|
||||||
const Clipboard = await import("expo-clipboard");
|
|
||||||
const text = [
|
|
||||||
`[${log.level}] ${new Date(log.timestamp).toLocaleString()}`,
|
|
||||||
log.message,
|
|
||||||
log.data ? JSON.stringify(log.data, null, 2) : null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
await Clipboard.setStringAsync(text);
|
|
||||||
toast.success(t("home.settings.logs.copied"));
|
|
||||||
},
|
|
||||||
[logs, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -110,15 +88,8 @@ export default function Page() {
|
|||||||
}, [share, loading]);
|
}, [share, loading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<View className='flex-1'>
|
||||||
// Like the sibling settings pages, let iOS auto-inset the content below the
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||||
// transparent header (no manual header-height math). The filter bar is a
|
|
||||||
// sticky header so it stays pinned just under the header while logs scroll.
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
stickyHeaderIndices={[0]}
|
|
||||||
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
|
||||||
>
|
|
||||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2 bg-black'>
|
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
queryKey='log'
|
queryKey='log'
|
||||||
@@ -141,77 +112,67 @@ export default function Page() {
|
|||||||
multiple={true}
|
multiple={true}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 px-4'>
|
<ScrollView
|
||||||
{filteredLogs?.map((log, index) => (
|
className='pb-4 px-4'
|
||||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||||
<TouchableOpacity
|
>
|
||||||
disabled={!log.data}
|
<View className='flex flex-col space-y-2'>
|
||||||
onPress={() =>
|
{filteredLogs?.map((log, index) => (
|
||||||
setState((v) => ({
|
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||||
...v,
|
<TouchableOpacity
|
||||||
[log.timestamp]: !v[log.timestamp],
|
disabled={!log.data}
|
||||||
}))
|
onPress={() =>
|
||||||
}
|
setState((v) => ({
|
||||||
>
|
...v,
|
||||||
<View className='flex flex-row justify-between'>
|
[log.timestamp]: !v[log.timestamp],
|
||||||
<Text
|
}))
|
||||||
className={`mb-1
|
}
|
||||||
|
>
|
||||||
|
<View className='flex flex-row justify-between'>
|
||||||
|
<Text
|
||||||
|
className={`mb-1
|
||||||
${log.level === "INFO" && "text-blue-500"}
|
${log.level === "INFO" && "text-blue-500"}
|
||||||
${log.level === "ERROR" && "text-red-500"}
|
${log.level === "ERROR" && "text-red-500"}
|
||||||
${log.level === "DEBUG" && "text-purple-500"}
|
${log.level === "DEBUG" && "text-purple-500"}
|
||||||
`}
|
`}
|
||||||
>
|
|
||||||
{log.level}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className='text-xs'>
|
|
||||||
{new Date(log.timestamp).toLocaleString()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text className='text-xs'>{log.message}</Text>
|
|
||||||
{/* Keep the whole collapsed row tappable: the hint lives inside
|
|
||||||
the toggle so tapping it expands too. */}
|
|
||||||
{log.data && !state[log.timestamp] && (
|
|
||||||
<Text className='text-xs mt-0.5'>
|
|
||||||
{t("home.settings.logs.click_for_more_info")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{log.data && (
|
|
||||||
<Collapsible collapsed={!state[log.timestamp]}>
|
|
||||||
<View className='mt-2 flex flex-col space-y-2'>
|
|
||||||
<ScrollView
|
|
||||||
className='rounded-xl'
|
|
||||||
style={codeBlockStyle}
|
|
||||||
nestedScrollEnabled
|
|
||||||
>
|
>
|
||||||
{/* Only the raw payload is selectable (per request); the
|
{log.level}
|
||||||
header/message stay tap-to-toggle. */}
|
</Text>
|
||||||
<Text selectable>{JSON.stringify(log.data, null, 2)}</Text>
|
|
||||||
</ScrollView>
|
<Text className='text-xs'>
|
||||||
{!Platform.isTV && (
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
<TouchableOpacity
|
</Text>
|
||||||
onPress={() => copyLog(log)}
|
|
||||||
className='flex flex-row items-center self-end px-2 py-1'
|
|
||||||
>
|
|
||||||
<Ionicons name='copy-outline' size={16} color='white' />
|
|
||||||
<Text className='text-xs ml-1'>
|
|
||||||
{t("home.settings.logs.copy")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Collapsible>
|
<Text selectable className='text-xs'>
|
||||||
)}
|
{log.message}
|
||||||
</View>
|
</Text>
|
||||||
))}
|
</TouchableOpacity>
|
||||||
{filteredLogs?.length === 0 && (
|
|
||||||
<Text className='opacity-50'>
|
{log.data && (
|
||||||
{t("home.settings.logs.no_logs_available")}
|
<>
|
||||||
</Text>
|
{!state[log.timestamp] && (
|
||||||
)}
|
<Text className='text-xs mt-0.5'>
|
||||||
</View>
|
{t("home.settings.logs.click_for_more_info")}
|
||||||
</ScrollView>
|
</Text>
|
||||||
|
)}
|
||||||
|
<Collapsible collapsed={!state[log.timestamp]}>
|
||||||
|
<View className='mt-2 flex flex-col space-y-2'>
|
||||||
|
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
||||||
|
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Collapsible>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{filteredLogs?.length === 0 && (
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.settings.logs.no_logs_available")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,8 +85,7 @@ configureReanimatedLogger({
|
|||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowBanner: true,
|
shouldShowAlert: true,
|
||||||
shouldShowList: true,
|
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -351,12 +350,9 @@ function Layout() {
|
|||||||
notificationListener.current =
|
notificationListener.current =
|
||||||
Notifications?.addNotificationReceivedListener(
|
Notifications?.addNotificationReceivedListener(
|
||||||
(notification: Notification) => {
|
(notification: Notification) => {
|
||||||
// Log only the title — serializing the whole notification touches
|
|
||||||
// the deprecated dataString getter (deprecation warning) and dumps
|
|
||||||
// noisy payloads into the console.
|
|
||||||
console.log(
|
console.log(
|
||||||
"Notification received while app running:",
|
"Notification received while app running",
|
||||||
notification.request.content.title,
|
notification,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -31,7 +31,6 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.18",
|
"expo-build-properties": "~56.0.18",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
"expo-clipboard": "~56.0.4",
|
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
@@ -1002,8 +1001,6 @@
|
|||||||
|
|
||||||
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
||||||
|
|
||||||
"expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="],
|
|
||||||
|
|
||||||
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
||||||
|
|
||||||
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
||||||
@@ -23,7 +23,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
className='flex items-center justify-center w-9 h-9'
|
className='flex items-center justify-center w-9 h-9'
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Ionicons name='arrow-back' size={24} color='white' />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -36,10 +36,10 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
intensity={100}
|
intensity={100}
|
||||||
className='overflow-hidden rounded-full p-2'
|
className='overflow-hidden rounded-full p-2'
|
||||||
>
|
>
|
||||||
<Feather
|
<Ionicons
|
||||||
className='drop-shadow-2xl'
|
className='drop-shadow-2xl'
|
||||||
name='chevron-left'
|
name='arrow-back'
|
||||||
size={28}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
@@ -49,16 +49,13 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
// Match the Settings page back button: chevron flush to the edge with a
|
className=' rounded-full p-2'
|
||||||
// 16px gap before the title (the old `p-2` pushed both arrow and title
|
|
||||||
// too far right). drop-shadow keeps it readable over images.
|
|
||||||
style={{ marginRight: 16 }}
|
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
>
|
>
|
||||||
<Feather
|
<Ionicons
|
||||||
className='drop-shadow-2xl'
|
className='drop-shadow-2xl'
|
||||||
name='chevron-left'
|
name='arrow-back'
|
||||||
size={28}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
|
||||||
// instead of freezing the first-rendered series' image (empty deps bug).
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
const seriesId = items[0]?.SeriesId;
|
return storage.getString(items[0].SeriesId!);
|
||||||
return seriesId ? storage.getString(seriesId) : undefined;
|
}, []);
|
||||||
}, [items[0]?.SeriesId]);
|
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
const deleteSeries = useCallback(
|
||||||
async () =>
|
async () =>
|
||||||
|
|||||||
@@ -221,22 +221,33 @@ const HomeMobile = () => {
|
|||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (!api) return [];
|
if (!api) return [];
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
// Use getItems (not getLatestMedia) so we get item-level results
|
||||||
const allData =
|
// filtered by type from a specific library. getLatestMedia is
|
||||||
(
|
// episode-oriented and groups results, which drops Series when
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
// combined with a parentId + includeItemTypes filter.
|
||||||
userId: user?.Id,
|
//
|
||||||
limit: 10,
|
// The specific reason for this is jellyfin 12.0 returns episodes, seasons, or shows,
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
// but we only handle shows in our recently added in [shows] section. So we need to filter by type at the item level.
|
||||||
imageTypeLimit: 1,
|
//
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
// For Series we sort by DateLastContentAdded so shows bubble up when
|
||||||
includeItemTypes,
|
// a new episode is added (series cards for new episodes, matching how
|
||||||
parentId,
|
// Jellyfin's "Latest" row worked pre-12.0). Movies use DateCreated.
|
||||||
})
|
const response = await getItemsApi(api).getItems({
|
||||||
).data || [];
|
userId: user?.Id,
|
||||||
|
parentId,
|
||||||
// Simulate pagination by slicing
|
includeItemTypes,
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
recursive: true,
|
||||||
|
sortBy: includeItemTypes.includes("Series")
|
||||||
|
? ["DateLastContentAdded"]
|
||||||
|
: ["DateCreated"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: pageSize,
|
||||||
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
},
|
},
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -250,9 +261,7 @@ const HomeMobile = () => {
|
|||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
const latestMediaViews = collections.map((c) => {
|
||||||
const includeItemTypes: BaseItemKind[] =
|
const includeItemTypes: BaseItemKind[] =
|
||||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||||
? []
|
|
||||||
: ["Movie"];
|
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
const queryKey: string[] = [
|
const queryKey: string[] = [
|
||||||
"home",
|
"home",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
|
|||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
|
|||||||
used: (app.total - app.remaining) / app.total,
|
used: (app.total - app.remaining) / app.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// Keep the bar moving while a download is writing to disk.
|
|
||||||
refetchInterval: 10 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteClicked = () => {
|
const onDeleteClicked = async () => {
|
||||||
Alert.alert(
|
try {
|
||||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
await deleteAllFiles();
|
||||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
successHapticFeedback();
|
||||||
[
|
} catch (_e) {
|
||||||
{
|
errorHapticFeedback();
|
||||||
text: t("common.cancel"),
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
style: "cancel",
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.ok"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => {
|
|
||||||
try {
|
|
||||||
await deleteAllFiles();
|
|
||||||
successHapticFeedback();
|
|
||||||
} catch (_e) {
|
|
||||||
errorHapticFeedback();
|
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
|
||||||
} finally {
|
|
||||||
// Reflect the freed space immediately instead of waiting for
|
|
||||||
// the next poll.
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculatePercentage = (value: number, total: number) => {
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
|
|||||||
@@ -54,7 +54,6 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.18",
|
"expo-build-properties": "~56.0.18",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
"expo-clipboard": "~56.0.4",
|
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
|
|||||||
@@ -96,24 +96,5 @@ export function getDownloadedItemSize(id: string): number {
|
|||||||
*/
|
*/
|
||||||
export function calculateTotalDownloadedSize(): number {
|
export function calculateTotalDownloadedSize(): number {
|
||||||
const items = getAllDownloadedItems();
|
const items = getAllDownloadedItems();
|
||||||
return items.reduce((sum, item) => {
|
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||||
// Trickplay bytes count too — getDownloadedItemSize models per-item size
|
|
||||||
// as video + trickplay, the total must match.
|
|
||||||
const trickplaySize = item.trickPlayData?.size ?? 0;
|
|
||||||
// Read the live file size on disk so the total reflects actual usage and
|
|
||||||
// self-heals items whose stored videoFileSize is 0 (old schema, or
|
|
||||||
// `fileInfo.size` was undefined at download time). Fall back to the stored
|
|
||||||
// value if the file can't be stat'd.
|
|
||||||
if (item.videoFilePath) {
|
|
||||||
try {
|
|
||||||
const file = new File(filePathToUri(item.videoFilePath));
|
|
||||||
if (file.exists) {
|
|
||||||
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to stat downloaded file for size:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sum + (item.videoFileSize ?? 0) + trickplaySize;
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,24 +289,7 @@ export function useDownloadOperations({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const appSizeUsage = useCallback(async () => {
|
const appSizeUsage = useCallback(async () => {
|
||||||
let totalSize = calculateTotalDownloadedSize();
|
const totalSize = calculateTotalDownloadedSize();
|
||||||
|
|
||||||
// Also count in-progress downloads (they write straight to their final
|
|
||||||
// path) so the growing file shows up as app usage instead of drifting
|
|
||||||
// into the generic device share until completion.
|
|
||||||
for (const process of processes) {
|
|
||||||
try {
|
|
||||||
const file = new File(
|
|
||||||
Paths.document,
|
|
||||||
`${generateFilename(process.item)}.mp4`,
|
|
||||||
);
|
|
||||||
if (file.exists) {
|
|
||||||
totalSize += file.size ?? 0;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// File not created yet — ignore.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||||
@@ -327,7 +310,7 @@ export function useDownloadOperations({
|
|||||||
appSize: totalSize,
|
appSize: totalSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [processes]);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const protocol = api.basePath.includes("https") ? "wss" : "ws";
|
const protocol = api.basePath.includes("https") ? "wss" : "ws";
|
||||||
const url = `${protocol}://${api.basePath
|
const url = `${protocol}://${api.basePath
|
||||||
.replace("https://", "")
|
.replace("https://", "")
|
||||||
.replace("http://", "")}/socket?api_key=${
|
.replace("http://", "")}/socket?ApiKey=${
|
||||||
api.accessToken
|
api.accessToken
|
||||||
}&deviceId=${deviceId}`;
|
}&deviceId=${deviceId}`;
|
||||||
|
|
||||||
|
|||||||
@@ -384,8 +384,6 @@
|
|||||||
"device_usage": "Device {{availableSpace}}%",
|
"device_usage": "Device {{availableSpace}}%",
|
||||||
"size_used": "{{used}} of {{total}} used",
|
"size_used": "{{used}} of {{total}} used",
|
||||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
"delete_all_downloaded_files": "Delete all downloaded files",
|
||||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
|
||||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
|
||||||
"music_cache_title": "Music cache",
|
"music_cache_title": "Music cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear music cache",
|
"clear_music_cache": "Clear music cache",
|
||||||
@@ -410,9 +408,7 @@
|
|||||||
"click_for_more_info": "Click for more info",
|
"click_for_more_info": "Click for more info",
|
||||||
"level": "Level",
|
"level": "Level",
|
||||||
"no_logs_available": "No logs available",
|
"no_logs_available": "No logs available",
|
||||||
"delete_all_logs": "Delete all logs",
|
"delete_all_logs": "Delete all logs"
|
||||||
"copy": "Copy",
|
|
||||||
"copied": "Copied to clipboard"
|
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Languages",
|
"title": "Languages",
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const getAudioStreamUrl = async (
|
|||||||
container: mediaSource?.Container || "mp3",
|
container: mediaSource?.Container || "mp3",
|
||||||
mediaSourceId: mediaSource?.Id || "",
|
mediaSourceId: mediaSource?.Id || "",
|
||||||
deviceId: api.deviceInfo.id,
|
deviceId: api.deviceInfo.id,
|
||||||
api_key: api.accessToken,
|
ApiKey: api.accessToken,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const getDownloadUrl = async ({
|
|||||||
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
|
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
|
||||||
console.log("Downloading item directly");
|
console.log("Downloading item directly");
|
||||||
return {
|
return {
|
||||||
url: `${api.basePath}/Items/${mediaSource.Id}/Download?api_key=${api.accessToken}`,
|
url: `${api.basePath}/Items/${mediaSource.Id}/Download?ApiKey=${api.accessToken}`,
|
||||||
mediaSource: streamDetails?.mediaSource ?? null,
|
mediaSource: streamDetails?.mediaSource ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const getPlaybackUrl = async (
|
|||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
deviceId: api.deviceInfo?.id || "",
|
deviceId: api.deviceInfo?.id || "",
|
||||||
api_key: api.accessToken || "",
|
ApiKey: api.accessToken || "",
|
||||||
Tag: ETag || "",
|
Tag: ETag || "",
|
||||||
MediaSourceId: Id || "",
|
MediaSourceId: Id || "",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const getPlaybackUrl = (
|
|||||||
subtitleStreamIndex: params.subtitleStreamIndex?.toString() || "",
|
subtitleStreamIndex: params.subtitleStreamIndex?.toString() || "",
|
||||||
audioStreamIndex: params.audioStreamIndex?.toString() || "",
|
audioStreamIndex: params.audioStreamIndex?.toString() || "",
|
||||||
deviceId: params.deviceId || api.deviceInfo.id,
|
deviceId: params.deviceId || api.deviceInfo.id,
|
||||||
api_key: api.accessToken,
|
ApiKey: api.accessToken,
|
||||||
startTimeTicks: params.startTimeTicks?.toString() || "0",
|
startTimeTicks: params.startTimeTicks?.toString() || "0",
|
||||||
maxStreamingBitrate: params.maxStreamingBitrate?.toString() || "",
|
maxStreamingBitrate: params.maxStreamingBitrate?.toString() || "",
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
|
|||||||
@@ -61,5 +61,5 @@ export const generateTrickplayUrl = (item: BaseItemDto, sheetIndex: number) => {
|
|||||||
const api = store.get(apiAtom);
|
const api = store.get(apiAtom);
|
||||||
const resolution = getTrickplayInfo(item)?.resolution;
|
const resolution = getTrickplayInfo(item)?.resolution;
|
||||||
if (!resolution || !api) return null;
|
if (!resolution || !api) return null;
|
||||||
return `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${sheetIndex}.jpg?api_key=${api.accessToken}`;
|
return `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${sheetIndex}.jpg?ApiKey=${api.accessToken}`;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user