mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 10:14:17 +01:00
Compare commits
9 Commits
chore/i18n
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd4215407d | ||
|
|
b7bdd99cdd | ||
|
|
6ba1111794 | ||
|
|
9fea9015eb | ||
|
|
500da94583 | ||
|
|
fb8f11460b | ||
|
|
112d9bef96 | ||
|
|
692e02e42f | ||
|
|
8618e2d6c3 |
@@ -324,44 +324,32 @@ export default function SettingsTV() {
|
||||
// MPV alignment options
|
||||
const alignXOptions: TVOptionItem<string>[] = useMemo(
|
||||
() => [
|
||||
{ label: "Left", value: "left", selected: currentAlignX === "left" },
|
||||
{
|
||||
label: t("home.settings.subtitles.align.left"),
|
||||
value: "left",
|
||||
selected: currentAlignX === "left",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.subtitles.align.center"),
|
||||
label: "Center",
|
||||
value: "center",
|
||||
selected: currentAlignX === "center",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.subtitles.align.right"),
|
||||
value: "right",
|
||||
selected: currentAlignX === "right",
|
||||
},
|
||||
{ label: "Right", value: "right", selected: currentAlignX === "right" },
|
||||
],
|
||||
[currentAlignX, t],
|
||||
[currentAlignX],
|
||||
);
|
||||
|
||||
const alignYOptions: TVOptionItem<string>[] = useMemo(
|
||||
() => [
|
||||
{ label: "Top", value: "top", selected: currentAlignY === "top" },
|
||||
{
|
||||
label: t("home.settings.subtitles.align.top"),
|
||||
value: "top",
|
||||
selected: currentAlignY === "top",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.subtitles.align.center"),
|
||||
label: "Center",
|
||||
value: "center",
|
||||
selected: currentAlignY === "center",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.subtitles.align.bottom"),
|
||||
label: "Bottom",
|
||||
value: "bottom",
|
||||
selected: currentAlignY === "bottom",
|
||||
},
|
||||
],
|
||||
[currentAlignY, t],
|
||||
[currentAlignY],
|
||||
);
|
||||
|
||||
// Cache mode options
|
||||
@@ -511,13 +499,13 @@ export default function SettingsTV() {
|
||||
|
||||
const alignXLabel = useMemo(() => {
|
||||
const option = alignXOptions.find((o) => o.selected);
|
||||
return option?.label || t("home.settings.subtitles.align.center");
|
||||
}, [alignXOptions, t]);
|
||||
return option?.label || "Center";
|
||||
}, [alignXOptions]);
|
||||
|
||||
const alignYLabel = useMemo(() => {
|
||||
const option = alignYOptions.find((o) => o.selected);
|
||||
return option?.label || t("home.settings.subtitles.align.bottom");
|
||||
}, [alignYOptions, t]);
|
||||
return option?.label || "Bottom";
|
||||
}, [alignYOptions]);
|
||||
|
||||
const typographyScaleLabel = useMemo(() => {
|
||||
const option = typographyScaleOptions.find((o) => o.selected);
|
||||
@@ -657,7 +645,7 @@ export default function SettingsTV() {
|
||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||
/>
|
||||
<TVSettingsStepper
|
||||
label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
||||
label='Vertical Margin'
|
||||
value={settings.mpvSubtitleMarginY ?? 0}
|
||||
onDecrease={() => {
|
||||
const newValue = Math.max(
|
||||
@@ -675,11 +663,11 @@ export default function SettingsTV() {
|
||||
}}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||
label='Horizontal Alignment'
|
||||
value={alignXLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
||||
title: "Horizontal Alignment",
|
||||
options: alignXOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
@@ -689,11 +677,11 @@ export default function SettingsTV() {
|
||||
}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||
label='Vertical Alignment'
|
||||
value={alignYLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.subtitles.mpv_subtitle_align_y"),
|
||||
title: "Vertical Alignment",
|
||||
options: alignYOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<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>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<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>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { ScrollView } from "react-native";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function JellyseerrPluginPage() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
@@ -16,12 +13,9 @@ export default function JellyseerrPluginPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<View className='p-4'>
|
||||
<JellyseerrSettings />
|
||||
</DisabledSetting>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { ScrollView } from "react-native";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function KefinTweaksPage() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
@@ -16,12 +13,9 @@ export default function KefinTweaksPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<View className='px-4'>
|
||||
<KefinTweaksSettings />
|
||||
</DisabledSetting>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Linking,
|
||||
@@ -14,22 +14,24 @@ import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function MarlinSearchPage() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
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) => {
|
||||
updateSettings({
|
||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||
@@ -41,15 +43,8 @@ export default function MarlinSearchPage() {
|
||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||
};
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
return (
|
||||
pluginSettings?.searchEngine?.locked === true &&
|
||||
pluginSettings?.marlinServerUrl?.locked === true
|
||||
);
|
||||
}, [pluginSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||
if (!marlinUrlLocked) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<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;
|
||||
|
||||
@@ -72,52 +67,42 @@ export default function MarlinSearchPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting disabled={disabled} className='px-4'>
|
||||
<View className='px-4'>
|
||||
<ListGroup>
|
||||
<DisabledSetting
|
||||
disabled={
|
||||
pluginSettings?.searchEngine?.locked === true ||
|
||||
!!pluginSettings?.streamyStatsServerUrl?.value
|
||||
}
|
||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||
{/* disabledByAdmin renders the "Disabled by admin" notice as the row's
|
||||
subtitle (same pattern as the Streamystats settings) — no clipping. */}
|
||||
<ListItem
|
||||
title={t(
|
||||
"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={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||
)}
|
||||
onPress={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
<Switch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
disabled={searchEngineLocked || hasStreamystats}
|
||||
onValueChange={(val) => {
|
||||
updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
searchEngine: value ? "Marlin" : "Jellyfin",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</DisabledSetting>
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||
showText={!pluginSettings?.searchEngine?.locked}
|
||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||
>
|
||||
<View
|
||||
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
|
||||
<ListGroup className='mt-2'>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.marlin_search.url")}
|
||||
disabledByAdmin={marlinUrlLocked}
|
||||
>
|
||||
<Text className='mr-4'>
|
||||
{t("home.settings.plugins.marlin_search.url")}
|
||||
</Text>
|
||||
<TextInput
|
||||
editable={settings.searchEngine === "Marlin"}
|
||||
className='text-white'
|
||||
editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
|
||||
className='text-white text-right flex-1'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||
)}
|
||||
@@ -128,15 +113,16 @@ export default function MarlinSearchPage() {
|
||||
textContentType='URL'
|
||||
onChangeText={(text) => setValue(text)}
|
||||
/>
|
||||
</View>
|
||||
</DisabledSetting>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||
</Text>
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function PluginsPage() {
|
||||
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 (
|
||||
<ScrollView
|
||||
@@ -18,6 +37,17 @@ export default function PluginsPage() {
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<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>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -22,12 +22,7 @@ export default function StreamystatsPage() {
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const {
|
||||
settings,
|
||||
updateSettings,
|
||||
pluginSettings,
|
||||
refreshStreamyfinPluginSettings,
|
||||
} = useSettings();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
// Local state for all editable fields
|
||||
@@ -49,7 +44,21 @@ export default function StreamystatsPage() {
|
||||
);
|
||||
|
||||
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 cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
@@ -113,17 +122,6 @@ export default function StreamystatsPage() {
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -146,7 +144,7 @@ export default function StreamystatsPage() {
|
||||
placeholder={t(
|
||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||
)}
|
||||
value={url}
|
||||
value={effectiveUrl}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
@@ -171,11 +169,18 @@ export default function StreamystatsPage() {
|
||||
>
|
||||
<ListItem
|
||||
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
|
||||
value={useForSearch}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
value={
|
||||
searchLocked
|
||||
? settings?.searchEngine === "Streamystats"
|
||||
: useForSearch
|
||||
}
|
||||
disabled={!isStreamystatsEnabled || searchLocked}
|
||||
onValueChange={setUseForSearch}
|
||||
/>
|
||||
</ListItem>
|
||||
@@ -183,52 +188,62 @@ export default function StreamystatsPage() {
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||
}
|
||||
disabledByAdmin={movieRecsLocked}
|
||||
>
|
||||
<Switch
|
||||
value={movieRecs}
|
||||
value={
|
||||
movieRecsLocked
|
||||
? (settings?.streamyStatsMovieRecommendations ?? false)
|
||||
: movieRecs
|
||||
}
|
||||
onValueChange={setMovieRecs}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
disabled={!isStreamystatsEnabled || movieRecsLocked}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||
}
|
||||
disabledByAdmin={seriesRecsLocked}
|
||||
>
|
||||
<Switch
|
||||
value={seriesRecs}
|
||||
value={
|
||||
seriesRecsLocked
|
||||
? (settings?.streamyStatsSeriesRecommendations ?? false)
|
||||
: seriesRecs
|
||||
}
|
||||
onValueChange={setSeriesRecs}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
disabled={!isStreamystatsEnabled || seriesRecsLocked}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||
}
|
||||
disabledByAdmin={promotedWatchlistsLocked}
|
||||
>
|
||||
<Switch
|
||||
value={promotedWatchlists}
|
||||
value={
|
||||
promotedWatchlistsLocked
|
||||
? (settings?.streamyStatsPromotedWatchlists ?? false)
|
||||
: promotedWatchlists
|
||||
}
|
||||
onValueChange={setPromotedWatchlists}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||
disabledByAdmin={hideWatchlistsTabLocked}
|
||||
>
|
||||
<Switch
|
||||
value={hideWatchlistsTab}
|
||||
value={
|
||||
hideWatchlistsTabLocked
|
||||
? (settings?.hideWatchlistsTab ?? false)
|
||||
: hideWatchlistsTab
|
||||
}
|
||||
onValueChange={setHideWatchlistsTab}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
@@ -236,15 +251,6 @@ export default function StreamystatsPage() {
|
||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleRefreshFromServer}
|
||||
className='mt-6 py-3 rounded-xl bg-neutral-800'
|
||||
>
|
||||
<Text className='text-center text-blue-500'>
|
||||
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
||||
{!isUrlLocked && isStreamystatsEnabled && (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
{t("music.missing_library_id")}
|
||||
Missing music library id.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
{t("music.missing_library_id")}
|
||||
Missing music library id.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
{t("music.missing_library_id")}
|
||||
Missing music library id.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
@@ -73,7 +72,6 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
||||
type ViewMode = "player" | "queue";
|
||||
|
||||
export default function NowPlayingScreen() {
|
||||
const { t } = useTranslation();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const router = useRouter();
|
||||
@@ -232,9 +230,7 @@ export default function NowPlayingScreen() {
|
||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||
}}
|
||||
>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("music.no_track_playing")}
|
||||
</Text>
|
||||
<Text className='text-neutral-500'>No track playing</Text>
|
||||
</View>
|
||||
</BottomSheetModalProvider>
|
||||
);
|
||||
@@ -271,7 +267,7 @@ export default function NowPlayingScreen() {
|
||||
: "text-neutral-500"
|
||||
}
|
||||
>
|
||||
{t("music.now_playing")}
|
||||
Now Playing
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -722,7 +718,6 @@ const QueueView: React.FC<QueueViewProps> = ({
|
||||
onRemoveFromQueue,
|
||||
onReorderQueue,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const renderQueueItem = useCallback(
|
||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||
const index = getIndex() ?? 0;
|
||||
@@ -836,15 +831,13 @@ const QueueView: React.FC<QueueViewProps> = ({
|
||||
ListHeaderComponent={
|
||||
<View className='px-4 py-2'>
|
||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||
{history.length > 0
|
||||
? t("music.playing_from_queue")
|
||||
: t("music.up_next")}
|
||||
{history.length > 0 ? "Playing from queue" : "Up next"}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View className='flex-1 items-center justify-center py-20'>
|
||||
<Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
|
||||
<Text className='text-neutral-500'>Queue is empty</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1318,7 +1318,7 @@ export default function DirectPlayerPage() {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
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);
|
||||
}}
|
||||
|
||||
@@ -192,7 +192,6 @@ const SubtitleResultCard = React.forwardRef<
|
||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -329,7 +328,7 @@ const SubtitleResultCard = React.forwardRef<
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
||||
{t("player.hash_match")}
|
||||
Hash Match
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: t("home.oops") }} />
|
||||
<Stack.Screen options={{ title: "Oops!" }} />
|
||||
<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}>
|
||||
<ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
|
||||
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
</>
|
||||
|
||||
@@ -85,8 +85,7 @@ configureReanimatedLogger({
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
@@ -351,12 +350,9 @@ function Layout() {
|
||||
notificationListener.current =
|
||||
Notifications?.addNotificationReceivedListener(
|
||||
(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(
|
||||
"Notification received while app running:",
|
||||
notification.request.content.title,
|
||||
"Notification received while app running",
|
||||
notification,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
@@ -210,7 +209,6 @@ const PlatformDropdownComponent = ({
|
||||
expoUIConfig,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
|
||||
// Handle controlled open state for Android
|
||||
@@ -382,7 +380,7 @@ const PlatformDropdownComponent = ({
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
accessibilityLabel={t("accessibility.play_button")}
|
||||
accessibilityHint={t("accessibility.play_hint")}
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
onPress={onPress}
|
||||
className={"relative flex-1"}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -37,7 +36,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
colors,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||
|
||||
// Use colors prop if provided, otherwise fallback to global atom
|
||||
@@ -170,8 +168,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
accessibilityLabel={t("accessibility.play_button")}
|
||||
accessibilityHint={t("accessibility.play_hint")}
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
onPress={onPress}
|
||||
className={"relative"}
|
||||
{...props}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
@@ -32,7 +31,6 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||
const { t } = useTranslation();
|
||||
const handlePlayInSession = async (sessionId: string) => {
|
||||
if (!api || !item.Id) return;
|
||||
|
||||
@@ -67,9 +65,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
<View style={styles.centeredView}>
|
||||
<View style={styles.modalView}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{t("home.sessions.select_session")}
|
||||
</Text>
|
||||
<Text style={styles.modalTitle}>Select Session</Text>
|
||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
@@ -82,7 +78,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
</View>
|
||||
) : !sessions || sessions.length === 0 ? (
|
||||
<Text style={styles.noSessionsText}>
|
||||
{t("home.sessions.no_active_sessions")}
|
||||
No active sessions found
|
||||
</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
@@ -102,7 +98,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
</Text>
|
||||
{session.NowPlayingItem && (
|
||||
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||
{t("home.sessions.now_playing")}{" "}
|
||||
Now playing:{" "}
|
||||
{session.NowPlayingItem.SeriesName
|
||||
? `${session.NowPlayingItem.SeriesName} :`
|
||||
: ""}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
TouchableOpacity,
|
||||
@@ -35,7 +35,6 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
||||
}
|
||||
|
||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { cancelDownload } = useDownload();
|
||||
const router = useRouter();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
@@ -174,9 +173,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
|
||||
{isTranscoding && (
|
||||
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||
<Text className='text-xs text-purple-400'>
|
||||
{t("home.downloads.transcoding")}
|
||||
</Text>
|
||||
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
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 seriesId = items[0]?.SeriesId;
|
||||
return seriesId ? storage.getString(seriesId) : undefined;
|
||||
}, [items[0]?.SeriesId]);
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
}, []);
|
||||
|
||||
const deleteSeries = useCallback(
|
||||
async () =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
@@ -32,7 +33,10 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
disabledByAdmin = false,
|
||||
...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;
|
||||
if (onPress)
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
@@ -23,7 +22,6 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
||||
disabled = false,
|
||||
refSetter,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
||||
scaleAmount: 1,
|
||||
@@ -70,7 +68,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
||||
<Text
|
||||
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.live")}
|
||||
LIVE
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{t("live_tv.title")}
|
||||
Live TV
|
||||
</Text>
|
||||
|
||||
{/* Tab Bar */}
|
||||
|
||||
@@ -146,7 +146,7 @@ export const Login: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occurred");
|
||||
: t("login.an_unexpected_error_occured");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
} finally {
|
||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occurred");
|
||||
: t("login.an_unexpected_error_occured");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
@@ -89,8 +88,6 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
||||
showDiscover,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!showDiscover) {
|
||||
return null;
|
||||
}
|
||||
@@ -104,13 +101,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
||||
}}
|
||||
>
|
||||
<TVSearchTabBadge
|
||||
label={t("search.library")}
|
||||
label='Library'
|
||||
isSelected={searchType === "Library"}
|
||||
onPress={() => setSearchType("Library")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TVSearchTabBadge
|
||||
label={t("search.discover")}
|
||||
label='Discover'
|
||||
isSelected={searchType === "Discover"}
|
||||
onPress={() => setSearchType("Discover")}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -20,7 +20,10 @@ export const JellyseerrSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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<
|
||||
string | undefined
|
||||
@@ -30,22 +33,25 @@ export const JellyseerrSettings = () => {
|
||||
string | 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({
|
||||
mutationFn: async () => {
|
||||
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
||||
throw new Error("Missing server url");
|
||||
if (!effectiveServerUrl) throw new Error("Missing server url");
|
||||
if (!user?.Name)
|
||||
throw new Error("Missing required information for login");
|
||||
const jellyseerrTempApi = new JellyseerrApi(
|
||||
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
|
||||
);
|
||||
const jellyseerrTempApi = new JellyseerrApi(effectiveServerUrl);
|
||||
const testResult = await jellyseerrTempApi.test();
|
||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
setJellyseerrUser(user);
|
||||
updateSettings({ jellyseerrServerUrl });
|
||||
updateSettings({ jellyseerrServerUrl: effectiveServerUrl });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("jellyseerr.failed_to_login"));
|
||||
@@ -115,30 +121,41 @@ export const JellyseerrSettings = () => {
|
||||
</>
|
||||
) : (
|
||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||
</Text>
|
||||
<View className='flex flex-col shrink mb-2'>
|
||||
<Text className='text-xs text-gray-600'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||
<View style={{ opacity: urlLocked ? 0.5 : 1 }}>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||
</Text>
|
||||
</View>
|
||||
<Input
|
||||
className='border border-neutral-800 mb-2'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||
<View className='flex flex-col shrink mb-2'>
|
||||
<Text className='text-xs text-gray-600'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Input
|
||||
className='border border-neutral-800 mb-2'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||
)}
|
||||
value={
|
||||
urlLocked
|
||||
? settings?.jellyseerrServerUrl
|
||||
: (jellyseerrServerUrl ?? settings?.jellyseerrServerUrl)
|
||||
}
|
||||
defaultValue={
|
||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||
}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={setjellyseerrServerUrl}
|
||||
editable={!urlLocked && !loginToJellyseerrMutation.isPending}
|
||||
/>
|
||||
{urlLocked && (
|
||||
<Text className='text-xs text-red-600 mb-2'>
|
||||
{t("home.settings.disabled_by_admin")}
|
||||
</Text>
|
||||
)}
|
||||
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
||||
defaultValue={
|
||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||
}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={setjellyseerrServerUrl}
|
||||
editable={!loginToJellyseerrMutation.isPending}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<Text className='font-bold mb-2'>
|
||||
{t("home.settings.plugins.jellyseerr.password")}
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, Text, View } from "react-native";
|
||||
import { Switch } from "react-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const KefinTweaksSettings = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isEnabled = settings?.useKefinTweaks ?? false;
|
||||
const locked = pluginSettings?.useKefinTweaks?.locked === true;
|
||||
|
||||
return (
|
||||
<View className=''>
|
||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||
<Text className='text-xs text-red-600 mb-2'>
|
||||
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||
</Text>
|
||||
|
||||
<View className='flex flex-row items-center justify-between mt-2'>
|
||||
<Text className='text-white'>
|
||||
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
|
||||
</Text>
|
||||
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||
trackColor={{ false: "#555", true: "purple" }}
|
||||
thumbColor={isEnabled ? "#fff" : "#ccc"}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||
disabledByAdmin={locked}
|
||||
>
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
disabled={locked}
|
||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, Switch, View, type ViewProps } from "react-native";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -18,21 +17,20 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
const isTv = Platform.isTV;
|
||||
const media = useMedia();
|
||||
const { settings, updateSettings } = media;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
||||
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
||||
|
||||
const alignXLabels: Record<AlignX, string> = {
|
||||
left: t("home.settings.subtitles.align.left"),
|
||||
center: t("home.settings.subtitles.align.center"),
|
||||
right: t("home.settings.subtitles.align.right"),
|
||||
left: "Left",
|
||||
center: "Center",
|
||||
right: "Right",
|
||||
};
|
||||
|
||||
const alignYLabels: Record<AlignY, string> = {
|
||||
top: t("home.settings.subtitles.align.top"),
|
||||
center: t("home.settings.subtitles.align.center"),
|
||||
bottom: t("home.settings.subtitles.align.bottom"),
|
||||
top: "Top",
|
||||
center: "Center",
|
||||
bottom: "Bottom",
|
||||
};
|
||||
|
||||
const alignXOptionGroups = useMemo(() => {
|
||||
@@ -62,18 +60,16 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup
|
||||
title={t("home.settings.subtitles.mpv_settings_title")}
|
||||
title='MPV Subtitle Settings'
|
||||
description={
|
||||
<Text className='text-[#8E8D91] text-xs'>
|
||||
{t("home.settings.subtitles.mpv_settings_description")}
|
||||
Advanced subtitle customization for MPV player
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{!isTv && (
|
||||
<>
|
||||
<ListItem
|
||||
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
||||
>
|
||||
<ListItem title='Vertical Margin'>
|
||||
<Stepper
|
||||
value={settings.mpvSubtitleMarginY ?? 0}
|
||||
step={5}
|
||||
@@ -85,7 +81,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
|
||||
<ListItem title='Horizontal Alignment'>
|
||||
<PlatformDropdown
|
||||
groups={alignXOptionGroups}
|
||||
trigger={
|
||||
@@ -100,11 +96,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||
title='Horizontal Alignment'
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
|
||||
<ListItem title='Vertical Alignment'>
|
||||
<PlatformDropdown
|
||||
groups={alignYOptionGroups}
|
||||
trigger={
|
||||
@@ -119,13 +115,13 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||
title='Vertical Alignment'
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ListItem title={t("home.settings.subtitles.opaque_background")}>
|
||||
<ListItem title='Opaque Background'>
|
||||
<Switch
|
||||
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
||||
onValueChange={(value) =>
|
||||
@@ -135,7 +131,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
</ListItem>
|
||||
|
||||
{settings.mpvSubtitleBackgroundEnabled && (
|
||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
||||
<ListItem title='Background Opacity'>
|
||||
<Stepper
|
||||
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
||||
step={5}
|
||||
|
||||
@@ -20,12 +20,7 @@ export const PluginSettings = () => {
|
||||
>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
||||
title={"Jellyseerr"}
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
||||
title='Marlin Search'
|
||||
title='Jellyseerr'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
|
||||
title='Streamystats'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
||||
title='Marlin Search'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||
title='KefinTweaks'
|
||||
|
||||
@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
successHapticFeedback();
|
||||
Alert.alert(
|
||||
t("home.settings.quick_connect.success"),
|
||||
t("home.settings.quick_connect.quick_connect_authorized"),
|
||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||
);
|
||||
setQuickConnectCode(undefined);
|
||||
bottomSheetModalRef?.current?.close();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { Platform, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
|
||||
export const StorageSettings = () => {
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
|
||||
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
|
||||
used: (app.total - app.remaining) / app.total,
|
||||
};
|
||||
},
|
||||
// Keep the bar moving while a download is writing to disk.
|
||||
refetchInterval: 10 * 1000,
|
||||
});
|
||||
|
||||
const onDeleteClicked = () => {
|
||||
Alert.alert(
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.ok"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
} finally {
|
||||
// Reflect the freed space immediately instead of waiting for
|
||||
// the next poll.
|
||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
};
|
||||
|
||||
const calculatePercentage = (value: number, total: number) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
@@ -29,7 +28,6 @@ export const TVSubtitleResultCard = React.forwardRef<
|
||||
const styles = createStyles(typography);
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
{result.hearingImpaired && (
|
||||
|
||||
@@ -183,7 +183,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText={t("player.skip_intro")}
|
||||
buttonText='Skip Intro'
|
||||
/>
|
||||
{/* Smart Skip Credits behavior:
|
||||
- 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)
|
||||
}
|
||||
onPress={skipCredit}
|
||||
buttonText={t("player.skip_credits")}
|
||||
buttonText='Skip Credits'
|
||||
/>
|
||||
{settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
|
||||
@@ -27,7 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
||||
}
|
||||
>
|
||||
<Text className='text-2xl font-bold text-white py-4 '>
|
||||
{t("player.still_watching")}
|
||||
Are you still watching ?
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
@@ -58,7 +57,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
showTechnicalInfo = false,
|
||||
onToggleTechnicalInfo,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
@@ -129,8 +127,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
onPress={toggleOrientation}
|
||||
disabled={isTogglingOrientation}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
accessibilityLabel={t("accessibility.toggle_orientation")}
|
||||
accessibilityHint={t("accessibility.toggle_orientation_hint")}
|
||||
accessibilityLabel='Toggle screen orientation'
|
||||
accessibilityHint='Toggles the screen orientation between portrait and landscape'
|
||||
>
|
||||
<MaterialIcons
|
||||
name='screen-rotation'
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, StyleSheet, Text, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -185,7 +184,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
currentAudioIndex,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const safeInsets = useControlsSafeAreaInsets();
|
||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||
@@ -314,13 +312,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
)}
|
||||
{info?.videoCodec && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.video")} {formatCodec(info.videoCodec)}
|
||||
Video: {formatCodec(info.videoCodec)}
|
||||
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.audioCodec && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
|
||||
Audio: {formatCodec(info.audioCodec)}
|
||||
{streamInfo?.audioChannels
|
||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
||||
: ""}
|
||||
@@ -328,13 +326,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
)}
|
||||
{streamInfo?.subtitleCodec && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.subtitle")}{" "}
|
||||
{formatCodec(streamInfo.subtitleCodec)}
|
||||
Subtitle: {formatCodec(streamInfo.subtitleCodec)}
|
||||
</Text>
|
||||
)}
|
||||
{(info?.videoBitrate || info?.audioBitrate) && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.bitrate")}{" "}
|
||||
Bitrate:{" "}
|
||||
{info.videoBitrate
|
||||
? formatBitrate(info.videoBitrate)
|
||||
: info.audioBitrate
|
||||
@@ -344,9 +341,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
)}
|
||||
{info?.cacheSeconds !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.buffer_seconds", {
|
||||
seconds: info.cacheSeconds.toFixed(1),
|
||||
})}
|
||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||
{info?.demuxerMaxBytes !== undefined
|
||||
? ` (cap ${info.demuxerMaxBytes}MB` +
|
||||
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
||||
@@ -357,7 +352,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
)}
|
||||
{info?.voDriver && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.vo")} {info.voDriver}
|
||||
VO: {info.voDriver}
|
||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
@@ -369,14 +364,10 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
)}
|
||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||
<Text style={[textStyle, styles.warningText]}>
|
||||
{t("player.technical_info.dropped_frames", {
|
||||
count: info.droppedFrames,
|
||||
})}
|
||||
Dropped: {info.droppedFrames} frames
|
||||
</Text>
|
||||
)}
|
||||
{!info && !playMethod && (
|
||||
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
|
||||
)}
|
||||
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import {
|
||||
type OptionGroup,
|
||||
@@ -55,7 +54,6 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
||||
onRatioChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const handleRatioSelect = (ratio: AspectRatio) => {
|
||||
@@ -68,10 +66,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
||||
{
|
||||
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
||||
type: "radio" as const,
|
||||
label:
|
||||
option.id === "default"
|
||||
? t("player.aspect_ratio_original")
|
||||
: option.label,
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
selected: option.id === currentRatio,
|
||||
onPress: () => handleRatioSelect(option.id),
|
||||
@@ -99,7 +94,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title={t("player.aspect_ratio")}
|
||||
title='Aspect Ratio'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
bottomSheetConfig={{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import {
|
||||
@@ -48,7 +47,6 @@ const DropdownView = ({
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
@@ -103,7 +101,7 @@ const DropdownView = ({
|
||||
// Quality Section
|
||||
if (!isOffline) {
|
||||
groups.push({
|
||||
title: t("player.menu.quality"),
|
||||
title: "Quality",
|
||||
options:
|
||||
BITRATES?.map((bitrate) => ({
|
||||
type: "radio" as const,
|
||||
@@ -118,7 +116,7 @@ const DropdownView = ({
|
||||
// Subtitle Section
|
||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||
groups.push({
|
||||
title: t("player.menu.subtitles"),
|
||||
title: "Subtitles",
|
||||
options: subtitleTracks.map((sub) => ({
|
||||
type: "radio" as const,
|
||||
label: sub.name,
|
||||
@@ -130,7 +128,7 @@ const DropdownView = ({
|
||||
|
||||
// Subtitle Scale Section
|
||||
groups.push({
|
||||
title: t("player.menu.subtitle_scale"),
|
||||
title: "Subtitle Scale",
|
||||
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
||||
type: "radio" as const,
|
||||
label: preset.label,
|
||||
@@ -144,7 +142,7 @@ const DropdownView = ({
|
||||
// Audio Section
|
||||
if (audioTracks && audioTracks.length > 0) {
|
||||
groups.push({
|
||||
title: t("player.menu.audio"),
|
||||
title: "Audio",
|
||||
options: audioTracks.map((track) => ({
|
||||
type: "radio" as const,
|
||||
label: track.name,
|
||||
@@ -158,7 +156,7 @@ const DropdownView = ({
|
||||
// Speed Section
|
||||
if (setPlaybackSpeed) {
|
||||
groups.push({
|
||||
title: t("player.menu.speed"),
|
||||
title: "Speed",
|
||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||
type: "radio" as const,
|
||||
label: speed.label,
|
||||
@@ -176,8 +174,8 @@ const DropdownView = ({
|
||||
{
|
||||
type: "action" as const,
|
||||
label: showTechnicalInfo
|
||||
? t("player.menu.hide_technical_info")
|
||||
: t("player.menu.show_technical_info"),
|
||||
? "Hide Technical Info"
|
||||
: "Show Technical Info",
|
||||
onPress: onToggleTechnicalInfo,
|
||||
},
|
||||
],
|
||||
@@ -187,7 +185,6 @@ const DropdownView = ({
|
||||
return groups;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
t,
|
||||
isOffline,
|
||||
bitrateValue,
|
||||
changeBitrate,
|
||||
@@ -220,7 +217,7 @@ const DropdownView = ({
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title={t("player.menu.playback_options")}
|
||||
title='Playback Options'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
expoUIConfig={{}}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Alert } from "react-native";
|
||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
interface UseRemoteControlProps {
|
||||
showControls: boolean;
|
||||
@@ -125,23 +124,17 @@ export function useRemoteControl({
|
||||
|
||||
// Controls are hidden, so confirm before leaving playback.
|
||||
Alert.alert(
|
||||
i18n.t("player.stopPlayback"),
|
||||
"Stop Playback",
|
||||
videoTitleRef.current
|
||||
? i18n.t("player.stopPlayingTitle", {
|
||||
title: videoTitleRef.current,
|
||||
})
|
||||
: i18n.t("player.stopPlayingConfirm"),
|
||||
? `Stop playing "${videoTitleRef.current}"?`
|
||||
: "Are you sure you want to stop playback?",
|
||||
[
|
||||
{
|
||||
text: i18n.t("common.cancel"),
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
onPress: () => onCancelExitRef.current?.(),
|
||||
},
|
||||
{
|
||||
text: i18n.t("common.stop"),
|
||||
style: "destructive",
|
||||
onPress: onBackRef.current,
|
||||
},
|
||||
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
|
||||
],
|
||||
);
|
||||
return true;
|
||||
|
||||
@@ -143,7 +143,7 @@ export class JellyseerrApi {
|
||||
if (inRange(status, 200, 299)) {
|
||||
if (data.version < "2.0.0") {
|
||||
const error = t(
|
||||
"jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
|
||||
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
||||
);
|
||||
toast.error(error);
|
||||
throw Error(error);
|
||||
|
||||
@@ -96,24 +96,5 @@ export function getDownloadedItemSize(id: string): number {
|
||||
*/
|
||||
export function calculateTotalDownloadedSize(): number {
|
||||
const items = getAllDownloadedItems();
|
||||
return items.reduce((sum, item) => {
|
||||
// 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);
|
||||
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||
}
|
||||
|
||||
@@ -289,24 +289,7 @@ export function useDownloadOperations({
|
||||
);
|
||||
|
||||
const appSizeUsage = useCallback(async () => {
|
||||
let 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.
|
||||
}
|
||||
}
|
||||
const totalSize = calculateTotalDownloadedSize();
|
||||
|
||||
try {
|
||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||
@@ -327,7 +310,7 @@ export function useDownloadOperations({
|
||||
appSize: totalSize,
|
||||
};
|
||||
}
|
||||
}, [processes]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startBackgroundDownload,
|
||||
|
||||
@@ -386,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
default:
|
||||
throw new Error(
|
||||
t(
|
||||
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
|
||||
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
"got_it": "Got it",
|
||||
"connection_failed": "Connection failed",
|
||||
"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",
|
||||
"invalid_username_or_password": "Invalid username or password",
|
||||
"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_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",
|
||||
"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_description": "Please update Jellyfin to the latest version"
|
||||
},
|
||||
@@ -118,6 +118,7 @@
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "Settings",
|
||||
"disabled_by_admin": "Disabled by admin",
|
||||
"log_out_button": "Log out",
|
||||
"switch_user": {
|
||||
"title": "Switch user",
|
||||
@@ -188,7 +189,7 @@
|
||||
"authorize_button": "Authorize Quick Connect",
|
||||
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
||||
"success": "Success",
|
||||
"quick_connect_authorized": "Quick Connect authorized",
|
||||
"quick_connect_autorized": "Quick Connect authorized",
|
||||
"error": "Error",
|
||||
"invalid_code": "Invalid code",
|
||||
"authorize": "Authorize"
|
||||
@@ -270,10 +271,6 @@
|
||||
"mpv_subtitle_margin_y": "Vertical margin",
|
||||
"mpv_subtitle_align_x": "Horizontal 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": {
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
@@ -302,7 +299,7 @@
|
||||
"show_custom_menu_links": "Show custom menu links",
|
||||
"show_large_home_carousel": "Show large home carousel (beta)",
|
||||
"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",
|
||||
"default_quality": "Default quality",
|
||||
"default_playback_speed": "Default playback speed",
|
||||
@@ -374,6 +371,7 @@
|
||||
"toasts": {
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"refresh_failed": "Couldn't refresh settings from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Refresh settings from server"
|
||||
@@ -388,8 +386,6 @@
|
||||
"device_usage": "Device {{availableSpace}}%",
|
||||
"size_used": "{{used}} of {{total}} used",
|
||||
"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_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"clear_music_cache": "Clear music cache",
|
||||
@@ -441,13 +437,10 @@
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sessions",
|
||||
"no_active_sessions": "No active sessions",
|
||||
"select_session": "Select Session",
|
||||
"now_playing": "Now playing:"
|
||||
"no_active_sessions": "No active sessions"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"transcoding": "Transcoding",
|
||||
"series": "Series",
|
||||
"movies": "Movies",
|
||||
"other_media": "Other media",
|
||||
@@ -503,8 +496,6 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"open_menu": "Open Menu",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
@@ -606,34 +597,10 @@
|
||||
},
|
||||
"player": {
|
||||
"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",
|
||||
"aspect_ratio": "Aspect Ratio",
|
||||
"aspect_ratio_original": "Original",
|
||||
"hash_match": "Hash Match",
|
||||
"still_watching": "Are you still watching?",
|
||||
"error": "Error",
|
||||
"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",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from server: {{message}}",
|
||||
@@ -730,7 +697,6 @@
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"title": "Live TV",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"coming_soon": "Coming soon",
|
||||
@@ -802,7 +768,7 @@
|
||||
"request_selected": "Request selected",
|
||||
"n_selected": "{{count}} selected",
|
||||
"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.",
|
||||
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
|
||||
"issue_submitted": "Issue submitted!",
|
||||
@@ -815,16 +781,6 @@
|
||||
"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": {
|
||||
"home": "Home",
|
||||
"search": "Search",
|
||||
@@ -835,12 +791,6 @@
|
||||
},
|
||||
"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": {
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
|
||||
@@ -449,11 +449,6 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
|
||||
const hasMeaningfulSettingValue = (value: unknown) =>
|
||||
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 = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [_settings, setSettings] = useAtom(settingsAtom);
|
||||
@@ -510,7 +505,17 @@ export const useSettings = () => {
|
||||
if (!_settings) {
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -519,7 +524,7 @@ export const useSettings = () => {
|
||||
const newSettings = {
|
||||
...defaultValues,
|
||||
..._settings,
|
||||
...update,
|
||||
...sanitizedUpdate,
|
||||
} as Settings;
|
||||
setSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
@@ -542,13 +547,24 @@ export const useSettings = () => {
|
||||
// Normalize object-typed settings from plugin (plain primitive → { key, 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
|
||||
? value
|
||||
: hasMeaningfulSettingValue(effectiveValue)
|
||||
? effectiveValue
|
||||
: value;
|
||||
: userDiverged
|
||||
? userValue
|
||||
: hasMeaningfulSettingValue(value)
|
||||
? value
|
||||
: defaultValues[settingsKey];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
Reference in New Issue
Block a user