mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-12 17:00:23 +01:00
Compare commits
5 Commits
fix/ui-and
...
sync-subti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f4664a39d | ||
|
|
be63d6cc55 | ||
|
|
ccdd7770c9 | ||
|
|
24cb679c0b | ||
|
|
af50b023ef |
@@ -645,7 +645,7 @@ export default function SettingsTV() {
|
|||||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||||
/>
|
/>
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
label='Vertical Margin'
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
onDecrease={() => {
|
onDecrease={() => {
|
||||||
const newValue = Math.max(
|
const newValue = Math.max(
|
||||||
@@ -663,11 +663,11 @@ export default function SettingsTV() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
label='Horizontal Alignment'
|
||||||
value={alignXLabel}
|
value={alignXLabel}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
title: "Horizontal Alignment",
|
||||||
options: alignXOptions,
|
options: alignXOptions,
|
||||||
onSelect: (value) =>
|
onSelect: (value) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -677,11 +677,11 @@ export default function SettingsTV() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
label='Vertical Alignment'
|
||||||
value={alignYLabel}
|
value={alignYLabel}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: t("home.settings.subtitles.mpv_subtitle_align_y"),
|
title: "Vertical Alignment",
|
||||||
options: alignYOptions,
|
options: alignYOptions,
|
||||||
onSelect: (value) =>
|
onSelect: (value) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,21 +49,7 @@ export default function StreamystatsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||||
const searchLocked = pluginSettings?.searchEngine?.locked === true;
|
const isStreamystatsEnabled = !!url;
|
||||||
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;
|
||||||
@@ -160,7 +146,7 @@ export default function StreamystatsPage() {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
value={effectiveUrl}
|
value={url}
|
||||||
keyboardType='url'
|
keyboardType='url'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
@@ -185,18 +171,11 @@ export default function StreamystatsPage() {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||||
disabledByAdmin={searchLocked}
|
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
||||||
>
|
>
|
||||||
{/* 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={
|
value={useForSearch}
|
||||||
searchLocked
|
disabled={!isStreamystatsEnabled}
|
||||||
? settings?.searchEngine === "Streamystats"
|
|
||||||
: useForSearch
|
|
||||||
}
|
|
||||||
disabled={!isStreamystatsEnabled || searchLocked}
|
|
||||||
onValueChange={setUseForSearch}
|
onValueChange={setUseForSearch}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -204,62 +183,52 @@ export default function StreamystatsPage() {
|
|||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={movieRecsLocked}
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={
|
value={movieRecs}
|
||||||
movieRecsLocked
|
|
||||||
? (settings?.streamyStatsMovieRecommendations ?? false)
|
|
||||||
: movieRecs
|
|
||||||
}
|
|
||||||
onValueChange={setMovieRecs}
|
onValueChange={setMovieRecs}
|
||||||
disabled={!isStreamystatsEnabled || movieRecsLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={seriesRecsLocked}
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={
|
value={seriesRecs}
|
||||||
seriesRecsLocked
|
|
||||||
? (settings?.streamyStatsSeriesRecommendations ?? false)
|
|
||||||
: seriesRecs
|
|
||||||
}
|
|
||||||
onValueChange={setSeriesRecs}
|
onValueChange={setSeriesRecs}
|
||||||
disabled={!isStreamystatsEnabled || seriesRecsLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={promotedWatchlistsLocked}
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={
|
value={promotedWatchlists}
|
||||||
promotedWatchlistsLocked
|
|
||||||
? (settings?.streamyStatsPromotedWatchlists ?? false)
|
|
||||||
: promotedWatchlists
|
|
||||||
}
|
|
||||||
onValueChange={setPromotedWatchlists}
|
onValueChange={setPromotedWatchlists}
|
||||||
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||||
disabledByAdmin={hideWatchlistsTabLocked}
|
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={
|
value={hideWatchlistsTab}
|
||||||
hideWatchlistsTabLocked
|
|
||||||
? (settings?.hideWatchlistsTab ?? false)
|
|
||||||
: hideWatchlistsTab
|
|
||||||
}
|
|
||||||
onValueChange={setHideWatchlistsTab}
|
onValueChange={setHideWatchlistsTab}
|
||||||
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
{t("music.missing_library_id")}
|
Missing music library id.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
{t("music.missing_library_id")}
|
Missing music library id.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
{t("music.missing_library_id")}
|
Missing music library id.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -73,7 +72,6 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
|||||||
type ViewMode = "player" | "queue";
|
type ViewMode = "player" | "queue";
|
||||||
|
|
||||||
export default function NowPlayingScreen() {
|
export default function NowPlayingScreen() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -232,9 +230,7 @@ export default function NowPlayingScreen() {
|
|||||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-neutral-500'>
|
<Text className='text-neutral-500'>No track playing</Text>
|
||||||
{t("music.no_track_playing")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
);
|
);
|
||||||
@@ -271,7 +267,7 @@ export default function NowPlayingScreen() {
|
|||||||
: "text-neutral-500"
|
: "text-neutral-500"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("music.now_playing")}
|
Now Playing
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -722,7 +718,6 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
onReorderQueue,
|
onReorderQueue,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const renderQueueItem = useCallback(
|
const renderQueueItem = useCallback(
|
||||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||||
const index = getIndex() ?? 0;
|
const index = getIndex() ?? 0;
|
||||||
@@ -836,15 +831,13 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View className='px-4 py-2'>
|
<View className='px-4 py-2'>
|
||||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||||
{history.length > 0
|
{history.length > 0 ? "Playing from queue" : "Up next"}
|
||||||
? t("music.playing_from_queue")
|
|
||||||
: t("music.up_next")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex-1 items-center justify-center py-20'>
|
<View className='flex-1 items-center justify-center py-20'>
|
||||||
<Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
|
<Text className='text-neutral-500'>Queue is empty</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 subtitleIndex = subtitleIndexStr
|
const subtitleIndexFromUrl = subtitleIndexStr
|
||||||
? Number.parseInt(subtitleIndexStr, 10)
|
? Number.parseInt(subtitleIndexStr, 10)
|
||||||
: -1;
|
: undefined;
|
||||||
const bitrateValue = bitrateValueStr
|
const bitrateValue = bitrateValueStr
|
||||||
? Number.parseInt(bitrateValueStr, 10)
|
? Number.parseInt(bitrateValueStr, 10)
|
||||||
: BITRATES[0].value;
|
: BITRATES[0].value;
|
||||||
@@ -185,6 +185,23 @@ 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.
|
||||||
@@ -1267,7 +1284,7 @@ export default function DirectPlayerPage() {
|
|||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.an_error_occurred_while_playing_the_video"),
|
t("player.an_error_occured_while_playing_the_video"),
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -329,7 +328,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
||||||
{t("player.hash_match")}
|
Hash Match
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { Link, Stack } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: t("home.oops") }} />
|
<Stack.Screen options={{ title: "Oops!" }} />
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedText type='title'>{t("not_found.title")}</ThemedText>
|
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
||||||
<Link href={"/home"} style={styles.link}>
|
<Link href={"/home"} style={styles.link}>
|
||||||
<ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
|
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||||
</Link>
|
</Link>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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";
|
||||||
@@ -85,8 +84,7 @@ configureReanimatedLogger({
|
|||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowBanner: true,
|
shouldShowAlert: true,
|
||||||
shouldShowList: true,
|
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -335,12 +333,9 @@ function Layout() {
|
|||||||
notificationListener.current =
|
notificationListener.current =
|
||||||
Notifications?.addNotificationReceivedListener(
|
Notifications?.addNotificationReceivedListener(
|
||||||
(notification: Notification) => {
|
(notification: Notification) => {
|
||||||
// Log only the title — serializing the whole notification touches
|
|
||||||
// the deprecated dataString getter (deprecation warning) and dumps
|
|
||||||
// noisy payloads into the console.
|
|
||||||
console.log(
|
console.log(
|
||||||
"Notification received while app running:",
|
"Notification received while app running",
|
||||||
notification.request.content.title,
|
notification,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -535,7 +530,6 @@ function Layout() {
|
|||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
{!Platform.isTV && <GlobalModal />}
|
{!Platform.isTV && <GlobalModal />}
|
||||||
{!Platform.isTV && <PendingAccountSaveModal />}
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntroSheetProvider>
|
</IntroSheetProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -31,7 +31,6 @@
|
|||||||
"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",
|
||||||
@@ -956,8 +955,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
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";
|
||||||
@@ -62,7 +61,6 @@ 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)
|
||||||
@@ -94,10 +92,7 @@ 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={() => {
|
onPress={() => setOpen(true)}
|
||||||
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}
|
||||||
@@ -108,7 +103,6 @@ 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] : []}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ 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";
|
||||||
@@ -59,6 +60,9 @@ 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();
|
||||||
@@ -105,17 +109,30 @@ 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: defaultSubtitleIndex ?? -1,
|
subtitleIndex:
|
||||||
audioIndex: defaultAudioIndex,
|
offlineUserData && !offlineUserData.isTranscoded
|
||||||
|
? 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(() => {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ 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";
|
||||||
@@ -28,6 +30,14 @@ 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",
|
||||||
@@ -72,34 +82,36 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
const optionGroups: OptionGroup[] = useMemo(() => {
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||||
const groups: OptionGroup[] = [];
|
const groups: OptionGroup[] = [];
|
||||||
|
|
||||||
// Bitrate group
|
if (!isOffline) {
|
||||||
groups.push({
|
// Bitrate group
|
||||||
title: t("item_card.quality"),
|
|
||||||
options: BITRATES.map((bitrate) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: bitrate.key,
|
|
||||||
value: bitrate,
|
|
||||||
selected: bitrate.value === selectedOptions.bitrate?.value,
|
|
||||||
onPress: () =>
|
|
||||||
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Media Source group (only if multiple sources)
|
|
||||||
if (item?.MediaSources && item.MediaSources.length > 1) {
|
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("item_card.video"),
|
title: t("item_card.quality"),
|
||||||
options: item.MediaSources.map((source) => ({
|
options: BITRATES.map((bitrate) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: getMediaSourceDisplayName(source),
|
label: bitrate.key,
|
||||||
value: source,
|
value: bitrate,
|
||||||
selected: source.Id === selectedOptions.mediaSource?.Id,
|
selected: bitrate.value === selectedOptions.bitrate?.value,
|
||||||
onPress: () =>
|
onPress: () =>
|
||||||
setSelectedOptions(
|
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
||||||
(prev) => prev && { ...prev, mediaSource: source },
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Media Source group (only if multiple sources)
|
||||||
|
if (item?.MediaSources && item.MediaSources.length > 1) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.video"),
|
||||||
|
options: item.MediaSources.map((source) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: getMediaSourceDisplayName(source),
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selectedOptions.mediaSource?.Id,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, mediaSource: source },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio track group
|
// Audio track group
|
||||||
@@ -150,6 +162,7 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
return groups;
|
return groups;
|
||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
|
isOffline,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
audioStreams,
|
audioStreams,
|
||||||
subtitleStreams,
|
subtitleStreams,
|
||||||
@@ -178,6 +191,8 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isTranscodedDownload) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
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, useRef, useState } from "react";
|
import { useCallback, useMemo, 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";
|
||||||
@@ -24,7 +23,6 @@ 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");
|
||||||
@@ -46,10 +44,7 @@ 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={() => {
|
onPress={() => setOpen(true)}
|
||||||
setOpen(true);
|
|
||||||
sheetModalRef.current?.present();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -58,7 +53,6 @@ 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] : []}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -210,7 +209,6 @@ const PlatformDropdownComponent = ({
|
|||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
@@ -382,7 +380,7 @@ const PlatformDropdownComponent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||||
{trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
|
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,14 +96,23 @@ 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) {
|
||||||
@@ -292,6 +301,29 @@ 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;
|
||||||
|
|
||||||
@@ -302,13 +334,7 @@ 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) {
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer(buildOfflineQueryParams(downloadedItem).toString());
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,13 +357,9 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
hideModal();
|
hideModal();
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer(
|
||||||
itemId: item.Id!,
|
buildOfflineQueryParams(downloadedItem).toString(),
|
||||||
offline: "true",
|
);
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
}}
|
}}
|
||||||
color='purple'
|
color='purple'
|
||||||
>
|
>
|
||||||
@@ -374,13 +396,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
{
|
{
|
||||||
text: t("player.downloaded_file_yes"),
|
text: t("player.downloaded_file_yes"),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer(buildOfflineQueryParams(downloadedItem).toString());
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
},
|
},
|
||||||
isPreferred: true,
|
isPreferred: true,
|
||||||
},
|
},
|
||||||
@@ -502,8 +518,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={!item}
|
disabled={!item}
|
||||||
accessibilityLabel={t("accessibility.play_button")}
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint={t("accessibility.play_hint")}
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative flex-1"}
|
className={"relative flex-1"}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -37,7 +36,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
colors,
|
colors,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
// Use colors prop if provided, otherwise fallback to global atom
|
||||||
@@ -170,8 +168,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityLabel={t("accessibility.play_button")}
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint={t("accessibility.play_hint")}
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative"}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
FlatList,
|
FlatList,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -32,7 +31,6 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||||
const { t } = useTranslation();
|
|
||||||
const handlePlayInSession = async (sessionId: string) => {
|
const handlePlayInSession = async (sessionId: string) => {
|
||||||
if (!api || !item.Id) return;
|
if (!api || !item.Id) return;
|
||||||
|
|
||||||
@@ -67,9 +65,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
<View style={styles.centeredView}>
|
<View style={styles.centeredView}>
|
||||||
<View style={styles.modalView}>
|
<View style={styles.modalView}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={styles.modalTitle}>
|
<Text style={styles.modalTitle}>Select Session</Text>
|
||||||
{t("home.sessions.select_session")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||||
<Ionicons name='close' size={24} color='white' />
|
<Ionicons name='close' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -82,7 +78,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
) : !sessions || sessions.length === 0 ? (
|
) : !sessions || sessions.length === 0 ? (
|
||||||
<Text style={styles.noSessionsText}>
|
<Text style={styles.noSessionsText}>
|
||||||
{t("home.sessions.no_active_sessions")}
|
No active sessions found
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -102,7 +98,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
{session.NowPlayingItem && (
|
{session.NowPlayingItem && (
|
||||||
<Text style={styles.nowPlaying} numberOfLines={1}>
|
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||||
{t("home.sessions.now_playing")}{" "}
|
Now playing:{" "}
|
||||||
{session.NowPlayingItem.SeriesName
|
{session.NowPlayingItem.SeriesName
|
||||||
? `${session.NowPlayingItem.SeriesName} :`
|
? `${session.NowPlayingItem.SeriesName} :`
|
||||||
: ""}
|
: ""}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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, useRef, useState } from "react";
|
import { useMemo, 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";
|
||||||
@@ -50,7 +49,6 @@ 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;
|
||||||
|
|
||||||
@@ -60,10 +58,7 @@ 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={() => {
|
onPress={() => setOpen(true)}
|
||||||
setOpen(true);
|
|
||||||
sheetModalRef.current?.present();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text numberOfLines={1}>
|
<Text numberOfLines={1}>
|
||||||
{selected === -1 && streamType === "Subtitle"
|
{selected === -1 && streamType === "Subtitle"
|
||||||
@@ -75,7 +70,6 @@ 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={
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
|
import { t } from "i18next";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -35,7 +35,6 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { cancelDownload } = useDownload();
|
const { cancelDownload } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
@@ -174,9 +173,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
|
|
||||||
{isTranscoding && (
|
{isTranscoding && (
|
||||||
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||||
<Text className='text-xs text-purple-400'>
|
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
||||||
{t("home.downloads.transcoding")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
|
||||||
// instead of freezing the first-rendered series' image (empty deps bug).
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
const seriesId = items[0]?.SeriesId;
|
return storage.getString(items[0].SeriesId!);
|
||||||
return seriesId ? storage.getString(seriesId) : undefined;
|
}, []);
|
||||||
}, [items[0]?.SeriesId]);
|
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
const deleteSeries = useCallback(
|
||||||
async () =>
|
async () =>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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 { useRef, useState } from "react";
|
import { 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";
|
||||||
@@ -35,9 +34,8 @@ 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, isLoading } = useQuery<T[]>({
|
const { data: filters } = useQuery<T[]>({
|
||||||
queryKey: ["filters", title, queryKey, id],
|
queryKey: ["filters", title, queryKey, id],
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
@@ -46,15 +44,9 @@ 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={() => {
|
||||||
setOpen(true);
|
filters?.length && setOpen(true);
|
||||||
sheetModalRef.current?.present();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@@ -97,8 +89,6 @@ 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}
|
||||||
|
|||||||
@@ -7,14 +7,7 @@ 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 {
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
useCallback,
|
|
||||||
useDeferredValue,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -26,21 +19,11 @@ 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;
|
||||||
@@ -83,18 +66,16 @@ 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 = modalRef;
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
const snapPoints = useMemo(() => ["85%"], []);
|
const snapPoints = useMemo(() => ["85%"], []);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -103,24 +84,19 @@ 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 (!deferredSearch) return _data;
|
if (!search) 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], deferredSearch)) {
|
if (_data && searchFilter?.(_data[i], search)) {
|
||||||
results.push(_data[i]);
|
results.push(_data[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results.slice(0, 100);
|
return results.slice(0, 100);
|
||||||
}, [deferredSearch, _data, searchFilter]);
|
}, [search, _data, searchFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || data.length === 0 || disableSearch) return;
|
if (!data || data.length === 0 || disableSearch) return;
|
||||||
@@ -151,28 +127,21 @@ 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 && wasPresentedRef.current) {
|
if (open) bottomSheetModalRef.current?.present();
|
||||||
bottomSheetModalRef.current?.dismiss();
|
else bottomSheetModalRef.current?.dismiss();
|
||||||
}
|
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {
|
const handleSheetChanges = useCallback((index: number) => {
|
||||||
if (index >= 0) {
|
if (index === -1) {
|
||||||
wasPresentedRef.current = true;
|
|
||||||
} else if (index === -1) {
|
|
||||||
wasPresentedRef.current = false;
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderData = useMemo(() => {
|
const renderData = useMemo(() => {
|
||||||
if (deferredSearch.length > 0 && showSearch) return filteredData;
|
if (search.length > 0 && showSearch) return filteredData;
|
||||||
return data;
|
return data;
|
||||||
}, [deferredSearch, showSearch, filteredData, data]);
|
}, [search, filteredData, data]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -185,54 +154,6 @@ 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}
|
||||||
@@ -261,15 +182,9 @@ export const FilterSheet = <T,>({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='font-bold text-2xl'>{title}</Text>
|
<Text className='font-bold text-2xl'>{title}</Text>
|
||||||
{loading ? (
|
<Text className='mb-2 text-neutral-500'>
|
||||||
<View className='my-8 flex items-center justify-center'>
|
{t("search.x_items", { count: _data?.length })}
|
||||||
<Loader />
|
</Text>
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text className='mb-2 text-neutral-500'>
|
|
||||||
{t("search.x_items", { count: _data?.length })}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{showSearch && (
|
{showSearch && (
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("search.search")}
|
placeholder={t("search.search")}
|
||||||
@@ -288,7 +203,43 @@ export const FilterSheet = <T,>({
|
|||||||
}}
|
}}
|
||||||
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
||||||
>
|
>
|
||||||
{renderedRows}
|
{renderData?.map((item, index) => (
|
||||||
|
<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
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -23,7 +22,6 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
refSetter,
|
refSetter,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
||||||
scaleAmount: 1,
|
scaleAmount: 1,
|
||||||
@@ -70,7 +68,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.live")}
|
LIVE
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
|
|||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("live_tv.title")}
|
Live TV
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Tab Bar */}
|
{/* Tab Bar */}
|
||||||
|
|||||||
@@ -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, useSetAtom } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -20,16 +20,14 @@ 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 {
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
apiAtom,
|
import type {
|
||||||
pendingAccountSaveAtom,
|
AccountSecurityType,
|
||||||
useJellyfin,
|
SavedServer,
|
||||||
userAtom,
|
} from "@/utils/secureCredentials";
|
||||||
} 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")),
|
||||||
@@ -39,17 +37,14 @@ 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,
|
||||||
@@ -69,43 +64,13 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Quick Connect code shown in the in-app sheet while polling for authorization
|
// Save account state
|
||||||
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(() => {
|
||||||
@@ -152,34 +117,55 @@ export const Login: React.FC = () => {
|
|||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
if (!result.success) return;
|
if (!result.success) return;
|
||||||
|
|
||||||
const ok = await performLogin(credentials.username, credentials.password);
|
if (saveAccount) {
|
||||||
// The protection picker shows AFTER a successful login (global modal) —
|
setPendingLogin({
|
||||||
// never for a failed one.
|
username: credentials.username,
|
||||||
if (ok && saveAccount) {
|
password: credentials.password,
|
||||||
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,
|
||||||
): Promise<boolean> => {
|
options?: {
|
||||||
|
saveAccount?: boolean;
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(username, password, serverName);
|
await login(username, password, serverName, options);
|
||||||
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_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -273,7 +259,15 @@ export const Login: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
setQuickConnectCode(code);
|
Alert.alert(
|
||||||
|
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(
|
||||||
@@ -408,7 +402,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={t("server.server_url")}
|
aria-label='Server URL'
|
||||||
placeholder={t("server.server_url_placeholder")}
|
placeholder={t("server.server_url_placeholder")}
|
||||||
onChangeText={setServerURL}
|
onChangeText={setServerURL}
|
||||||
value={serverURL}
|
value={serverURL}
|
||||||
@@ -450,11 +444,14 @@ export const Login: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
{/* Dismissing only hides the code — polling continues so the login still
|
<SaveAccountModal
|
||||||
completes if the code is authorized from another device afterwards. */}
|
visible={showSaveModal}
|
||||||
<QuickConnectCodeModal
|
onClose={() => {
|
||||||
code={quickConnectCode}
|
setShowSaveModal(false);
|
||||||
onClose={() => setQuickConnectCode(null)}
|
setPendingLogin(null);
|
||||||
|
}}
|
||||||
|
onSave={handleSaveAccountConfirm}
|
||||||
|
username={pendingLogin?.username || credentials.username}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occurred");
|
: t("login.an_unexpected_error_occured");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occurred");
|
: t("login.an_unexpected_error_occured");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -89,8 +88,6 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
showDiscover,
|
showDiscover,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!showDiscover) {
|
if (!showDiscover) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -104,13 +101,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label={t("search.library")}
|
label='Library'
|
||||||
isSelected={searchType === "Library"}
|
isSelected={searchType === "Library"}
|
||||||
onPress={() => setSearchType("Library")}
|
onPress={() => setSearchType("Library")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label={t("search.discover")}
|
label='Discover'
|
||||||
isSelected={searchType === "Discover"}
|
isSelected={searchType === "Discover"}
|
||||||
onPress={() => setSearchType("Discover")}
|
onPress={() => setSearchType("Discover")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, Switch, View, type ViewProps } from "react-native";
|
import { Platform, Switch, View, type ViewProps } from "react-native";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -18,21 +17,20 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
||||||
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
||||||
|
|
||||||
const alignXLabels: Record<AlignX, string> = {
|
const alignXLabels: Record<AlignX, string> = {
|
||||||
left: t("home.settings.subtitles.align.left"),
|
left: "Left",
|
||||||
center: t("home.settings.subtitles.align.center"),
|
center: "Center",
|
||||||
right: t("home.settings.subtitles.align.right"),
|
right: "Right",
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignYLabels: Record<AlignY, string> = {
|
const alignYLabels: Record<AlignY, string> = {
|
||||||
top: t("home.settings.subtitles.align.top"),
|
top: "Top",
|
||||||
center: t("home.settings.subtitles.align.center"),
|
center: "Center",
|
||||||
bottom: t("home.settings.subtitles.align.bottom"),
|
bottom: "Bottom",
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignXOptionGroups = useMemo(() => {
|
const alignXOptionGroups = useMemo(() => {
|
||||||
@@ -62,18 +60,16 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
title={t("home.settings.subtitles.mpv_settings_title")}
|
title='MPV Subtitle Settings'
|
||||||
description={
|
description={
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
{t("home.settings.subtitles.mpv_settings_description")}
|
Advanced subtitle customization for MPV player
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!isTv && (
|
{!isTv && (
|
||||||
<>
|
<>
|
||||||
<ListItem
|
<ListItem title='Vertical Margin'>
|
||||||
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
|
||||||
>
|
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -85,7 +81,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
|
<ListItem title='Horizontal Alignment'>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignXOptionGroups}
|
groups={alignXOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -100,11 +96,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
title='Horizontal Alignment'
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
|
<ListItem title='Vertical Alignment'>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignYOptionGroups}
|
groups={alignYOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -119,13 +115,13 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
title='Vertical Alignment'
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ListItem title={t("home.settings.subtitles.opaque_background")}>
|
<ListItem title='Opaque Background'>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -135,7 +131,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
{settings.mpvSubtitleBackgroundEnabled && (
|
{settings.mpvSubtitleBackgroundEnabled && (
|
||||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
<ListItem title='Background Opacity'>
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
||||||
step={5}
|
step={5}
|
||||||
|
|||||||
@@ -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/streamystats/page")}
|
|
||||||
title='Streamystats'
|
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
|
|||||||
title='Marlin Search'
|
title='Marlin Search'
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
||||||
|
title='Streamystats'
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||||
title='KefinTweaks'
|
title='KefinTweaks'
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.quick_connect.success"),
|
t("home.settings.quick_connect.success"),
|
||||||
t("home.settings.quick_connect.quick_connect_authorized"),
|
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
|
|||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
|
|||||||
used: (app.total - app.remaining) / app.total,
|
used: (app.total - app.remaining) / app.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// Keep the bar moving while a download is writing to disk.
|
|
||||||
refetchInterval: 10 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteClicked = () => {
|
const onDeleteClicked = async () => {
|
||||||
Alert.alert(
|
try {
|
||||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
await deleteAllFiles();
|
||||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
successHapticFeedback();
|
||||||
[
|
} catch (_e) {
|
||||||
{
|
errorHapticFeedback();
|
||||||
text: t("common.cancel"),
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
style: "cancel",
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.ok"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => {
|
|
||||||
try {
|
|
||||||
await deleteAllFiles();
|
|
||||||
successHapticFeedback();
|
|
||||||
} catch (_e) {
|
|
||||||
errorHapticFeedback();
|
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
|
||||||
} finally {
|
|
||||||
// Reflect the freed space immediately instead of waiting for
|
|
||||||
// the next poll.
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculatePercentage = (value: number, total: number) => {
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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,
|
||||||
@@ -107,7 +106,6 @@ 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();
|
||||||
@@ -373,7 +371,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("music.now_playing")}
|
Now Playing
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null;
|
) : null;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
@@ -29,7 +28,6 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
const styles = createStyles(typography);
|
const styles = createStyles(typography);
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -154,7 +152,7 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.flagText}>{t("player.hash_match")}</Text>
|
<Text style={styles.flagText}>Hash Match</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{result.hearingImpaired && (
|
{result.hearingImpaired && (
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
buttonText={t("player.skip_intro")}
|
buttonText='Skip Intro'
|
||||||
/>
|
/>
|
||||||
{/* Smart Skip Credits behavior:
|
{/* Smart Skip Credits behavior:
|
||||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||||
@@ -193,7 +193,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||||
}
|
}
|
||||||
onPress={skipCredit}
|
onPress={skipCredit}
|
||||||
buttonText={t("player.skip_credits")}
|
buttonText='Skip Credits'
|
||||||
/>
|
/>
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text className='text-2xl font-bold text-white py-4 '>
|
<Text className='text-2xl font-bold text-white py-4 '>
|
||||||
{t("player.still_watching")}
|
Are you still watching ?
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { type FC, useCallback, useState } from "react";
|
import { type FC, useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
@@ -58,7 +57,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
showTechnicalInfo = false,
|
showTechnicalInfo = false,
|
||||||
onToggleTechnicalInfo,
|
onToggleTechnicalInfo,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
@@ -129,8 +127,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
onPress={toggleOrientation}
|
onPress={toggleOrientation}
|
||||||
disabled={isTogglingOrientation}
|
disabled={isTogglingOrientation}
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
accessibilityLabel={t("accessibility.toggle_orientation")}
|
accessibilityLabel='Toggle screen orientation'
|
||||||
accessibilityHint={t("accessibility.toggle_orientation_hint")}
|
accessibilityHint='Toggles the screen orientation between portrait and landscape'
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name='screen-rotation'
|
name='screen-rotation'
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, StyleSheet, Text, View } from "react-native";
|
import { Platform, StyleSheet, Text, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -185,7 +184,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { t } = useTranslation();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const safeInsets = useControlsSafeAreaInsets();
|
const safeInsets = useControlsSafeAreaInsets();
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
@@ -314,13 +312,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.videoCodec && (
|
{info?.videoCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.video")} {formatCodec(info.videoCodec)}
|
Video: {formatCodec(info.videoCodec)}
|
||||||
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.audioCodec && (
|
{info?.audioCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
|
Audio: {formatCodec(info.audioCodec)}
|
||||||
{streamInfo?.audioChannels
|
{streamInfo?.audioChannels
|
||||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -328,13 +326,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{streamInfo?.subtitleCodec && (
|
{streamInfo?.subtitleCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.subtitle")}{" "}
|
Subtitle: {formatCodec(streamInfo.subtitleCodec)}
|
||||||
{formatCodec(streamInfo.subtitleCodec)}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{(info?.videoBitrate || info?.audioBitrate) && (
|
{(info?.videoBitrate || info?.audioBitrate) && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.bitrate")}{" "}
|
Bitrate:{" "}
|
||||||
{info.videoBitrate
|
{info.videoBitrate
|
||||||
? formatBitrate(info.videoBitrate)
|
? formatBitrate(info.videoBitrate)
|
||||||
: info.audioBitrate
|
: info.audioBitrate
|
||||||
@@ -344,27 +341,21 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.cacheSeconds !== undefined && (
|
{info?.cacheSeconds !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.buffer_seconds", {
|
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||||
seconds: info.cacheSeconds.toFixed(1),
|
|
||||||
})}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.voDriver && (
|
{info?.voDriver && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.vo")} {info.voDriver}
|
VO: {info.voDriver}
|
||||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||||
<Text style={[textStyle, styles.warningText]}>
|
<Text style={[textStyle, styles.warningText]}>
|
||||||
{t("player.technical_info.dropped_frames", {
|
Dropped: {info.droppedFrames} frames
|
||||||
count: info.droppedFrames,
|
|
||||||
})}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{!info && !playMethod && (
|
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
|
||||||
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
type OptionGroup,
|
type OptionGroup,
|
||||||
@@ -55,7 +54,6 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
onRatioChange,
|
onRatioChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const handleRatioSelect = (ratio: AspectRatio) => {
|
const handleRatioSelect = (ratio: AspectRatio) => {
|
||||||
@@ -68,10 +66,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
{
|
{
|
||||||
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label:
|
label: option.label,
|
||||||
option.id === "default"
|
|
||||||
? t("player.aspect_ratio_original")
|
|
||||||
: option.label,
|
|
||||||
value: option.id,
|
value: option.id,
|
||||||
selected: option.id === currentRatio,
|
selected: option.id === currentRatio,
|
||||||
onPress: () => handleRatioSelect(option.id),
|
onPress: () => handleRatioSelect(option.id),
|
||||||
@@ -99,7 +94,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
title={t("player.aspect_ratio")}
|
title='Aspect Ratio'
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
bottomSheetConfig={{
|
bottomSheetConfig={{
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +47,6 @@ const DropdownView = ({
|
|||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||||
useLocalSearchParams<{
|
useLocalSearchParams<{
|
||||||
@@ -103,7 +101,7 @@ const DropdownView = ({
|
|||||||
// Quality Section
|
// Quality Section
|
||||||
if (!isOffline) {
|
if (!isOffline) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.quality"),
|
title: "Quality",
|
||||||
options:
|
options:
|
||||||
BITRATES?.map((bitrate) => ({
|
BITRATES?.map((bitrate) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
@@ -118,7 +116,7 @@ const DropdownView = ({
|
|||||||
// Subtitle Section
|
// Subtitle Section
|
||||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.subtitles"),
|
title: "Subtitles",
|
||||||
options: subtitleTracks.map((sub) => ({
|
options: subtitleTracks.map((sub) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: sub.name,
|
label: sub.name,
|
||||||
@@ -130,7 +128,7 @@ const DropdownView = ({
|
|||||||
|
|
||||||
// Subtitle Scale Section
|
// Subtitle Scale Section
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.subtitle_scale"),
|
title: "Subtitle Scale",
|
||||||
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: preset.label,
|
label: preset.label,
|
||||||
@@ -144,7 +142,7 @@ const DropdownView = ({
|
|||||||
// Audio Section
|
// Audio Section
|
||||||
if (audioTracks && audioTracks.length > 0) {
|
if (audioTracks && audioTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.audio"),
|
title: "Audio",
|
||||||
options: audioTracks.map((track) => ({
|
options: audioTracks.map((track) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: track.name,
|
label: track.name,
|
||||||
@@ -158,7 +156,7 @@ const DropdownView = ({
|
|||||||
// Speed Section
|
// Speed Section
|
||||||
if (setPlaybackSpeed) {
|
if (setPlaybackSpeed) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.speed"),
|
title: "Speed",
|
||||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: speed.label,
|
label: speed.label,
|
||||||
@@ -176,8 +174,8 @@ const DropdownView = ({
|
|||||||
{
|
{
|
||||||
type: "action" as const,
|
type: "action" as const,
|
||||||
label: showTechnicalInfo
|
label: showTechnicalInfo
|
||||||
? t("player.menu.hide_technical_info")
|
? "Hide Technical Info"
|
||||||
: t("player.menu.show_technical_info"),
|
: "Show Technical Info",
|
||||||
onPress: onToggleTechnicalInfo,
|
onPress: onToggleTechnicalInfo,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -187,7 +185,6 @@ const DropdownView = ({
|
|||||||
return groups;
|
return groups;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
t,
|
|
||||||
isOffline,
|
isOffline,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
changeBitrate,
|
changeBitrate,
|
||||||
@@ -220,7 +217,7 @@ const DropdownView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
title={t("player.menu.playback_options")}
|
title='Playback Options'
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
expoUIConfig={{}}
|
expoUIConfig={{}}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Alert } from "react-native";
|
|||||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||||
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
||||||
import i18n from "@/i18n";
|
|
||||||
|
|
||||||
interface UseRemoteControlProps {
|
interface UseRemoteControlProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
@@ -125,23 +124,17 @@ export function useRemoteControl({
|
|||||||
|
|
||||||
// Controls are hidden, so confirm before leaving playback.
|
// Controls are hidden, so confirm before leaving playback.
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
i18n.t("player.stopPlayback"),
|
"Stop Playback",
|
||||||
videoTitleRef.current
|
videoTitleRef.current
|
||||||
? i18n.t("player.stopPlayingTitle", {
|
? `Stop playing "${videoTitleRef.current}"?`
|
||||||
title: videoTitleRef.current,
|
: "Are you sure you want to stop playback?",
|
||||||
})
|
|
||||||
: i18n.t("player.stopPlayingConfirm"),
|
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: i18n.t("common.cancel"),
|
text: "Cancel",
|
||||||
style: "cancel",
|
style: "cancel",
|
||||||
onPress: () => onCancelExitRef.current?.(),
|
onPress: () => onCancelExitRef.current?.(),
|
||||||
},
|
},
|
||||||
{
|
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
|
||||||
text: i18n.t("common.stop"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: onBackRef.current,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
// 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 { NavigationContext } from "expo-router/react-navigation";
|
import { useCallback, useMemo } from "react";
|
||||||
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 and guards against duplicate
|
* preserves offline state across navigation.
|
||||||
* 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";
|
||||||
@@ -25,18 +19,8 @@ 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 {
|
||||||
@@ -52,7 +36,7 @@ export function useAppRouter() {
|
|||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, isOffline, navigation],
|
[router, isOffline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const replace = useCallback(
|
const replace = useCallback(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -15,12 +16,27 @@ 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) {
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export class JellyseerrApi {
|
|||||||
if (inRange(status, 200, 299)) {
|
if (inRange(status, 200, 299)) {
|
||||||
if (data.version < "2.0.0") {
|
if (data.version < "2.0.0") {
|
||||||
const error = t(
|
const error = t(
|
||||||
"jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
|
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
||||||
);
|
);
|
||||||
toast.error(error);
|
toast.error(error);
|
||||||
throw Error(error);
|
throw Error(error);
|
||||||
|
|||||||
@@ -191,6 +191,20 @@ 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] });
|
||||||
|
|||||||
@@ -54,7 +54,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -96,24 +96,5 @@ export function getDownloadedItemSize(id: string): number {
|
|||||||
*/
|
*/
|
||||||
export function calculateTotalDownloadedSize(): number {
|
export function calculateTotalDownloadedSize(): number {
|
||||||
const items = getAllDownloadedItems();
|
const items = getAllDownloadedItems();
|
||||||
return items.reduce((sum, item) => {
|
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||||
// Trickplay bytes count too — getDownloadedItemSize models per-item size
|
|
||||||
// as video + trickplay, the total must match.
|
|
||||||
const trickplaySize = item.trickPlayData?.size ?? 0;
|
|
||||||
// Read the live file size on disk so the total reflects actual usage and
|
|
||||||
// self-heals items whose stored videoFileSize is 0 (old schema, or
|
|
||||||
// `fileInfo.size` was undefined at download time). Fall back to the stored
|
|
||||||
// value if the file can't be stat'd.
|
|
||||||
if (item.videoFilePath) {
|
|
||||||
try {
|
|
||||||
const file = new File(filePathToUri(item.videoFilePath));
|
|
||||||
if (file.exists) {
|
|
||||||
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to stat downloaded file for size:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sum + (item.videoFileSize ?? 0) + trickplaySize;
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,24 +289,7 @@ export function useDownloadOperations({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const appSizeUsage = useCallback(async () => {
|
const appSizeUsage = useCallback(async () => {
|
||||||
let totalSize = calculateTotalDownloadedSize();
|
const totalSize = calculateTotalDownloadedSize();
|
||||||
|
|
||||||
// Also count in-progress downloads (they write straight to their final
|
|
||||||
// path) so the growing file shows up as app usage instead of drifting
|
|
||||||
// into the generic device share until completion.
|
|
||||||
for (const process of processes) {
|
|
||||||
try {
|
|
||||||
const file = new File(
|
|
||||||
Paths.document,
|
|
||||||
`${generateFilename(process.item)}.mp4`,
|
|
||||||
);
|
|
||||||
if (file.exists) {
|
|
||||||
totalSize += file.size ?? 0;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// File not created yet — ignore.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||||
@@ -327,7 +310,7 @@ export function useDownloadOperations({
|
|||||||
appSize: totalSize,
|
appSize: totalSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [processes]);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -92,12 +91,6 @@ 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;
|
||||||
@@ -115,11 +108,6 @@ 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;
|
||||||
@@ -177,46 +165,6 @@ 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 {
|
||||||
@@ -359,37 +307,6 @@ 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,
|
||||||
@@ -469,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t(
|
t(
|
||||||
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
|
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -592,9 +509,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// Expected, handled case (e.g. revoked token → "Session Expired", or
|
console.error("Quick login failed:", error);
|
||||||
// server unreachable): the UI surfaces the message, so warn, don't error.
|
|
||||||
console.warn("Quick login failed:", error);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -705,62 +620,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(storedUser);
|
setUser(storedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the token and refresh user data in the background. Do NOT
|
// Dismiss splash screen with cached data immediately,
|
||||||
// await this: the Jellyfin SDK axios instance has no timeout, so when
|
// fetch fresh user data in the background
|
||||||
// offline this call hangs for the full OS TCP timeout (75-120s) and
|
setInitialLoaded(true);
|
||||||
// blocks splash dismissal. The cached storedUser (set above) is enough
|
|
||||||
// to render; on success we just refresh it.
|
|
||||||
getUserApi(apiInstance)
|
|
||||||
.getCurrentUser()
|
|
||||||
.then(async (response) => {
|
|
||||||
setUser(response.data);
|
|
||||||
|
|
||||||
// Migrate current session to secure storage if not already saved
|
try {
|
||||||
if (storedUser?.Id && storedUser?.Name) {
|
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||||
const existingCredential = await getAccountCredential(
|
setUser(response.data);
|
||||||
serverUrl,
|
|
||||||
storedUser.Id,
|
// Migrate current session to secure storage if not already saved
|
||||||
);
|
if (storedUser?.Id && storedUser?.Name) {
|
||||||
if (!existingCredential) {
|
const existingCredential = await getAccountCredential(
|
||||||
await saveAccountCredential({
|
serverUrl,
|
||||||
serverUrl,
|
storedUser.Id,
|
||||||
serverName: "",
|
|
||||||
token,
|
|
||||||
userId: storedUser.Id,
|
|
||||||
username: storedUser.Name,
|
|
||||||
savedAt: Date.now(),
|
|
||||||
securityType: "none",
|
|
||||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
response.data.PrimaryImageTag !==
|
|
||||||
existingCredential.primaryImageTag
|
|
||||||
) {
|
|
||||||
// Update image tag if it has changed
|
|
||||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
|
||||||
userId: existingCredential.userId,
|
|
||||||
username: existingCredential.username,
|
|
||||||
securityType: existingCredential.securityType,
|
|
||||||
savedAt: existingCredential.savedAt,
|
|
||||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
// Expected, handled case (offline, or a token the server rejects —
|
|
||||||
// the UI prompts re-login): warn, don't error. Log only
|
|
||||||
// status/message — never the raw error (axios errors carry the
|
|
||||||
// request config incl. the Authorization header / token).
|
|
||||||
console.warn(
|
|
||||||
"Background user validation failed:",
|
|
||||||
e?.response?.status ?? e?.message ?? "unknown error",
|
|
||||||
);
|
);
|
||||||
});
|
if (!existingCredential) {
|
||||||
|
await saveAccountCredential({
|
||||||
|
serverUrl,
|
||||||
|
serverName: "",
|
||||||
|
token,
|
||||||
|
userId: storedUser.Id,
|
||||||
|
username: storedUser.Name,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
securityType: "none",
|
||||||
|
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
response.data.PrimaryImageTag !==
|
||||||
|
existingCredential.primaryImageTag
|
||||||
|
) {
|
||||||
|
// Update image tag if it has changed
|
||||||
|
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||||
|
userId: existingCredential.userId,
|
||||||
|
username: existingCredential.username,
|
||||||
|
securityType: existingCredential.securityType,
|
||||||
|
savedAt: existingCredential.savedAt,
|
||||||
|
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Background fetch failed — app already rendered with cached data
|
||||||
|
console.warn("Background user fetch failed, using cached data:", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setInitialLoaded(true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
|
||||||
setInitialLoaded(true);
|
setInitialLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -774,7 +681,6 @@ 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,
|
||||||
|
|||||||
@@ -12,21 +12,18 @@
|
|||||||
"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_occurred": "An unexpected error occurred",
|
"an_unexpected_error_occured": "An unexpected error occurred",
|
||||||
"change_server": "Change server",
|
"change_server": "Change server",
|
||||||
"invalid_username_or_password": "Invalid username or password",
|
"invalid_username_or_password": "Invalid username or password",
|
||||||
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
||||||
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
||||||
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
||||||
"there_is_a_server_error": "There is a server error",
|
"there_is_a_server_error": "There is a server error",
|
||||||
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
||||||
"too_old_server_text": "Unsupported Jellyfin server discovered",
|
"too_old_server_text": "Unsupported Jellyfin server discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
@@ -36,7 +33,6 @@
|
|||||||
"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...",
|
||||||
@@ -192,7 +188,7 @@
|
|||||||
"authorize_button": "Authorize Quick Connect",
|
"authorize_button": "Authorize Quick Connect",
|
||||||
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"quick_connect_authorized": "Quick Connect authorized",
|
"quick_connect_autorized": "Quick Connect authorized",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Invalid code",
|
"invalid_code": "Invalid code",
|
||||||
"authorize": "Authorize"
|
"authorize": "Authorize"
|
||||||
@@ -274,10 +270,6 @@
|
|||||||
"mpv_subtitle_margin_y": "Vertical margin",
|
"mpv_subtitle_margin_y": "Vertical margin",
|
||||||
"mpv_subtitle_align_x": "Horizontal align",
|
"mpv_subtitle_align_x": "Horizontal align",
|
||||||
"mpv_subtitle_align_y": "Vertical align",
|
"mpv_subtitle_align_y": "Vertical align",
|
||||||
"mpv_settings_title": "MPV Subtitle Settings",
|
|
||||||
"mpv_settings_description": "Advanced subtitle customization for MPV player",
|
|
||||||
"opaque_background": "Opaque Background",
|
|
||||||
"background_opacity": "Background Opacity",
|
|
||||||
"align": {
|
"align": {
|
||||||
"left": "Left",
|
"left": "Left",
|
||||||
"center": "Center",
|
"center": "Center",
|
||||||
@@ -306,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Show custom menu links",
|
"show_custom_menu_links": "Show custom menu links",
|
||||||
"show_large_home_carousel": "Show large home carousel (beta)",
|
"show_large_home_carousel": "Show large home carousel (beta)",
|
||||||
"hide_libraries": "Hide libraries",
|
"hide_libraries": "Hide libraries",
|
||||||
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||||
"disable_haptic_feedback": "Disable haptic feedback",
|
"disable_haptic_feedback": "Disable haptic feedback",
|
||||||
"default_quality": "Default quality",
|
"default_quality": "Default quality",
|
||||||
"default_playback_speed": "Default playback speed",
|
"default_playback_speed": "Default playback speed",
|
||||||
@@ -393,8 +385,6 @@
|
|||||||
"device_usage": "Device {{availableSpace}}%",
|
"device_usage": "Device {{availableSpace}}%",
|
||||||
"size_used": "{{used}} of {{total}} used",
|
"size_used": "{{used}} of {{total}} used",
|
||||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
"delete_all_downloaded_files": "Delete all downloaded files",
|
||||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
|
||||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
|
||||||
"music_cache_title": "Music cache",
|
"music_cache_title": "Music cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear music cache",
|
"clear_music_cache": "Clear music cache",
|
||||||
@@ -450,13 +440,10 @@
|
|||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
"title": "Sessions",
|
"title": "Sessions",
|
||||||
"no_active_sessions": "No active sessions",
|
"no_active_sessions": "No active sessions"
|
||||||
"select_session": "Select Session",
|
|
||||||
"now_playing": "Now playing:"
|
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"transcoding": "Transcoding",
|
|
||||||
"series": "Series",
|
"series": "Series",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
@@ -513,8 +500,6 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"open_menu": "Open Menu",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
@@ -616,34 +601,10 @@
|
|||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"live": "LIVE",
|
"live": "LIVE",
|
||||||
"menu": {
|
|
||||||
"quality": "Quality",
|
|
||||||
"subtitles": "Subtitles",
|
|
||||||
"subtitle_scale": "Subtitle Scale",
|
|
||||||
"audio": "Audio",
|
|
||||||
"speed": "Speed",
|
|
||||||
"playback_options": "Playback Options",
|
|
||||||
"show_technical_info": "Show Technical Info",
|
|
||||||
"hide_technical_info": "Hide Technical Info"
|
|
||||||
},
|
|
||||||
"technical_info": {
|
|
||||||
"video": "Video:",
|
|
||||||
"audio": "Audio:",
|
|
||||||
"subtitle": "Subtitle:",
|
|
||||||
"bitrate": "Bitrate:",
|
|
||||||
"buffer_seconds": "Buffer: {{seconds}}s",
|
|
||||||
"vo": "VO:",
|
|
||||||
"dropped_frames": "Dropped: {{count}} frames",
|
|
||||||
"loading": "Loading..."
|
|
||||||
},
|
|
||||||
"mpv_player_title": "MPV player",
|
"mpv_player_title": "MPV player",
|
||||||
"aspect_ratio": "Aspect Ratio",
|
|
||||||
"aspect_ratio_original": "Original",
|
|
||||||
"hash_match": "Hash Match",
|
|
||||||
"still_watching": "Are you still watching?",
|
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
"client_error": "Client error",
|
"client_error": "Client error",
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from server: {{message}}",
|
"message_from_server": "Message from server: {{message}}",
|
||||||
@@ -741,7 +702,6 @@
|
|||||||
"no_data_available": "No data available"
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"title": "Live TV",
|
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"coming_soon": "Coming soon",
|
"coming_soon": "Coming soon",
|
||||||
@@ -813,7 +773,7 @@
|
|||||||
"request_selected": "Request selected",
|
"request_selected": "Request selected",
|
||||||
"n_selected": "{{count}} selected",
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseerr_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||||
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
||||||
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
|
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
|
||||||
"issue_submitted": "Issue submitted!",
|
"issue_submitted": "Issue submitted!",
|
||||||
@@ -826,16 +786,6 @@
|
|||||||
"failed_to_decline_request": "Failed to decline request"
|
"failed_to_decline_request": "Failed to decline request"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"accessibility": {
|
|
||||||
"play_button": "Play button",
|
|
||||||
"play_hint": "Tap to play the media",
|
|
||||||
"toggle_orientation": "Toggle screen orientation",
|
|
||||||
"toggle_orientation_hint": "Toggles the screen orientation between portrait and landscape"
|
|
||||||
},
|
|
||||||
"not_found": {
|
|
||||||
"title": "This screen doesn't exist.",
|
|
||||||
"go_home": "Go to home screen!"
|
|
||||||
},
|
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
@@ -846,12 +796,6 @@
|
|||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"no_track_playing": "No track playing",
|
|
||||||
"queue_empty": "Queue is empty",
|
|
||||||
"playing_from_queue": "Playing from queue",
|
|
||||||
"up_next": "Up next",
|
|
||||||
"now_playing": "Now Playing",
|
|
||||||
"missing_library_id": "Missing music library id.",
|
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"suggestions": "Suggestions",
|
"suggestions": "Suggestions",
|
||||||
"albums": "Albums",
|
"albums": "Albums",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -60,36 +59,32 @@ export const sortOptions: {
|
|||||||
|
|
||||||
export const useFilterOptions = () => {
|
export const useFilterOptions = () => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
// Memoized so the array identity stays stable across renders. A fresh array
|
// We want to only show the watchlist option if someone has ticked that setting.
|
||||||
// each render cascades into ListHeaderComponent re-creation and, under heavy
|
const filterOptions = settings?.useKefinTweaks
|
||||||
// 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(
|
key: FilterByOption.IsFavoriteOrLiked,
|
||||||
() =>
|
value: "Is Favorite Or Liked",
|
||||||
settings?.useKefinTweaks
|
},
|
||||||
? [
|
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
||||||
{
|
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
||||||
key: FilterByOption.IsFavoriteOrLiked,
|
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||||
value: "Is Favorite Or Liked",
|
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||||
},
|
{ key: FilterByOption.Likes, value: "Watchlist" },
|
||||||
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
]
|
||||||
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
: [
|
||||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
{
|
||||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
key: FilterByOption.IsFavoriteOrLiked,
|
||||||
{ key: FilterByOption.Likes, value: "Watchlist" },
|
value: "Is Favorite Or Liked",
|
||||||
]
|
},
|
||||||
: [
|
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
||||||
{
|
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
||||||
key: FilterByOption.IsFavoriteOrLiked,
|
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||||
value: "Is Favorite Or Liked",
|
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||||
},
|
];
|
||||||
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
console.log("filterOptions");
|
||||||
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
console.log(filterOptions);
|
||||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
return filterOptions;
|
||||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
|
||||||
],
|
|
||||||
[settings?.useKefinTweaks],
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sortOrderOptions: {
|
export const sortOrderOptions: {
|
||||||
|
|||||||
@@ -504,17 +504,7 @@ export const useSettings = () => {
|
|||||||
if (!_settings) {
|
if (!_settings) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Admin-locked settings are enforced at write time too: a control that
|
const hasChanges = Object.entries(update).some(
|
||||||
// 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,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -523,7 +513,7 @@ export const useSettings = () => {
|
|||||||
const newSettings = {
|
const newSettings = {
|
||||||
...defaultValues,
|
...defaultValues,
|
||||||
..._settings,
|
..._settings,
|
||||||
...sanitizedUpdate,
|
...update,
|
||||||
} as Settings;
|
} as Settings;
|
||||||
setSettings(newSettings);
|
setSettings(newSettings);
|
||||||
saveSettings(newSettings);
|
saveSettings(newSettings);
|
||||||
|
|||||||
Reference in New Issue
Block a user