Merge remote-tracking branch 'origin/develop' into feat/android/choose-download-location

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
Lance Chant
2026-07-17 13:50:57 +02:00
706 changed files with 164712 additions and 14657 deletions

View File

@@ -1,9 +1,9 @@
import { useRouter } from "expo-router";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Linking, Switch } from "react-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -42,11 +42,13 @@ export const AppearanceSettings: React.FC = () => {
}
/>
</ListItem>
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
<ListItem
title={t("home.settings.appearance.merge_next_up_continue_watching")}
>
<Switch
value={settings.showLargeHomeCarousel}
value={settings.mergeNextUpAndContinueWatching}
onValueChange={(value) =>
updateSettings({ showLargeHomeCarousel: value })
updateSettings({ mergeNextUpAndContinueWatching: value })
}
/>
</ListItem>
@@ -57,6 +59,16 @@ export const AppearanceSettings: React.FC = () => {
title={t("home.settings.other.hide_libraries")}
showArrow
/>
<ListItem
title={t("home.settings.appearance.hide_remote_session_button")}
>
<Switch
value={settings.hideRemoteSessionButton}
onValueChange={(value) =>
updateSettings({ hideRemoteSessionButton: value })
}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);

View File

@@ -3,7 +3,7 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { useSettings } from "@/utils/atoms/settings";
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -54,6 +54,70 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
];
}, [cultures, settings?.defaultAudioLanguage, t, updateSettings]);
const audioTranscodeModeLabels: Record<AudioTranscodeMode, string> = {
[AudioTranscodeMode.Auto]: t("home.settings.audio.transcode_mode.auto"),
[AudioTranscodeMode.ForceStereo]: t(
"home.settings.audio.transcode_mode.stereo",
),
[AudioTranscodeMode.Allow51]: t("home.settings.audio.transcode_mode.5_1"),
[AudioTranscodeMode.AllowAll]: t(
"home.settings.audio.transcode_mode.passthrough",
),
};
const audioTranscodeModeOptions = useMemo(
() => [
{
options: [
{
type: "radio" as const,
label: t("home.settings.audio.transcode_mode.auto"),
value: AudioTranscodeMode.Auto,
selected:
settings?.audioTranscodeMode === AudioTranscodeMode.Auto ||
!settings?.audioTranscodeMode,
onPress: () =>
updateSettings({ audioTranscodeMode: AudioTranscodeMode.Auto }),
},
{
type: "radio" as const,
label: t("home.settings.audio.transcode_mode.stereo"),
value: AudioTranscodeMode.ForceStereo,
selected:
settings?.audioTranscodeMode === AudioTranscodeMode.ForceStereo,
onPress: () =>
updateSettings({
audioTranscodeMode: AudioTranscodeMode.ForceStereo,
}),
},
{
type: "radio" as const,
label: t("home.settings.audio.transcode_mode.5_1"),
value: AudioTranscodeMode.Allow51,
selected:
settings?.audioTranscodeMode === AudioTranscodeMode.Allow51,
onPress: () =>
updateSettings({
audioTranscodeMode: AudioTranscodeMode.Allow51,
}),
},
{
type: "radio" as const,
label: t("home.settings.audio.transcode_mode.passthrough"),
value: AudioTranscodeMode.AllowAll,
selected:
settings?.audioTranscodeMode === AudioTranscodeMode.AllowAll,
onPress: () =>
updateSettings({
audioTranscodeMode: AudioTranscodeMode.AllowAll,
}),
},
],
},
],
[settings?.audioTranscodeMode, t, updateSettings],
);
if (isTv) return null;
if (!settings) return null;
@@ -98,6 +162,31 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
title={t("home.settings.audio.language")}
/>
</ListItem>
<ListItem
title={t("home.settings.audio.transcode_mode.title")}
subtitle={t("home.settings.audio.transcode_mode.description")}
>
<PlatformDropdown
groups={audioTranscodeModeOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{
audioTranscodeModeLabels[
settings?.audioTranscodeMode || AudioTranscodeMode.Auto
]
}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.audio.transcode_mode.title")}
/>
</ListItem>
</ListGroup>
</View>
);

View File

@@ -1,29 +0,0 @@
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const Dashboard = () => {
const { settings } = useSettings();
const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
<ListItem
className={sessions.length !== 0 ? "bg-purple-900" : ""}
onPress={() => router.push("/settings/dashboard/sessions")}
title={t("home.settings.dashboard.sessions_title")}
showArrow
/>
</ListGroup>
</View>
);
};

View File

@@ -11,12 +11,12 @@ const DisabledSetting: React.FC<
}}
>
<View {...props}>
{children}
{disabled && showText && (
<Text className='text-center text-red-700 my-4'>
{text ?? "Currently disabled by admin."}
<Text className='text-xs text-red-600 px-4 mt-1'>
{text ?? "Disabled by admin"}
</Text>
)}
{children}
</View>
</View>
);

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -19,7 +19,9 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
() =>
pluginSettings?.enableHorizontalSwipeSkip?.locked === true &&
pluginSettings?.enableLeftSideBrightnessSwipe?.locked === true &&
pluginSettings?.enableRightSideVolumeSwipe?.locked === true,
pluginSettings?.enableRightSideVolumeSwipe?.locked === true &&
pluginSettings?.hideVolumeSlider?.locked === true &&
pluginSettings?.hideBrightnessSlider?.locked === true,
[pluginSettings],
);
@@ -77,6 +79,38 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
}
/>
</ListItem>
<ListItem
title={t("home.settings.gesture_controls.hide_volume_slider")}
subtitle={t(
"home.settings.gesture_controls.hide_volume_slider_description",
)}
disabled={pluginSettings?.hideVolumeSlider?.locked}
>
<Switch
value={settings.hideVolumeSlider}
disabled={pluginSettings?.hideVolumeSlider?.locked}
onValueChange={(hideVolumeSlider) =>
updateSettings({ hideVolumeSlider })
}
/>
</ListItem>
<ListItem
title={t("home.settings.gesture_controls.hide_brightness_slider")}
subtitle={t(
"home.settings.gesture_controls.hide_brightness_slider_description",
)}
disabled={pluginSettings?.hideBrightnessSlider?.locked}
>
<Switch
value={settings.hideBrightnessSlider}
disabled={pluginSettings?.hideBrightnessSlider?.locked}
onValueChange={(hideBrightnessSlider) =>
updateSettings({ hideBrightnessSlider })
}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);

View File

@@ -115,9 +115,6 @@ export const JellyseerrSettings = () => {
</>
) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>

View File

@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { Switch, Text, View } from "react-native";
import { useSettings } from "@/utils/atoms/settings";
export const KefinTweaksSettings = () => {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation();
const isEnabled = settings?.useKefinTweaks ?? false;
return (
<View className=''>
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
</Text>
<View className='flex flex-row items-center justify-between mt-2'>
<Text className='text-white'>
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
</Text>
<Switch
value={isEnabled}
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
trackColor={{ false: "#555", true: "purple" }}
thumbColor={isEnabled ? "#fff" : "#ccc"}
/>
</View>
</View>
</View>
);
};

View File

@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
/>
</OptionGroup>
<OptionGroup title='Options'>
<OptionGroup title={t("library.options.options_title")}>
<ToggleItem
label={t("library.options.show_titles")}
value={settings.showTitles}

View File

@@ -0,0 +1,224 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Switch, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import { useWifiSSID } from "@/hooks/useWifiSSID";
import { useServerUrl } from "@/providers/ServerUrlProvider";
import { storage } from "@/utils/mmkv";
import {
getServerLocalConfig,
type LocalNetworkConfig,
updateServerLocalConfig,
} from "@/utils/secureCredentials";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
const DEFAULT_CONFIG: LocalNetworkConfig = {
localUrl: "",
homeWifiSSIDs: [],
enabled: false,
};
interface StatusDisplayProps {
currentSSID: string | null;
isUsingLocalUrl: boolean;
t: (key: string) => string;
}
function StatusDisplay({
currentSSID,
isUsingLocalUrl,
t,
}: StatusDisplayProps): React.ReactElement {
const wifiStatus = currentSSID ?? t("home.settings.network.not_connected");
const urlType = isUsingLocalUrl
? t("home.settings.network.local")
: t("home.settings.network.remote");
const urlTypeColor = isUsingLocalUrl ? "text-green-500" : "text-blue-500";
return (
<View className='px-4 py-2 bg-neutral-900 rounded-xl mt-4'>
<View className='flex-row justify-between items-center py-1'>
<Text className='text-neutral-400'>
{t("home.settings.network.current_wifi")}
</Text>
<Text>{wifiStatus}</Text>
</View>
<View className='flex-row justify-between items-center py-1'>
<Text className='text-neutral-400'>
{t("home.settings.network.using_url")}
</Text>
<Text className={urlTypeColor}>{urlType}</Text>
</View>
</View>
);
}
export function LocalNetworkSettings(): React.ReactElement | null {
const { t } = useTranslation();
const { permissionStatus, requestPermission } = useWifiSSID();
const { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl();
const remoteUrl = storage.getString("serverUrl");
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
useEffect(() => {
if (remoteUrl) {
const existingConfig = getServerLocalConfig(remoteUrl);
if (existingConfig) {
setConfig(existingConfig);
}
}
}, [remoteUrl]);
const saveConfig = useCallback(
(newConfig: LocalNetworkConfig) => {
if (!remoteUrl) return;
setConfig(newConfig);
updateServerLocalConfig(remoteUrl, newConfig);
// Trigger URL re-evaluation after config change
refreshUrlState();
},
[remoteUrl, refreshUrlState],
);
const handleToggleEnabled = useCallback(
async (enabled: boolean) => {
if (enabled && permissionStatus !== "granted") {
const granted = await requestPermission();
if (!granted) {
toast.error(t("home.settings.network.permission_denied"));
return;
}
}
saveConfig({ ...config, enabled });
},
[config, permissionStatus, requestPermission, saveConfig, t],
);
const handleLocalUrlChange = useCallback(
(localUrl: string) => {
saveConfig({ ...config, localUrl });
},
[config, saveConfig],
);
const handleAddCurrentNetwork = useCallback(() => {
if (!currentSSID) {
toast.error(t("home.settings.network.no_wifi_connected"));
return;
}
if (config.homeWifiSSIDs.includes(currentSSID)) {
toast.info(t("home.settings.network.network_already_added"));
return;
}
saveConfig({
...config,
homeWifiSSIDs: [...config.homeWifiSSIDs, currentSSID],
});
toast.success(t("home.settings.network.network_added"));
}, [config, currentSSID, saveConfig, t]);
const handleRemoveNetwork = useCallback(
(ssidToRemove: string) => {
saveConfig({
...config,
homeWifiSSIDs: config.homeWifiSSIDs.filter((s) => s !== ssidToRemove),
});
},
[config, saveConfig],
);
if (!remoteUrl) return null;
const addNetworkButtonText = currentSSID
? t("home.settings.network.add_current_network", { ssid: currentSSID })
: t("home.settings.network.not_connected_to_wifi");
return (
<View>
<ListGroup title={t("home.settings.network.local_network")}>
<ListItem
title={t("home.settings.network.auto_switch_enabled")}
subtitle={t("home.settings.network.auto_switch_description")}
>
<Switch value={config.enabled} onValueChange={handleToggleEnabled} />
</ListItem>
</ListGroup>
{config.enabled && (
<View className='pt-4'>
<ListGroup
title={t("home.settings.network.local_url")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.network.local_url_hint")}
</Text>
}
>
<View className=''>
<Input
placeholder={t("home.settings.network.local_url_placeholder")}
value={config.localUrl}
onChangeText={handleLocalUrlChange}
keyboardType='url'
autoCapitalize='none'
autoCorrect={false}
/>
</View>
</ListGroup>
<ListGroup
title={t("home.settings.network.home_wifi_networks")}
className='mt-4'
>
{config.homeWifiSSIDs.map((wifiSSID) => (
<ListItem key={wifiSSID} title={wifiSSID}>
<TouchableOpacity
onPress={() => handleRemoveNetwork(wifiSSID)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name='close-circle' size={22} color='#EF4444' />
</TouchableOpacity>
</ListItem>
))}
{config.homeWifiSSIDs.length === 0 && (
<ListItem
title={t("home.settings.network.no_networks_configured")}
subtitle={t("home.settings.network.add_network_hint")}
/>
)}
</ListGroup>
<View className='py-2'>
<Button
onPress={handleAddCurrentNetwork}
disabled={!currentSSID || permissionStatus !== "granted"}
>
{addNetworkButtonText}
</Button>
</View>
<StatusDisplay
currentSSID={currentSSID}
isUsingLocalUrl={isUsingLocalUrl}
t={t}
/>
</View>
)}
{permissionStatus === "denied" && (
<View className='py-2'>
<Text className='text-xs text-red-500'>
{t("home.settings.network.permission_denied_explanation")}
</Text>
</View>
)}
</View>
);
}

View File

@@ -4,9 +4,10 @@ import type {
UserDto,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getLocalizationApi, getUserApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { createContext, type ReactNode, useContext, useEffect } from "react";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { apiAtom } from "@/providers/JellyfinProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings";
@@ -30,7 +31,7 @@ export const useMedia = () => {
export const MediaProvider = ({ children }: { children: ReactNode }) => {
const { settings, updateSettings } = useSettings();
const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();
const queryClient = useNetworkAwareQueryClient();
const updateSetingsWrapper = (update: Partial<Settings>) => {
const updateUserConfiguration = async (

View File

@@ -0,0 +1,100 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Stepper } from "@/components/inputs/Stepper";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
{ key: "home.settings.buffer.cache_auto", value: "auto" },
{ key: "home.settings.buffer.cache_yes", value: "yes" },
{ key: "home.settings.buffer.cache_no", value: "no" },
];
export const MpvBufferSettings: React.FC = () => {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation();
const cacheModeOptions = useMemo(
() => [
{
options: CACHE_MODE_OPTIONS.map((option) => ({
type: "radio" as const,
label: t(option.key),
value: option.value,
selected: option.value === (settings?.mpvCacheEnabled ?? "auto"),
onPress: () => updateSettings({ mpvCacheEnabled: option.value }),
})),
},
],
[settings?.mpvCacheEnabled, t, updateSettings],
);
const currentCacheModeLabel = useMemo(() => {
const option = CACHE_MODE_OPTIONS.find(
(o) => o.value === (settings?.mpvCacheEnabled ?? "auto"),
);
return option ? t(option.key) : t("home.settings.buffer.cache_auto");
}, [settings?.mpvCacheEnabled, t]);
if (!settings) return null;
return (
<ListGroup title={t("home.settings.buffer.title")} className='mb-4'>
<ListItem title={t("home.settings.buffer.cache_mode")}>
<PlatformDropdown
groups={cacheModeOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{currentCacheModeLabel}
</Text>
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
</View>
}
title={t("home.settings.buffer.cache_mode")}
/>
</ListItem>
<ListItem title={t("home.settings.buffer.buffer_duration")}>
<Stepper
value={settings.mpvCacheSeconds ?? 10}
step={5}
min={5}
max={120}
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
appendValue='s'
/>
</ListItem>
<ListItem title={t("home.settings.buffer.max_cache_size")}>
<Stepper
value={settings.mpvDemuxerMaxBytes ?? 150}
step={25}
min={50}
max={500}
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
appendValue=' MB'
/>
</ListItem>
<ListItem title={t("home.settings.buffer.max_backward_cache")}>
<Stepper
value={settings.mpvDemuxerMaxBackBytes ?? 50}
step={25}
min={25}
max={200}
onUpdate={(value) =>
updateSettings({ mpvDemuxerMaxBackBytes: value })
}
appendValue=' MB'
/>
</ListItem>
</ListGroup>
);
};

View File

@@ -0,0 +1,150 @@
import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react";
import { Platform, Switch, View, type ViewProps } from "react-native";
import { Stepper } from "@/components/inputs/Stepper";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { PlatformDropdown } from "../PlatformDropdown";
import { useMedia } from "./MediaContext";
interface Props extends ViewProps {}
type AlignX = "left" | "center" | "right";
type AlignY = "top" | "center" | "bottom";
export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV;
const media = useMedia();
const { settings, updateSettings } = media;
const alignXOptions: AlignX[] = ["left", "center", "right"];
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
const alignXLabels: Record<AlignX, string> = {
left: "Left",
center: "Center",
right: "Right",
};
const alignYLabels: Record<AlignY, string> = {
top: "Top",
center: "Center",
bottom: "Bottom",
};
const alignXOptionGroups = useMemo(() => {
const options = alignXOptions.map((align) => ({
type: "radio" as const,
label: alignXLabels[align],
value: align,
selected: align === (settings?.mpvSubtitleAlignX ?? "center"),
onPress: () => updateSettings({ mpvSubtitleAlignX: align }),
}));
return [{ options }];
}, [settings?.mpvSubtitleAlignX, updateSettings]);
const alignYOptionGroups = useMemo(() => {
const options = alignYOptions.map((align) => ({
type: "radio" as const,
label: alignYLabels[align],
value: align,
selected: align === (settings?.mpvSubtitleAlignY ?? "bottom"),
onPress: () => updateSettings({ mpvSubtitleAlignY: align }),
}));
return [{ options }];
}, [settings?.mpvSubtitleAlignY, updateSettings]);
if (!settings) return null;
return (
<View {...props}>
<ListGroup
title='MPV Subtitle Settings'
description={
<Text className='text-[#8E8D91] text-xs'>
Advanced subtitle customization for MPV player
</Text>
}
>
{!isTv && (
<>
<ListItem title='Vertical Margin'>
<Stepper
value={settings.mpvSubtitleMarginY ?? 0}
step={5}
min={0}
max={100}
onUpdate={(value) =>
updateSettings({ mpvSubtitleMarginY: value })
}
/>
</ListItem>
<ListItem title='Horizontal Alignment'>
<PlatformDropdown
groups={alignXOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title='Horizontal Alignment'
/>
</ListItem>
<ListItem title='Vertical Alignment'>
<PlatformDropdown
groups={alignYOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title='Vertical Alignment'
/>
</ListItem>
</>
)}
<ListItem title='Opaque Background'>
<Switch
value={settings.mpvSubtitleBackgroundEnabled ?? false}
onValueChange={(value) =>
updateSettings({ mpvSubtitleBackgroundEnabled: value })
}
/>
</ListItem>
{settings.mpvSubtitleBackgroundEnabled && (
<ListItem title='Background Opacity'>
<Stepper
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
step={5}
min={10}
max={100}
appendValue='%'
onUpdate={(value) =>
updateSettings({ mpvSubtitleBackgroundOpacity: value })
}
/>
</ListItem>
)}
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,66 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
{ key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
{ key: "home.settings.vo_driver.gpu", value: "gpu" },
];
export const MpvVoSettings: React.FC = () => {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation();
const voDriverOptions = useMemo(
() => [
{
options: VO_DRIVER_OPTIONS.map((option) => ({
type: "radio" as const,
label: t(option.key),
value: option.value,
selected: option.value === (settings?.mpvVoDriver ?? "gpu-next"),
onPress: () => updateSettings({ mpvVoDriver: option.value }),
})),
},
],
[settings?.mpvVoDriver, t, updateSettings],
);
const currentVoDriverLabel = useMemo(() => {
const option = VO_DRIVER_OPTIONS.find(
(o) => o.value === (settings?.mpvVoDriver ?? "gpu-next"),
);
return option ? t(option.key) : t("home.settings.vo_driver.gpu_next");
}, [settings?.mpvVoDriver, t]);
// Only show on Android
if (Platform.OS !== "android") return null;
if (!settings) return null;
return (
<ListGroup title={t("home.settings.vo_driver.title")} className='mb-4'>
<ListItem title={t("home.settings.vo_driver.vo_mode")}>
<PlatformDropdown
groups={voDriverOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{currentVoDriverLabel}
</Text>
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
</View>
}
title={t("home.settings.vo_driver.vo_mode")}
/>
</ListItem>
</ListGroup>
);
};

View File

@@ -1,5 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { TFunction } from "i18next";
import type React from "react";
import { useMemo } from "react";
@@ -8,6 +7,7 @@ import { Linking, Switch, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
@@ -141,36 +141,6 @@ export const OtherSettings: React.FC = () => {
/>
</ListItem>
{/* {(Platform.OS === "ios" || Platform.isTVOS)&& (
<ListItem
title={t("home.settings.other.video_player")}
disabled={pluginSettings?.defaultPlayer?.locked}
>
<Dropdown
data={Object.values(VideoPlayer).filter(isNumber)}
disabled={pluginSettings?.defaultPlayer?.locked}
keyExtractor={String}
titleExtractor={(item) => t(`home.settings.other.video_players.${VideoPlayer[item]}`)}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-1.5 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{t(`home.settings.other.video_players.${VideoPlayer[settings.defaultPlayer]}`)}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
}
label={t("home.settings.other.orientation")}
onSelected={(defaultPlayer) =>
updateSettings({ defaultPlayer })
}
/>
</ListItem>
)} */}
<ListItem
title={t("home.settings.other.show_custom_menu_links")}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
@@ -188,14 +158,6 @@ export const OtherSettings: React.FC = () => {
}
/>
</ListItem>
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
<Switch
value={settings.showLargeHomeCarousel}
onValueChange={(value) =>
updateSettings({ showLargeHomeCarousel: value })
}
/>
</ListItem>
<ListItem
onPress={() => router.push("/settings/hide-libraries/page")}
title={t("home.settings.other.hide_libraries")}
@@ -234,7 +196,10 @@ export const OtherSettings: React.FC = () => {
}
/>
</ListItem>
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
<ListItem
title={t("home.settings.other.max_auto_play_episode_count")}
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
>
<PlatformDropdown
groups={autoPlayEpisodeOptions}
trigger={

View File

@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { Switch, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import DisabledSetting from "@/components/settings/DisabledSetting";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
@@ -28,6 +29,7 @@ export const PlaybackControlsSettings: React.FC = () => {
const orientations = [
ScreenOrientation.OrientationLock.DEFAULT,
ScreenOrientation.OrientationLock.PORTRAIT_UP,
ScreenOrientation.OrientationLock.LANDSCAPE,
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
];
@@ -38,6 +40,8 @@ export const PlaybackControlsSettings: React.FC = () => {
"home.settings.other.orientations.DEFAULT",
[ScreenOrientation.OrientationLock.PORTRAIT_UP]:
"home.settings.other.orientations.PORTRAIT_UP",
[ScreenOrientation.OrientationLock.LANDSCAPE]:
"home.settings.other.orientations.LANDSCAPE",
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]:
"home.settings.other.orientations.LANDSCAPE_LEFT",
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]:
@@ -92,6 +96,21 @@ export const PlaybackControlsSettings: React.FC = () => {
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
);
const playbackSpeedOptions = useMemo(
() => [
{
options: PLAYBACK_SPEEDS.map((speed) => ({
type: "radio" as const,
label: speed.label,
value: speed.value,
selected: speed.value === settings?.defaultPlaybackSpeed,
onPress: () => updateSettings({ defaultPlaybackSpeed: speed.value }),
})),
},
],
[settings?.defaultPlaybackSpeed, updateSettings],
);
if (!settings) return null;
return (
@@ -158,6 +177,30 @@ export const PlaybackControlsSettings: React.FC = () => {
/>
</ListItem>
<ListItem
title={t("home.settings.other.default_playback_speed")}
disabled={pluginSettings?.defaultPlaybackSpeed?.locked}
>
<PlatformDropdown
groups={playbackSpeedOptions}
trigger={
<View className='flex flex-row items-center justify-between pl-3 py-1.5'>
<Text className='mr-1 text-[#8E8D91]'>
{PLAYBACK_SPEEDS.find(
(s) => s.value === settings.defaultPlaybackSpeed,
)?.label ?? "1x"}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.default_playback_speed")}
/>
</ListItem>
<ListItem
title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked}
@@ -171,7 +214,26 @@ export const PlaybackControlsSettings: React.FC = () => {
/>
</ListItem>
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
<ListItem
title={t("home.settings.other.auto_play_next_episode")}
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
>
<Switch
value={settings.autoPlayNextEpisode}
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
onValueChange={(autoPlayNextEpisode) =>
updateSettings({ autoPlayNextEpisode })
}
/>
</ListItem>
<ListItem
title={t("home.settings.other.max_auto_play_episode_count")}
disabled={
!settings.autoPlayNextEpisode ||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
}
>
<PlatformDropdown
groups={autoPlayEpisodeOptions}
trigger={

View File

@@ -1,5 +1,5 @@
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -28,6 +28,16 @@ export const PluginSettings = () => {
title='Marlin Search'
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/streamystats/page")}
title='Streamystats'
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
title='KefinTweaks'
showArrow
/>
</ListGroup>
);
};

View File

@@ -7,7 +7,7 @@ import {
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import type React from "react";
import { useCallback, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
@@ -28,6 +28,11 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const snapPoints = useMemo(
() => (Platform.OS === "android" ? ["100%"] : ["40%"]),
[],
);
const isAndroid = Platform.OS === "android";
const { t } = useTranslation();
@@ -53,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
successHapticFeedback();
Alert.alert(
t("home.settings.quick_connect.success"),
t("home.settings.quick_connect.quick_connect_autorized"),
t("home.settings.quick_connect.quick_connect_authorized"),
);
setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close();
@@ -92,7 +97,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
snapPoints={snapPoints}
handleIndicatorStyle={{
backgroundColor: "white",
}}
@@ -100,9 +105,10 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
keyboardBehavior='interactive'
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>

View File

@@ -1,8 +1,8 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { Alert, Platform, View } from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
@@ -18,6 +18,7 @@ export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const { settings } = useSettings();
const { t } = useTranslation();
const queryClient = useQueryClient();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -34,6 +35,8 @@ export const StorageSettings = () => {
used: (app.total - app.remaining) / app.total,
};
},
// Keep the bar moving while a download is writing to disk.
refetchInterval: 10 * 1000,
});
const { data: storageLabel } = useQuery({
@@ -42,14 +45,34 @@ export const StorageSettings = () => {
enabled: Platform.OS === "android",
});
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
const onDeleteClicked = () => {
Alert.alert(
t("home.settings.storage.delete_all_downloaded_files_confirm"),
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
[
{
text: t("common.cancel"),
style: "cancel",
},
{
text: t("common.ok"),
style: "destructive",
onPress: async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
} finally {
// Reflect the freed space immediately instead of waiting for
// the next poll.
queryClient.invalidateQueries({ queryKey: ["appSize"] });
}
},
},
],
);
};
const calculatePercentage = (value: number, total: number) => {

View File

@@ -1,16 +1,11 @@
import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { Input } from "@/components/common/Input";
import { Stepper } from "@/components/inputs/Stepper";
import {
OUTLINE_THICKNESS,
type OutlineThickness,
VLC_COLORS,
type VLCColor,
} from "@/constants/SubtitleConstants";
import { useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
@@ -29,6 +24,11 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const cultures = media.cultures;
const { t } = useTranslation();
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
settings?.openSubtitlesApiKey || "",
);
const subtitleModes = [
SubtitlePlaybackMode.Default,
SubtitlePlaybackMode.Smart,
@@ -92,84 +92,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
];
}, [settings?.subtitleMode, t, updateSettings]);
const textColorOptionGroups = useMemo(() => {
const colors = Object.keys(VLC_COLORS) as VLCColor[];
const options = colors.map((color) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.colors.${color}`),
value: color,
selected: (settings?.vlcTextColor || "White") === color,
onPress: () => updateSettings({ vlcTextColor: color }),
}));
return [{ options }];
}, [settings?.vlcTextColor, t, updateSettings]);
const backgroundColorOptionGroups = useMemo(() => {
const colors = Object.keys(VLC_COLORS) as VLCColor[];
const options = colors.map((color) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.colors.${color}`),
value: color,
selected: (settings?.vlcBackgroundColor || "Black") === color,
onPress: () => updateSettings({ vlcBackgroundColor: color }),
}));
return [{ options }];
}, [settings?.vlcBackgroundColor, t, updateSettings]);
const outlineColorOptionGroups = useMemo(() => {
const colors = Object.keys(VLC_COLORS) as VLCColor[];
const options = colors.map((color) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.colors.${color}`),
value: color,
selected: (settings?.vlcOutlineColor || "Black") === color,
onPress: () => updateSettings({ vlcOutlineColor: color }),
}));
return [{ options }];
}, [settings?.vlcOutlineColor, t, updateSettings]);
const outlineThicknessOptionGroups = useMemo(() => {
const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[];
const options = thicknesses.map((thickness) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.thickness.${thickness}`),
value: thickness,
selected: (settings?.vlcOutlineThickness || "Normal") === thickness,
onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
}));
return [{ options }];
}, [settings?.vlcOutlineThickness, t, updateSettings]);
const backgroundOpacityOptionGroups = useMemo(() => {
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
const options = opacities.map((opacity) => ({
type: "radio" as const,
label: `${Math.round((opacity / 255) * 100)}%`,
value: opacity,
selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity,
onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }),
}));
return [{ options }];
}, [settings?.vlcBackgroundOpacity, updateSettings]);
const outlineOpacityOptionGroups = useMemo(() => {
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
const options = opacities.map((opacity) => ({
type: "radio" as const,
label: `${Math.round((opacity / 255) * 100)}%`,
value: opacity,
selected: (settings?.vlcOutlineOpacity ?? 255) === opacity,
onPress: () => updateSettings({ vlcOutlineOpacity: opacity }),
}));
return [{ options }];
}, [settings?.vlcOutlineOpacity, updateSettings]);
if (isTv) return null;
if (!settings) return null;
@@ -244,132 +166,54 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
disabled={pluginSettings?.subtitleSize?.locked}
>
<Stepper
value={settings.subtitleSize}
value={settings.mpvSubtitleScale ?? 1.0}
disabled={pluginSettings?.subtitleSize?.locked}
step={5}
min={0}
max={120}
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.text_color")}>
<PlatformDropdown
groups={textColorOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
step={0.1}
min={0.1}
max={3.0}
onUpdate={(value) =>
updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 })
}
title={t("home.settings.subtitles.text_color")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.background_color")}>
<PlatformDropdown
groups={backgroundColorOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
</ListGroup>
{/* OpenSubtitles API Key for client-side subtitle fetching */}
<ListGroup
title={
t("home.settings.subtitles.opensubtitles_title") || "OpenSubtitles"
}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.subtitles.opensubtitles_hint") ||
"Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."}
</Text>
}
>
<View className='p-4'>
<Text className='text-xs text-gray-400 mb-2'>
{t("home.settings.subtitles.opensubtitles_api_key") || "API Key"}
</Text>
<Input
className='border border-neutral-800'
placeholder={
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
"Enter API key..."
}
title={t("home.settings.subtitles.background_color")}
value={openSubtitlesApiKey}
onChangeText={setOpenSubtitlesApiKey}
onBlur={() => {
updateSettings({ openSubtitlesApiKey });
}}
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.outline_color")}>
<PlatformDropdown
groups={outlineColorOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.subtitles.outline_color")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
<PlatformDropdown
groups={outlineThicknessOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.subtitles.outline_thickness")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.background_opacity")}>
<PlatformDropdown
groups={backgroundOpacityOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.subtitles.background_opacity")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
<PlatformDropdown
groups={outlineOpacityOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.subtitles.outline_opacity")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.bold_text")}>
<Switch
value={settings?.vlcIsBold ?? false}
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
/>
</ListItem>
<Text className='text-xs text-gray-500 mt-2'>
{t("home.settings.subtitles.opensubtitles_get_key") ||
"Get your free API key at opensubtitles.com/en/consumers"}
</Text>
</View>
</ListGroup>
</View>
);

View File

@@ -1,8 +1,8 @@
import * as Application from "expo-application";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getVersionInfo } from "@/utils/version";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -13,10 +13,9 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const version =
Application?.nativeApplicationVersion ||
Application?.nativeBuildVersion ||
"N/A";
// Graduated build identifier — see utils/version.ts:
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
const { display: version } = getVersionInfo();
return (
<View {...props}>