Compare commits

..

9 Commits

Author SHA1 Message Date
Gauvain
fd4215407d fix(review): effective streamystats gate, honest refresh toast, locked jellyseerr url, i18n admin notice 2026-07-17 17:41:12 +02:00
Gauvain
b7bdd99cdd fix(streamystats): derive control enablement from the effective server URL 2026-07-15 00:34:19 +02:00
Gauvain
6ba1111794 fix(settings): apply the plugin (admin) default when a setting is unlocked
A setting that was always locked then unlocked showed the app's hardcoded
default instead of the plugin's configured default: the unlocked branch
treated the persisted app default as a meaningful user value, so the plugin
value was effectively dead code for any key with a non-empty app default.

Keep the user's value only when it diverges from the app default; otherwise
the plugin (admin) default wins over the hardcoded app default. Shared via
useSettings, so mobile and TV are both fixed.
2026-07-15 00:33:56 +02:00
Gauvain
9fea9015eb refactor(kefin-tweaks): use the standard ListItem/disabledByAdmin pattern
Rebuild the KefinTweaks toggle on ListGroup + ListItem like the other
plugin settings: drop the bespoke card, the red label, the custom switch
colours, and the `t("Watchlist On")`/`t("Watchlist Off")` literal-as-key
strings. The admin lock now shows via the ListItem subtitle instead of
wrapping the whole screen in DisabledSetting.
2026-07-15 00:33:51 +02:00
Gauvain
500da94583 fix(jellyseerr): lock only the server URL, keep the password editable
The page greyed out the whole settings screen when the admin locked the
server URL, so the user couldn't even type their password to sign in to
the pinned server. Now only the URL field is disabled (greyed + "Disabled
by admin"); the password input stays editable.
2026-07-15 00:33:42 +02:00
Gauvain
fb8f11460b refactor(settings): move "refresh from server" to the plugins index
The button pulls the centralised Streamyfin plugin settings for every
plugin, so it belongs on the plugins list page rather than buried inside
the Streamystats screen.
2026-07-15 00:33:35 +02:00
Gauvain
112d9bef96 fix(marlin-search): show the admin-lock notice via the ListItem subtitle
The "Disabled by admin" notice was rendered by DisabledSetting inside the
rounded, overflow-hidden card, which clipped the text. Switch to the same
pattern as the Streamystats settings: plain ListItems with the
`disabledByAdmin` prop, so the notice renders as the row subtitle and the
URL/toggle disable per-field.
2026-07-15 00:33:30 +02:00
Gauvain
692e02e42f fix(settings): enforce admin-locked settings at write time
updateSettings persisted any key into user storage, including ones the
admin locked via the Streamyfin plugin. The read memo already overrides
locked keys at runtime, but the write still landed in storage and
several settings screens never disable their controls, so locked
settings appeared changeable. Strip locked keys before persisting.
2026-07-15 00:33:24 +02:00
Gauvain
8618e2d6c3 fix(settings): restore plugin list order
Jellyseerr / Streamystats / Marlin Search rows were reordered by
mistake; put them back in the original order.
2026-07-15 00:33:17 +02:00
48 changed files with 358 additions and 503 deletions

View File

@@ -324,44 +324,32 @@ export default function SettingsTV() {
// MPV alignment options // MPV alignment options
const alignXOptions: TVOptionItem<string>[] = useMemo( const alignXOptions: TVOptionItem<string>[] = useMemo(
() => [ () => [
{ label: "Left", value: "left", selected: currentAlignX === "left" },
{ {
label: t("home.settings.subtitles.align.left"), label: "Center",
value: "left",
selected: currentAlignX === "left",
},
{
label: t("home.settings.subtitles.align.center"),
value: "center", value: "center",
selected: currentAlignX === "center", selected: currentAlignX === "center",
}, },
{ { label: "Right", value: "right", selected: currentAlignX === "right" },
label: t("home.settings.subtitles.align.right"),
value: "right",
selected: currentAlignX === "right",
},
], ],
[currentAlignX, t], [currentAlignX],
); );
const alignYOptions: TVOptionItem<string>[] = useMemo( const alignYOptions: TVOptionItem<string>[] = useMemo(
() => [ () => [
{ label: "Top", value: "top", selected: currentAlignY === "top" },
{ {
label: t("home.settings.subtitles.align.top"), label: "Center",
value: "top",
selected: currentAlignY === "top",
},
{
label: t("home.settings.subtitles.align.center"),
value: "center", value: "center",
selected: currentAlignY === "center", selected: currentAlignY === "center",
}, },
{ {
label: t("home.settings.subtitles.align.bottom"), label: "Bottom",
value: "bottom", value: "bottom",
selected: currentAlignY === "bottom", selected: currentAlignY === "bottom",
}, },
], ],
[currentAlignY, t], [currentAlignY],
); );
// Cache mode options // Cache mode options
@@ -511,13 +499,13 @@ export default function SettingsTV() {
const alignXLabel = useMemo(() => { const alignXLabel = useMemo(() => {
const option = alignXOptions.find((o) => o.selected); const option = alignXOptions.find((o) => o.selected);
return option?.label || t("home.settings.subtitles.align.center"); return option?.label || "Center";
}, [alignXOptions, t]); }, [alignXOptions]);
const alignYLabel = useMemo(() => { const alignYLabel = useMemo(() => {
const option = alignYOptions.find((o) => o.selected); const option = alignYOptions.find((o) => o.selected);
return option?.label || t("home.settings.subtitles.align.bottom"); return option?.label || "Bottom";
}, [alignYOptions, t]); }, [alignYOptions]);
const typographyScaleLabel = useMemo(() => { const typographyScaleLabel = useMemo(() => {
const option = typographyScaleOptions.find((o) => o.selected); const option = typographyScaleOptions.find((o) => o.selected);
@@ -657,7 +645,7 @@ export default function SettingsTV() {
formatValue={(v) => `${v.toFixed(1)}x`} formatValue={(v) => `${v.toFixed(1)}x`}
/> />
<TVSettingsStepper <TVSettingsStepper
label={t("home.settings.subtitles.mpv_subtitle_margin_y")} label='Vertical Margin'
value={settings.mpvSubtitleMarginY ?? 0} value={settings.mpvSubtitleMarginY ?? 0}
onDecrease={() => { onDecrease={() => {
const newValue = Math.max( const newValue = Math.max(
@@ -675,11 +663,11 @@ export default function SettingsTV() {
}} }}
/> />
<TVSettingsOptionButton <TVSettingsOptionButton
label={t("home.settings.subtitles.mpv_subtitle_align_x")} label='Horizontal Alignment'
value={alignXLabel} value={alignXLabel}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: t("home.settings.subtitles.mpv_subtitle_align_x"), title: "Horizontal Alignment",
options: alignXOptions, options: alignXOptions,
onSelect: (value) => onSelect: (value) =>
updateSettings({ updateSettings({
@@ -689,11 +677,11 @@ export default function SettingsTV() {
} }
/> />
<TVSettingsOptionButton <TVSettingsOptionButton
label={t("home.settings.subtitles.mpv_subtitle_align_y")} label='Vertical Alignment'
value={alignYLabel} value={alignYLabel}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: t("home.settings.subtitles.mpv_subtitle_align_y"), title: "Vertical Alignment",
options: alignYOptions, options: alignYOptions,
onSelect: (value) => onSelect: (value) =>
updateSettings({ updateSettings({

View File

@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
))} ))}
</ListGroup> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")} {t("home.settings.other.select_liraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
</ScrollView> </ScrollView>

View File

@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
))} ))}
</ListGroup> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")} {t("home.settings.other.select_liraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
); );

View File

@@ -1,11 +1,8 @@
import { ScrollView } 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 DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function JellyseerrPluginPage() { export default function JellyseerrPluginPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (
@@ -16,12 +13,9 @@ export default function JellyseerrPluginPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<DisabledSetting <View className='p-4'>
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='p-4'
>
<JellyseerrSettings /> <JellyseerrSettings />
</DisabledSetting> </View>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,11 +1,8 @@
import { ScrollView } 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 DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks"; import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings";
export default function KefinTweaksPage() { export default function KefinTweaksPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (
@@ -16,12 +13,9 @@ export default function KefinTweaksPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<DisabledSetting <View className='px-4'>
disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='p-4'
>
<KefinTweaksSettings /> <KefinTweaksSettings />
</DisabledSetting> </View>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,5 +1,5 @@
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Linking, Linking,
@@ -14,22 +14,24 @@ import { toast } from "sonner-native";
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";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function MarlinSearchPage() { export default function MarlinSearchPage() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || ""); const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const searchEngineLocked = pluginSettings?.searchEngine?.locked === true;
const marlinUrlLocked = pluginSettings?.marlinServerUrl?.locked === true;
// Effective (user/admin merged) URL, same source the search screen uses —
// the raw plugin value misses a user-configured Streamystats.
const hasStreamystats = !!settings?.streamyStatsServerUrl;
const onSave = (val: string) => { const onSave = (val: string) => {
updateSettings({ updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1), marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
@@ -41,15 +43,8 @@ export default function MarlinSearchPage() {
Linking.openURL("https://github.com/fredrikburmester/marlin-search"); Linking.openURL("https://github.com/fredrikburmester/marlin-search");
}; };
const disabled = useMemo(() => {
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => { useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) { if (!marlinUrlLocked) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)} className='px-2'> <TouchableOpacity onPress={() => onSave(value)} className='px-2'>
@@ -60,7 +55,7 @@ export default function MarlinSearchPage() {
), ),
}); });
} }
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]); }, [navigation, value, marlinUrlLocked, t]);
if (!settings) return null; if (!settings) return null;
@@ -72,19 +67,18 @@ export default function MarlinSearchPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<DisabledSetting disabled={disabled} className='px-4'> <View className='px-4'>
<ListGroup> <ListGroup>
<DisabledSetting {/* disabledByAdmin renders the "Disabled by admin" notice as the row's
disabled={ subtitle (same pattern as the Streamystats settings) — no clipping. */}
pluginSettings?.searchEngine?.locked === true ||
!!pluginSettings?.streamyStatsServerUrl?.value
}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem <ListItem
title={t( title={t(
"home.settings.plugins.marlin_search.enable_marlin_search", "home.settings.plugins.marlin_search.enable_marlin_search",
)} )}
disabledByAdmin={searchEngineLocked}
// Streamystats owns the search engine while configured — block the
// row tap too, not just the Switch, so it can't force "Jellyfin".
disabled={hasStreamystats}
onPress={() => { onPress={() => {
updateSettings({ searchEngine: "Jellyfin" }); updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
@@ -92,32 +86,23 @@ export default function MarlinSearchPage() {
> >
<Switch <Switch
value={settings.searchEngine === "Marlin"} value={settings.searchEngine === "Marlin"}
disabled={!!pluginSettings?.streamyStatsServerUrl?.value} disabled={searchEngineLocked || hasStreamystats}
onValueChange={(value) => { onValueChange={(val) => {
updateSettings({ updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
searchEngine: value ? "Marlin" : "Jellyfin",
});
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
}} }}
/> />
</ListItem> </ListItem>
</DisabledSetting>
</ListGroup> </ListGroup>
<DisabledSetting <ListGroup className='mt-2'>
disabled={pluginSettings?.marlinServerUrl?.locked === true} <ListItem
showText={!pluginSettings?.searchEngine?.locked} title={t("home.settings.plugins.marlin_search.url")}
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4' disabledByAdmin={marlinUrlLocked}
> >
<View
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
>
<Text className='mr-4'>
{t("home.settings.plugins.marlin_search.url")}
</Text>
<TextInput <TextInput
editable={settings.searchEngine === "Marlin"} editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
className='text-white' className='text-white text-right flex-1'
placeholder={t( placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder", "home.settings.plugins.marlin_search.server_url_placeholder",
)} )}
@@ -128,15 +113,16 @@ export default function MarlinSearchPage() {
textContentType='URL' textContentType='URL'
onChangeText={(text) => setValue(text)} onChangeText={(text) => setValue(text)}
/> />
</View> </ListItem>
</DisabledSetting> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "} {t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}> <Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")} {t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text> </Text>
</Text> </Text>
</DisabledSetting> </View>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,9 +1,28 @@
import { Platform, ScrollView, View } from "react-native"; import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } 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 { Text } from "@/components/common/Text";
import { PluginSettings } from "@/components/settings/PluginSettings"; import { PluginSettings } from "@/components/settings/PluginSettings";
import { useSettings } from "@/utils/atoms/settings";
export default function PluginsPage() { export default function PluginsPage() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { refreshStreamyfinPluginSettings } = useSettings();
const handleRefreshFromServer = useCallback(async () => {
// Returns undefined when the API call fails (handled internally).
const refreshed = await refreshStreamyfinPluginSettings();
if (refreshed) {
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
} else {
toast.error(
t("home.settings.plugins.streamystats.toasts.refresh_failed"),
);
}
}, [refreshStreamyfinPluginSettings, t]);
return ( return (
<ScrollView <ScrollView
@@ -18,6 +37,17 @@ export default function PluginsPage() {
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
> >
<PluginSettings /> <PluginSettings />
{/* Pulls the centralised Streamyfin plugin settings for every plugin,
so it lives on the plugins index rather than inside Streamystats. */}
<TouchableOpacity
onPress={handleRefreshFromServer}
className='py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-blue-500'>
{t("home.settings.plugins.streamystats.refresh_from_server")}
</Text>
</TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
); );

View File

@@ -22,12 +22,7 @@ export default function StreamystatsPage() {
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { const { settings, updateSettings, pluginSettings } = useSettings();
settings,
updateSettings,
pluginSettings,
refreshStreamyfinPluginSettings,
} = useSettings();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
// Local state for all editable fields // Local state for all editable fields
@@ -49,7 +44,21 @@ export default function StreamystatsPage() {
); );
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true; const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
const isStreamystatsEnabled = !!url; const searchLocked = pluginSettings?.searchEngine?.locked === true;
const movieRecsLocked =
pluginSettings?.streamyStatsMovieRecommendations?.locked === true;
const seriesRecsLocked =
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true;
const promotedWatchlistsLocked =
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true;
const hideWatchlistsTabLocked =
pluginSettings?.hideWatchlistsTab?.locked === true;
// The input renders the locked admin URL; enablement must follow the same
// effective value or every toggle stays disabled until local state syncs.
const effectiveUrl = isUrlLocked
? (settings?.streamyStatsServerUrl ?? "")
: url;
const isStreamystatsEnabled = !!effectiveUrl;
const onSave = useCallback(() => { const onSave = useCallback(() => {
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url; const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
@@ -113,17 +122,6 @@ export default function StreamystatsPage() {
Linking.openURL("https://github.com/fredrikburmester/streamystats"); Linking.openURL("https://github.com/fredrikburmester/streamystats");
}; };
const handleRefreshFromServer = useCallback(async () => {
const newPluginSettings = await refreshStreamyfinPluginSettings();
// Update local state with new values
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
setUrl(newUrl);
if (newUrl) {
setUseForSearch(true);
}
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
}, [refreshStreamyfinPluginSettings, t]);
if (!settings) return null; if (!settings) return null;
return ( return (
@@ -146,7 +144,7 @@ export default function StreamystatsPage() {
placeholder={t( placeholder={t(
"home.settings.plugins.streamystats.server_url_placeholder", "home.settings.plugins.streamystats.server_url_placeholder",
)} )}
value={url} value={effectiveUrl}
keyboardType='url' keyboardType='url'
returnKeyType='done' returnKeyType='done'
autoCapitalize='none' autoCapitalize='none'
@@ -171,11 +169,18 @@ export default function StreamystatsPage() {
> >
<ListItem <ListItem
title={t("home.settings.plugins.streamystats.enable_search")} title={t("home.settings.plugins.streamystats.enable_search")}
disabledByAdmin={pluginSettings?.searchEngine?.locked === true} disabledByAdmin={searchLocked}
> >
{/* Locked controls show the live admin value and can't be toggled —
local form state would let the switch flip while the write guard
drops the change. */}
<Switch <Switch
value={useForSearch} value={
disabled={!isStreamystatsEnabled} searchLocked
? settings?.searchEngine === "Streamystats"
: useForSearch
}
disabled={!isStreamystatsEnabled || searchLocked}
onValueChange={setUseForSearch} onValueChange={setUseForSearch}
/> />
</ListItem> </ListItem>
@@ -183,52 +188,62 @@ export default function StreamystatsPage() {
title={t( title={t(
"home.settings.plugins.streamystats.enable_movie_recommendations", "home.settings.plugins.streamystats.enable_movie_recommendations",
)} )}
disabledByAdmin={ disabledByAdmin={movieRecsLocked}
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
}
> >
<Switch <Switch
value={movieRecs} value={
movieRecsLocked
? (settings?.streamyStatsMovieRecommendations ?? false)
: movieRecs
}
onValueChange={setMovieRecs} onValueChange={setMovieRecs}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || movieRecsLocked}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t( title={t(
"home.settings.plugins.streamystats.enable_series_recommendations", "home.settings.plugins.streamystats.enable_series_recommendations",
)} )}
disabledByAdmin={ disabledByAdmin={seriesRecsLocked}
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
}
> >
<Switch <Switch
value={seriesRecs} value={
seriesRecsLocked
? (settings?.streamyStatsSeriesRecommendations ?? false)
: seriesRecs
}
onValueChange={setSeriesRecs} onValueChange={setSeriesRecs}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || seriesRecsLocked}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t( title={t(
"home.settings.plugins.streamystats.enable_promoted_watchlists", "home.settings.plugins.streamystats.enable_promoted_watchlists",
)} )}
disabledByAdmin={ disabledByAdmin={promotedWatchlistsLocked}
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
}
> >
<Switch <Switch
value={promotedWatchlists} value={
promotedWatchlistsLocked
? (settings?.streamyStatsPromotedWatchlists ?? false)
: promotedWatchlists
}
onValueChange={setPromotedWatchlists} onValueChange={setPromotedWatchlists}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")} title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true} disabledByAdmin={hideWatchlistsTabLocked}
> >
<Switch <Switch
value={hideWatchlistsTab} value={
hideWatchlistsTabLocked
? (settings?.hideWatchlistsTab ?? false)
: hideWatchlistsTab
}
onValueChange={setHideWatchlistsTab} onValueChange={setHideWatchlistsTab}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
/> />
</ListItem> </ListItem>
</ListGroup> </ListGroup>
@@ -236,15 +251,6 @@ export default function StreamystatsPage() {
{t("home.settings.plugins.streamystats.home_sections_hint")} {t("home.settings.plugins.streamystats.home_sections_hint")}
</Text> </Text>
<TouchableOpacity
onPress={handleRefreshFromServer}
className='mt-6 py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-blue-500'>
{t("home.settings.plugins.streamystats.refresh_from_server")}
</Text>
</TouchableOpacity>
{/* Disable button - only show if URL is not locked and Streamystats is enabled */} {/* Disable button - only show if URL is not locked and Streamystats is enabled */}
{!isUrlLocked && isStreamystatsEnabled && ( {!isUrlLocked && isStreamystatsEnabled && (
<TouchableOpacity <TouchableOpacity

View File

@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
return ( return (
<View className='flex-1 justify-center items-center bg-black px-6'> <View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'> <Text className='text-neutral-500 text-center'>
{t("music.missing_library_id")} Missing music library id.
</Text> </Text>
</View> </View>
); );

View File

@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
return ( return (
<View className='flex-1 justify-center items-center bg-black px-6'> <View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'> <Text className='text-neutral-500 text-center'>
{t("music.missing_library_id")} Missing music library id.
</Text> </Text>
</View> </View>
); );

View File

@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
return ( return (
<View className='flex-1 justify-center items-center bg-black px-6'> <View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'> <Text className='text-neutral-500 text-center'>
{t("music.missing_library_id")} Missing music library id.
</Text> </Text>
</View> </View>
); );

View File

@@ -14,7 +14,6 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
@@ -73,7 +72,6 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
type ViewMode = "player" | "queue"; type ViewMode = "player" | "queue";
export default function NowPlayingScreen() { export default function NowPlayingScreen() {
const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const router = useRouter(); const router = useRouter();
@@ -232,9 +230,7 @@ export default function NowPlayingScreen() {
paddingBottom: Platform.OS === "android" ? insets.bottom : 0, paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}} }}
> >
<Text className='text-neutral-500'> <Text className='text-neutral-500'>No track playing</Text>
{t("music.no_track_playing")}
</Text>
</View> </View>
</BottomSheetModalProvider> </BottomSheetModalProvider>
); );
@@ -271,7 +267,7 @@ export default function NowPlayingScreen() {
: "text-neutral-500" : "text-neutral-500"
} }
> >
{t("music.now_playing")} Now Playing
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -722,7 +718,6 @@ const QueueView: React.FC<QueueViewProps> = ({
onRemoveFromQueue, onRemoveFromQueue,
onReorderQueue, onReorderQueue,
}) => { }) => {
const { t } = useTranslation();
const renderQueueItem = useCallback( const renderQueueItem = useCallback(
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => { ({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
const index = getIndex() ?? 0; const index = getIndex() ?? 0;
@@ -836,15 +831,13 @@ const QueueView: React.FC<QueueViewProps> = ({
ListHeaderComponent={ ListHeaderComponent={
<View className='px-4 py-2'> <View className='px-4 py-2'>
<Text className='text-neutral-400 text-xs uppercase tracking-wider'> <Text className='text-neutral-400 text-xs uppercase tracking-wider'>
{history.length > 0 {history.length > 0 ? "Playing from queue" : "Up next"}
? t("music.playing_from_queue")
: t("music.up_next")}
</Text> </Text>
</View> </View>
} }
ListEmptyComponent={ ListEmptyComponent={
<View className='flex-1 items-center justify-center py-20'> <View className='flex-1 items-center justify-center py-20'>
<Text className='text-neutral-500'>{t("music.queue_empty")}</Text> <Text className='text-neutral-500'>Queue is empty</Text>
</View> </View>
} }
/> />

View File

@@ -1318,7 +1318,7 @@ export default function DirectPlayerPage() {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(
t("player.error"), t("player.error"),
t("player.an_error_occurred_while_playing_the_video"), t("player.an_error_occured_while_playing_the_video"),
); );
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}

View File

@@ -192,7 +192,6 @@ const SubtitleResultCard = React.forwardRef<
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => { >(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.03 }); useTVFocusAnimation({ scaleAmount: 1.03 });
const { t } = useTranslation();
return ( return (
<Pressable <Pressable
@@ -329,7 +328,7 @@ const SubtitleResultCard = React.forwardRef<
]} ]}
> >
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}> <Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
{t("player.hash_match")} Hash Match
</Text> </Text>
</View> </View>
)} )}

View File

@@ -1,20 +1,17 @@
import { Link, Stack } from "expo-router"; import { Link, Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native"; import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView"; import { ThemedView } from "@/components/ThemedView";
export default function NotFoundScreen() { export default function NotFoundScreen() {
const { t } = useTranslation();
return ( return (
<> <>
<Stack.Screen options={{ title: t("home.oops") }} /> <Stack.Screen options={{ title: "Oops!" }} />
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ThemedText type='title'>{t("not_found.title")}</ThemedText> <ThemedText type='title'>This screen doesn't exist.</ThemedText>
<Link href={"/home"} style={styles.link}> <Link href={"/home"} style={styles.link}>
<ThemedText type='link'>{t("not_found.go_home")}</ThemedText> <ThemedText type='link'>Go to home screen!</ThemedText>
</Link> </Link>
</ThemedView> </ThemedView>
</> </>

View File

@@ -85,8 +85,7 @@ configureReanimatedLogger({
if (!Platform.isTV) { if (!Platform.isTV) {
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async () => ({ handleNotification: async () => ({
shouldShowBanner: true, shouldShowAlert: true,
shouldShowList: true,
shouldPlaySound: true, shouldPlaySound: true,
shouldSetBadge: false, shouldSetBadge: false,
}), }),
@@ -351,12 +350,9 @@ function Layout() {
notificationListener.current = notificationListener.current =
Notifications?.addNotificationReceivedListener( Notifications?.addNotificationReceivedListener(
(notification: Notification) => { (notification: Notification) => {
// Log only the title — serializing the whole notification touches
// the deprecated dataString getter (deprecation warning) and dumps
// noisy payloads into the console.
console.log( console.log(
"Notification received while app running:", "Notification received while app running",
notification.request.content.title, notification,
); );
}, },
); );

View File

@@ -1,7 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -210,7 +209,6 @@ const PlatformDropdownComponent = ({
expoUIConfig, expoUIConfig,
bottomSheetConfig, bottomSheetConfig,
}: PlatformDropdownProps) => { }: PlatformDropdownProps) => {
const { t } = useTranslation();
const { showModal, hideModal, isVisible } = useGlobalModal(); const { showModal, hideModal, isVisible } = useGlobalModal();
// Handle controlled open state for Android // Handle controlled open state for Android
@@ -382,7 +380,7 @@ const PlatformDropdownComponent = ({
return ( return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}> <TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>{t("common.open_menu")}</Text>} {trigger || <Text className='text-white'>Open Menu</Text>}
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
disabled={!item} disabled={!item}
accessibilityLabel={t("accessibility.play_button")} accessibilityLabel='Play button'
accessibilityHint={t("accessibility.play_hint")} accessibilityHint='Tap to play the media'
onPress={onPress} onPress={onPress}
className={"relative flex-1"} className={"relative flex-1"}
> >

View File

@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
@@ -37,7 +36,6 @@ export const PlayButton: React.FC<Props> = ({
colors, colors,
...props ...props
}: Props) => { }: Props) => {
const { t } = useTranslation();
const [globalColorAtom] = useAtom(itemThemeColorAtom); const [globalColorAtom] = useAtom(itemThemeColorAtom);
// Use colors prop if provided, otherwise fallback to global atom // Use colors prop if provided, otherwise fallback to global atom
@@ -170,8 +168,8 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
accessibilityLabel={t("accessibility.play_button")} accessibilityLabel='Play button'
accessibilityHint={t("accessibility.play_hint")} accessibilityHint='Tap to play the media'
onPress={onPress} onPress={onPress}
className={"relative"} className={"relative"}
{...props} {...props}

View File

@@ -6,7 +6,6 @@ import {
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
FlatList, FlatList,
Modal, Modal,
@@ -32,7 +31,6 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { sessions, isLoading } = useAllSessions({} as useSessionsProps); const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
const { t } = useTranslation();
const handlePlayInSession = async (sessionId: string) => { const handlePlayInSession = async (sessionId: string) => {
if (!api || !item.Id) return; if (!api || !item.Id) return;
@@ -67,9 +65,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
<View style={styles.centeredView}> <View style={styles.centeredView}>
<View style={styles.modalView}> <View style={styles.modalView}>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
<Text style={styles.modalTitle}> <Text style={styles.modalTitle}>Select Session</Text>
{t("home.sessions.select_session")}
</Text>
<TouchableOpacity onPress={() => setModalVisible(false)}> <TouchableOpacity onPress={() => setModalVisible(false)}>
<Ionicons name='close' size={24} color='white' /> <Ionicons name='close' size={24} color='white' />
</TouchableOpacity> </TouchableOpacity>
@@ -82,7 +78,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
</View> </View>
) : !sessions || sessions.length === 0 ? ( ) : !sessions || sessions.length === 0 ? (
<Text style={styles.noSessionsText}> <Text style={styles.noSessionsText}>
{t("home.sessions.no_active_sessions")} No active sessions found
</Text> </Text>
) : ( ) : (
<FlatList <FlatList
@@ -102,7 +98,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
</Text> </Text>
{session.NowPlayingItem && ( {session.NowPlayingItem && (
<Text style={styles.nowPlaying} numberOfLines={1}> <Text style={styles.nowPlaying} numberOfLines={1}>
{t("home.sessions.now_playing")}{" "} Now playing:{" "}
{session.NowPlayingItem.SeriesName {session.NowPlayingItem.SeriesName
? `${session.NowPlayingItem.SeriesName} :` ? `${session.NowPlayingItem.SeriesName} :`
: ""} : ""}

View File

@@ -1,7 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { t } from "i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
TouchableOpacity, TouchableOpacity,
@@ -35,7 +35,6 @@ interface DownloadCardProps extends TouchableOpacityProps {
} }
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { t } = useTranslation();
const { cancelDownload } = useDownload(); const { cancelDownload } = useDownload();
const router = useRouter(); const router = useRouter();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
@@ -174,9 +173,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
{isTranscoding && ( {isTranscoding && (
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'> <View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
<Text className='text-xs text-purple-400'> <Text className='text-xs text-purple-400'>Transcoding</Text>
{t("home.downloads.transcoding")}
</Text>
</View> </View>
)} )}

View File

@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const router = useRouter(); const router = useRouter();
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
// instead of freezing the first-rendered series' image (empty deps bug).
const base64Image = useMemo(() => { const base64Image = useMemo(() => {
const seriesId = items[0]?.SeriesId; return storage.getString(items[0].SeriesId!);
return seriesId ? storage.getString(seriesId) : undefined; }, []);
}, [items[0]?.SeriesId]);
const deleteSeries = useCallback( const deleteSeries = useCallback(
async () => async () =>

View File

@@ -1,5 +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 { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -32,7 +33,10 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
disabledByAdmin = false, disabledByAdmin = false,
...viewProps ...viewProps
}) => { }) => {
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle; const { t } = useTranslation();
const effectiveSubtitle = disabledByAdmin
? t("home.settings.disabled_by_admin")
: subtitle;
const isDisabled = disabled || disabledByAdmin; const isDisabled = disabled || disabledByAdmin;
if (onPress) if (onPress)
return ( return (

View File

@@ -1,6 +1,5 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, StyleSheet, View } from "react-native"; import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
@@ -23,7 +22,6 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
disabled = false, disabled = false,
refSetter, refSetter,
}) => { }) => {
const { t } = useTranslation();
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({ const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
scaleAmount: 1, scaleAmount: 1,
@@ -70,7 +68,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
<Text <Text
style={[styles.liveBadgeText, { fontSize: typography.callout }]} style={[styles.liveBadgeText, { fontSize: typography.callout }]}
> >
{t("player.live")} LIVE
</Text> </Text>
</View> </View>
)} )}

View File

@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
marginBottom: 24, marginBottom: 24,
}} }}
> >
{t("live_tv.title")} Live TV
</Text> </Text>
{/* Tab Bar */} {/* Tab Bar */}

View File

@@ -146,7 +146,7 @@ export const Login: React.FC = () => {
} else { } else {
Alert.alert( Alert.alert(
t("login.connection_failed"), t("login.connection_failed"),
t("login.an_unexpected_error_occurred"), t("login.an_unexpected_error_occured"),
); );
} }
} finally { } finally {

View File

@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
} else { } else {
Alert.alert( Alert.alert(
t("login.connection_failed"), t("login.connection_failed"),
t("login.an_unexpected_error_occurred"), t("login.an_unexpected_error_occured"),
); );
} }
} finally { } finally {
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
const message = const message =
error instanceof Error error instanceof Error
? error.message ? error.message
: t("login.an_unexpected_error_occurred"); : t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message); Alert.alert(t("login.connection_failed"), message);
goToQRScreen(); goToQRScreen();
} finally { } finally {
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
} else { } else {
Alert.alert( Alert.alert(
t("login.connection_failed"), t("login.connection_failed"),
t("login.an_unexpected_error_occurred"), t("login.an_unexpected_error_occured"),
); );
} }
} finally { } finally {
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
const message = const message =
error instanceof Error error instanceof Error
? error.message ? error.message
: t("login.an_unexpected_error_occurred"); : t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message); Alert.alert(t("login.connection_failed"), message);
goToQRScreen(); goToQRScreen();
}); });

View File

@@ -1,5 +1,4 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, View } from "react-native"; import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
@@ -89,8 +88,6 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
showDiscover, showDiscover,
disabled = false, disabled = false,
}) => { }) => {
const { t } = useTranslation();
if (!showDiscover) { if (!showDiscover) {
return null; return null;
} }
@@ -104,13 +101,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
}} }}
> >
<TVSearchTabBadge <TVSearchTabBadge
label={t("search.library")} label='Library'
isSelected={searchType === "Library"} isSelected={searchType === "Library"}
onPress={() => setSearchType("Library")} onPress={() => setSearchType("Library")}
disabled={disabled} disabled={disabled}
/> />
<TVSearchTabBadge <TVSearchTabBadge
label={t("search.discover")} label='Discover'
isSelected={searchType === "Discover"} isSelected={searchType === "Discover"}
onPress={() => setSearchType("Discover")} onPress={() => setSearchType("Discover")}
disabled={disabled} disabled={disabled}

View File

@@ -20,7 +20,10 @@ export const JellyseerrSettings = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { settings, updateSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
// Only the server URL is admin-lockable — the password stays editable so
// the user can still sign in to the admin-pinned Jellyseerr server.
const urlLocked = pluginSettings?.jellyseerrServerUrl?.locked === true;
const [jellyseerrPassword, setJellyseerrPassword] = useState< const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined string | undefined
@@ -30,22 +33,25 @@ export const JellyseerrSettings = () => {
string | undefined string | undefined
>(settings?.jellyseerrServerUrl || undefined); >(settings?.jellyseerrServerUrl || undefined);
// When the URL is admin-pinned, ignore any locally-typed value so the login
// targets the same server the (read-only) input displays.
const effectiveServerUrl = urlLocked
? settings?.jellyseerrServerUrl
: jellyseerrServerUrl || settings?.jellyseerrServerUrl;
const loginToJellyseerrMutation = useMutation({ const loginToJellyseerrMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl) if (!effectiveServerUrl) throw new Error("Missing server url");
throw new Error("Missing server url");
if (!user?.Name) if (!user?.Name)
throw new Error("Missing required information for login"); throw new Error("Missing required information for login");
const jellyseerrTempApi = new JellyseerrApi( const jellyseerrTempApi = new JellyseerrApi(effectiveServerUrl);
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
);
const testResult = await jellyseerrTempApi.test(); const testResult = await jellyseerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url"); if (!testResult.isValid) throw new Error("Invalid server url");
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || ""); return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
}, },
onSuccess: (user) => { onSuccess: (user) => {
setJellyseerrUser(user); setJellyseerrUser(user);
updateSettings({ jellyseerrServerUrl }); updateSettings({ jellyseerrServerUrl: effectiveServerUrl });
}, },
onError: () => { onError: () => {
toast.error(t("jellyseerr.failed_to_login")); toast.error(t("jellyseerr.failed_to_login"));
@@ -115,6 +121,7 @@ export const JellyseerrSettings = () => {
</> </>
) : ( ) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'> <View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<View style={{ opacity: urlLocked ? 0.5 : 1 }}>
<Text className='font-bold mb-1'> <Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")} {t("home.settings.plugins.jellyseerr.server_url")}
</Text> </Text>
@@ -128,7 +135,11 @@ export const JellyseerrSettings = () => {
placeholder={t( placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder", "home.settings.plugins.jellyseerr.server_url_placeholder",
)} )}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl} value={
urlLocked
? settings?.jellyseerrServerUrl
: (jellyseerrServerUrl ?? settings?.jellyseerrServerUrl)
}
defaultValue={ defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
} }
@@ -137,8 +148,14 @@ export const JellyseerrSettings = () => {
autoCapitalize='none' autoCapitalize='none'
textContentType='URL' textContentType='URL'
onChangeText={setjellyseerrServerUrl} onChangeText={setjellyseerrServerUrl}
editable={!loginToJellyseerrMutation.isPending} editable={!urlLocked && !loginToJellyseerrMutation.isPending}
/> />
{urlLocked && (
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.disabled_by_admin")}
</Text>
)}
</View>
<View> <View>
<Text className='font-bold mb-2'> <Text className='font-bold mb-2'>
{t("home.settings.plugins.jellyseerr.password")} {t("home.settings.plugins.jellyseerr.password")}

View File

@@ -1,33 +1,28 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Switch, Text, View } from "react-native"; import { Switch } from "react-native";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const KefinTweaksSettings = () => { export const KefinTweaksSettings = () => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const isEnabled = settings?.useKefinTweaks ?? false; const isEnabled = settings?.useKefinTweaks ?? false;
const locked = pluginSettings?.useKefinTweaks?.locked === true;
return ( return (
<View className=''> <ListGroup>
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'> <ListItem
<Text className='text-xs text-red-600 mb-2'> title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")} disabledByAdmin={locked}
</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 <Switch
value={isEnabled} value={isEnabled}
disabled={locked}
onValueChange={(value) => updateSettings({ useKefinTweaks: value })} onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
trackColor={{ false: "#555", true: "purple" }}
thumbColor={isEnabled ? "#fff" : "#ccc"}
/> />
</View> </ListItem>
</View> </ListGroup>
</View>
); );
}; };

View File

@@ -1,6 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, Switch, View, type ViewProps } from "react-native"; import { Platform, Switch, View, type ViewProps } from "react-native";
import { Stepper } from "@/components/inputs/Stepper"; import { Stepper } from "@/components/inputs/Stepper";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -18,21 +17,20 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
const media = useMedia(); const media = useMedia();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
const { t } = useTranslation();
const alignXOptions: AlignX[] = ["left", "center", "right"]; const alignXOptions: AlignX[] = ["left", "center", "right"];
const alignYOptions: AlignY[] = ["top", "center", "bottom"]; const alignYOptions: AlignY[] = ["top", "center", "bottom"];
const alignXLabels: Record<AlignX, string> = { const alignXLabels: Record<AlignX, string> = {
left: t("home.settings.subtitles.align.left"), left: "Left",
center: t("home.settings.subtitles.align.center"), center: "Center",
right: t("home.settings.subtitles.align.right"), right: "Right",
}; };
const alignYLabels: Record<AlignY, string> = { const alignYLabels: Record<AlignY, string> = {
top: t("home.settings.subtitles.align.top"), top: "Top",
center: t("home.settings.subtitles.align.center"), center: "Center",
bottom: t("home.settings.subtitles.align.bottom"), bottom: "Bottom",
}; };
const alignXOptionGroups = useMemo(() => { const alignXOptionGroups = useMemo(() => {
@@ -62,18 +60,16 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
return ( return (
<View {...props}> <View {...props}>
<ListGroup <ListGroup
title={t("home.settings.subtitles.mpv_settings_title")} title='MPV Subtitle Settings'
description={ description={
<Text className='text-[#8E8D91] text-xs'> <Text className='text-[#8E8D91] text-xs'>
{t("home.settings.subtitles.mpv_settings_description")} Advanced subtitle customization for MPV player
</Text> </Text>
} }
> >
{!isTv && ( {!isTv && (
<> <>
<ListItem <ListItem title='Vertical Margin'>
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
>
<Stepper <Stepper
value={settings.mpvSubtitleMarginY ?? 0} value={settings.mpvSubtitleMarginY ?? 0}
step={5} step={5}
@@ -85,7 +81,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}> <ListItem title='Horizontal Alignment'>
<PlatformDropdown <PlatformDropdown
groups={alignXOptionGroups} groups={alignXOptionGroups}
trigger={ trigger={
@@ -100,11 +96,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</View> </View>
} }
title={t("home.settings.subtitles.mpv_subtitle_align_x")} title='Horizontal Alignment'
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}> <ListItem title='Vertical Alignment'>
<PlatformDropdown <PlatformDropdown
groups={alignYOptionGroups} groups={alignYOptionGroups}
trigger={ trigger={
@@ -119,13 +115,13 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</View> </View>
} }
title={t("home.settings.subtitles.mpv_subtitle_align_y")} title='Vertical Alignment'
/> />
</ListItem> </ListItem>
</> </>
)} )}
<ListItem title={t("home.settings.subtitles.opaque_background")}> <ListItem title='Opaque Background'>
<Switch <Switch
value={settings.mpvSubtitleBackgroundEnabled ?? false} value={settings.mpvSubtitleBackgroundEnabled ?? false}
onValueChange={(value) => onValueChange={(value) =>
@@ -135,7 +131,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
</ListItem> </ListItem>
{settings.mpvSubtitleBackgroundEnabled && ( {settings.mpvSubtitleBackgroundEnabled && (
<ListItem title={t("home.settings.subtitles.background_opacity")}> <ListItem title='Background Opacity'>
<Stepper <Stepper
value={settings.mpvSubtitleBackgroundOpacity ?? 75} value={settings.mpvSubtitleBackgroundOpacity ?? 75}
step={5} step={5}

View File

@@ -20,12 +20,7 @@ export const PluginSettings = () => {
> >
<ListItem <ListItem
onPress={() => router.push("/settings/plugins/jellyseerr/page")} onPress={() => router.push("/settings/plugins/jellyseerr/page")}
title={"Jellyseerr"} title='Jellyseerr'
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/marlin-search/page")}
title='Marlin Search'
showArrow showArrow
/> />
<ListItem <ListItem
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
title='Streamystats' title='Streamystats'
showArrow showArrow
/> />
<ListItem
onPress={() => router.push("/settings/plugins/marlin-search/page")}
title='Marlin Search'
showArrow
/>
<ListItem <ListItem
onPress={() => router.push("/settings/plugins/kefinTweaks/page")} onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
title='KefinTweaks' title='KefinTweaks'

View File

@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
successHapticFeedback(); successHapticFeedback();
Alert.alert( Alert.alert(
t("home.settings.quick_connect.success"), t("home.settings.quick_connect.success"),
t("home.settings.quick_connect.quick_connect_authorized"), t("home.settings.quick_connect.quick_connect_autorized"),
); );
setQuickConnectCode(undefined); setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close(); bottomSheetModalRef?.current?.close();

View File

@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
export const StorageSettings = () => { export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload(); const { deleteAllFiles, appSizeUsage } = useDownload();
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const successHapticFeedback = useHaptic("success"); const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error"); const errorHapticFeedback = useHaptic("error");
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
used: (app.total - app.remaining) / app.total, used: (app.total - app.remaining) / app.total,
}; };
}, },
// Keep the bar moving while a download is writing to disk.
refetchInterval: 10 * 1000,
}); });
const onDeleteClicked = () => { const onDeleteClicked = async () => {
Alert.alert(
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 { try {
await deleteAllFiles(); await deleteAllFiles();
successHapticFeedback(); successHapticFeedback();
} catch (_e) { } catch (_e) {
errorHapticFeedback(); errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files")); toast.error(t("home.settings.toasts.error_deleting_files"));
} finally {
// Reflect the freed space immediately instead of waiting for
// the next poll.
queryClient.invalidateQueries({ queryKey: ["appSize"] });
} }
},
},
],
);
}; };
const calculatePercentage = (value: number, total: number) => { const calculatePercentage = (value: number, total: number) => {

View File

@@ -1,6 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Animated, Animated,
@@ -29,7 +28,6 @@ export const TVSubtitleResultCard = React.forwardRef<
const styles = createStyles(typography); const styles = createStyles(typography);
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.03 }); useTVFocusAnimation({ scaleAmount: 1.03 });
const { t } = useTranslation();
return ( return (
<Pressable <Pressable
@@ -154,7 +152,7 @@ export const TVSubtitleResultCard = React.forwardRef<
}, },
]} ]}
> >
<Text style={styles.flagText}>{t("player.hash_match")}</Text> <Text style={styles.flagText}>Hash Match</Text>
</View> </View>
)} )}
{result.hearingImpaired && ( {result.hearingImpaired && (

View File

@@ -183,7 +183,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
<SkipButton <SkipButton
showButton={showSkipButton} showButton={showSkipButton}
onPress={skipIntro} onPress={skipIntro}
buttonText={t("player.skip_intro")} buttonText='Skip Intro'
/> />
{/* Smart Skip Credits behavior: {/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode - Show "Skip Credits" if there's content after credits OR no next episode
@@ -193,7 +193,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
showSkipCreditButton && (hasContentAfterCredits || !nextItem) showSkipCreditButton && (hasContentAfterCredits || !nextItem)
} }
onPress={skipCredit} onPress={skipCredit}
buttonText={t("player.skip_credits")} buttonText='Skip Credits'
/> />
{settings.autoPlayNextEpisode !== false && {settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 || (settings.maxAutoPlayEpisodeCount.value === -1 ||

View File

@@ -27,7 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
} }
> >
<Text className='text-2xl font-bold text-white py-4 '> <Text className='text-2xl font-bold text-white py-4 '>
{t("player.still_watching")} Are you still watching ?
</Text> </Text>
<Button <Button
onPress={() => { onPress={() => {

View File

@@ -4,7 +4,6 @@ import type {
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { type FC, useCallback, useState } from "react"; import { type FC, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
@@ -58,7 +57,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
showTechnicalInfo = false, showTechnicalInfo = false,
onToggleTechnicalInfo, onToggleTechnicalInfo,
}) => { }) => {
const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const insets = useControlsSafeAreaInsets(); const insets = useControlsSafeAreaInsets();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
@@ -129,8 +127,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
onPress={toggleOrientation} onPress={toggleOrientation}
disabled={isTogglingOrientation} disabled={isTogglingOrientation}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
accessibilityLabel={t("accessibility.toggle_orientation")} accessibilityLabel='Toggle screen orientation'
accessibilityHint={t("accessibility.toggle_orientation_hint")} accessibilityHint='Toggles the screen orientation between portrait and landscape'
> >
<MaterialIcons <MaterialIcons
name='screen-rotation' name='screen-rotation'

View File

@@ -7,7 +7,6 @@ import {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, Text, View } from "react-native"; import { Platform, StyleSheet, Text, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
@@ -185,7 +184,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
currentAudioIndex, currentAudioIndex,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const safeInsets = useControlsSafeAreaInsets(); const safeInsets = useControlsSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null); const [info, setInfo] = useState<TechnicalInfo | null>(null);
@@ -314,13 +312,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.videoCodec && ( {info?.videoCodec && (
<Text style={textStyle}> <Text style={textStyle}>
{t("player.technical_info.video")} {formatCodec(info.videoCodec)} Video: {formatCodec(info.videoCodec)}
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""} {info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
</Text> </Text>
)} )}
{info?.audioCodec && ( {info?.audioCodec && (
<Text style={textStyle}> <Text style={textStyle}>
{t("player.technical_info.audio")} {formatCodec(info.audioCodec)} Audio: {formatCodec(info.audioCodec)}
{streamInfo?.audioChannels {streamInfo?.audioChannels
? ` ${formatAudioChannels(streamInfo.audioChannels)}` ? ` ${formatAudioChannels(streamInfo.audioChannels)}`
: ""} : ""}
@@ -328,13 +326,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{streamInfo?.subtitleCodec && ( {streamInfo?.subtitleCodec && (
<Text style={textStyle}> <Text style={textStyle}>
{t("player.technical_info.subtitle")}{" "} Subtitle: {formatCodec(streamInfo.subtitleCodec)}
{formatCodec(streamInfo.subtitleCodec)}
</Text> </Text>
)} )}
{(info?.videoBitrate || info?.audioBitrate) && ( {(info?.videoBitrate || info?.audioBitrate) && (
<Text style={textStyle}> <Text style={textStyle}>
{t("player.technical_info.bitrate")}{" "} Bitrate:{" "}
{info.videoBitrate {info.videoBitrate
? formatBitrate(info.videoBitrate) ? formatBitrate(info.videoBitrate)
: info.audioBitrate : info.audioBitrate
@@ -344,9 +341,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.cacheSeconds !== undefined && ( {info?.cacheSeconds !== undefined && (
<Text style={textStyle}> <Text style={textStyle}>
{t("player.technical_info.buffer_seconds", { Buffer: {info.cacheSeconds.toFixed(1)}s
seconds: info.cacheSeconds.toFixed(1),
})}
{info?.demuxerMaxBytes !== undefined {info?.demuxerMaxBytes !== undefined
? ` (cap ${info.demuxerMaxBytes}MB` + ? ` (cap ${info.demuxerMaxBytes}MB` +
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` + `${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
@@ -357,7 +352,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.voDriver && ( {info?.voDriver && (
<Text style={textStyle}> <Text style={textStyle}>
{t("player.technical_info.vo")} {info.voDriver} VO: {info.voDriver}
{info.hwdec ? ` / ${info.hwdec}` : ""} {info.hwdec ? ` / ${info.hwdec}` : ""}
</Text> </Text>
)} )}
@@ -369,14 +364,10 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && ( {info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
<Text style={[textStyle, styles.warningText]}> <Text style={[textStyle, styles.warningText]}>
{t("player.technical_info.dropped_frames", { Dropped: {info.droppedFrames} frames
count: info.droppedFrames,
})}
</Text> </Text>
)} )}
{!info && !playMethod && ( {!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
)}
</View> </View>
</Animated.View> </Animated.View>
); );

View File

@@ -1,6 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { import {
type OptionGroup, type OptionGroup,
@@ -55,7 +54,6 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
onRatioChange, onRatioChange,
disabled = false, disabled = false,
}) => { }) => {
const { t } = useTranslation();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const handleRatioSelect = (ratio: AspectRatio) => { const handleRatioSelect = (ratio: AspectRatio) => {
@@ -68,10 +66,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
{ {
options: ASPECT_RATIO_OPTIONS.map((option) => ({ options: ASPECT_RATIO_OPTIONS.map((option) => ({
type: "radio" as const, type: "radio" as const,
label: label: option.label,
option.id === "default"
? t("player.aspect_ratio_original")
: option.label,
value: option.id, value: option.id,
selected: option.id === currentRatio, selected: option.id === currentRatio,
onPress: () => handleRatioSelect(option.id), onPress: () => handleRatioSelect(option.id),
@@ -99,7 +94,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
return ( return (
<PlatformDropdown <PlatformDropdown
title={t("player.aspect_ratio")} title='Aspect Ratio'
groups={optionGroups} groups={optionGroups}
trigger={trigger} trigger={trigger}
bottomSheetConfig={{ bottomSheetConfig={{

View File

@@ -1,7 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { import {
@@ -48,7 +47,6 @@ const DropdownView = ({
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { t } = useTranslation();
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } = const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
useLocalSearchParams<{ useLocalSearchParams<{
@@ -103,7 +101,7 @@ const DropdownView = ({
// Quality Section // Quality Section
if (!isOffline) { if (!isOffline) {
groups.push({ groups.push({
title: t("player.menu.quality"), title: "Quality",
options: options:
BITRATES?.map((bitrate) => ({ BITRATES?.map((bitrate) => ({
type: "radio" as const, type: "radio" as const,
@@ -118,7 +116,7 @@ const DropdownView = ({
// Subtitle Section // Subtitle Section
if (subtitleTracks && subtitleTracks.length > 0) { if (subtitleTracks && subtitleTracks.length > 0) {
groups.push({ groups.push({
title: t("player.menu.subtitles"), title: "Subtitles",
options: subtitleTracks.map((sub) => ({ options: subtitleTracks.map((sub) => ({
type: "radio" as const, type: "radio" as const,
label: sub.name, label: sub.name,
@@ -130,7 +128,7 @@ const DropdownView = ({
// Subtitle Scale Section // Subtitle Scale Section
groups.push({ groups.push({
title: t("player.menu.subtitle_scale"), title: "Subtitle Scale",
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({ options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
type: "radio" as const, type: "radio" as const,
label: preset.label, label: preset.label,
@@ -144,7 +142,7 @@ const DropdownView = ({
// Audio Section // Audio Section
if (audioTracks && audioTracks.length > 0) { if (audioTracks && audioTracks.length > 0) {
groups.push({ groups.push({
title: t("player.menu.audio"), title: "Audio",
options: audioTracks.map((track) => ({ options: audioTracks.map((track) => ({
type: "radio" as const, type: "radio" as const,
label: track.name, label: track.name,
@@ -158,7 +156,7 @@ const DropdownView = ({
// Speed Section // Speed Section
if (setPlaybackSpeed) { if (setPlaybackSpeed) {
groups.push({ groups.push({
title: t("player.menu.speed"), title: "Speed",
options: PLAYBACK_SPEEDS.map((speed) => ({ options: PLAYBACK_SPEEDS.map((speed) => ({
type: "radio" as const, type: "radio" as const,
label: speed.label, label: speed.label,
@@ -176,8 +174,8 @@ const DropdownView = ({
{ {
type: "action" as const, type: "action" as const,
label: showTechnicalInfo label: showTechnicalInfo
? t("player.menu.hide_technical_info") ? "Hide Technical Info"
: t("player.menu.show_technical_info"), : "Show Technical Info",
onPress: onToggleTechnicalInfo, onPress: onToggleTechnicalInfo,
}, },
], ],
@@ -187,7 +185,6 @@ const DropdownView = ({
return groups; return groups;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
t,
isOffline, isOffline,
bitrateValue, bitrateValue,
changeBitrate, changeBitrate,
@@ -220,7 +217,7 @@ const DropdownView = ({
return ( return (
<PlatformDropdown <PlatformDropdown
title={t("player.menu.playback_options")} title='Playback Options'
groups={optionGroups} groups={optionGroups}
trigger={trigger} trigger={trigger}
expoUIConfig={{}} expoUIConfig={{}}

View File

@@ -3,7 +3,6 @@ import { Alert } from "react-native";
import { type SharedValue, useSharedValue } from "react-native-reanimated"; import { type SharedValue, useSharedValue } from "react-native-reanimated";
import { useTVBackPress } from "@/hooks/useTVBackPress"; import { useTVBackPress } from "@/hooks/useTVBackPress";
import { useTVEventHandler } from "@/hooks/useTVEventHandler"; import { useTVEventHandler } from "@/hooks/useTVEventHandler";
import i18n from "@/i18n";
interface UseRemoteControlProps { interface UseRemoteControlProps {
showControls: boolean; showControls: boolean;
@@ -125,23 +124,17 @@ export function useRemoteControl({
// Controls are hidden, so confirm before leaving playback. // Controls are hidden, so confirm before leaving playback.
Alert.alert( Alert.alert(
i18n.t("player.stopPlayback"), "Stop Playback",
videoTitleRef.current videoTitleRef.current
? i18n.t("player.stopPlayingTitle", { ? `Stop playing "${videoTitleRef.current}"?`
title: videoTitleRef.current, : "Are you sure you want to stop playback?",
})
: i18n.t("player.stopPlayingConfirm"),
[ [
{ {
text: i18n.t("common.cancel"), text: "Cancel",
style: "cancel", style: "cancel",
onPress: () => onCancelExitRef.current?.(), onPress: () => onCancelExitRef.current?.(),
}, },
{ { text: "Stop", style: "destructive", onPress: onBackRef.current },
text: i18n.t("common.stop"),
style: "destructive",
onPress: onBackRef.current,
},
], ],
); );
return true; return true;

View File

@@ -143,7 +143,7 @@ export class JellyseerrApi {
if (inRange(status, 200, 299)) { if (inRange(status, 200, 299)) {
if (data.version < "2.0.0") { if (data.version < "2.0.0") {
const error = t( const error = t(
"jellyseerr.toasts.jellyseerr_does_not_meet_requirements", "jellyseerr.toasts.jellyseer_does_not_meet_requirements",
); );
toast.error(error); toast.error(error);
throw Error(error); throw Error(error);

View File

@@ -96,24 +96,5 @@ export function getDownloadedItemSize(id: string): number {
*/ */
export function calculateTotalDownloadedSize(): number { export function calculateTotalDownloadedSize(): number {
const items = getAllDownloadedItems(); const items = getAllDownloadedItems();
return items.reduce((sum, item) => { return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
// Trickplay bytes count too — getDownloadedItemSize models per-item size
// as video + trickplay, the total must match.
const trickplaySize = item.trickPlayData?.size ?? 0;
// Read the live file size on disk so the total reflects actual usage and
// self-heals items whose stored videoFileSize is 0 (old schema, or
// `fileInfo.size` was undefined at download time). Fall back to the stored
// value if the file can't be stat'd.
if (item.videoFilePath) {
try {
const file = new File(filePathToUri(item.videoFilePath));
if (file.exists) {
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
}
} catch (error) {
console.warn("Failed to stat downloaded file for size:", error);
}
}
return sum + (item.videoFileSize ?? 0) + trickplaySize;
}, 0);
} }

View File

@@ -289,24 +289,7 @@ export function useDownloadOperations({
); );
const appSizeUsage = useCallback(async () => { const appSizeUsage = useCallback(async () => {
let totalSize = calculateTotalDownloadedSize(); const totalSize = calculateTotalDownloadedSize();
// Also count in-progress downloads (they write straight to their final
// path) so the growing file shows up as app usage instead of drifting
// into the generic device share until completion.
for (const process of processes) {
try {
const file = new File(
Paths.document,
`${generateFilename(process.item)}.mp4`,
);
if (file.exists) {
totalSize += file.size ?? 0;
}
} catch {
// File not created yet — ignore.
}
}
try { try {
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([ const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
@@ -327,7 +310,7 @@ export function useDownloadOperations({
appSize: totalSize, appSize: totalSize,
}; };
} }
}, [processes]); }, []);
return { return {
startBackgroundDownload, startBackgroundDownload,

View File

@@ -386,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
default: default:
throw new Error( throw new Error(
t( t(
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url", "login.an_unexpected_error_occured_did_you_enter_the_correct_url",
), ),
); );
} }

View File

@@ -16,14 +16,14 @@
"got_it": "Got it", "got_it": "Got it",
"connection_failed": "Connection failed", "connection_failed": "Connection failed",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.", "could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
"an_unexpected_error_occurred": "An unexpected error occurred", "an_unexpected_error_occured": "An unexpected error occurred",
"change_server": "Change server", "change_server": "Change server",
"invalid_username_or_password": "Invalid username or password", "invalid_username_or_password": "Invalid username or password",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in", "user_does_not_have_permission_to_log_in": "User does not have permission to log in",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later", "server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.", "server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error", "there_is_a_server_error": "There is a server error",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"too_old_server_text": "Unsupported Jellyfin server discovered", "too_old_server_text": "Unsupported Jellyfin server discovered",
"too_old_server_description": "Please update Jellyfin to the latest version" "too_old_server_description": "Please update Jellyfin to the latest version"
}, },
@@ -118,6 +118,7 @@
}, },
"settings": { "settings": {
"settings_title": "Settings", "settings_title": "Settings",
"disabled_by_admin": "Disabled by admin",
"log_out_button": "Log out", "log_out_button": "Log out",
"switch_user": { "switch_user": {
"title": "Switch user", "title": "Switch user",
@@ -188,7 +189,7 @@
"authorize_button": "Authorize Quick Connect", "authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the Quick Connect code...", "enter_the_quick_connect_code": "Enter the Quick Connect code...",
"success": "Success", "success": "Success",
"quick_connect_authorized": "Quick Connect authorized", "quick_connect_autorized": "Quick Connect authorized",
"error": "Error", "error": "Error",
"invalid_code": "Invalid code", "invalid_code": "Invalid code",
"authorize": "Authorize" "authorize": "Authorize"
@@ -270,10 +271,6 @@
"mpv_subtitle_margin_y": "Vertical margin", "mpv_subtitle_margin_y": "Vertical margin",
"mpv_subtitle_align_x": "Horizontal align", "mpv_subtitle_align_x": "Horizontal align",
"mpv_subtitle_align_y": "Vertical align", "mpv_subtitle_align_y": "Vertical align",
"mpv_settings_title": "MPV Subtitle Settings",
"mpv_settings_description": "Advanced subtitle customization for MPV player",
"opaque_background": "Opaque Background",
"background_opacity": "Background Opacity",
"align": { "align": {
"left": "Left", "left": "Left",
"center": "Center", "center": "Center",
@@ -302,7 +299,7 @@
"show_custom_menu_links": "Show custom menu links", "show_custom_menu_links": "Show custom menu links",
"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_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable haptic feedback", "disable_haptic_feedback": "Disable haptic feedback",
"default_quality": "Default quality", "default_quality": "Default quality",
"default_playback_speed": "Default playback speed", "default_playback_speed": "Default playback speed",
@@ -374,6 +371,7 @@
"toasts": { "toasts": {
"saved": "Saved", "saved": "Saved",
"refreshed": "Settings refreshed from server", "refreshed": "Settings refreshed from server",
"refresh_failed": "Couldn't refresh settings from server",
"disabled": "Streamystats disabled" "disabled": "Streamystats disabled"
}, },
"refresh_from_server": "Refresh settings from server" "refresh_from_server": "Refresh settings from server"
@@ -388,8 +386,6 @@
"device_usage": "Device {{availableSpace}}%", "device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} used", "size_used": "{{used}} of {{total}} used",
"delete_all_downloaded_files": "Delete all downloaded files", "delete_all_downloaded_files": "Delete all downloaded files",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music cache", "music_cache_title": "Music cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear music cache", "clear_music_cache": "Clear music cache",
@@ -441,13 +437,10 @@
}, },
"sessions": { "sessions": {
"title": "Sessions", "title": "Sessions",
"no_active_sessions": "No active sessions", "no_active_sessions": "No active sessions"
"select_session": "Select Session",
"now_playing": "Now playing:"
}, },
"downloads": { "downloads": {
"downloads_title": "Downloads", "downloads_title": "Downloads",
"transcoding": "Transcoding",
"series": "Series", "series": "Series",
"movies": "Movies", "movies": "Movies",
"other_media": "Other media", "other_media": "Other media",
@@ -503,8 +496,6 @@
"none": "None", "none": "None",
"track": "Track", "track": "Track",
"cancel": "Cancel", "cancel": "Cancel",
"stop": "Stop",
"open_menu": "Open Menu",
"delete": "Delete", "delete": "Delete",
"ok": "OK", "ok": "OK",
"remove": "Remove", "remove": "Remove",
@@ -606,34 +597,10 @@
}, },
"player": { "player": {
"live": "LIVE", "live": "LIVE",
"menu": {
"quality": "Quality",
"subtitles": "Subtitles",
"subtitle_scale": "Subtitle Scale",
"audio": "Audio",
"speed": "Speed",
"playback_options": "Playback Options",
"show_technical_info": "Show Technical Info",
"hide_technical_info": "Hide Technical Info"
},
"technical_info": {
"video": "Video:",
"audio": "Audio:",
"subtitle": "Subtitle:",
"bitrate": "Bitrate:",
"buffer_seconds": "Buffer: {{seconds}}s",
"vo": "VO:",
"dropped_frames": "Dropped: {{count}} frames",
"loading": "Loading..."
},
"mpv_player_title": "MPV player", "mpv_player_title": "MPV player",
"aspect_ratio": "Aspect Ratio",
"aspect_ratio_original": "Original",
"hash_match": "Hash Match",
"still_watching": "Are you still watching?",
"error": "Error", "error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL", "failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client error", "client_error": "Client error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast", "could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from server: {{message}}", "message_from_server": "Message from server: {{message}}",
@@ -730,7 +697,6 @@
"no_data_available": "No data available" "no_data_available": "No data available"
}, },
"live_tv": { "live_tv": {
"title": "Live TV",
"next": "Next", "next": "Next",
"previous": "Previous", "previous": "Previous",
"coming_soon": "Coming soon", "coming_soon": "Coming soon",
@@ -802,7 +768,7 @@
"request_selected": "Request selected", "request_selected": "Request selected",
"n_selected": "{{count}} selected", "n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseerr_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0", "jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.", "jellyseerr_test_failed": "Seerr test failed. Please try again.",
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url", "failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
"issue_submitted": "Issue submitted!", "issue_submitted": "Issue submitted!",
@@ -815,16 +781,6 @@
"failed_to_decline_request": "Failed to decline request" "failed_to_decline_request": "Failed to decline request"
} }
}, },
"accessibility": {
"play_button": "Play button",
"play_hint": "Tap to play the media",
"toggle_orientation": "Toggle screen orientation",
"toggle_orientation_hint": "Toggles the screen orientation between portrait and landscape"
},
"not_found": {
"title": "This screen doesn't exist.",
"go_home": "Go to home screen!"
},
"tabs": { "tabs": {
"home": "Home", "home": "Home",
"search": "Search", "search": "Search",
@@ -835,12 +791,6 @@
}, },
"music": { "music": {
"title": "Music", "title": "Music",
"no_track_playing": "No track playing",
"queue_empty": "Queue is empty",
"playing_from_queue": "Playing from queue",
"up_next": "Up next",
"now_playing": "Now Playing",
"missing_library_id": "Missing music library id.",
"tabs": { "tabs": {
"suggestions": "Suggestions", "suggestions": "Suggestions",
"albums": "Albums", "albums": "Albums",

View File

@@ -449,11 +449,6 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
const hasMeaningfulSettingValue = (value: unknown) => const hasMeaningfulSettingValue = (value: unknown) =>
value !== undefined && value !== null && value !== ""; value !== undefined && value !== null && value !== "";
const getEffectiveSettingValue = <K extends keyof Settings>(
settings: Partial<Settings> | null | undefined,
settingsKey: K,
) => settings?.[settingsKey] ?? defaultValues[settingsKey];
export const useSettings = () => { export const useSettings = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const [_settings, setSettings] = useAtom(settingsAtom); const [_settings, setSettings] = useAtom(settingsAtom);
@@ -510,7 +505,17 @@ export const useSettings = () => {
if (!_settings) { if (!_settings) {
return; return;
} }
const hasChanges = Object.entries(update).some( // Admin-locked settings are enforced at write time too: a control that
// isn't disabled in the UI must not persist a value the admin pinned.
// The read memo already overrides locked keys, but without this guard the
// write would silently land in user storage and resurface once unlocked.
const sanitizedUpdate = Object.fromEntries(
Object.entries(update).filter(
([key]) => pluginSettings?.[key as keyof Settings]?.locked !== true,
),
) as Partial<Settings>;
const hasChanges = Object.entries(sanitizedUpdate).some(
([key, value]) => _settings[key as keyof Settings] !== value, ([key, value]) => _settings[key as keyof Settings] !== value,
); );
@@ -519,7 +524,7 @@ export const useSettings = () => {
const newSettings = { const newSettings = {
...defaultValues, ...defaultValues,
..._settings, ..._settings,
...update, ...sanitizedUpdate,
} as Settings; } as Settings;
setSettings(newSettings); setSettings(newSettings);
saveSettings(newSettings); saveSettings(newSettings);
@@ -542,13 +547,24 @@ export const useSettings = () => {
// Normalize object-typed settings from plugin (plain primitive → { key, value }) // Normalize object-typed settings from plugin (plain primitive → { key, value })
value = normalizePluginValue(settingsKey, value); value = normalizePluginValue(settingsKey, value);
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey); // When unlocked, keep the user's value only if they explicitly diverged
// from the app default. Otherwise the plugin value is the admin's
// default and must win over the hardcoded app default — e.g. a toggle
// that was always locked then unlocked should reflect the plugin
// default, not the app's `false`. Object-typed settings compare by
// reference, so their behaviour is unchanged.
const userValue = _settings?.[settingsKey];
const userDiverged =
hasMeaningfulSettingValue(userValue) &&
userValue !== defaultValues[settingsKey];
(acc as any)[settingsKey] = locked (acc as any)[settingsKey] = locked
? value ? value
: hasMeaningfulSettingValue(effectiveValue) : userDiverged
? effectiveValue ? userValue
: value; : hasMeaningfulSettingValue(value)
? value
: defaultValues[settingsKey];
} }
return acc; return acc;
}, {}); }, {});