Compare commits

..

18 Commits

Author SHA1 Message Date
Gauvino
599096f883 fix(review): address second CodeRabbit pass
- streamystats: derive toggle enablement from the same effective URL the
  input renders (locked admin URL no longer disables every switch)
- FilterSheet: use the deep-equality rule for toggling that rendering
  already uses — option objects are recreated across renders
- DownloadCard: take t from useTranslation so badge labels re-render on
  language change
- fileOperations: count trickplay bytes in the storage total, matching
  the per-item size model
- PendingAccountSaveModal: warn instead of silently swallowing a failed
  account save
2026-06-12 16:23:08 +02:00
Gauvino
3247bf709c fix(review): address CodeRabbit feedback
- swap direct i18next t imports for the useTranslation hook so the four
  touched components re-render on language change
- localize the buffer seconds unit via a buffer_seconds key instead of a
  literal trailing s
- reword the useAppRouter guard comment to match its real scope
2026-06-12 15:00:49 +02:00
Gauvain
2af252d639 Merge branch 'develop' into fix/ui-and-bugs 2026-06-12 11:03:08 +02:00
Gauvain
1636523d48 fix(login): ask how to protect a saved account after the login succeeds
The protection picker used to show before the login attempt, so a wrong
password still walked the user through choosing a PIN/password for an
account that never logged in - and a Quick Connect login could not save
the account at all.

Login flows now only flag the intent (pendingAccountSaveAtom); the
picker is a global PendingAccountSaveModal mounted at the root, shown
once the session is authorized - the login screen unmounts on success,
so it cannot host the modal itself. Works identically for the password
and Quick Connect flows; the credential is saved from the live session
token (saveCurrentAccount). Cancelling saves nothing, and a logout
before answering drops the intent.
2026-06-11 00:42:59 +02:00
Gauvain
855957707a fix(notifications): drop deprecated handler flags and payload logging
- shouldShowAlert is deprecated in expo-notifications: specify
  shouldShowBanner and shouldShowList instead (same behavior).
- The foreground listener logged the entire notification object, which
  touches the deprecated dataString getter (another deprecation warning)
  and dumps noisy payloads into the console - log only the title.
2026-06-11 00:42:59 +02:00
Gauvain
4bad8ae054 fix(filters): keep the search input responsive on large option lists
Typing in the filter-sheet search re-filtered and re-rendered up to 100
option rows per keystroke. On large lists (2000+ tags) that blocked the
JS thread long enough for the controlled TextInput to snap back to a
stale value - letters were dropped and deleted text reappeared.

Defer the search value (useDeferredValue) so the keystroke render stays
cheap and the filtering/list update runs after, and memoize the row
elements so urgent renders don't rebuild them.
2026-06-11 00:42:58 +02:00
Gauvain
16188ac2a3 fix(login): show the Quick Connect code in an auto-dismissing sheet
The code was shown in a native Alert, which has no programmatic
dismiss: after another device authorized the code and polling logged
the user in, the alert stayed open on top of the app.

Replace it with an in-app bottom sheet that closes itself once the
session is authorized. Dismissing only hides the code - polling
continues so login still completes if the code is authorized
afterwards; polling stops when leaving the login screen (parity with
TVLogin). The code can be tapped to copy (expo-clipboard, probed via
requireOptionalNativeModule so builds without the native module just
no-op).
2026-06-11 00:42:57 +02:00
Gauvain
d12d62863e fix(filters): present the filter sheet from the press handler
On the new architecture with Reanimated 4, BottomSheetModal.present()
called from a useEffect after a state update silently no-ops: the press
registered, open flipped to true, the effect called present() on a
valid ref - and nothing mounted (no onChange, nothing in the native
tree). Sheets that present() directly inside their press handler
(downloads, account picker) kept working, which is what pinned it down.

FilterSheet now takes a modalRef and the opener presents imperatively
from the gesture handler. The [open] effect only handles closing, and
never dismisses a modal that was never presented. The sheet also opens
immediately with a loader while options load, instead of the old
data-loaded press gate that left the button silently dead.

This restores genre/year/tag/sort filters in libraries and collections,
and the same pattern is applied to the bitrate/media-source/track
sheets that share FilterSheet.
2026-06-11 00:42:56 +02:00
Gauvain
7eb65ba430 fix(auth): clear the session on any 401 via a response interceptor
When the server revokes the token (device/session deleted), a 401 can
surface from any authenticated request. Nothing cleaned it up: the dead
token stayed in storage, every reload re-fired authenticated calls (401
spam, uncaught rejections) and the app lingered half-authenticated.

A response interceptor on the authenticated api clears the session once
on the first 401 so the app drops cleanly to the login screen. It only
attaches when api.accessToken is set, so a wrong-password 401 on the
login screen is never treated as session expiry. Saved credentials are
kept for quick re-login.
2026-06-11 00:42:55 +02:00
Gauvain
43d469f398 fix(auth): stop the offline splash hang and soften handled auth logs
On a cold start without network, startup awaited getCurrentUser on an
axios instance with no timeout, so the splash hung for the full OS TCP
timeout (75-120 s). Render from the cached user immediately and run the
token validation/refresh in the background; setInitialLoaded moves to a
finally so every path dismisses the splash.

Handled failures (quick-login with a revoked token, background
validation while offline) now log as warnings, and the background path
logs only status/message - axios errors carry the Authorization header.
2026-06-11 00:42:55 +02:00
Gauvain
d397233991 fix(filters): memoize useFilterOptions and drop debug logging
The hook returned a fresh array on every render (and console.logged
it). The unstable identity cascaded into list-header re-creation and,
under heavy re-rendering, tripped React maximum-update-depth.
2026-06-11 00:42:54 +02:00
Gauvain
7f020120b3 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-06-11 00:42:53 +02:00
Gauvain
aec3444829 fix(nav): drop duplicate pushes from rapid taps
Tapping an item twice before the pushed screen rendered stacked the
screen twice. A push blurs the source screen synchronously in the
navigation state, so a second tap sees an unfocused screen and is
dropped (focus-based guard, no timers).
2026-06-11 00:42:52 +02:00
Gauvain
24f9c38098 fix(downloads): key the series poster cache read on SeriesId
The cached base64 poster was read in a useMemo with empty deps, so
recycled list cells kept showing the first-rendered series poster.
2026-06-11 00:42:51 +02:00
Gauvain
1dd1940334 fix(downloads): compute storage usage from live file sizes
The storage bar showed 0.00% because calculateTotalDownloadedSize
summed the stored videoFileSize, which is 0 for items downloaded before
the size was recorded (or when fileInfo.size was undefined). Stat the
file on disk and fall back to the stored value.
2026-06-11 00:42:51 +02:00
Gauvain
1e537bc11e fix(downloads): confirm before deleting all downloaded files
The "Delete all downloaded files" row wiped everything on a single tap.
Ask for confirmation first (destructive action, cannot be undone).
2026-06-10 22:29:51 +02:00
Gauvain
b163c2abb4 fix(settings): restore plugin list order
Jellyseerr / Streamystats / Marlin Search rows were reordered by
mistake; put them back in the original order.
2026-06-10 22:29:48 +02:00
Gauvain
0d47c8d43a feat(i18n): localize hardcoded UI strings and fix misspelled keys
Move remaining hardcoded English strings (player menus, technical-info
overlay, music/now-playing, live TV, TV search badges, MPV subtitle
settings, accessibility labels, not-found screen, session picker) to
en.json, and correct misspelled keys (occured -> occurred, autorized ->
authorized, liraries -> libraries, jellyseer -> jellyseerr) along with
their usages.
2026-06-10 22:29:16 +02:00
57 changed files with 991 additions and 479 deletions

View File

@@ -645,7 +645,7 @@ export default function SettingsTV() {
formatValue={(v) => `${v.toFixed(1)}x`} formatValue={(v) => `${v.toFixed(1)}x`}
/> />
<TVSettingsStepper <TVSettingsStepper
label='Vertical Margin' label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
value={settings.mpvSubtitleMarginY ?? 0} value={settings.mpvSubtitleMarginY ?? 0}
onDecrease={() => { onDecrease={() => {
const newValue = Math.max( const newValue = Math.max(
@@ -663,11 +663,11 @@ export default function SettingsTV() {
}} }}
/> />
<TVSettingsOptionButton <TVSettingsOptionButton
label='Horizontal Alignment' label={t("home.settings.subtitles.mpv_subtitle_align_x")}
value={alignXLabel} value={alignXLabel}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: "Horizontal Alignment", title: t("home.settings.subtitles.mpv_subtitle_align_x"),
options: alignXOptions, options: alignXOptions,
onSelect: (value) => onSelect: (value) =>
updateSettings({ updateSettings({
@@ -677,11 +677,11 @@ export default function SettingsTV() {
} }
/> />
<TVSettingsOptionButton <TVSettingsOptionButton
label='Vertical Alignment' label={t("home.settings.subtitles.mpv_subtitle_align_y")}
value={alignYLabel} value={alignYLabel}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: "Vertical Alignment", title: t("home.settings.subtitles.mpv_subtitle_align_y"),
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_liraries_you_want_to_hide")} {t("home.settings.other.select_libraries_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_liraries_you_want_to_hide")} {t("home.settings.other.select_libraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
); );

View File

@@ -49,7 +49,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;
@@ -146,7 +160,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 +185,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 +204,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>

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'>
Missing music library id. {t("music.missing_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'>
Missing music library id. {t("music.missing_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'>
Missing music library id. {t("music.missing_library_id")}
</Text> </Text>
</View> </View>
); );

View File

@@ -14,6 +14,7 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
@@ -72,6 +73,7 @@ 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();
@@ -230,7 +232,9 @@ export default function NowPlayingScreen() {
paddingBottom: Platform.OS === "android" ? insets.bottom : 0, paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}} }}
> >
<Text className='text-neutral-500'>No track playing</Text> <Text className='text-neutral-500'>
{t("music.no_track_playing")}
</Text>
</View> </View>
</BottomSheetModalProvider> </BottomSheetModalProvider>
); );
@@ -267,7 +271,7 @@ export default function NowPlayingScreen() {
: "text-neutral-500" : "text-neutral-500"
} }
> >
Now Playing {t("music.now_playing")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -718,6 +722,7 @@ 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;
@@ -831,13 +836,15 @@ 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 ? "Playing from queue" : "Up next"} {history.length > 0
? 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'>Queue is empty</Text> <Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
</View> </View>
} }
/> />

View File

@@ -147,9 +147,9 @@ export default function DirectPlayerPage() {
const audioIndexFromUrl = audioIndexStr const audioIndexFromUrl = audioIndexStr
? Number.parseInt(audioIndexStr, 10) ? Number.parseInt(audioIndexStr, 10)
: undefined; : undefined;
const subtitleIndexFromUrl = subtitleIndexStr const subtitleIndex = subtitleIndexStr
? Number.parseInt(subtitleIndexStr, 10) ? Number.parseInt(subtitleIndexStr, 10)
: undefined; : -1;
const bitrateValue = bitrateValueStr const bitrateValue = bitrateValueStr
? Number.parseInt(bitrateValueStr, 10) ? Number.parseInt(bitrateValueStr, 10)
: BITRATES[0].value; : BITRATES[0].value;
@@ -185,23 +185,6 @@ export default function DirectPlayerPage() {
return undefined; return undefined;
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]); }, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
// Resolve subtitle index: use URL param if provided, otherwise use stored index for offline playback
const subtitleIndex = useMemo(() => {
if (subtitleIndexFromUrl !== undefined) {
return subtitleIndexFromUrl;
}
if (
offline &&
downloadedItem?.userData?.subtitleStreamIndex !== undefined
) {
return downloadedItem.userData.subtitleStreamIndex;
}
return -1;
}, [
subtitleIndexFromUrl,
offline,
downloadedItem?.userData?.subtitleStreamIndex,
]);
// Initialize TV audio/subtitle indices from URL params. // Initialize TV audio/subtitle indices from URL params.
// No undefined guard: when a new episode's URL omits audioIndex, reset to // No undefined guard: when a new episode's URL omits audioIndex, reset to
// undefined (media default) rather than leaking the previous episode's track. // undefined (media default) rather than leaking the previous episode's track.
@@ -1284,7 +1267,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_occured_while_playing_the_video"), t("player.an_error_occurred_while_playing_the_video"),
); );
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}

View File

@@ -192,6 +192,7 @@ 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
@@ -328,7 +329,7 @@ const SubtitleResultCard = React.forwardRef<
]} ]}
> >
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}> <Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
Hash Match {t("player.hash_match")}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -1,17 +1,20 @@
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: "Oops!" }} /> <Stack.Screen options={{ title: t("home.oops") }} />
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ThemedText type='title'>This screen doesn't exist.</ThemedText> <ThemedText type='title'>{t("not_found.title")}</ThemedText>
<Link href={"/home"} style={styles.link}> <Link href={"/home"} style={styles.link}>
<ThemedText type='link'>Go to home screen!</ThemedText> <ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
</Link> </Link>
</ThemedView> </ThemedView>
</> </>

View File

@@ -10,6 +10,7 @@ import * as Device from "expo-device";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation"; import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal"; import { GlobalModal } from "@/components/GlobalModal";
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler"; import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
@@ -84,7 +85,8 @@ configureReanimatedLogger({
if (!Platform.isTV) { if (!Platform.isTV) {
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async () => ({ handleNotification: async () => ({
shouldShowAlert: true, shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true, shouldPlaySound: true,
shouldSetBadge: false, shouldSetBadge: false,
}), }),
@@ -333,9 +335,12 @@ 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, notification.request.content.title,
); );
}, },
); );
@@ -530,6 +535,7 @@ function Layout() {
closeButton closeButton
/> />
{!Platform.isTV && <GlobalModal />} {!Platform.isTV && <GlobalModal />}
{!Platform.isTV && <PendingAccountSaveModal />}
</ThemeProvider> </ThemeProvider>
</IntroSheetProvider> </IntroSheetProvider>
</BottomSheetModalProvider> </BottomSheetModalProvider>

View File

@@ -31,6 +31,7 @@
"expo-brightness": "~56.0.5", "expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.15", "expo-build-properties": "~56.0.15",
"expo-camera": "~56.0.7", "expo-camera": "~56.0.7",
"expo-clipboard": "~56.0.4",
"expo-constants": "~56.0.16", "expo-constants": "~56.0.16",
"expo-crypto": "~56.0.4", "expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.16", "expo-dev-client": "~56.0.16",
@@ -955,6 +956,8 @@
"expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="], "expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="],
"expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="],
"expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="], "expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="],
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="], "expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from "react"; import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -61,6 +62,7 @@ export const BitrateSheet: React.FC<Props> = ({
const isTv = Platform.isTV; const isTv = Platform.isTV;
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const sorted = useMemo(() => { const sorted = useMemo(() => {
if (inverted) if (inverted)
@@ -92,7 +94,10 @@ export const BitrateSheet: React.FC<Props> = ({
</Text> </Text>
<TouchableOpacity <TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between' className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)} onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
> >
<Text numberOfLines={1}> <Text numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key} {BITRATES.find((b) => b.value === selected?.value)?.key}
@@ -103,6 +108,7 @@ export const BitrateSheet: React.FC<Props> = ({
<FilterSheet <FilterSheet
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.quality")} title={t("item_card.quality")}
data={sorted} data={sorted}
values={selected ? [selected] : []} values={selected ? [selected] : []}

View File

@@ -24,7 +24,6 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -60,9 +59,6 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { getDownloadedItemById } = useDownload();
const downloadedItem =
isOffline && item?.Id ? getDownloadedItemById(item.Id) : null;
const { settings } = useSettings(); const { settings } = useSettings();
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const navigation = useNavigation(); const navigation = useNavigation();
@@ -109,30 +105,17 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
// Needs to automatically change the selected to the default values for default indexes. // Needs to automatically change the selected to the default values for default indexes.
useEffect(() => { useEffect(() => {
// When offline, use the indices stored in userData (the last-used tracks for this file)
// rather than the server's defaults, so MediaSourceButton reflects what will actually play.
const offlineUserData = downloadedItem?.userData;
setSelectedOptions(() => ({ setSelectedOptions(() => ({
bitrate: defaultBitrate, bitrate: defaultBitrate,
mediaSource: defaultMediaSource ?? undefined, mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: subtitleIndex: defaultSubtitleIndex ?? -1,
offlineUserData && !offlineUserData.isTranscoded audioIndex: defaultAudioIndex,
? offlineUserData.subtitleStreamIndex
: (defaultSubtitleIndex ?? -1),
audioIndex:
offlineUserData && !offlineUserData.isTranscoded
? offlineUserData.audioStreamIndex
: defaultAudioIndex,
})); }));
}, [ }, [
defaultAudioIndex, defaultAudioIndex,
defaultBitrate, defaultBitrate,
defaultSubtitleIndex, defaultSubtitleIndex,
defaultMediaSource, defaultMediaSource,
downloadedItem?.userData?.audioStreamIndex,
downloadedItem?.userData?.subtitleStreamIndex,
downloadedItem?.userData?.isTranscoded,
]); ]);
useEffect(() => { useEffect(() => {

View File

@@ -7,8 +7,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { useDownload } from "@/providers/DownloadProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { BITRATES } from "./BitRateSheet"; import { BITRATES } from "./BitRateSheet";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown"; import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
@@ -30,14 +28,6 @@ export const MediaSourceButton: React.FC<Props> = ({
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const isOffline = useOfflineMode();
const { getDownloadedItemById } = useDownload();
// For transcoded downloads there's only one burned-in track — nothing to pick
const isTranscodedDownload = useMemo(() => {
if (!isOffline || !item?.Id) return false;
return getDownloadedItemById(item.Id)?.userData?.isTranscoded === true;
}, [isOffline, item?.Id, getDownloadedItemById]);
const effectiveColors = colors || { const effectiveColors = colors || {
primary: "#7c3aed", primary: "#7c3aed",
@@ -82,7 +72,6 @@ export const MediaSourceButton: React.FC<Props> = ({
const optionGroups: OptionGroup[] = useMemo(() => { const optionGroups: OptionGroup[] = useMemo(() => {
const groups: OptionGroup[] = []; const groups: OptionGroup[] = [];
if (!isOffline) {
// Bitrate group // Bitrate group
groups.push({ groups.push({
title: t("item_card.quality"), title: t("item_card.quality"),
@@ -112,7 +101,6 @@ export const MediaSourceButton: React.FC<Props> = ({
})), })),
}); });
} }
}
// Audio track group // Audio track group
if (audioStreams.length > 0) { if (audioStreams.length > 0) {
@@ -162,7 +150,6 @@ export const MediaSourceButton: React.FC<Props> = ({
return groups; return groups;
}, [ }, [
item, item,
isOffline,
selectedOptions, selectedOptions,
audioStreams, audioStreams,
subtitleStreams, subtitleStreams,
@@ -191,8 +178,6 @@ export const MediaSourceButton: React.FC<Props> = ({
</TouchableOpacity> </TouchableOpacity>
); );
if (isTranscodedDownload) return null;
return ( return (
<PlatformDropdown <PlatformDropdown
groups={optionGroups} groups={optionGroups}

View File

@@ -1,8 +1,9 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import type { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -23,6 +24,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
const isTv = Platform.isTV; const isTv = Platform.isTV;
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const getDisplayName = useCallback((source: MediaSourceInfo) => { const getDisplayName = useCallback((source: MediaSourceInfo) => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video"); const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
@@ -44,7 +46,10 @@ export const MediaSourceSheet: React.FC<Props> = ({
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text> <Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
<TouchableOpacity <TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center' className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
onPress={() => setOpen(true)} onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
> >
<Text numberOfLines={1}>{selectedName}</Text> <Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -53,6 +58,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
<FilterSheet <FilterSheet
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.video")} title={t("item_card.video")}
data={item.MediaSources || []} data={item.MediaSources || []}
values={selected ? [selected] : []} values={selected ? [selected] : []}

View File

@@ -0,0 +1,45 @@
import { useAtom, useAtomValue } from "jotai";
import type React from "react";
import { useEffect } from "react";
import { Platform } from "react-native";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import {
pendingAccountSaveAtom,
useJellyfin,
userAtom,
} from "@/providers/JellyfinProvider";
/**
* Post-login save-account prompt. Login flows (password or Quick Connect)
* only flag the intent via pendingAccountSaveAtom; the protection picker
* shows here, AFTER the session is authorized — the login screen itself
* unmounts as soon as the user is set, so it can't host the modal.
*/
export const PendingAccountSaveModal: React.FC = () => {
const [pending, setPending] = useAtom(pendingAccountSaveAtom);
const user = useAtomValue(userAtom);
const { saveCurrentAccount } = useJellyfin();
// A logout before answering drops the intent — it must not resurface on
// the next (possibly different) login.
useEffect(() => {
if (!user && pending) setPending(null);
}, [user, pending, setPending]);
if (Platform.isTV) return null;
return (
<SaveAccountModal
visible={!!pending && !!user}
username={user?.Name ?? ""}
onClose={() => setPending(null)}
onSave={(securityType, pinCode) => {
const serverName = pending?.serverName;
setPending(null);
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
(error) => console.warn("Failed to save account:", error),
);
}}
/>
);
};

View File

@@ -1,6 +1,7 @@
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";
@@ -209,6 +210,7 @@ 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
@@ -380,7 +382,7 @@ const PlatformDropdownComponent = ({
return ( return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}> <TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>} {trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -96,23 +96,14 @@ export const PlayButton: React.FC<Props> = ({
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id!, itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0", playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false", offline: isOffline ? "true" : "false",
}); });
if (selectedOptions.audioIndex !== undefined) {
queryParams.set("audioIndex", selectedOptions.audioIndex.toString());
}
if (selectedOptions.subtitleIndex !== undefined) {
queryParams.set(
"subtitleIndex",
selectedOptions.subtitleIndex.toString(),
);
}
const queryString = queryParams.toString(); const queryString = queryParams.toString();
if (!client) { if (!client) {
@@ -301,29 +292,6 @@ export const PlayButton: React.FC<Props> = ({
t, t,
]); ]);
const buildOfflineQueryParams = useCallback(
(downloadedItem: NonNullable<ReturnType<typeof getDownloadedItemById>>) => {
const isTranscoded = downloadedItem.userData?.isTranscoded === true;
const audioIdx = isTranscoded
? downloadedItem.userData?.audioStreamIndex
: selectedOptions.audioIndex;
const subtitleIdx = isTranscoded
? downloadedItem.userData?.subtitleStreamIndex
: selectedOptions.subtitleIndex;
const params = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
if (audioIdx !== undefined) params.set("audioIndex", audioIdx.toString());
if (subtitleIdx !== undefined)
params.set("subtitleIndex", subtitleIdx.toString());
return params;
},
[item, selectedOptions],
);
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
if (!item) return; if (!item) return;
@@ -334,7 +302,13 @@ export const PlayButton: React.FC<Props> = ({
// If already in offline mode, play downloaded file directly // If already in offline mode, play downloaded file directly
if (isOffline && downloadedItem) { if (isOffline && downloadedItem) {
goToPlayer(buildOfflineQueryParams(downloadedItem).toString()); const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
return; return;
} }
@@ -357,9 +331,13 @@ export const PlayButton: React.FC<Props> = ({
<Button <Button
onPress={() => { onPress={() => {
hideModal(); hideModal();
goToPlayer( const queryParams = new URLSearchParams({
buildOfflineQueryParams(downloadedItem).toString(), itemId: item.Id!,
); offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}} }}
color='purple' color='purple'
> >
@@ -396,7 +374,13 @@ export const PlayButton: React.FC<Props> = ({
{ {
text: t("player.downloaded_file_yes"), text: t("player.downloaded_file_yes"),
onPress: () => { onPress: () => {
goToPlayer(buildOfflineQueryParams(downloadedItem).toString()); const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}, },
isPreferred: true, isPreferred: true,
}, },
@@ -518,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
disabled={!item} disabled={!item}
accessibilityLabel='Play button' accessibilityLabel={t("accessibility.play_button")}
accessibilityHint='Tap to play the media' accessibilityHint={t("accessibility.play_hint")}
onPress={onPress} onPress={onPress}
className={"relative flex-1"} className={"relative flex-1"}
> >

View File

@@ -2,6 +2,7 @@ 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,
@@ -36,6 +37,7 @@ 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
@@ -168,8 +170,8 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
accessibilityLabel='Play button' accessibilityLabel={t("accessibility.play_button")}
accessibilityHint='Tap to play the media' accessibilityHint={t("accessibility.play_hint")}
onPress={onPress} onPress={onPress}
className={"relative"} className={"relative"}
{...props} {...props}

View File

@@ -6,6 +6,7 @@ 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,
@@ -31,6 +32,7 @@ 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;
@@ -65,7 +67,9 @@ 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}>Select Session</Text> <Text style={styles.modalTitle}>
{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>
@@ -78,7 +82,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}>
No active sessions found {t("home.sessions.no_active_sessions")}
</Text> </Text>
) : ( ) : (
<FlatList <FlatList
@@ -98,7 +102,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}>
Now playing:{" "} {t("home.sessions.now_playing")}{" "}
{session.NowPlayingItem.SeriesName {session.NowPlayingItem.SeriesName
? `${session.NowPlayingItem.SeriesName} :` ? `${session.NowPlayingItem.SeriesName} :`
: ""} : ""}

View File

@@ -1,5 +1,6 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -49,6 +50,7 @@ export const TrackSheet: React.FC<Props> = ({
return streams; return streams;
}, [streams, streamType, noneOption]); }, [streams, streamType, noneOption]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
if (isTv || (streams && streams.length === 0)) return null; if (isTv || (streams && streams.length === 0)) return null;
@@ -58,7 +60,10 @@ export const TrackSheet: React.FC<Props> = ({
<Text className='opacity-50 mb-1 text-xs'>{title}</Text> <Text className='opacity-50 mb-1 text-xs'>{title}</Text>
<TouchableOpacity <TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between' className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)} onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
> >
<Text numberOfLines={1}> <Text numberOfLines={1}>
{selected === -1 && streamType === "Subtitle" {selected === -1 && streamType === "Subtitle"
@@ -70,6 +75,7 @@ export const TrackSheet: React.FC<Props> = ({
<FilterSheet <FilterSheet
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
modalRef={sheetModalRef}
title={title} title={title}
data={addNoneToSubtitles || []} data={addNoneToSubtitles || []}
values={ values={

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,6 +35,7 @@ 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();
@@ -173,7 +174,9 @@ 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'>Transcoding</Text> <Text className='text-xs text-purple-400'>
{t("home.downloads.transcoding")}
</Text>
</View> </View>
)} )}

View File

@@ -16,9 +16,12 @@ 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(() => {
return storage.getString(items[0].SeriesId!); const seriesId = items[0]?.SeriesId;
}, []); return seriesId ? storage.getString(seriesId) : undefined;
}, [items[0]?.SeriesId]);
const deleteSeries = useCallback( const deleteSeries = useCallback(
async () => async () =>

View File

@@ -1,6 +1,7 @@
import { FontAwesome, Ionicons } from "@expo/vector-icons"; import { FontAwesome, Ionicons } from "@expo/vector-icons";
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useRef, useState } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { FilterSheet } from "./FilterSheet"; import { FilterSheet } from "./FilterSheet";
@@ -34,8 +35,9 @@ export const FilterButton = <T,>({
...props ...props
}: FilterButtonProps<T>) => { }: FilterButtonProps<T>) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const { data: filters } = useQuery<T[]>({ const { data: filters, isLoading } = useQuery<T[]>({
queryKey: ["filters", title, queryKey, id], queryKey: ["filters", title, queryKey, id],
queryFn, queryFn,
staleTime: 0, staleTime: 0,
@@ -44,9 +46,15 @@ export const FilterButton = <T,>({
return ( return (
<> <>
{/* present() must be called here, inside the press handler: calling it
from an effect after a state update silently no-ops on the new
architecture and the sheet never appears. Opening immediately also
replaces the old data-loaded gate that left the button silently
dead while options were still loading (the sheet shows a loader). */}
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
filters?.length && setOpen(true); setOpen(true);
sheetModalRef.current?.present();
}} }}
> >
<View <View
@@ -89,6 +97,8 @@ export const FilterButton = <T,>({
title={title} title={title}
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
modalRef={sheetModalRef}
loading={isLoading}
data={filters} data={filters}
values={values} values={values}
set={set} set={set}

View File

@@ -7,7 +7,14 @@ import {
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
StyleSheet, StyleSheet,
@@ -19,11 +26,21 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Button } from "../Button"; import { Button } from "../Button";
import { Input } from "../common/Input"; import { Input } from "../common/Input";
import { Loader } from "../Loader";
interface Props<T> extends ViewProps { interface Props<T> extends ViewProps {
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
/**
* Modal ref the opener must use to present() the sheet from inside its
* press handler. On the new architecture with Reanimated 4, present()
* called from an effect after a state update silently no-ops — the sheet
* mounts nothing. Presenting straight from the gesture handler works.
*/
modalRef: React.RefObject<BottomSheetModal | null>;
data?: T[] | null; data?: T[] | null;
/** True while the options are loading — shows a loader inside the sheet. */
loading?: boolean;
values: T[]; values: T[];
set: (value: T[]) => void; set: (value: T[]) => void;
title: string; title: string;
@@ -66,16 +83,18 @@ const LIMIT = 100;
export const FilterSheet = <T,>({ export const FilterSheet = <T,>({
values, values,
data: _data, data: _data,
loading = false,
open, open,
set, set,
setOpen, setOpen,
modalRef,
title, title,
searchFilter, searchFilter,
renderItemLabel, renderItemLabel,
disableSearch = false, disableSearch = false,
multiple = false, multiple = false,
}: Props<T>) => { }: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = modalRef;
const snapPoints = useMemo(() => ["85%"], []); const snapPoints = useMemo(() => ["85%"], []);
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -84,19 +103,24 @@ export const FilterSheet = <T,>({
const [offset, setOffset] = useState<number>(0); const [offset, setOffset] = useState<number>(0);
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
// Filtering and re-rendering the option list on every keystroke blocks the
// JS thread on large lists (2000+ tags); the controlled input then snaps the
// native text back to a stale value (lost/reappearing letters). Deferring the
// value keeps the keystroke render cheap and runs the list update after.
const deferredSearch = useDeferredValue(search);
const [showSearch, setShowSearch] = useState<boolean>(false); const [showSearch, setShowSearch] = useState<boolean>(false);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!search) return _data; if (!deferredSearch) return _data;
const results = []; const results = [];
for (let i = 0; i < (_data?.length || 0); i++) { for (let i = 0; i < (_data?.length || 0); i++) {
if (_data && searchFilter?.(_data[i], search)) { if (_data && searchFilter?.(_data[i], deferredSearch)) {
results.push(_data[i]); results.push(_data[i]);
} }
} }
return results.slice(0, 100); return results.slice(0, 100);
}, [search, _data, searchFilter]); }, [deferredSearch, _data, searchFilter]);
useEffect(() => { useEffect(() => {
if (!data || data.length === 0 || disableSearch) return; if (!data || data.length === 0 || disableSearch) return;
@@ -127,21 +151,28 @@ export const FilterSheet = <T,>({
setData(newData); setData(newData);
}, [offset, _data]); }, [offset, _data]);
// Opening is imperative (see the modalRef prop); this effect only closes.
// It also never calls dismiss() on a modal that was never presented.
const wasPresentedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (open) bottomSheetModalRef.current?.present(); if (!open && wasPresentedRef.current) {
else bottomSheetModalRef.current?.dismiss(); bottomSheetModalRef.current?.dismiss();
}
}, [open]); }, [open]);
const handleSheetChanges = useCallback((index: number) => { const handleSheetChanges = useCallback((index: number) => {
if (index === -1) { if (index >= 0) {
wasPresentedRef.current = true;
} else if (index === -1) {
wasPresentedRef.current = false;
setOpen(false); setOpen(false);
} }
}, []); }, []);
const renderData = useMemo(() => { const renderData = useMemo(() => {
if (search.length > 0 && showSearch) return filteredData; if (deferredSearch.length > 0 && showSearch) return filteredData;
return data; return data;
}, [search, filteredData, data]); }, [deferredSearch, showSearch, filteredData, data]);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
@@ -154,6 +185,54 @@ export const FilterSheet = <T,>({
[], [],
); );
// Memoized so typing in the search input (urgent render with an unchanged
// deferred value) doesn't rebuild up to 100 row elements per keystroke.
const renderedRows = useMemo(
() =>
renderData?.map((item, index) => (
<View key={index}>
<TouchableOpacity
onPress={() => {
// Match the deep-equality rule used to render the selected
// state below — option objects are recreated across renders,
// so reference checks would re-add an already selected item.
const isSelected = values.some((value) => isEqual(value, item));
if (multiple) {
if (!isSelected) set(values.concat(item));
else set(values.filter((value) => !isEqual(value, item)));
setTimeout(() => {
setOpen(false);
}, 250);
} else {
if (!isSelected) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}
}}
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='h-1 divide-neutral-700 '
/>
</View>
)),
[renderData, values, multiple, set, setOpen, renderItemLabel],
);
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
@@ -182,9 +261,15 @@ export const FilterSheet = <T,>({
}} }}
> >
<Text className='font-bold text-2xl'>{title}</Text> <Text className='font-bold text-2xl'>{title}</Text>
{loading ? (
<View className='my-8 flex items-center justify-center'>
<Loader />
</View>
) : (
<Text className='mb-2 text-neutral-500'> <Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })} {t("search.x_items", { count: _data?.length })}
</Text> </Text>
)}
{showSearch && ( {showSearch && (
<Input <Input
placeholder={t("search.search")} placeholder={t("search.search")}
@@ -203,43 +288,7 @@ export const FilterSheet = <T,>({
}} }}
className='mb-4 flex flex-col rounded-xl overflow-hidden' className='mb-4 flex flex-col rounded-xl overflow-hidden'
> >
{renderData?.map((item, index) => ( {renderedRows}
<View key={index}>
<TouchableOpacity
onPress={() => {
if (multiple) {
if (!values.includes(item)) set(values.concat(item));
else set(values.filter((v) => v !== item));
setTimeout(() => {
setOpen(false);
}, 250);
} else {
if (!values.includes(item)) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}
}}
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='h-1 divide-neutral-700 '
/>
</View>
))}
</View> </View>
{data.length < (_data?.length || 0) && ( {data.length < (_data?.length || 0) && (
<Button <Button

View File

@@ -1,5 +1,6 @@
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";
@@ -22,6 +23,7 @@ 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,
@@ -68,7 +70,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
<Text <Text
style={[styles.liveBadgeText, { fontSize: typography.callout }]} style={[styles.liveBadgeText, { fontSize: typography.callout }]}
> >
LIVE {t("player.live")}
</Text> </Text>
</View> </View>
)} )}

View File

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

View File

@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtomValue } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
@@ -20,14 +20,16 @@ import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
import { PreviousServersList } from "@/components/PreviousServersList"; import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import {
import type { apiAtom,
AccountSecurityType, pendingAccountSaveAtom,
SavedServer, useJellyfin,
} from "@/utils/secureCredentials"; userAtom,
} from "@/providers/JellyfinProvider";
import type { SavedServer } from "@/utils/secureCredentials";
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")), username: z.string().min(1, t("login.username_required")),
@@ -37,14 +39,17 @@ export const Login: React.FC = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const navigation = useNavigation(); const navigation = useNavigation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const user = useAtomValue(userAtom);
const { const {
setServer, setServer,
login, login,
removeServer, removeServer,
initiateQuickConnect, initiateQuickConnect,
stopQuickConnectPolling,
loginWithSavedCredential, loginWithSavedCredential,
loginWithPassword, loginWithPassword,
} = useJellyfin(); } = useJellyfin();
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
const { const {
apiUrl: _apiUrl, apiUrl: _apiUrl,
@@ -64,13 +69,43 @@ export const Login: React.FC = () => {
password: _password || "", password: _password || "",
}); });
// Save account state // Quick Connect code shown in the in-app sheet while polling for authorization
const [quickConnectCode, setQuickConnectCode] = useState<string | null>(null);
// Close the code sheet as soon as the session is authorized — the native
// Alert used before had no programmatic dismiss and stayed open after login.
// A Quick Connect login with "save account" on flags the post-login save:
// the protection picker shows globally once the session exists (this screen
// unmounts on login, so it can't host the modal).
useEffect(() => {
if (user) {
if (quickConnectCode && saveAccount) {
setPendingAccountSave({ serverName });
}
setQuickConnectCode(null);
}
}, [user]);
// Stop Quick Connect polling when leaving the login page (parity with TVLogin)
useEffect(() => {
return () => {
stopQuickConnectPolling();
};
}, [stopQuickConnectPolling]);
// Going back to server selection keeps this component mounted (same screen,
// different state), so the unmount cleanup above doesn't run. Without this a
// code authorized after leaving would silently log the user in later.
useEffect(() => {
if (!api?.basePath) {
stopQuickConnectPolling();
setQuickConnectCode(null);
}
}, [api?.basePath, stopQuickConnectPolling]);
// Save account state — only the intent lives here; the protection picker is
// the global PendingAccountSaveModal, shown after the login succeeds.
const [saveAccount, setSaveAccount] = useState(false); const [saveAccount, setSaveAccount] = useState(false);
const [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{
username: string;
password: string;
} | null>(null);
// Handle URL params for server connection // Handle URL params for server connection
useEffect(() => { useEffect(() => {
@@ -117,55 +152,34 @@ export const Login: React.FC = () => {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return; if (!result.success) return;
if (saveAccount) { const ok = await performLogin(credentials.username, credentials.password);
setPendingLogin({ // The protection picker shows AFTER a successful login (global modal) —
username: credentials.username, // never for a failed one.
password: credentials.password, if (ok && saveAccount) {
}); setPendingAccountSave({ serverName });
setShowSaveModal(true);
} else {
await performLogin(credentials.username, credentials.password);
} }
}; };
const performLogin = async ( const performLogin = async (
username: string, username: string,
password: string, password: string,
options?: { ): Promise<boolean> => {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true); setLoading(true);
try { try {
await login(username, password, serverName, options); await login(username, password, serverName);
return true;
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message); Alert.alert(t("login.connection_failed"), error.message);
} else { } else {
Alert.alert( Alert.alert(
t("login.connection_failed"), t("login.connection_failed"),
t("login.an_unexpected_error_occured"), t("login.an_unexpected_error_occurred"),
); );
} }
return false;
} finally { } finally {
setLoading(false); setLoading(false);
setPendingLogin(null);
}
};
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
if (pendingLogin) {
await performLogin(pendingLogin.username, pendingLogin.password, {
saveAccount: true,
securityType,
pinCode,
});
} }
}; };
@@ -259,15 +273,7 @@ export const Login: React.FC = () => {
try { try {
const code = await initiateQuickConnect(); const code = await initiateQuickConnect();
if (code) { if (code) {
Alert.alert( setQuickConnectCode(code);
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[
{
text: t("login.got_it"),
},
],
);
} }
} catch (_error) { } catch (_error) {
Alert.alert( Alert.alert(
@@ -402,7 +408,7 @@ export const Login: React.FC = () => {
{t("server.enter_url_to_jellyfin_server")} {t("server.enter_url_to_jellyfin_server")}
</Text> </Text>
<Input <Input
aria-label='Server URL' aria-label={t("server.server_url")}
placeholder={t("server.server_url_placeholder")} placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL} onChangeText={setServerURL}
value={serverURL} value={serverURL}
@@ -444,14 +450,11 @@ export const Login: React.FC = () => {
)} )}
</KeyboardAvoidingView> </KeyboardAvoidingView>
<SaveAccountModal {/* Dismissing only hides the code — polling continues so the login still
visible={showSaveModal} completes if the code is authorized from another device afterwards. */}
onClose={() => { <QuickConnectCodeModal
setShowSaveModal(false); code={quickConnectCode}
setPendingLogin(null); onClose={() => setQuickConnectCode(null)}
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/> />
</SafeAreaView> </SafeAreaView>
); );

View File

@@ -0,0 +1,137 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { requireOptionalNativeModule } from "expo-modules-core";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "../Button";
import { Text } from "../common/Text";
interface Props {
/** The Quick Connect code to display, or null when hidden. */
code: string | null;
onClose: () => void;
}
/**
* Shows the Quick Connect code while the app polls for authorization.
* In-app sheet instead of a native Alert so it can dismiss itself once the
* session is authorized — a native alert has no programmatic dismiss and
* lingers over the app after login completes.
*/
export const QuickConnectCodeModal: React.FC<Props> = ({ code, onClose }) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["50%"], []);
const isPresentedRef = useRef(false);
// Keep the last code around so the dismiss animation doesn't flash empty
// when the parent clears the code to close the sheet.
const lastCodeRef = useRef<string | null>(null);
if (code) lastCodeRef.current = code;
useEffect(() => {
if (code) {
bottomSheetModalRef.current?.present();
} else if (isPresentedRef.current) {
bottomSheetModalRef.current?.dismiss();
isPresentedRef.current = false;
}
}, [code]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index >= 0) {
isPresentedRef.current = true;
} else if (index === -1 && isPresentedRef.current) {
isPresentedRef.current = false;
onClose();
}
},
[onClose],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const copyCode = useCallback(async () => {
const value = code ?? lastCodeRef.current;
if (!value) return;
// Builds that don't ship the expo-clipboard native module yet: probe with
// requireOptionalNativeModule (returns null instead of throwing/logging)
// and skip — importing the JS wrapper there would error out.
if (!requireOptionalNativeModule("ExpoClipboard")) return;
const Clipboard = await import("expo-clipboard");
await Clipboard.setStringAsync(value);
toast.success(t("login.code_copied"));
}, [code, t]);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
>
<BottomSheetView
style={{
flex: 1,
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: Math.max(16, insets.bottom),
}}
>
<View className='flex-1'>
<Text className='font-bold text-2xl text-neutral-100'>
{t("login.quick_connect")}
</Text>
<TouchableOpacity
className='mt-6 p-6 border border-neutral-800 rounded-xl bg-neutral-900 flex flex-row items-center justify-center'
onPress={copyCode}
>
<Text
className='text-center font-bold text-5xl text-neutral-100'
style={{ letterSpacing: 10 }}
>
{code ?? lastCodeRef.current}
</Text>
<Ionicons
name='copy-outline'
size={22}
color='white'
style={{ opacity: 0.4, marginLeft: 16 }}
/>
</TouchableOpacity>
<Text className='mt-2 text-neutral-500 text-center text-xs'>
{t("login.tap_code_to_copy")}
</Text>
<Text className='mt-3 mb-5 text-neutral-400 text-center px-4'>
{t("login.quick_connect_instructions")}
</Text>
<Button className='mt-auto' color='purple' onPress={onClose}>
{t("login.got_it")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
);
};

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_occured"), t("login.an_unexpected_error_occurred"),
); );
} }
} 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_occured"); : t("login.an_unexpected_error_occurred");
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_occured"), t("login.an_unexpected_error_occurred"),
); );
} }
} 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_occured"); : t("login.an_unexpected_error_occurred");
Alert.alert(t("login.connection_failed"), message); Alert.alert(t("login.connection_failed"), message);
goToQRScreen(); goToQRScreen();
}); });

View File

@@ -1,4 +1,5 @@
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";
@@ -88,6 +89,8 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
showDiscover, showDiscover,
disabled = false, disabled = false,
}) => { }) => {
const { t } = useTranslation();
if (!showDiscover) { if (!showDiscover) {
return null; return null;
} }
@@ -101,13 +104,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
}} }}
> >
<TVSearchTabBadge <TVSearchTabBadge
label='Library' label={t("search.library")}
isSelected={searchType === "Library"} isSelected={searchType === "Library"}
onPress={() => setSearchType("Library")} onPress={() => setSearchType("Library")}
disabled={disabled} disabled={disabled}
/> />
<TVSearchTabBadge <TVSearchTabBadge
label='Discover' label={t("search.discover")}
isSelected={searchType === "Discover"} isSelected={searchType === "Discover"}
onPress={() => setSearchType("Discover")} onPress={() => setSearchType("Discover")}
disabled={disabled} disabled={disabled}

View File

@@ -1,5 +1,6 @@
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";
@@ -17,20 +18,21 @@ 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: "Left", left: t("home.settings.subtitles.align.left"),
center: "Center", center: t("home.settings.subtitles.align.center"),
right: "Right", right: t("home.settings.subtitles.align.right"),
}; };
const alignYLabels: Record<AlignY, string> = { const alignYLabels: Record<AlignY, string> = {
top: "Top", top: t("home.settings.subtitles.align.top"),
center: "Center", center: t("home.settings.subtitles.align.center"),
bottom: "Bottom", bottom: t("home.settings.subtitles.align.bottom"),
}; };
const alignXOptionGroups = useMemo(() => { const alignXOptionGroups = useMemo(() => {
@@ -60,16 +62,18 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
return ( return (
<View {...props}> <View {...props}>
<ListGroup <ListGroup
title='MPV Subtitle Settings' title={t("home.settings.subtitles.mpv_settings_title")}
description={ description={
<Text className='text-[#8E8D91] text-xs'> <Text className='text-[#8E8D91] text-xs'>
Advanced subtitle customization for MPV player {t("home.settings.subtitles.mpv_settings_description")}
</Text> </Text>
} }
> >
{!isTv && ( {!isTv && (
<> <>
<ListItem title='Vertical Margin'> <ListItem
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
>
<Stepper <Stepper
value={settings.mpvSubtitleMarginY ?? 0} value={settings.mpvSubtitleMarginY ?? 0}
step={5} step={5}
@@ -81,7 +85,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</ListItem> </ListItem>
<ListItem title='Horizontal Alignment'> <ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
<PlatformDropdown <PlatformDropdown
groups={alignXOptionGroups} groups={alignXOptionGroups}
trigger={ trigger={
@@ -96,11 +100,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</View> </View>
} }
title='Horizontal Alignment' title={t("home.settings.subtitles.mpv_subtitle_align_x")}
/> />
</ListItem> </ListItem>
<ListItem title='Vertical Alignment'> <ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
<PlatformDropdown <PlatformDropdown
groups={alignYOptionGroups} groups={alignYOptionGroups}
trigger={ trigger={
@@ -115,13 +119,13 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</View> </View>
} }
title='Vertical Alignment' title={t("home.settings.subtitles.mpv_subtitle_align_y")}
/> />
</ListItem> </ListItem>
</> </>
)} )}
<ListItem title='Opaque Background'> <ListItem title={t("home.settings.subtitles.opaque_background")}>
<Switch <Switch
value={settings.mpvSubtitleBackgroundEnabled ?? false} value={settings.mpvSubtitleBackgroundEnabled ?? false}
onValueChange={(value) => onValueChange={(value) =>
@@ -131,7 +135,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
</ListItem> </ListItem>
{settings.mpvSubtitleBackgroundEnabled && ( {settings.mpvSubtitleBackgroundEnabled && (
<ListItem title='Background Opacity'> <ListItem title={t("home.settings.subtitles.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_autorized"), t("home.settings.quick_connect.quick_connect_authorized"),
); );
setQuickConnectCode(undefined); setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close(); bottomSheetModalRef?.current?.close();

View File

@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Alert, 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,6 +12,7 @@ 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");
@@ -27,16 +28,38 @@ 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 = async () => { 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 { 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

@@ -3,6 +3,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { useMemo, useRef, useState } from "react"; import React, { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
Animated, Animated,
Easing, Easing,
@@ -106,6 +107,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
scaleAmount = 1.05, scaleAmount = 1.05,
imageUrlGetter, imageUrlGetter,
}) => { }) => {
const { t } = useTranslation();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const posterSizes = useScaledTVPosterSizes(); const posterSizes = useScaledTVPosterSizes();
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
@@ -371,7 +373,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
fontWeight: "700", fontWeight: "700",
}} }}
> >
Now Playing {t("music.now_playing")}
</Text> </Text>
</View> </View>
) : null; ) : null;

View File

@@ -1,5 +1,6 @@
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,
@@ -28,6 +29,7 @@ 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
@@ -152,7 +154,7 @@ export const TVSubtitleResultCard = React.forwardRef<
}, },
]} ]}
> >
<Text style={styles.flagText}>Hash Match</Text> <Text style={styles.flagText}>{t("player.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='Skip Intro' buttonText={t("player.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='Skip Credits' buttonText={t("player.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 '>
Are you still watching ? {t("player.still_watching")}
</Text> </Text>
<Button <Button
onPress={() => { onPress={() => {

View File

@@ -4,6 +4,7 @@ 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";
@@ -57,6 +58,7 @@ 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");
@@ -127,8 +129,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='Toggle screen orientation' accessibilityLabel={t("accessibility.toggle_orientation")}
accessibilityHint='Toggles the screen orientation between portrait and landscape' accessibilityHint={t("accessibility.toggle_orientation_hint")}
> >
<MaterialIcons <MaterialIcons
name='screen-rotation' name='screen-rotation'

View File

@@ -7,6 +7,7 @@ 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,
@@ -184,6 +185,7 @@ 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);
@@ -312,13 +314,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.videoCodec && ( {info?.videoCodec && (
<Text style={textStyle}> <Text style={textStyle}>
Video: {formatCodec(info.videoCodec)} {t("player.technical_info.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}>
Audio: {formatCodec(info.audioCodec)} {t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
{streamInfo?.audioChannels {streamInfo?.audioChannels
? ` ${formatAudioChannels(streamInfo.audioChannels)}` ? ` ${formatAudioChannels(streamInfo.audioChannels)}`
: ""} : ""}
@@ -326,12 +328,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{streamInfo?.subtitleCodec && ( {streamInfo?.subtitleCodec && (
<Text style={textStyle}> <Text style={textStyle}>
Subtitle: {formatCodec(streamInfo.subtitleCodec)} {t("player.technical_info.subtitle")}{" "}
{formatCodec(streamInfo.subtitleCodec)}
</Text> </Text>
)} )}
{(info?.videoBitrate || info?.audioBitrate) && ( {(info?.videoBitrate || info?.audioBitrate) && (
<Text style={textStyle}> <Text style={textStyle}>
Bitrate:{" "} {t("player.technical_info.bitrate")}{" "}
{info.videoBitrate {info.videoBitrate
? formatBitrate(info.videoBitrate) ? formatBitrate(info.videoBitrate)
: info.audioBitrate : info.audioBitrate
@@ -341,21 +344,27 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.cacheSeconds !== undefined && ( {info?.cacheSeconds !== undefined && (
<Text style={textStyle}> <Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s {t("player.technical_info.buffer_seconds", {
seconds: info.cacheSeconds.toFixed(1),
})}
</Text> </Text>
)} )}
{info?.voDriver && ( {info?.voDriver && (
<Text style={textStyle}> <Text style={textStyle}>
VO: {info.voDriver} {t("player.technical_info.vo")} {info.voDriver}
{info.hwdec ? ` / ${info.hwdec}` : ""} {info.hwdec ? ` / ${info.hwdec}` : ""}
</Text> </Text>
)} )}
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && ( {info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
<Text style={[textStyle, styles.warningText]}> <Text style={[textStyle, styles.warningText]}>
Dropped: {info.droppedFrames} frames {t("player.technical_info.dropped_frames", {
count: info.droppedFrames,
})}
</Text> </Text>
)} )}
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>} {!info && !playMethod && (
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
)}
</View> </View>
</Animated.View> </Animated.View>
); );

View File

@@ -1,5 +1,6 @@
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,
@@ -54,6 +55,7 @@ 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) => {
@@ -66,7 +68,10 @@ 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: option.label, 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),
@@ -94,7 +99,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
return ( return (
<PlatformDropdown <PlatformDropdown
title='Aspect Ratio' title={t("player.aspect_ratio")}
groups={optionGroups} groups={optionGroups}
trigger={trigger} trigger={trigger}
bottomSheetConfig={{ bottomSheetConfig={{

View File

@@ -1,6 +1,7 @@
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 {
@@ -47,6 +48,7 @@ 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<{
@@ -101,7 +103,7 @@ const DropdownView = ({
// Quality Section // Quality Section
if (!isOffline) { if (!isOffline) {
groups.push({ groups.push({
title: "Quality", title: t("player.menu.quality"),
options: options:
BITRATES?.map((bitrate) => ({ BITRATES?.map((bitrate) => ({
type: "radio" as const, type: "radio" as const,
@@ -116,7 +118,7 @@ const DropdownView = ({
// Subtitle Section // Subtitle Section
if (subtitleTracks && subtitleTracks.length > 0) { if (subtitleTracks && subtitleTracks.length > 0) {
groups.push({ groups.push({
title: "Subtitles", title: t("player.menu.subtitles"),
options: subtitleTracks.map((sub) => ({ options: subtitleTracks.map((sub) => ({
type: "radio" as const, type: "radio" as const,
label: sub.name, label: sub.name,
@@ -128,7 +130,7 @@ const DropdownView = ({
// Subtitle Scale Section // Subtitle Scale Section
groups.push({ groups.push({
title: "Subtitle Scale", title: t("player.menu.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,
@@ -142,7 +144,7 @@ const DropdownView = ({
// Audio Section // Audio Section
if (audioTracks && audioTracks.length > 0) { if (audioTracks && audioTracks.length > 0) {
groups.push({ groups.push({
title: "Audio", title: t("player.menu.audio"),
options: audioTracks.map((track) => ({ options: audioTracks.map((track) => ({
type: "radio" as const, type: "radio" as const,
label: track.name, label: track.name,
@@ -156,7 +158,7 @@ const DropdownView = ({
// Speed Section // Speed Section
if (setPlaybackSpeed) { if (setPlaybackSpeed) {
groups.push({ groups.push({
title: "Speed", title: t("player.menu.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,
@@ -174,8 +176,8 @@ const DropdownView = ({
{ {
type: "action" as const, type: "action" as const,
label: showTechnicalInfo label: showTechnicalInfo
? "Hide Technical Info" ? t("player.menu.hide_technical_info")
: "Show Technical Info", : t("player.menu.show_technical_info"),
onPress: onToggleTechnicalInfo, onPress: onToggleTechnicalInfo,
}, },
], ],
@@ -185,6 +187,7 @@ 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,
@@ -217,7 +220,7 @@ const DropdownView = ({
return ( return (
<PlatformDropdown <PlatformDropdown
title='Playback Options' title={t("player.menu.playback_options")}
groups={optionGroups} groups={optionGroups}
trigger={trigger} trigger={trigger}
expoUIConfig={{}} expoUIConfig={{}}

View File

@@ -3,6 +3,7 @@ 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;
@@ -124,17 +125,23 @@ export function useRemoteControl({
// Controls are hidden, so confirm before leaving playback. // Controls are hidden, so confirm before leaving playback.
Alert.alert( Alert.alert(
"Stop Playback", i18n.t("player.stopPlayback"),
videoTitleRef.current videoTitleRef.current
? `Stop playing "${videoTitleRef.current}"?` ? i18n.t("player.stopPlayingTitle", {
: "Are you sure you want to stop playback?", title: videoTitleRef.current,
})
: i18n.t("player.stopPlayingConfirm"),
[ [
{ {
text: "Cancel", text: i18n.t("common.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

@@ -1,13 +1,19 @@
// Imported from expo-router's bundled copy, NOT "@react-navigation/*": as of
// SDK 56 expo-router's Metro check rejects direct @react-navigation imports.
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react"; import { NavigationContext } from "expo-router/react-navigation";
import { useCallback, useContext, useMemo } from "react";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
/** /**
* Drop-in replacement for expo-router's useRouter that automatically * Drop-in replacement for expo-router's useRouter that automatically
* preserves offline state across navigation. * preserves offline state across navigation and guards against duplicate
* screens from rapid taps.
* *
* - For object-form navigation, automatically adds offline=true when in offline context * - For object-form navigation, automatically adds offline=true when in offline context
* - For string URLs, passes through unchanged (caller handles offline param) * - For string URLs, passes through unchanged (caller handles offline param)
* - push() is a no-op while the source screen is not focused, so taps fired
* before the pushed screen has rendered (slow devices) can't stack duplicates
* *
* @example * @example
* import useRouter from "@/hooks/useAppRouter"; * import useRouter from "@/hooks/useAppRouter";
@@ -19,8 +25,18 @@ export function useAppRouter() {
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
// Optional: undefined when used outside a navigator (root layout, providers).
// When present it reflects the focus state of the screen this hook lives in.
const navigation = useContext(NavigationContext);
const push = useCallback( const push = useCallback(
(href: Parameters<typeof router.push>[0]) => { (href: Parameters<typeof router.push>[0]) => {
// Rapid-push guard: a push blurs the source screen synchronously in the
// navigation state (only the native render is slow). Any further push from
// this screen — duplicate or not — is dropped until focus returns, so taps
// fired before the pushed screen renders can't stack screens.
// No navigation context => nothing to guard (deep-link pushes from root).
if (navigation?.isFocused?.() === false) return;
if (typeof href === "string") { if (typeof href === "string") {
router.push(href as any); router.push(href as any);
} else { } else {
@@ -36,7 +52,7 @@ export function useAppRouter() {
} as any); } as any);
} }
}, },
[router, isOffline], [router, isOffline, navigation],
); );
const replace = useCallback( const replace = useCallback(

View File

@@ -1,7 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react"; import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { getDownloadedItemById } from "@/providers/Downloads/database";
import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
@@ -16,27 +15,12 @@ export const useDownloadedFileOpener = () => {
console.error("Attempted to open a file without an ID."); console.error("Attempted to open a file without an ID.");
return; return;
} }
const downloadedItem = getDownloadedItemById(item.Id);
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id, itemId: item.Id,
offline: "true", offline: "true",
playbackPosition: playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0", item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
}); });
if (downloadedItem?.userData?.audioStreamIndex !== undefined) {
queryParams.set(
"audioIndex",
downloadedItem.userData.audioStreamIndex.toString(),
);
}
if (downloadedItem?.userData?.subtitleStreamIndex !== undefined) {
queryParams.set(
"subtitleIndex",
downloadedItem.userData.subtitleStreamIndex.toString(),
);
}
try { try {
router.push(`/player/direct-player?${queryParams.toString()}`); router.push(`/player/direct-player?${queryParams.toString()}`);
} catch (error) { } catch (error) {

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.jellyseer_does_not_meet_requirements", "jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
); );
toast.error(error); toast.error(error);
throw Error(error); throw Error(error);

View File

@@ -191,20 +191,6 @@ export const usePlaybackManager = ({
: playedPercentage, : playedPercentage,
}, },
}, },
// Sync selected audio/subtitle tracks so next playback resumes with
// the same tracks the user had active — but only for non-transcoded
// downloads where the user can freely switch tracks.
userData: localItem.userData.isTranscoded
? localItem.userData
: {
...localItem.userData,
audioStreamIndex:
playbackProgressInfo.AudioStreamIndex ??
localItem.userData.audioStreamIndex,
subtitleStreamIndex:
playbackProgressInfo.SubtitleStreamIndex ??
localItem.userData.subtitleStreamIndex,
},
}); });
// Force invalidate queries so they refetch from updated local database // Force invalidate queries so they refetch from updated local database
queryClient.invalidateQueries({ queryKey: ["item", itemId] }); queryClient.invalidateQueries({ queryKey: ["item", itemId] });

View File

@@ -54,6 +54,7 @@
"expo-brightness": "~56.0.5", "expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.15", "expo-build-properties": "~56.0.15",
"expo-camera": "~56.0.7", "expo-camera": "~56.0.7",
"expo-clipboard": "~56.0.4",
"expo-constants": "~56.0.16", "expo-constants": "~56.0.16",
"expo-crypto": "~56.0.4", "expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.16", "expo-dev-client": "~56.0.16",

View File

@@ -96,5 +96,24 @@ 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) => sum + (item.videoFileSize || 0), 0); 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);
} }

View File

@@ -289,7 +289,24 @@ export function useDownloadOperations({
); );
const appSizeUsage = useCallback(async () => { const appSizeUsage = useCallback(async () => {
const totalSize = calculateTotalDownloadedSize(); 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.
}
}
try { try {
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([ const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
@@ -310,7 +327,7 @@ export function useDownloadOperations({
appSize: totalSize, appSize: totalSize,
}; };
} }
}, []); }, [processes]);
return { return {
startBackgroundDownload, startBackgroundDownload,

View File

@@ -15,6 +15,7 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -91,6 +92,12 @@ export const apiAtom = atom<Api | null>(initialApi);
export const userAtom = atom<UserDto | null>(initialUser); export const userAtom = atom<UserDto | null>(initialUser);
export const wsAtom = atom<WebSocket | null>(null); export const wsAtom = atom<WebSocket | null>(null);
export const cacheVersionAtom = atom<number>(0); export const cacheVersionAtom = atom<number>(0);
// Set by a login flow that wants the account saved: the protection picker
// shows AFTER the session is authorized (the login screen unmounts on
// success, so the modal lives at the root — see PendingAccountSaveModal).
export const pendingAccountSaveAtom = atom<{ serverName?: string } | null>(
null,
);
interface LoginOptions { interface LoginOptions {
saveAccount?: boolean; saveAccount?: boolean;
@@ -108,6 +115,11 @@ interface JellyfinContextValue {
serverName?: string, serverName?: string,
options?: LoginOptions, options?: LoginOptions,
) => Promise<void>; ) => Promise<void>;
saveCurrentAccount: (options?: {
securityType?: AccountSecurityType;
pinCode?: string;
serverName?: string;
}) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>; initiateQuickConnect: () => Promise<string | undefined>;
stopQuickConnectPolling: () => void; stopQuickConnectPolling: () => void;
@@ -165,6 +177,46 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(); const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// --- Session-expiry handling ----------------------------------------------
// When the server revokes the token (e.g. the device/session is deleted), a
// 401 can surface from any authenticated request. Without central handling
// the dead token stays in storage, so every reload re-fires authed calls →
// 401 spam + uncaught rejections, and the app lingers in a half-authenticated
// state. A single response interceptor on the authenticated api clears the
// session on the first 401 so the app drops cleanly to the login screen.
const sessionExpiredRef = useRef(false);
const handleSessionExpired = useCallback(() => {
if (sessionExpiredRef.current) return; // run once per session
sessionExpiredRef.current = true;
storage.remove("token");
storage.remove("user");
setUser(null);
setApi(null);
queryClient.clear();
storage.remove("REACT_QUERY_OFFLINE_CACHE");
// Saved credentials are kept so the user can quick-login again.
}, [setUser, setApi, queryClient]);
useEffect(() => {
// Only guard an authenticated session. A pre-auth api (login screen) keeps
// its own handling — a wrong-password 401 is not a session expiry.
if (!api?.accessToken) return;
sessionExpiredRef.current = false; // re-arm for this fresh session
const interceptorId = api.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401) {
handleSessionExpired();
}
return Promise.reject(error);
},
);
return () => {
api.axiosInstance.interceptors.response.eject(interceptorId);
};
}, [api, handleSessionExpired]);
const headers = useMemo(() => { const headers = useMemo(() => {
if (!deviceId) return {}; if (!deviceId) return {};
return { return {
@@ -307,6 +359,37 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}, },
}); });
// Persist the CURRENT session to secure storage — used by the post-login
// save-account modal (the protection picker shows AFTER a successful
// login, for both the password and Quick Connect flows).
const saveCurrentAccount = useCallback(
async (options?: {
securityType?: AccountSecurityType;
pinCode?: string;
serverName?: string;
}) => {
const token = storage.getString("token");
if (!api?.basePath || !user?.Id || !user.Name || !token) return;
const securityType = options?.securityType || "none";
let pinHash: string | undefined;
if (securityType === "pin" && options?.pinCode) {
pinHash = await hashPIN(options.pinCode);
}
await saveAccountCredential({
serverUrl: api.basePath,
serverName: options?.serverName || "",
token,
userId: user.Id,
username: user.Name,
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: user.PrimaryImageTag ?? undefined,
});
},
[api?.basePath, user],
);
const loginMutation = useMutation({ const loginMutation = useMutation({
mutationFn: async ({ mutationFn: async ({
username, username,
@@ -386,7 +469,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
default: default:
throw new Error( throw new Error(
t( t(
"login.an_unexpected_error_occured_did_you_enter_the_correct_url", "login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
), ),
); );
} }
@@ -509,7 +592,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
} }
}, },
onError: (error) => { onError: (error) => {
console.error("Quick login failed:", error); // Expected, handled case (e.g. revoked token → "Session Expired", or
// server unreachable): the UI surfaces the message, so warn, don't error.
console.warn("Quick login failed:", error);
}, },
}); });
@@ -620,12 +705,14 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser); setUser(storedUser);
} }
// Dismiss splash screen with cached data immediately, // Validate the token and refresh user data in the background. Do NOT
// fetch fresh user data in the background // await this: the Jellyfin SDK axios instance has no timeout, so when
setInitialLoaded(true); // offline this call hangs for the full OS TCP timeout (75-120s) and
// blocks splash dismissal. The cached storedUser (set above) is enough
try { // to render; on success we just refresh it.
const response = await getUserApi(apiInstance).getCurrentUser(); getUserApi(apiInstance)
.getCurrentUser()
.then(async (response) => {
setUser(response.data); setUser(response.data);
// Migrate current session to secure storage if not already saved // Migrate current session to secure storage if not already saved
@@ -659,15 +746,21 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}); });
} }
} }
} catch (e) { })
// Background fetch failed — app already rendered with cached data .catch((e) => {
console.warn("Background user fetch failed, using cached data:", e); // Expected, handled case (offline, or a token the server rejects —
} // the UI prompts re-login): warn, don't error. Log only
} else { // status/message — never the raw error (axios errors carry the
setInitialLoaded(true); // request config incl. the Authorization header / token).
console.warn(
"Background user validation failed:",
e?.response?.status ?? e?.message ?? "unknown error",
);
});
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally {
setInitialLoaded(true); setInitialLoaded(true);
} }
}; };
@@ -681,6 +774,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
removeServer: () => removeServerMutation.mutateAsync(), removeServer: () => removeServerMutation.mutateAsync(),
login: (username, password, serverName, options) => login: (username, password, serverName, options) =>
loginMutation.mutateAsync({ username, password, serverName, options }), loginMutation.mutateAsync({ username, password, serverName, options }),
saveCurrentAccount,
logout: () => logoutMutation.mutateAsync(), logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect, initiateQuickConnect,
stopQuickConnectPolling, stopQuickConnectPolling,

View File

@@ -12,18 +12,21 @@
"login_button": "Log in", "login_button": "Log in",
"quick_connect": "Quick Connect", "quick_connect": "Quick Connect",
"enter_code_to_login": "Enter code {{code}} to log in", "enter_code_to_login": "Enter code {{code}} to log in",
"quick_connect_instructions": "Enter this code on a signed-in device — you'll be logged in automatically.",
"tap_code_to_copy": "Tap the code to copy it",
"code_copied": "Code copied",
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect", "failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
"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_occured": "An unexpected error occurred", "an_unexpected_error_occurred": "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_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?", "an_unexpected_error_occurred_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"
}, },
@@ -33,6 +36,7 @@
"connect_button": "Connect", "connect_button": "Connect",
"previous_servers": "Previous servers", "previous_servers": "Previous servers",
"clear_button": "Clear all", "clear_button": "Clear all",
"server_url": "Server URL",
"swipe_to_remove": "Swipe to remove", "swipe_to_remove": "Swipe to remove",
"search_for_local_servers": "Search for local servers", "search_for_local_servers": "Search for local servers",
"searching": "Searching...", "searching": "Searching...",
@@ -188,7 +192,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_autorized": "Quick Connect authorized", "quick_connect_authorized": "Quick Connect authorized",
"error": "Error", "error": "Error",
"invalid_code": "Invalid code", "invalid_code": "Invalid code",
"authorize": "Authorize" "authorize": "Authorize"
@@ -270,6 +274,10 @@
"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",
@@ -298,7 +306,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_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.", "select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"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",
@@ -385,6 +393,8 @@
"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",
@@ -440,10 +450,13 @@
}, },
"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",
@@ -500,6 +513,8 @@
"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",
@@ -601,10 +616,34 @@
}, },
"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_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", "an_error_occurred_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}}",
@@ -702,6 +741,7 @@
"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",
@@ -773,7 +813,7 @@
"request_selected": "Request selected", "request_selected": "Request selected",
"n_selected": "{{count}} selected", "n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0", "jellyseerr_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!",
@@ -786,6 +826,16 @@
"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",
@@ -796,6 +846,12 @@
}, },
"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

@@ -1,5 +1,6 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils"; import { atomWithStorage } from "jotai/utils";
import { useMemo } from "react";
import { storage } from "../mmkv"; import { storage } from "../mmkv";
import { useSettings } from "./settings"; import { useSettings } from "./settings";
@@ -59,8 +60,13 @@ export const sortOptions: {
export const useFilterOptions = () => { export const useFilterOptions = () => {
const { settings } = useSettings(); const { settings } = useSettings();
// We want to only show the watchlist option if someone has ticked that setting. // Memoized so the array identity stays stable across renders. A fresh array
const filterOptions = settings?.useKefinTweaks // each render cascades into ListHeaderComponent re-creation and, under heavy
// re-rendering (active downloads), trips React's max-update-depth guard.
// We only show the watchlist option if someone has ticked that setting.
return useMemo(
() =>
settings?.useKefinTweaks
? [ ? [
{ {
key: FilterByOption.IsFavoriteOrLiked, key: FilterByOption.IsFavoriteOrLiked,
@@ -81,10 +87,9 @@ export const useFilterOptions = () => {
{ key: FilterByOption.IsPlayed, value: "Is Played" }, { key: FilterByOption.IsPlayed, value: "Is Played" },
{ key: FilterByOption.IsFavorite, value: "Is Favorite" }, { key: FilterByOption.IsFavorite, value: "Is Favorite" },
{ key: FilterByOption.IsResumable, value: "Is Resumable" }, { key: FilterByOption.IsResumable, value: "Is Resumable" },
]; ],
console.log("filterOptions"); [settings?.useKefinTweaks],
console.log(filterOptions); );
return filterOptions;
}; };
export const sortOrderOptions: { export const sortOrderOptions: {

View File

@@ -504,7 +504,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,
); );
@@ -513,7 +523,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);