mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-19 02:34:17 +01:00
Compare commits
7 Commits
feat/andro
...
fix/settin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2802b4570 | ||
|
|
e79ae53e4e | ||
|
|
d5d0976ff9 | ||
|
|
6dd98ccae8 | ||
|
|
b4eb853048 | ||
|
|
dbdf4767a5 | ||
|
|
bee8323110 |
@@ -854,6 +854,13 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.use_episode_images_next_up")}
|
||||||
|
value={settings.useEpisodeImagesForNextUp}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ useEpisodeImagesForNextUp: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
<TVSettingsToggle
|
<TVSettingsToggle
|
||||||
label={t("home.settings.appearance.show_home_backdrop")}
|
label={t("home.settings.appearance.show_home_backdrop")}
|
||||||
value={settings.showHomeBackdrop}
|
value={settings.showHomeBackdrop}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, Switch, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
@@ -50,12 +51,12 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
>
|
>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
className='px-4'
|
className='px-4 pt-4'
|
||||||
>
|
>
|
||||||
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
@@ -46,7 +47,7 @@ export default function HideLibrariesPage() {
|
|||||||
<ListGroup>
|
<ListGroup>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -136,7 +136,7 @@ export default function MusicSettingsPage() {
|
|||||||
title={t("home.settings.music.prefer_downloaded")}
|
title={t("home.settings.music.prefer_downloaded")}
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.preferLocalAudio}
|
value={settings.preferLocalAudio}
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -159,7 +159,7 @@ export default function MusicSettingsPage() {
|
|||||||
title={t("home.settings.music.lookahead_enabled")}
|
title={t("home.settings.music.lookahead_enabled")}
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.audioLookaheadEnabled}
|
value={settings.audioLookaheadEnabled}
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -233,7 +233,7 @@ export default function MusicSettingsPage() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<ListGroup>
|
<ListGroup className='mt-4'>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor='red'
|
textColor='red'
|
||||||
onPress={onDeleteDownloadedSongsClicked}
|
onPress={onDeleteDownloadedSongsClicked}
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ export default function PlaybackControlsPage() {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className='p-4 flex flex-col'
|
className='p-4 flex flex-col'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<View className='mb-4'>
|
<View>
|
||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<MediaToggles className='mb-4' />
|
<MediaToggles className='mb-4' />
|
||||||
<GestureControls className='mb-4' />
|
<GestureControls className='mb-4' />
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Switch,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -72,7 +72,7 @@ export default function MarlinSearchPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DisabledSetting disabled={disabled} className='px-4'>
|
<DisabledSetting disabled={disabled} className='px-4 pt-4'>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={
|
disabled={
|
||||||
@@ -90,7 +90,7 @@ export default function MarlinSearchPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.searchEngine === "Marlin"}
|
value={settings.searchEngine === "Marlin"}
|
||||||
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Switch,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -134,7 +134,7 @@ export default function StreamystatsPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='px-4'>
|
<View className='px-4 pt-4'>
|
||||||
<ListGroup className='flex-1'>
|
<ListGroup className='flex-1'>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.url")}
|
title={t("home.settings.plugins.streamystats.url")}
|
||||||
@@ -173,7 +173,7 @@ export default function StreamystatsPage() {
|
|||||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||||
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={useForSearch}
|
value={useForSearch}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled}
|
||||||
onValueChange={setUseForSearch}
|
onValueChange={setUseForSearch}
|
||||||
@@ -187,7 +187,7 @@ export default function StreamystatsPage() {
|
|||||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={movieRecs}
|
value={movieRecs}
|
||||||
onValueChange={setMovieRecs}
|
onValueChange={setMovieRecs}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled}
|
||||||
@@ -201,7 +201,7 @@ export default function StreamystatsPage() {
|
|||||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={seriesRecs}
|
value={seriesRecs}
|
||||||
onValueChange={setSeriesRecs}
|
onValueChange={setSeriesRecs}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled}
|
||||||
@@ -215,7 +215,7 @@ export default function StreamystatsPage() {
|
|||||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={promotedWatchlists}
|
value={promotedWatchlists}
|
||||||
onValueChange={setPromotedWatchlists}
|
onValueChange={setPromotedWatchlists}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled}
|
||||||
@@ -225,7 +225,7 @@ export default function StreamystatsPage() {
|
|||||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||||
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={hideWatchlistsTab}
|
value={hideWatchlistsTab}
|
||||||
onValueChange={setHideWatchlistsTab}
|
onValueChange={setHideWatchlistsTab}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
RepeatMode,
|
RepeatMode,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import {
|
import {
|
||||||
|
getMediaInfoApi,
|
||||||
getPlaystateApi,
|
getPlaystateApi,
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
@@ -310,6 +311,37 @@ export default function DirectPlayerPage() {
|
|||||||
// Ref to store the stream fetch function for refreshing subtitle tracks
|
// Ref to store the stream fetch function for refreshing subtitle tracks
|
||||||
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
|
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
|
||||||
|
|
||||||
|
// Live TV opens a server-side live stream via autoOpenLiveStream. If it is
|
||||||
|
// never closed, Jellyfin's M3U tuner limit fills up and every channel then
|
||||||
|
// fails with "simultaneous stream limit has been reached". reportPlaybackStopped
|
||||||
|
// handles a clean stop, but a channel switch (the player re-fetches in place)
|
||||||
|
// or an unmount after an error bypass it, so track the open live stream and
|
||||||
|
// release it on those paths too.
|
||||||
|
const apiRef = useRef(api);
|
||||||
|
useEffect(() => {
|
||||||
|
apiRef.current = api;
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const releaseLiveStream = useCallback(
|
||||||
|
(liveStreamId: string | null) => {
|
||||||
|
if (!liveStreamId || !apiRef.current || offline) return;
|
||||||
|
// Best effort: a failed close must not break teardown, and the slot is
|
||||||
|
// also freed by the server's reap as a backstop.
|
||||||
|
getMediaInfoApi(apiRef.current)
|
||||||
|
.closeLiveStream({ liveStreamId })
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
[offline],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The effect cleanup releases the live stream both when it changes (channel
|
||||||
|
// switch, which re-runs the effect) and when the player unmounts, so no
|
||||||
|
// manual previous-id tracking is needed.
|
||||||
|
useEffect(() => {
|
||||||
|
const liveStreamId = stream?.mediaSource?.LiveStreamId ?? null;
|
||||||
|
return () => releaseLiveStream(liveStreamId);
|
||||||
|
}, [stream?.mediaSource?.LiveStreamId, releaseLiveStream]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStreamData = async (): Promise<Stream | null> => {
|
const fetchStreamData = async (): Promise<Stream | null> => {
|
||||||
setStreamStatus({ isLoading: true, isError: false });
|
setStreamStatus({ isLoading: true, isError: false });
|
||||||
@@ -445,6 +477,12 @@ export default function DirectPlayerPage() {
|
|||||||
MediaSourceId: mediaSourceId,
|
MediaSourceId: mediaSourceId,
|
||||||
PositionTicks: currentTimeInTicks,
|
PositionTicks: currentTimeInTicks,
|
||||||
PlaySessionId: stream.sessionId,
|
PlaySessionId: stream.sessionId,
|
||||||
|
// Release the server-side live stream (and its tuner slot) on stop.
|
||||||
|
// Jellyfin only closes a live stream opened via autoOpenLiveStream when
|
||||||
|
// the stop report carries its LiveStreamId; without it the stream leaks
|
||||||
|
// and Live TV eventually fails for everyone with "M3U simultaneous
|
||||||
|
// stream limit has been reached". Undefined for non-live items (no-op).
|
||||||
|
LiveStreamId: stream.mediaSource?.LiveStreamId ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [api, item, mediaSourceId, stream, progress, offline]);
|
}, [api, item, mediaSourceId, stream, progress, offline]);
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
// Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
|
||||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
// backdrop owner — otherwise the Thumb tag is requested on the wrong item → black.
|
||||||
|
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||||
|
return `${api?.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
@@ -61,7 +63,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}, [item]);
|
// useEpisodePoster in deps so flipping the prop re-computes the URL live.
|
||||||
|
}, [api, item, useEpisodePoster]);
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||||
|
|||||||
40
components/common/SettingSwitch.tsx
Normal file
40
components/common/SettingSwitch.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Platform, Switch, type SwitchProps, View } from "react-native";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings toggle. Android's native Switch lays out ~40px tall / ~56px wide and
|
||||||
|
* inflates list rows (iOS renders it ~31px). A plain `transform: scale` is
|
||||||
|
* visual-only and does NOT shrink the layout box, so we pin the Switch inside a
|
||||||
|
* FIXED-SIZE box (overflow hidden) and center it:
|
||||||
|
* - the fixed height caps the row height (compact, uniform rows),
|
||||||
|
* - the fixed width + centering keep the switch in the exact same spot in the
|
||||||
|
* on/off states (a non-fixed wrapper let its width fluctuate between states,
|
||||||
|
* which shifted the switch sideways on toggle).
|
||||||
|
* iOS renders the switch untouched.
|
||||||
|
*
|
||||||
|
* Tunables: BOX_H drives the row height; SCALE shrinks the visual to fit the
|
||||||
|
* box; keep BOX_W >= scaled visual width to avoid clipping the switch sideways.
|
||||||
|
*/
|
||||||
|
const BOX_W = 40;
|
||||||
|
const BOX_H = 30;
|
||||||
|
const SCALE = 0.9;
|
||||||
|
|
||||||
|
export const SettingSwitch: React.FC<SwitchProps> = (props) => {
|
||||||
|
if (Platform.OS !== "android") return <Switch {...props} />;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: BOX_W,
|
||||||
|
height: BOX_H,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
{...props}
|
||||||
|
style={[props.style, { transform: [{ scale: SCALE }] }]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Colors } from "../../constants/Colors";
|
import { Colors } from "../../constants/Colors";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
@@ -85,6 +86,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
}, [isSuccess, onLoaded]);
|
}, [isSuccess, onLoaded]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
||||||
const allItems = useMemo(() => {
|
const allItems = useMemo(() => {
|
||||||
@@ -186,7 +188,10 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster
|
||||||
|
item={item}
|
||||||
|
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
|
||||||
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
||||||
@@ -165,6 +166,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const allItems = useMemo(() => {
|
const allItems = useMemo(() => {
|
||||||
const items = data?.pages.flat() ?? [];
|
const items = data?.pages.flat() ?? [];
|
||||||
@@ -231,6 +233,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
hasTVPreferredFocus={isFirstItem}
|
hasTVPreferredFocus={isFirstItem}
|
||||||
onFocus={() => handleItemFocus(item)}
|
onFocus={() => handleItemFocus(item)}
|
||||||
width={itemWidth}
|
width={itemWidth}
|
||||||
|
preferEpisodeImage={settings?.useEpisodeImagesForNextUp}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -243,6 +246,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showItemActions,
|
showItemActions,
|
||||||
handleItemFocus,
|
handleItemFocus,
|
||||||
ITEM_GAP,
|
ITEM_GAP,
|
||||||
|
settings?.useEpisodeImagesForNextUp,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ScrollView, View, type ViewProps } from "react-native";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { useInView } from "@/hooks/useInView";
|
import { useInView } from "@/hooks/useInView";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
@@ -50,6 +51,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
||||||
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
||||||
@@ -108,7 +110,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster
|
||||||
|
item={item}
|
||||||
|
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
|
|||||||
@@ -65,10 +65,11 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
|||||||
const posterUrl = useMemo(() => {
|
const posterUrl = useMemo(() => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
|
|
||||||
// For episodes, always use series thumb
|
// For episodes, always use series thumb.
|
||||||
|
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
if (item.ParentThumbImageTag) {
|
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
if (item.SeriesId) {
|
if (item.SeriesId) {
|
||||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
{title ? (
|
||||||
{title}
|
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
||||||
</Text>
|
{title}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<View
|
<View
|
||||||
style={[]}
|
style={[]}
|
||||||
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { PropsWithChildren, ReactNode } from "react";
|
import type { PropsWithChildren, ReactNode } from "react";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -34,12 +34,17 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||||
const isDisabled = disabled || disabledByAdmin;
|
const isDisabled = disabled || disabledByAdmin;
|
||||||
|
// Keep the row floor uniform; Android trims padding slightly (its native
|
||||||
|
// controls sit taller). Switch height is capped via SettingSwitch so toggle
|
||||||
|
// rows match non-toggle rows.
|
||||||
|
const rowSizing =
|
||||||
|
Platform.OS === "android" ? "min-h-[42px] py-1.5" : "min-h-[42px] py-2";
|
||||||
if (onPress)
|
if (onPress)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
@@ -58,7 +63,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...viewProps}
|
{...viewProps}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, Switch } from "react-native";
|
import { Linking } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -27,6 +28,7 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.show_custom_menu_links")}
|
title={t("home.settings.other.show_custom_menu_links")}
|
||||||
|
subtitle={t("home.settings.other.show_custom_menu_links_hint")}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
@@ -34,7 +36,7 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.showCustomMenuLinks}
|
value={settings.showCustomMenuLinks}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -44,31 +46,51 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.appearance.merge_next_up_continue_watching_hint",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.mergeNextUpAndContinueWatching}
|
value={settings.mergeNextUpAndContinueWatching}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.appearance.use_episode_images_next_up")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.appearance.use_episode_images_next_up_hint",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SettingSwitch
|
||||||
|
value={settings.useEpisodeImagesForNextUp}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ useEpisodeImagesForNextUp: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.appearance.hide_remote_session_button")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.appearance.hide_remote_session_button_hint",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SettingSwitch
|
||||||
|
value={settings.hideRemoteSessionButton}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ hideRemoteSessionButton: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push("/settings/appearance/hide-libraries/page")
|
router.push("/settings/appearance/hide-libraries/page")
|
||||||
}
|
}
|
||||||
title={t("home.settings.other.hide_libraries")}
|
title={t("home.settings.other.hide_libraries")}
|
||||||
|
subtitle={t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.appearance.hide_remote_session_button")}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.hideRemoteSessionButton}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ hideRemoteSessionButton: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -135,7 +135,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={t("home.settings.audio.set_audio_track")}
|
title={t("home.settings.audio.set_audio_track")}
|
||||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.rememberAudioSelections}
|
value={settings.rememberAudioSelections}
|
||||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Switch, View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
@@ -9,7 +10,7 @@ export const ChromecastSettings: React.FC = ({ ...props }) => {
|
|||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={"Chromecast"}>
|
<ListGroup title={"Chromecast"}>
|
||||||
<ListItem title={"Enable H265 for Chromecast"}>
|
<ListItem title={"Enable H265 for Chromecast"}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableH265ForChromecast}
|
value={settings.enableH265ForChromecast}
|
||||||
onValueChange={(enableH265ForChromecast) =>
|
onValueChange={(enableH265ForChromecast) =>
|
||||||
updateSettings({ enableH265ForChromecast })
|
updateSettings({ enableH265ForChromecast })
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native";
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -39,7 +39,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableHorizontalSwipeSkip}
|
value={settings.enableHorizontalSwipeSkip}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||||
@@ -55,7 +55,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableLeftSideBrightnessSwipe}
|
value={settings.enableLeftSideBrightnessSwipe}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
||||||
@@ -71,7 +71,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableRightSideVolumeSwipe}
|
value={settings.enableRightSideVolumeSwipe}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
onValueChange={(enableRightSideVolumeSwipe) =>
|
onValueChange={(enableRightSideVolumeSwipe) =>
|
||||||
@@ -87,7 +87,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hideVolumeSlider}
|
value={settings.hideVolumeSlider}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
onValueChange={(hideVolumeSlider) =>
|
onValueChange={(hideVolumeSlider) =>
|
||||||
@@ -103,7 +103,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hideBrightnessSlider}
|
value={settings.hideBrightnessSlider}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
onValueChange={(hideBrightnessSlider) =>
|
onValueChange={(hideBrightnessSlider) =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export const KefinTweaksSettings = () => {
|
export const KefinTweaksSettings = () => {
|
||||||
@@ -20,7 +21,7 @@ export const KefinTweaksSettings = () => {
|
|||||||
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
|
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={isEnabled}
|
value={isEnabled}
|
||||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||||
trackColor={{ false: "#555", true: "purple" }}
|
trackColor={{ false: "#555", true: "purple" }}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
||||||
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
@@ -147,7 +148,10 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
|||||||
title={t("home.settings.network.auto_switch_enabled")}
|
title={t("home.settings.network.auto_switch_enabled")}
|
||||||
subtitle={t("home.settings.network.auto_switch_description")}
|
subtitle={t("home.settings.network.auto_switch_description")}
|
||||||
>
|
>
|
||||||
<Switch value={config.enabled} onValueChange={handleToggleEnabled} />
|
<SettingSwitch
|
||||||
|
value={config.enabled}
|
||||||
|
onValueChange={handleToggleEnabled}
|
||||||
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Platform, Switch, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -122,7 +123,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ListItem title='Opaque Background'>
|
<ListItem title='Opaque Background'>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ mpvSubtitleBackgroundEnabled: value })
|
updateSettings({ mpvSubtitleBackgroundEnabled: value })
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, Switch, View } from "react-native";
|
import { Linking, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
@@ -132,7 +133,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.safeAreaInControlsEnabled}
|
value={settings.safeAreaInControlsEnabled}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -150,7 +151,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.showCustomMenuLinks}
|
value={settings.showCustomMenuLinks}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -188,7 +189,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.disableHapticFeedback}
|
value={settings.disableHapticFeedback}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
onValueChange={(disableHapticFeedback) =>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
@@ -115,7 +116,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled}>
|
<DisabledSetting disabled={disabled}>
|
||||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
<ListGroup title={t("home.settings.other.other_title")} className='mb-4'>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.video_orientation")}
|
title={t("home.settings.other.video_orientation")}
|
||||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||||
@@ -146,7 +147,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.safeAreaInControlsEnabled}
|
value={settings.safeAreaInControlsEnabled}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -205,7 +206,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.disableHapticFeedback}
|
value={settings.disableHapticFeedback}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
onValueChange={(disableHapticFeedback) =>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
@@ -218,7 +219,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.auto_play_next_episode")}
|
title={t("home.settings.other.auto_play_next_episode")}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.autoPlayNextEpisode}
|
value={settings.autoPlayNextEpisode}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
onValueChange={(autoPlayNextEpisode) =>
|
onValueChange={(autoPlayNextEpisode) =>
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetScrollView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { forwardRef, useCallback, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
Platform,
|
|
||||||
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 type { StorageLocation } from "@/modules";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import {
|
|
||||||
clearStorageLocationsCache,
|
|
||||||
getAvailableStorageLocations,
|
|
||||||
} from "@/utils/storage";
|
|
||||||
|
|
||||||
interface StorageLocationPickerProps {
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StorageLocationPicker = forwardRef<
|
|
||||||
BottomSheetModal,
|
|
||||||
StorageLocationPickerProps
|
|
||||||
>(({ onClose }, ref) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>(
|
|
||||||
settings.downloadStorageLocation || "internal",
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: locations, isLoading } = useQuery({
|
|
||||||
queryKey: ["storageLocations"],
|
|
||||||
queryFn: getAvailableStorageLocations,
|
|
||||||
enabled: Platform.OS === "android",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSelect = (location: StorageLocation) => {
|
|
||||||
setSelectedId(location.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
updateSettings({ downloadStorageLocation: selectedId });
|
|
||||||
clearStorageLocationsCache(); // Clear cache so next download uses new location
|
|
||||||
toast.success(
|
|
||||||
t("settings.storage.storage_location_updated", {
|
|
||||||
defaultValue: "Storage location updated",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
|
||||||
(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Platform.OS !== "android") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={ref}
|
|
||||||
enableDynamicSizing
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
enablePanDownToClose
|
|
||||||
enableDismissOnClose
|
|
||||||
>
|
|
||||||
<BottomSheetScrollView
|
|
||||||
style={{
|
|
||||||
paddingLeft: Math.max(16, insets.left),
|
|
||||||
paddingRight: Math.max(16, insets.right),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className='px-4 pt-2'>
|
|
||||||
<Text className='text-lg font-semibold mb-1'>
|
|
||||||
{t("settings.storage.select_storage_location", {
|
|
||||||
defaultValue: "Select Storage Location",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-sm text-neutral-500 mb-4'>
|
|
||||||
{t("settings.storage.existing_downloads_note", {
|
|
||||||
defaultValue:
|
|
||||||
"Existing downloads will remain in their current location",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<View className='items-center justify-center py-8'>
|
|
||||||
<ActivityIndicator size='large' />
|
|
||||||
<Text className='mt-4 text-neutral-500'>
|
|
||||||
{t("settings.storage.loading_storage", {
|
|
||||||
defaultValue: "Loading storage options...",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : !locations || locations.length === 0 ? (
|
|
||||||
<View className='items-center justify-center py-8'>
|
|
||||||
<Text className='text-neutral-500'>
|
|
||||||
{t("settings.storage.no_storage_found", {
|
|
||||||
defaultValue: "No storage locations found",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{locations.map((location) => {
|
|
||||||
const isSelected = selectedId === location.id;
|
|
||||||
const freeSpaceGB = (location.freeSpace / 1024 ** 3).toFixed(2);
|
|
||||||
const totalSpaceGB = (location.totalSpace / 1024 ** 3).toFixed(
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
const usedPercent = (
|
|
||||||
((location.totalSpace - location.freeSpace) /
|
|
||||||
location.totalSpace) *
|
|
||||||
100
|
|
||||||
).toFixed(0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={location.id}
|
|
||||||
onPress={() => handleSelect(location)}
|
|
||||||
className={`p-4 mb-2 rounded-lg ${
|
|
||||||
isSelected
|
|
||||||
? "bg-purple-600/20 border border-purple-600"
|
|
||||||
: "bg-neutral-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className='flex-row items-center justify-between'>
|
|
||||||
<View className='flex-1'>
|
|
||||||
<View className='flex-row items-center'>
|
|
||||||
<Text className='text-base font-semibold'>
|
|
||||||
{location.label}
|
|
||||||
</Text>
|
|
||||||
{location.type === "external" && (
|
|
||||||
<View className='ml-2 px-2 py-0.5 bg-blue-600/30 rounded'>
|
|
||||||
<Text className='text-xs text-blue-400'>
|
|
||||||
{t("settings.storage.removable", {
|
|
||||||
defaultValue: "Removable",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Text className='text-sm text-neutral-500 mt-1'>
|
|
||||||
{t("settings.storage.space_info", {
|
|
||||||
defaultValue:
|
|
||||||
"{{free}} GB free of {{total}} GB ({{used}}% used)",
|
|
||||||
free: freeSpaceGB,
|
|
||||||
total: totalSpaceGB,
|
|
||||||
used: usedPercent,
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{isSelected && (
|
|
||||||
<View className='w-6 h-6 rounded-full bg-purple-600 items-center justify-center ml-2'>
|
|
||||||
<Text className='text-white text-xs'>✓</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<View className='flex-row gap-x-2 py-4'>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onClose}
|
|
||||||
className='flex-1 py-3 rounded-lg bg-neutral-800 items-center'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>
|
|
||||||
{t("common.cancel", { defaultValue: "Cancel" })}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleConfirm}
|
|
||||||
className='flex-1 py-3 rounded-lg bg-purple-600 items-center'
|
|
||||||
disabled={!selectedId}
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>
|
|
||||||
{t("common.confirm", { defaultValue: "Confirm" })}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</BottomSheetScrollView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View } from "react-native";
|
import { Alert, Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
@@ -8,20 +6,15 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getStorageLabel } from "@/utils/storage";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
import { StorageLocationPicker } from "./StorageLocationPicker";
|
|
||||||
|
|
||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
const { settings } = useSettings();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
||||||
|
|
||||||
const { data: size } = useQuery({
|
const { data: size } = useQuery({
|
||||||
queryKey: ["appSize"],
|
queryKey: ["appSize"],
|
||||||
@@ -39,12 +32,6 @@ export const StorageSettings = () => {
|
|||||||
refetchInterval: 10 * 1000,
|
refetchInterval: 10 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: storageLabel } = useQuery({
|
|
||||||
queryKey: ["storageLabel", settings.downloadStorageLocation],
|
|
||||||
queryFn: () => getStorageLabel(settings.downloadStorageLocation),
|
|
||||||
enabled: Platform.OS === "android",
|
|
||||||
});
|
|
||||||
|
|
||||||
const onDeleteClicked = () => {
|
const onDeleteClicked = () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||||
@@ -138,32 +125,14 @@ export const StorageSettings = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<ListGroup className={Platform.OS === "android" ? "mt-4" : undefined}>
|
||||||
{Platform.OS === "android" && (
|
<ListItem
|
||||||
<ListGroup>
|
textColor='red'
|
||||||
<ListItem
|
onPress={onDeleteClicked}
|
||||||
title={t("settings.storage.download_location", {
|
title={t("home.settings.storage.delete_all_downloaded_files")}
|
||||||
defaultValue: "Download Location",
|
/>
|
||||||
})}
|
</ListGroup>
|
||||||
value={storageLabel || "Internal Storage"}
|
|
||||||
onPress={() => bottomSheetModalRef.current?.present()}
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
)}
|
|
||||||
<ListGroup>
|
|
||||||
<ListItem
|
|
||||||
textColor='red'
|
|
||||||
onPress={onDeleteClicked}
|
|
||||||
title={t("home.settings.storage.delete_all_downloaded_files")}
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<StorageLocationPicker
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
onClose={() => bottomSheetModalRef.current?.dismiss()}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -98,6 +98,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
|
className='mb-4'
|
||||||
title={t("home.settings.subtitles.subtitle_title")}
|
title={t("home.settings.subtitles.subtitle_title")}
|
||||||
description={
|
description={
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
@@ -152,7 +153,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={t("home.settings.subtitles.set_subtitle_track")}
|
title={t("home.settings.subtitles.set_subtitle_track")}
|
||||||
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.rememberSubtitleSelections}
|
value={settings.rememberSubtitleSelections}
|
||||||
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export interface TVPosterCardProps {
|
|||||||
|
|
||||||
/** Custom image URL getter - if not provided, uses smart URL logic */
|
/** Custom image URL getter - if not provided, uses smart URL logic */
|
||||||
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
||||||
|
|
||||||
|
/** For horizontal episodes, prefer the episode's own image over the series thumb */
|
||||||
|
preferEpisodeImage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,6 +111,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
glowColor = "white",
|
glowColor = "white",
|
||||||
scaleAmount = 1.05,
|
scaleAmount = 1.05,
|
||||||
imageUrlGetter,
|
imageUrlGetter,
|
||||||
|
preferEpisodeImage = false,
|
||||||
}) => {
|
}) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const posterSizes = useScaledTVPosterSizes();
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
@@ -139,9 +143,14 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
if (orientation === "horizontal") {
|
if (orientation === "horizontal") {
|
||||||
// Episode: prefer series thumb image for consistent look (like hero section)
|
// Episode: prefer series thumb image for consistent look (like hero section)
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
// First try parent/series thumb (horizontal series artwork)
|
// Opt-in: use the episode's own image instead of the series thumb.
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
if (preferEpisodeImage && item.ImageTags?.Primary) {
|
||||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`;
|
||||||
|
}
|
||||||
|
// First try parent/series thumb (horizontal series artwork).
|
||||||
|
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||||
|
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||||
|
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
// Fall back to episode's own primary image
|
// Fall back to episode's own primary image
|
||||||
if (item.ImageTags?.Primary) {
|
if (item.ImageTags?.Primary) {
|
||||||
@@ -173,7 +182,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
item,
|
item,
|
||||||
width: width * 2, // 2x for quality on large screens
|
width: width * 2, // 2x for quality on large screens
|
||||||
});
|
});
|
||||||
}, [api, item, orientation, width, imageUrlGetter]);
|
}, [api, item, orientation, width, imageUrlGetter, preferEpisodeImage]);
|
||||||
|
|
||||||
// Progress calculation
|
// Progress calculation
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
|
|||||||
@@ -4,16 +4,11 @@ import android.content.ComponentName
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.storage.StorageManager
|
|
||||||
import android.os.storage.StorageVolume
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import expo.modules.kotlin.Promise
|
import expo.modules.kotlin.Promise
|
||||||
import expo.modules.kotlin.modules.Module
|
import expo.modules.kotlin.modules.Module
|
||||||
import expo.modules.kotlin.modules.ModuleDefinition
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
data class DownloadTaskInfo(
|
data class DownloadTaskInfo(
|
||||||
val url: String,
|
val url: String,
|
||||||
@@ -147,105 +142,6 @@ class BackgroundDownloaderModule : Module() {
|
|||||||
promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e)
|
promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("getAvailableStorageLocations") { promise: Promise ->
|
|
||||||
try {
|
|
||||||
val storageLocations = mutableListOf<Map<String, Any>>()
|
|
||||||
|
|
||||||
// Use getExternalFilesDirs which works reliably across all Android versions
|
|
||||||
// This returns app-specific directories on both internal and external storage
|
|
||||||
val externalDirs = context.getExternalFilesDirs(null)
|
|
||||||
|
|
||||||
Log.d(TAG, "getExternalFilesDirs returned ${externalDirs.size} locations")
|
|
||||||
|
|
||||||
// Also check with StorageManager for additional info
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
||||||
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
|
||||||
val volumes = storageManager.storageVolumes
|
|
||||||
Log.d(TAG, "StorageManager reports ${volumes.size} volumes")
|
|
||||||
for ((i, vol) in volumes.withIndex()) {
|
|
||||||
Log.d(TAG, " Volume $i: isPrimary=${vol.isPrimary}, isRemovable=${vol.isRemovable}, state=${vol.state}, uuid=${vol.uuid}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for ((index, dir) in externalDirs.withIndex()) {
|
|
||||||
try {
|
|
||||||
if (dir == null) {
|
|
||||||
Log.w(TAG, "Directory at index $index is null - SD card may not be mounted")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dir.exists()) {
|
|
||||||
Log.w(TAG, "Directory at index $index does not exist: ${dir.absolutePath}")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val isPrimary = index == 0
|
|
||||||
val isRemovable = !isPrimary && Environment.isExternalStorageRemovable(dir)
|
|
||||||
|
|
||||||
// Get volume UUID for better identification
|
|
||||||
val volumeId = if (isPrimary) {
|
|
||||||
"internal"
|
|
||||||
} else {
|
|
||||||
// Try to get a stable UUID for the SD card
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
||||||
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
|
||||||
try {
|
|
||||||
val storageVolume = storageManager.getStorageVolume(dir)
|
|
||||||
storageVolume?.uuid ?: "sdcard_$index"
|
|
||||||
} catch (e: Exception) {
|
|
||||||
"sdcard_$index"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"sdcard_$index"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get human-readable label
|
|
||||||
val label = if (isPrimary) {
|
|
||||||
"Internal Storage"
|
|
||||||
} else {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
||||||
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
|
||||||
try {
|
|
||||||
val storageVolume = storageManager.getStorageVolume(dir)
|
|
||||||
storageVolume?.getDescription(context) ?: "SD Card"
|
|
||||||
} catch (e: Exception) {
|
|
||||||
"SD Card"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"SD Card"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val totalSpace = dir.totalSpace
|
|
||||||
val freeSpace = dir.freeSpace
|
|
||||||
|
|
||||||
Log.d(TAG, "Storage location: $label (id: $volumeId, path: ${dir.absolutePath}, removable: $isRemovable)")
|
|
||||||
|
|
||||||
storageLocations.add(
|
|
||||||
mapOf(
|
|
||||||
"id" to volumeId,
|
|
||||||
"path" to dir.absolutePath,
|
|
||||||
"type" to (if (isRemovable || !isPrimary) "external" else "internal"),
|
|
||||||
"label" to label,
|
|
||||||
"totalSpace" to totalSpace,
|
|
||||||
"freeSpace" to freeSpace
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error processing storage at index $index: ${e.message}", e)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Returning ${storageLocations.size} storage locations")
|
|
||||||
promise.resolve(storageLocations)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error getting storage locations: ${e.message}", e)
|
|
||||||
promise.reject("ERROR", "Failed to get storage locations: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startDownloadInternal(urlString: String, destinationPath: String?): Int {
|
private fun startDownloadInternal(urlString: String, destinationPath: String?): Int {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
DownloadErrorEvent,
|
DownloadErrorEvent,
|
||||||
DownloadProgressEvent,
|
DownloadProgressEvent,
|
||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
StorageLocation,
|
|
||||||
} from "./src/BackgroundDownloader.types";
|
} from "./src/BackgroundDownloader.types";
|
||||||
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
|
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ export interface BackgroundDownloader {
|
|||||||
cancelQueuedDownload(url: string): void;
|
cancelQueuedDownload(url: string): void;
|
||||||
cancelAllDownloads(): void;
|
cancelAllDownloads(): void;
|
||||||
getActiveDownloads(): Promise<ActiveDownload[]>;
|
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||||
getAvailableStorageLocations(): Promise<StorageLocation[]>;
|
|
||||||
|
|
||||||
addProgressListener(
|
addProgressListener(
|
||||||
listener: (event: DownloadProgressEvent) => void,
|
listener: (event: DownloadProgressEvent) => void,
|
||||||
@@ -66,10 +64,6 @@ const BackgroundDownloader: BackgroundDownloader = {
|
|||||||
return await BackgroundDownloaderModule.getActiveDownloads();
|
return await BackgroundDownloaderModule.getActiveDownloads();
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAvailableStorageLocations(): Promise<StorageLocation[]> {
|
|
||||||
return await BackgroundDownloaderModule.getAvailableStorageLocations();
|
|
||||||
},
|
|
||||||
|
|
||||||
addProgressListener(
|
addProgressListener(
|
||||||
listener: (event: DownloadProgressEvent) => void,
|
listener: (event: DownloadProgressEvent) => void,
|
||||||
): EventSubscription {
|
): EventSubscription {
|
||||||
@@ -112,5 +106,4 @@ export type {
|
|||||||
DownloadErrorEvent,
|
DownloadErrorEvent,
|
||||||
DownloadProgressEvent,
|
DownloadProgressEvent,
|
||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
StorageLocation,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,15 +29,6 @@ export interface ActiveDownload {
|
|||||||
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
|
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StorageLocation {
|
|
||||||
id: string;
|
|
||||||
path: string;
|
|
||||||
type: "internal" | "external";
|
|
||||||
label: string;
|
|
||||||
totalSpace: number;
|
|
||||||
freeSpace: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackgroundDownloaderModuleType {
|
export interface BackgroundDownloaderModuleType {
|
||||||
startDownload(url: string, destinationPath?: string): Promise<number>;
|
startDownload(url: string, destinationPath?: string): Promise<number>;
|
||||||
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
|
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
|
||||||
@@ -45,7 +36,6 @@ export interface BackgroundDownloaderModuleType {
|
|||||||
cancelQueuedDownload(url: string): void;
|
cancelQueuedDownload(url: string): void;
|
||||||
cancelAllDownloads(): void;
|
cancelAllDownloads(): void;
|
||||||
getActiveDownloads(): Promise<ActiveDownload[]>;
|
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||||
getAvailableStorageLocations(): Promise<StorageLocation[]>;
|
|
||||||
addListener(
|
addListener(
|
||||||
eventName: string,
|
eventName: string,
|
||||||
listener: (event: any) => void,
|
listener: (event: any) => void,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export type {
|
|||||||
DownloadErrorEvent,
|
DownloadErrorEvent,
|
||||||
DownloadProgressEvent,
|
DownloadProgressEvent,
|
||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
StorageLocation,
|
|
||||||
} from "./background-downloader";
|
} from "./background-downloader";
|
||||||
export { default as BackgroundDownloader } from "./background-downloader";
|
export { default as BackgroundDownloader } from "./background-downloader";
|
||||||
// Glass Poster (tvOS 26+)
|
// Glass Poster (tvOS 26+)
|
||||||
|
|||||||
@@ -6,20 +6,16 @@ import type {
|
|||||||
import { Directory, File, Paths } from "expo-file-system";
|
import { Directory, File, Paths } from "expo-file-system";
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import { fetchAndParseSegments } from "@/utils/segments";
|
import { fetchAndParseSegments } from "@/utils/segments";
|
||||||
import { filePathToUri } from "@/utils/storage";
|
|
||||||
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
|
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
|
||||||
import type { MediaTimeSegment, TrickPlayData } from "./types";
|
import type { MediaTimeSegment, TrickPlayData } from "./types";
|
||||||
import { generateFilename } from "./utils";
|
import { generateFilename } from "./utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads trickplay images for an item
|
* Downloads trickplay images for an item
|
||||||
* @param item - The item to download trickplay images for
|
|
||||||
* @param storagePath - Optional custom storage path (for Android SD card support)
|
|
||||||
* @returns TrickPlayData with path and size, or undefined if not available
|
* @returns TrickPlayData with path and size, or undefined if not available
|
||||||
*/
|
*/
|
||||||
export async function downloadTrickplayImages(
|
export async function downloadTrickplayImages(
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
storagePath?: string,
|
|
||||||
): Promise<TrickPlayData | undefined> {
|
): Promise<TrickPlayData | undefined> {
|
||||||
const trickplayInfo = getTrickplayInfo(item);
|
const trickplayInfo = getTrickplayInfo(item);
|
||||||
if (!trickplayInfo || !item.Id) {
|
if (!trickplayInfo || !item.Id) {
|
||||||
@@ -27,11 +23,7 @@ export async function downloadTrickplayImages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filename = generateFilename(item);
|
const filename = generateFilename(item);
|
||||||
|
const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`);
|
||||||
// Use custom storage path if provided (Android SD card), otherwise use Paths.document
|
|
||||||
const trickplayDir = storagePath
|
|
||||||
? new Directory(filePathToUri(storagePath), `${filename}_trickplay`)
|
|
||||||
: new Directory(Paths.document, `${filename}_trickplay`);
|
|
||||||
|
|
||||||
// Create directory if it doesn't exist
|
// Create directory if it doesn't exist
|
||||||
if (!trickplayDir.exists) {
|
if (!trickplayDir.exists) {
|
||||||
@@ -77,17 +69,12 @@ export async function downloadTrickplayImages(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads external subtitle files and updates their delivery URLs to local paths
|
* Downloads external subtitle files and updates their delivery URLs to local paths
|
||||||
* @param mediaSource - The media source containing subtitle information
|
|
||||||
* @param item - The item to download subtitles for
|
|
||||||
* @param apiBasePath - The base path for the API
|
|
||||||
* @param storagePath - Optional custom storage path (for Android SD card support)
|
|
||||||
* @returns Updated media source with local subtitle paths
|
* @returns Updated media source with local subtitle paths
|
||||||
*/
|
*/
|
||||||
export async function downloadSubtitles(
|
export async function downloadSubtitles(
|
||||||
mediaSource: MediaSourceInfo,
|
mediaSource: MediaSourceInfo,
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
apiBasePath: string,
|
apiBasePath: string,
|
||||||
storagePath?: string,
|
|
||||||
): Promise<MediaSourceInfo> {
|
): Promise<MediaSourceInfo> {
|
||||||
const externalSubtitles = mediaSource.MediaStreams?.filter(
|
const externalSubtitles = mediaSource.MediaStreams?.filter(
|
||||||
(stream) =>
|
(stream) =>
|
||||||
@@ -104,17 +91,10 @@ export async function downloadSubtitles(
|
|||||||
|
|
||||||
const url = apiBasePath + subtitle.DeliveryUrl;
|
const url = apiBasePath + subtitle.DeliveryUrl;
|
||||||
const extension = subtitle.Codec || "srt";
|
const extension = subtitle.Codec || "srt";
|
||||||
|
const destination = new File(
|
||||||
// Use custom storage path if provided (Android SD card), otherwise use Paths.document
|
Paths.document,
|
||||||
const destination = storagePath
|
`${filename}_subtitle_${subtitle.Index}.${extension}`,
|
||||||
? new File(
|
);
|
||||||
filePathToUri(storagePath),
|
|
||||||
`${filename}_subtitle_${subtitle.Index}.${extension}`,
|
|
||||||
)
|
|
||||||
: new File(
|
|
||||||
Paths.document,
|
|
||||||
`${filename}_subtitle_${subtitle.Index}.${extension}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Skip if already exists
|
// Skip if already exists
|
||||||
if (destination.exists) {
|
if (destination.exists) {
|
||||||
@@ -228,21 +208,13 @@ export async function downloadAdditionalAssets(params: {
|
|||||||
api: Api;
|
api: Api;
|
||||||
saveImageFn: (itemId: string, url?: string) => Promise<void>;
|
saveImageFn: (itemId: string, url?: string) => Promise<void>;
|
||||||
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>;
|
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>;
|
||||||
storagePath?: string;
|
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
trickPlayData?: TrickPlayData;
|
trickPlayData?: TrickPlayData;
|
||||||
updatedMediaSource: MediaSourceInfo;
|
updatedMediaSource: MediaSourceInfo;
|
||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
creditSegments?: MediaTimeSegment[];
|
creditSegments?: MediaTimeSegment[];
|
||||||
}> {
|
}> {
|
||||||
const {
|
const { item, mediaSource, api, saveImageFn, saveSeriesImageFn } = params;
|
||||||
item,
|
|
||||||
mediaSource,
|
|
||||||
api,
|
|
||||||
saveImageFn,
|
|
||||||
saveSeriesImageFn,
|
|
||||||
storagePath,
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
// Run all downloads in parallel for speed
|
// Run all downloads in parallel for speed
|
||||||
const [
|
const [
|
||||||
@@ -251,11 +223,11 @@ export async function downloadAdditionalAssets(params: {
|
|||||||
segments,
|
segments,
|
||||||
// Cover images (fire and forget, errors are logged)
|
// Cover images (fire and forget, errors are logged)
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
downloadTrickplayImages(item, storagePath),
|
downloadTrickplayImages(item),
|
||||||
// Only download subtitles for non-transcoded streams
|
// Only download subtitles for non-transcoded streams
|
||||||
mediaSource.TranscodingUrl
|
mediaSource.TranscodingUrl
|
||||||
? Promise.resolve(mediaSource)
|
? Promise.resolve(mediaSource)
|
||||||
: downloadSubtitles(mediaSource, item, api.basePath || "", storagePath),
|
: downloadSubtitles(mediaSource, item, api.basePath || ""),
|
||||||
item.Id
|
item.Id
|
||||||
? fetchSegments(item.Id, api)
|
? fetchSegments(item.Id, api)
|
||||||
: Promise.resolve({
|
: Promise.resolve({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Directory, File } from "expo-file-system";
|
import { Directory, File, Paths } from "expo-file-system";
|
||||||
import { getAllDownloadedItems, getDownloadedItemById } from "./database";
|
import { getAllDownloadedItems, getDownloadedItemById } from "./database";
|
||||||
import type { DownloadedItem } from "./types";
|
import type { DownloadedItem } from "./types";
|
||||||
import { filePathToUri } from "./utils";
|
import { filePathToUri } from "./utils";
|
||||||
@@ -39,11 +39,13 @@ export function deleteAllAssociatedFiles(item: DownloadedItem): void {
|
|||||||
stream.DeliveryUrl
|
stream.DeliveryUrl
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Use the full path from DeliveryUrl (it's already a full file:// URI)
|
const subtitleFilename = stream.DeliveryUrl.split("/").pop();
|
||||||
const subtitleFile = new File(stream.DeliveryUrl);
|
if (subtitleFilename) {
|
||||||
if (subtitleFile.exists) {
|
const subtitleFile = new File(Paths.document, subtitleFilename);
|
||||||
subtitleFile.delete();
|
if (subtitleFile.exists) {
|
||||||
console.log(`[DELETE] Subtitle deleted: ${stream.DeliveryUrl}`);
|
subtitleFile.delete();
|
||||||
|
console.log(`[DELETE] Subtitle deleted: ${subtitleFilename}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[DELETE] Failed to delete subtitle:", error);
|
console.error("[DELETE] Failed to delete subtitle:", error);
|
||||||
@@ -55,13 +57,15 @@ export function deleteAllAssociatedFiles(item: DownloadedItem): void {
|
|||||||
// Delete trickplay directory
|
// Delete trickplay directory
|
||||||
if (item.trickPlayData?.path) {
|
if (item.trickPlayData?.path) {
|
||||||
try {
|
try {
|
||||||
// Use the full path from trickPlayData (it's already a full file:// URI)
|
const trickplayDirName = item.trickPlayData.path.split("/").pop();
|
||||||
const trickplayDir = new Directory(item.trickPlayData.path);
|
if (trickplayDirName) {
|
||||||
if (trickplayDir.exists) {
|
const trickplayDir = new Directory(Paths.document, trickplayDirName);
|
||||||
trickplayDir.delete();
|
if (trickplayDir.exists) {
|
||||||
console.log(
|
trickplayDir.delete();
|
||||||
`[DELETE] Trickplay directory deleted: ${item.trickPlayData.path}`,
|
console.log(
|
||||||
);
|
`[DELETE] Trickplay directory deleted: ${trickplayDirName}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[DELETE] Failed to delete trickplay directory:", error);
|
console.error("[DELETE] Failed to delete trickplay directory:", error);
|
||||||
|
|||||||
@@ -6,16 +6,13 @@ import { File, Paths } from "expo-file-system";
|
|||||||
import type { MutableRefObject } from "react";
|
import type { MutableRefObject } from "react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import DeviceInfo from "react-native-device-info";
|
import DeviceInfo from "react-native-device-info";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import type { Bitrate } from "@/components/BitrateSelector";
|
import type { Bitrate } from "@/components/BitrateSelector";
|
||||||
import useImageStorage from "@/hooks/useImageStorage";
|
import useImageStorage from "@/hooks/useImageStorage";
|
||||||
import { BackgroundDownloader } from "@/modules";
|
import { BackgroundDownloader } from "@/modules";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
import useDownloadHelper from "@/utils/download";
|
import useDownloadHelper from "@/utils/download";
|
||||||
import { getStoragePath } from "@/utils/storage";
|
|
||||||
import { downloadAdditionalAssets } from "../additionalDownloads";
|
import { downloadAdditionalAssets } from "../additionalDownloads";
|
||||||
import {
|
import {
|
||||||
clearAllDownloadedItems,
|
clearAllDownloadedItems,
|
||||||
@@ -52,7 +49,6 @@ export function useDownloadOperations({
|
|||||||
onDataChange,
|
onDataChange,
|
||||||
}: UseDownloadOperationsProps) {
|
}: UseDownloadOperationsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { settings } = useSettings();
|
|
||||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||||
const { saveImage } = useImageStorage();
|
const { saveImage } = useImageStorage();
|
||||||
|
|
||||||
@@ -85,12 +81,6 @@ export function useDownloadOperations({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get storage path if custom location is set
|
|
||||||
let storagePath: string | undefined;
|
|
||||||
if (Platform.OS === "android" && settings.downloadStorageLocation) {
|
|
||||||
storagePath = await getStoragePath(settings.downloadStorageLocation);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download all additional assets BEFORE starting native video download
|
// Download all additional assets BEFORE starting native video download
|
||||||
const additionalAssets = await downloadAdditionalAssets({
|
const additionalAssets = await downloadAdditionalAssets({
|
||||||
item,
|
item,
|
||||||
@@ -98,7 +88,6 @@ export function useDownloadOperations({
|
|||||||
api,
|
api,
|
||||||
saveImageFn: saveImage,
|
saveImageFn: saveImage,
|
||||||
saveSeriesImageFn: saveSeriesPrimaryImage,
|
saveSeriesImageFn: saveSeriesPrimaryImage,
|
||||||
storagePath,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure URL is absolute (not relative) before storing
|
// Ensure URL is absolute (not relative) before storing
|
||||||
@@ -134,19 +123,10 @@ export function useDownloadOperations({
|
|||||||
// Add to processes
|
// Add to processes
|
||||||
setProcesses((prev) => [...prev, jobStatus]);
|
setProcesses((prev) => [...prev, jobStatus]);
|
||||||
|
|
||||||
// Generate destination path using custom storage location if set
|
// Generate destination path
|
||||||
const filename = generateFilename(item);
|
const filename = generateFilename(item);
|
||||||
let destinationPath: string;
|
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
||||||
|
const destinationPath = uriToFilePath(videoFile.uri);
|
||||||
if (storagePath) {
|
|
||||||
// Use custom storage location
|
|
||||||
destinationPath = `${storagePath}/${filename}.mp4`;
|
|
||||||
console.log(`[DOWNLOAD] Using custom storage: ${destinationPath}`);
|
|
||||||
} else {
|
|
||||||
// Use default Paths.document
|
|
||||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
|
||||||
destinationPath = uriToFilePath(videoFile.uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[DOWNLOAD] Starting video: ${item.Name}`);
|
console.log(`[DOWNLOAD] Starting video: ${item.Name}`);
|
||||||
console.log(`[DOWNLOAD] Download URL: ${downloadUrl}`);
|
console.log(`[DOWNLOAD] Download URL: ${downloadUrl}`);
|
||||||
|
|||||||
@@ -137,7 +137,11 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"merge_next_up_continue_watching": "Merge Continue watching & Next up",
|
"merge_next_up_continue_watching": "Merge Continue watching & Next up",
|
||||||
|
"merge_next_up_continue_watching_hint": "Combine Continue Watching and Next Up into a single home row.",
|
||||||
|
"use_episode_images_next_up": "Use episode images for Next Up & Continue Watching",
|
||||||
|
"use_episode_images_next_up_hint": "Show each episode's own thumbnail in the Next Up and Continue Watching rows instead of the series image.",
|
||||||
"hide_remote_session_button": "Hide remote session button",
|
"hide_remote_session_button": "Hide remote session button",
|
||||||
|
"hide_remote_session_button_hint": "Hide the remote-sessions button from the home header.",
|
||||||
"show_home_backdrop": "Dynamic home backdrop",
|
"show_home_backdrop": "Dynamic home backdrop",
|
||||||
"show_hero_carousel": "Hero carousel",
|
"show_hero_carousel": "Hero carousel",
|
||||||
"show_series_poster_on_episode": "Show series poster on episodes",
|
"show_series_poster_on_episode": "Show series poster on episodes",
|
||||||
@@ -296,6 +300,7 @@
|
|||||||
},
|
},
|
||||||
"safe_area_in_controls": "Safe area in controls",
|
"safe_area_in_controls": "Safe area in controls",
|
||||||
"show_custom_menu_links": "Show custom menu links",
|
"show_custom_menu_links": "Show custom menu links",
|
||||||
|
"show_custom_menu_links_hint": "Show the custom links your server administrator added in the web config.",
|
||||||
"show_large_home_carousel": "Show large home carousel (beta)",
|
"show_large_home_carousel": "Show large home carousel (beta)",
|
||||||
"hide_libraries": "Hide libraries",
|
"hide_libraries": "Hide libraries",
|
||||||
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||||
|
|||||||
@@ -224,7 +224,6 @@ export type Settings = {
|
|||||||
streamyStatsSeriesRecommendations?: boolean;
|
streamyStatsSeriesRecommendations?: boolean;
|
||||||
streamyStatsPromotedWatchlists?: boolean;
|
streamyStatsPromotedWatchlists?: boolean;
|
||||||
downloadQuality?: DownloadOption;
|
downloadQuality?: DownloadOption;
|
||||||
downloadStorageLocation?: string;
|
|
||||||
defaultBitrate?: Bitrate;
|
defaultBitrate?: Bitrate;
|
||||||
libraryOptions: LibraryOptions;
|
libraryOptions: LibraryOptions;
|
||||||
defaultAudioLanguage: CultureDto | null;
|
defaultAudioLanguage: CultureDto | null;
|
||||||
@@ -275,6 +274,9 @@ export type Settings = {
|
|||||||
hideBrightnessSlider: boolean;
|
hideBrightnessSlider: boolean;
|
||||||
usePopularPlugin: boolean;
|
usePopularPlugin: boolean;
|
||||||
mergeNextUpAndContinueWatching: boolean;
|
mergeNextUpAndContinueWatching: boolean;
|
||||||
|
// Use the episode's own image (instead of the series thumb) for the
|
||||||
|
// "Next Up" and "Continue Watching" home rows.
|
||||||
|
useEpisodeImagesForNextUp: boolean;
|
||||||
// TV-specific settings
|
// TV-specific settings
|
||||||
showHomeBackdrop: boolean;
|
showHomeBackdrop: boolean;
|
||||||
showTVHeroCarousel: boolean;
|
showTVHeroCarousel: boolean;
|
||||||
@@ -322,7 +324,6 @@ export const defaultValues: Settings = {
|
|||||||
streamyStatsSeriesRecommendations: false,
|
streamyStatsSeriesRecommendations: false,
|
||||||
streamyStatsPromotedWatchlists: false,
|
streamyStatsPromotedWatchlists: false,
|
||||||
downloadQuality: DownloadOptions[0],
|
downloadQuality: DownloadOptions[0],
|
||||||
downloadStorageLocation: undefined,
|
|
||||||
defaultBitrate: BITRATES[0],
|
defaultBitrate: BITRATES[0],
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
display: "list",
|
display: "list",
|
||||||
@@ -384,6 +385,7 @@ export const defaultValues: Settings = {
|
|||||||
hideBrightnessSlider: false,
|
hideBrightnessSlider: false,
|
||||||
usePopularPlugin: true,
|
usePopularPlugin: true,
|
||||||
mergeNextUpAndContinueWatching: false,
|
mergeNextUpAndContinueWatching: false,
|
||||||
|
useEpisodeImagesForNextUp: false,
|
||||||
// TV-specific settings
|
// TV-specific settings
|
||||||
showHomeBackdrop: true,
|
showHomeBackdrop: true,
|
||||||
showTVHeroCarousel: true,
|
showTVHeroCarousel: true,
|
||||||
|
|||||||
143
utils/storage.ts
143
utils/storage.ts
@@ -1,143 +0,0 @@
|
|||||||
import { Directory, Paths } from "expo-file-system";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { BackgroundDownloader, type StorageLocation } from "@/modules";
|
|
||||||
|
|
||||||
let cachedStorageLocations: StorageLocation[] | null = null;
|
|
||||||
|
|
||||||
// Debug mode: Set to true to simulate an SD card for testing in emulator
|
|
||||||
// This creates a real writable directory that mimics SD card behavior
|
|
||||||
const DEBUG_SIMULATE_SD_CARD = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all available storage locations (Android only)
|
|
||||||
* Returns cached result on subsequent calls
|
|
||||||
*/
|
|
||||||
export async function getAvailableStorageLocations(): Promise<
|
|
||||||
StorageLocation[]
|
|
||||||
> {
|
|
||||||
if (Platform.OS !== "android") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cachedStorageLocations !== null) {
|
|
||||||
return cachedStorageLocations;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const locations = await BackgroundDownloader.getAvailableStorageLocations();
|
|
||||||
|
|
||||||
// Debug mode: Add a functional simulated SD card for testing
|
|
||||||
if (DEBUG_SIMULATE_SD_CARD && locations.length === 1) {
|
|
||||||
// Use a real writable path within the app's document directory
|
|
||||||
const sdcardSimDir = new Directory(Paths.document, "sdcard_sim");
|
|
||||||
|
|
||||||
// Create the directory if it doesn't exist
|
|
||||||
if (!sdcardSimDir.exists) {
|
|
||||||
sdcardSimDir.create({ intermediates: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockSdCard: StorageLocation = {
|
|
||||||
id: "sdcard_sim",
|
|
||||||
path: sdcardSimDir.uri.replace("file://", ""),
|
|
||||||
type: "external",
|
|
||||||
label: "SD Card (Simulated)",
|
|
||||||
totalSpace: 64 * 1024 * 1024 * 1024, // 64 GB
|
|
||||||
freeSpace: 32 * 1024 * 1024 * 1024, // 32 GB free
|
|
||||||
};
|
|
||||||
locations.push(mockSdCard);
|
|
||||||
console.log("[DEBUG] Added simulated SD card:", mockSdCard.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedStorageLocations = locations;
|
|
||||||
return locations;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to get storage locations:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the cached storage locations
|
|
||||||
* Useful when storage configuration might have changed
|
|
||||||
*/
|
|
||||||
export function clearStorageLocationsCache(): void {
|
|
||||||
cachedStorageLocations = null;
|
|
||||||
console.log("[Storage] Cache cleared");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a simplified label for a storage location ID
|
|
||||||
* @param storageId - The storage location ID (e.g., "internal", "sdcard_0")
|
|
||||||
* @returns Human-readable label (e.g., "Internal Storage", "SD Card")
|
|
||||||
*/
|
|
||||||
export async function getStorageLabel(storageId?: string): Promise<string> {
|
|
||||||
if (!storageId || Platform.OS !== "android") {
|
|
||||||
return "Internal Storage";
|
|
||||||
}
|
|
||||||
|
|
||||||
const locations = await getAvailableStorageLocations();
|
|
||||||
const location = locations.find((loc) => loc.id === storageId);
|
|
||||||
|
|
||||||
return location?.label || "Internal Storage";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the filesystem path for a storage location ID
|
|
||||||
* @param storageId - The storage location ID (e.g., "internal", "sdcard_0")
|
|
||||||
* @returns The filesystem path, or default path if not found
|
|
||||||
*/
|
|
||||||
export async function getStoragePath(storageId?: string): Promise<string> {
|
|
||||||
if (!storageId || Platform.OS !== "android") {
|
|
||||||
return getDefaultStoragePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
const locations = await getAvailableStorageLocations();
|
|
||||||
const location = locations.find((loc) => loc.id === storageId);
|
|
||||||
|
|
||||||
if (!location) {
|
|
||||||
console.warn(`Storage location not found: ${storageId}, using default`);
|
|
||||||
return getDefaultStoragePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
return location.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default storage path (current behavior using Paths.document)
|
|
||||||
* @returns The default storage path
|
|
||||||
*/
|
|
||||||
export function getDefaultStoragePath(): string {
|
|
||||||
// Paths.document returns a Directory with a URI like "file:///data/user/0/..."
|
|
||||||
// We need to extract the actual path
|
|
||||||
const uri = Paths.document.uri;
|
|
||||||
return uri.replace("file://", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a storage location by ID
|
|
||||||
* @param storageId - The storage location ID
|
|
||||||
* @returns The storage location or undefined if not found
|
|
||||||
*/
|
|
||||||
export async function getStorageLocationById(
|
|
||||||
storageId?: string,
|
|
||||||
): Promise<StorageLocation | undefined> {
|
|
||||||
if (!storageId || Platform.OS !== "android") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const locations = await getAvailableStorageLocations();
|
|
||||||
return locations.find((loc) => loc.id === storageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert plain file path to file:// URI
|
|
||||||
* Required for expo-file-system File constructor
|
|
||||||
* @param path - The file path
|
|
||||||
* @returns The file:// URI
|
|
||||||
*/
|
|
||||||
export function filePathToUri(path: string): string {
|
|
||||||
if (path.startsWith("file://")) {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
return `file://${path}`;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user