mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-04 13:08:33 +01:00
Compare commits
3 Commits
feat/setti
...
fix/refres
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d962255ca | ||
|
|
075712e064 | ||
|
|
43d3efbaf0 |
@@ -243,28 +243,6 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => <HeaderBackButton />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name='settings/account/page'
|
|
||||||
options={{
|
|
||||||
title: t("home.settings.account.title"),
|
|
||||||
headerShown: !Platform.isTV,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
headerLeft: () => <HeaderBackButton />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='settings/notifications/page'
|
|
||||||
options={{
|
|
||||||
title: t("home.settings.notifications.title"),
|
|
||||||
headerShown: !Platform.isTV,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
headerLeft: () => <HeaderBackButton />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,44 +1,38 @@
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
import { SettingsHero } from "@/components/settings/index/SettingsHero";
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
import { SettingsRow } from "@/components/settings/index/SettingsRow";
|
|
||||||
import { SettingsSearchBar } from "@/components/settings/index/SettingsSearchBar";
|
|
||||||
import { SettingsSection } from "@/components/settings/index/SettingsSection";
|
|
||||||
import {
|
|
||||||
SETTINGS_CATALOG,
|
|
||||||
type SettingsTarget,
|
|
||||||
} from "@/components/settings/index/settingsCatalog";
|
|
||||||
import { useSettingsSearch } from "@/components/settings/index/useSettingsSearch";
|
|
||||||
import {
|
|
||||||
QuickConnectSheet,
|
|
||||||
type QuickConnectSheetRef,
|
|
||||||
} from "@/components/settings/QuickConnect";
|
|
||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// TV-specific settings component
|
||||||
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
||||||
|
|
||||||
|
// Mobile settings component
|
||||||
function SettingsMobile() {
|
function SettingsMobile() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [_user] = useAtom(userAtom);
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const navigation = useNavigation();
|
|
||||||
const quickConnectRef = useRef<QuickConnectSheetRef>(null);
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
|
|
||||||
const results = useSettingsSearch(query);
|
|
||||||
const searching = query.trim().length > 0;
|
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => logout()}>
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Text className='text-red-600 px-2'>
|
<Text className='text-red-600 px-2'>
|
||||||
{t("home.settings.log_out_button")}
|
{t("home.settings.log_out_button")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -47,95 +41,96 @@ function SettingsMobile() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTarget = (target: SettingsTarget) => {
|
|
||||||
if (target.type === "action") {
|
|
||||||
if (target.action === "quickConnect") {
|
|
||||||
quickConnectRef.current?.present();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(target.route as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
keyboardShouldPersistTaps='handled'
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingTop: 8,
|
|
||||||
paddingBottom: 32,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!searching && (
|
<View
|
||||||
<SettingsHero
|
className='p-4 flex flex-col'
|
||||||
onPress={() => router.push("/settings/account/page" as any)}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
/>
|
>
|
||||||
)}
|
<View className='mb-4'>
|
||||||
<SettingsSearchBar value={query} onChange={setQuery} />
|
<UserInfo />
|
||||||
|
</View>
|
||||||
|
|
||||||
{searching ? (
|
<QuickConnect className='mb-4' />
|
||||||
<SettingsSection title={t("home.settings.search_results")}>
|
|
||||||
{results.length === 0 ? (
|
|
||||||
<View className='px-4 py-3'>
|
|
||||||
<Text className='text-[#9899A1]'>
|
|
||||||
{t("home.settings.search_no_results")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
results.map((r, i) => (
|
|
||||||
<SettingsRow
|
|
||||||
key={r.id}
|
|
||||||
title={r.title}
|
|
||||||
icon={r.icon}
|
|
||||||
value={r.subtitle}
|
|
||||||
onPress={() => handleTarget(r.target)}
|
|
||||||
isLast={i === results.length - 1}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<View className='mx-3 mb-5'>
|
|
||||||
<AppLanguageSelector />
|
|
||||||
</View>
|
|
||||||
{SETTINGS_CATALOG.map((section) => {
|
|
||||||
const entries = section.entries.filter(
|
|
||||||
(e) => !e.platforms || e.platforms.includes(os),
|
|
||||||
);
|
|
||||||
if (entries.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<SettingsSection key={section.id} title={t(section.titleKey)}>
|
|
||||||
{entries.map((e, i) => (
|
|
||||||
<SettingsRow
|
|
||||||
key={e.id}
|
|
||||||
title={t(e.titleKey)}
|
|
||||||
icon={e.icon}
|
|
||||||
onPress={() => handleTarget(e.target)}
|
|
||||||
isLast={i === entries.length - 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<SettingsSection>
|
|
||||||
<View className='p-3'>
|
|
||||||
<StorageSettings />
|
|
||||||
</View>
|
|
||||||
</SettingsSection>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<QuickConnectSheet ref={quickConnectRef} />
|
<View className='mb-4'>
|
||||||
|
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() =>
|
||||||
|
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||||
|
}
|
||||||
|
title={t("pairing.pair_with_phone")}
|
||||||
|
textColor='blue'
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='mb-4'>
|
||||||
|
<AppLanguageSelector />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='mb-4'>
|
||||||
|
<ListGroup title={t("home.settings.categories.title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/playback-controls/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.playback_controls.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/audio-subtitles/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.audio_subtitles.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/music/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.music.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/appearance/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.appearance.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/plugins/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.plugins.plugins_title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/intro/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.intro.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/network/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.network.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.logs.logs_title")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<StorageSettings />
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
|
// Use TV settings component on TV platforms
|
||||||
if (Platform.isTV && SettingsTV) {
|
if (Platform.isTV && SettingsTV) {
|
||||||
return <SettingsTV />;
|
return <SettingsTV />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SettingsMobile />;
|
return <SettingsMobile />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import * as Application from "expo-application";
|
|
||||||
import { setStringAsync } from "expo-clipboard";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Alert, ScrollView } from "react-native";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
export default function AccountPage() {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const [revealed, setRevealed] = useState(false);
|
|
||||||
const success = useHaptic("success");
|
|
||||||
const version = Application.nativeApplicationVersion ?? "N/A";
|
|
||||||
const token = api?.accessToken ?? "";
|
|
||||||
const masked = token ? `•••• •••• •••• ${token.slice(-4)}` : "";
|
|
||||||
|
|
||||||
const copyToken = async () => {
|
|
||||||
if (!token) return;
|
|
||||||
try {
|
|
||||||
await setStringAsync(token);
|
|
||||||
success();
|
|
||||||
Alert.alert(t("home.settings.account.copied"));
|
|
||||||
} catch {
|
|
||||||
Alert.alert(t("home.settings.account.copy_failed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
|
||||||
<ListGroup title={t("home.settings.user_info.user_info_title")}>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.user_info.user")}
|
|
||||||
value={user?.Name}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.user_info.server")}
|
|
||||||
value={api?.basePath}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.user_info.token")}
|
|
||||||
value={revealed ? token : masked}
|
|
||||||
onPress={() => setRevealed((r) => !r)}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.account.copy_token")}
|
|
||||||
textColor='blue'
|
|
||||||
onPress={copyToken}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.user_info.app_version")}
|
|
||||||
value={version}
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import * as Notifications from "expo-notifications";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Linking, ScrollView, Switch, View } from "react-native";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
const [perm, setPerm] =
|
|
||||||
useState<Notifications.NotificationPermissionsStatus | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Notifications.getPermissionsAsync().then(setPerm);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const requestPermission = async () => {
|
|
||||||
const res = await Notifications.requestPermissionsAsync();
|
|
||||||
setPerm(res);
|
|
||||||
// Only bounce to system settings when the OS will not prompt again.
|
|
||||||
if (!res.granted && res.canAskAgain === false) {
|
|
||||||
Linking.openSettings();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!settings || perm === null) return null;
|
|
||||||
|
|
||||||
if (!perm.granted) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 items-center justify-center px-8'>
|
|
||||||
<Ionicons name='notifications-off-outline' size={56} color='#5A5960' />
|
|
||||||
<Text className='text-white text-lg font-semibold mt-4 text-center'>
|
|
||||||
{t("home.settings.notifications.disabled_title")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-[#9899A1] text-center mt-2'>
|
|
||||||
{t("home.settings.notifications.disabled_description")}
|
|
||||||
</Text>
|
|
||||||
<Button color='purple' className='mt-6' onPress={requestPermission}>
|
|
||||||
{t("home.settings.notifications.enable_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
|
||||||
<ListGroup title={t("home.settings.notifications.events_title")}>
|
|
||||||
<ListItem title={t("home.settings.notifications.master")}>
|
|
||||||
<Switch
|
|
||||||
value={settings.notificationsEnabled}
|
|
||||||
onValueChange={(v) => updateSettings({ notificationsEnabled: v })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.notifications.downloads")}>
|
|
||||||
<Switch
|
|
||||||
value={settings.notifyDownloads}
|
|
||||||
disabled={!settings.notificationsEnabled}
|
|
||||||
onValueChange={(v) => updateSettings({ notifyDownloads: v })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -20,16 +20,18 @@ export default function PlaybackControlsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className='p-4'
|
className='p-4 flex flex-col'
|
||||||
style={{ gap: 16, paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<MediaProvider>
|
<View className='mb-4'>
|
||||||
<MediaToggles />
|
<MediaProvider>
|
||||||
<GestureControls />
|
<MediaToggles className='mb-4' />
|
||||||
<PlaybackControlsSettings />
|
<GestureControls className='mb-4' />
|
||||||
<MpvBufferSettings />
|
<PlaybackControlsSettings />
|
||||||
<MpvVoSettings />
|
<MpvBufferSettings />
|
||||||
</MediaProvider>
|
<MpvVoSettings />
|
||||||
|
</MediaProvider>
|
||||||
|
</View>
|
||||||
{!Platform.isTV && <ChromecastSettings />}
|
{!Platform.isTV && <ChromecastSettings />}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default function StreamystatsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshFromServer = useCallback(async () => {
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
||||||
// Update local state with new values
|
// Update local state with new values
|
||||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||||
setUrl(newUrl);
|
setUrl(newUrl);
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function IndexLayout() {
|
|||||||
open={dropdownOpen}
|
open={dropdownOpen}
|
||||||
onOpenChange={setDropdownOpen}
|
onOpenChange={setDropdownOpen}
|
||||||
trigger={
|
trigger={
|
||||||
<View>
|
<View className='pl-1.5'>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ const Layout = () => {
|
|||||||
keyboardDismissMode='none'
|
keyboardDismissMode='none'
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarBounces: true,
|
tabBarBounces: true,
|
||||||
tabBarActiveTintColor: "#FFFFFF",
|
|
||||||
tabBarInactiveTintColor: "#9CA3AF",
|
|
||||||
tabBarLabelStyle: {
|
tabBarLabelStyle: {
|
||||||
fontSize: TAB_LABEL_FONT_SIZE,
|
fontSize: TAB_LABEL_FONT_SIZE,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
|
|||||||
@@ -274,11 +274,6 @@ export default function DirectPlayerPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
setItem(null);
|
|
||||||
setDownloadedItem(null);
|
|
||||||
// Clear the previous episode's stream so the loader gate stays closed
|
|
||||||
// until the new item's stream resolves (avoids a stale MPV source frame).
|
|
||||||
setStream(null);
|
|
||||||
fetchItemData();
|
fetchItemData();
|
||||||
}
|
}
|
||||||
}, [itemId, offline, api, user?.Id]);
|
}, [itemId, offline, api, user?.Id]);
|
||||||
@@ -321,12 +316,6 @@ export default function DirectPlayerPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure item matches the current itemId to avoid race conditions
|
|
||||||
if (item.Id !== itemId) {
|
|
||||||
setStreamStatus({ isLoading: false, isError: false });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: Stream | null = null;
|
let result: Stream | null = null;
|
||||||
if (offline && downloadedItem?.mediaSource) {
|
if (offline && downloadedItem?.mediaSource) {
|
||||||
const url = downloadedItem.videoFilePath;
|
const url = downloadedItem.videoFilePath;
|
||||||
@@ -399,7 +388,6 @@ export default function DirectPlayerPage() {
|
|||||||
item,
|
item,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
downloadedItem,
|
downloadedItem,
|
||||||
offline,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
21
bun.lock
21
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.3",
|
|
||||||
"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",
|
||||||
@@ -104,10 +103,8 @@
|
|||||||
"@biomejs/biome": "2.4.16",
|
"@biomejs/biome": "2.4.16",
|
||||||
"@react-native-community/cli": "20.1.3",
|
"@react-native-community/cli": "20.1.3",
|
||||||
"@react-native-tvos/config-tv": "0.1.6",
|
"@react-native-tvos/config-tv": "0.1.6",
|
||||||
"@types/bun": "^1.3.14",
|
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.24",
|
"@types/lodash": "4.17.24",
|
||||||
"@types/node": "^24",
|
|
||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
@@ -590,8 +587,6 @@
|
|||||||
|
|
||||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
|
||||||
|
|
||||||
"@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
|
"@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
|
||||||
|
|
||||||
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
|
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
|
||||||
@@ -608,7 +603,7 @@
|
|||||||
|
|
||||||
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@24.13.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg=="],
|
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
|
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
|
||||||
|
|
||||||
@@ -748,8 +743,6 @@
|
|||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
|
||||||
|
|
||||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
|
|
||||||
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
|
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
|
||||||
@@ -962,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.3", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-8mCdhmAomm0yBIonJFjAhKUXvSkc2avdNh4+rBwoe7DSWF2AC4w3uy+pa419rvVFbTyVxOBmh83UHAbUwD6qAg=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
@@ -1836,7 +1827,7 @@
|
|||||||
|
|
||||||
"ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="],
|
"ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
|
|
||||||
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
|
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
|
||||||
|
|
||||||
@@ -2040,8 +2031,6 @@
|
|||||||
|
|
||||||
"brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
"bun-types/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
|
||||||
|
|
||||||
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
@@ -2140,8 +2129,6 @@
|
|||||||
|
|
||||||
"nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
"nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||||
|
|
||||||
"node-vibrant/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
|
||||||
|
|
||||||
"npm-package-arg/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
"npm-package-arg/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||||
|
|
||||||
"parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
|
"parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
|
||||||
@@ -2260,8 +2247,6 @@
|
|||||||
|
|
||||||
"ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
"ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
||||||
|
|
||||||
"bun-types/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
|
||||||
|
|
||||||
"chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
"chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
"chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||||
@@ -2318,8 +2303,6 @@
|
|||||||
|
|
||||||
"metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
"metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||||
|
|
||||||
"node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
|
||||||
|
|
||||||
"patch-package/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
"patch-package/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||||
|
|
||||||
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
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, useState } from "react";
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
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";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
@@ -211,6 +217,24 @@ const PlatformDropdownComponent = ({
|
|||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
|
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
||||||
|
// `matchContents` doesn't help here: it reports the native Menu's size via
|
||||||
|
// setStyleSize and overrides any explicit size. Instead we measure the
|
||||||
|
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
||||||
|
const [triggerSize, setTriggerSize] = useState<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
||||||
|
const { width, height } = e.nativeEvent.layout;
|
||||||
|
setTriggerSize((prev) =>
|
||||||
|
prev && prev.width === width && prev.height === height
|
||||||
|
? prev
|
||||||
|
: { width, height },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "android" && controlledOpen === true) {
|
if (Platform.OS === "android" && controlledOpen === true) {
|
||||||
@@ -241,11 +265,25 @@ const PlatformDropdownComponent = ({
|
|||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
|
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
||||||
// the trigger sizes the wrapper while the Host overlays the real Menu.
|
// fills its parent and reports its own size via setStyleSize, so it can't
|
||||||
|
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
||||||
|
// height depends on the parent while the parent depends on the Host — a
|
||||||
|
// circular dependency that collapses to 0 for any selector nested more than
|
||||||
|
// one level deep (so only the first, shallowest dropdown stays visible).
|
||||||
|
// Giving the wrapper the measured size breaks the cycle; the Host then
|
||||||
|
// fills a concrete box.
|
||||||
return (
|
return (
|
||||||
<View>
|
<View style={triggerSize ?? { opacity: 0 }}>
|
||||||
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
|
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
||||||
|
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
||||||
|
sizes to the trigger's content rather than to its parent. */}
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
||||||
|
pointerEvents='none'
|
||||||
|
aria-hidden
|
||||||
|
onLayout={handleMeasureTrigger}
|
||||||
|
>
|
||||||
{trigger}
|
{trigger}
|
||||||
</View>
|
</View>
|
||||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
|
||||||
import {
|
import {
|
||||||
type ChapterEntry,
|
type ChapterEntry,
|
||||||
chapterStartsMs,
|
chapterStartsMs,
|
||||||
@@ -39,7 +38,6 @@ function ChapterListComponent({
|
|||||||
onClose,
|
onClose,
|
||||||
}: ChapterListProps) {
|
}: ChapterListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const safeArea = useControlsSafeAreaInsets();
|
|
||||||
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||||
|
|
||||||
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||||
@@ -81,17 +79,7 @@ function ChapterListComponent({
|
|||||||
supportedOrientations={["portrait", "landscape"]}
|
supportedOrientations={["portrait", "landscape"]}
|
||||||
>
|
>
|
||||||
<Pressable onPress={onClose} style={styles.backdrop}>
|
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||||
<Pressable
|
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
style={[
|
|
||||||
styles.sheet,
|
|
||||||
{
|
|
||||||
marginLeft: safeArea.left,
|
|
||||||
marginRight: safeArea.right,
|
|
||||||
paddingBottom: safeArea.bottom,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>{t("chapters.title")}</Text>
|
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -172,12 +160,14 @@ const styles = StyleSheet.create({
|
|||||||
backdrop: {
|
backdrop: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
},
|
},
|
||||||
sheet: {
|
sheet: {
|
||||||
backgroundColor: Colors.background,
|
backgroundColor: Colors.background,
|
||||||
borderTopLeftRadius: 16,
|
borderTopLeftRadius: 16,
|
||||||
borderTopRightRadius: 16,
|
borderTopRightRadius: 16,
|
||||||
maxHeight: "70%",
|
maxHeight: "70%",
|
||||||
|
paddingBottom: 24,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ const HomeMobile = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
|
className='ml-1.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather
|
<Feather
|
||||||
|
|||||||
@@ -34,13 +34,12 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||||
const isDisabled = disabled || disabledByAdmin;
|
const isDisabled = disabled || disabledByAdmin;
|
||||||
const hasSubtitle = Boolean(effectiveSubtitle);
|
|
||||||
if (onPress)
|
if (onPress)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
@@ -59,7 +58,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...viewProps}
|
{...viewProps}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Pair with Phone */}
|
{/* Pair with Phone */}
|
||||||
{Platform.OS !== "ios" && onStartPairing && (
|
{onStartPairing && (
|
||||||
<View>
|
<View>
|
||||||
<Button
|
<Button
|
||||||
onPress={onStartPairing}
|
onPress={onStartPairing}
|
||||||
|
|||||||
@@ -401,6 +401,10 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const hasMovies = movieResults && movieResults.length > 0;
|
||||||
|
const hasTv = tvResults && tvResults.length > 0;
|
||||||
|
const hasPersons = personResults && personResults.length > 0;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -427,26 +431,22 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{/* No section requests `hasTVPreferredFocus`: the native search field
|
|
||||||
keeps focus while typing, otherwise the first result would re-grab
|
|
||||||
focus on every keystroke as results re-render. The user navigates
|
|
||||||
down to the grid manually. */}
|
|
||||||
<TVJellyseerrMovieSection
|
<TVJellyseerrMovieSection
|
||||||
title={t("search.request_movies")}
|
title={t("search.request_movies")}
|
||||||
items={movieResults}
|
items={movieResults}
|
||||||
isFirstSection={false}
|
isFirstSection={hasMovies}
|
||||||
onItemPress={onMoviePress}
|
onItemPress={onMoviePress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrTvSection
|
<TVJellyseerrTvSection
|
||||||
title={t("search.request_series")}
|
title={t("search.request_series")}
|
||||||
items={tvResults}
|
items={tvResults}
|
||||||
isFirstSection={false}
|
isFirstSection={!hasMovies && hasTv}
|
||||||
onItemPress={onTvPress}
|
onItemPress={onTvPress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrPersonSection
|
<TVJellyseerrPersonSection
|
||||||
title={t("search.actors")}
|
title={t("search.actors")}
|
||||||
items={personResults}
|
items={personResults}
|
||||||
isFirstSection={false}
|
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
||||||
onItemPress={onPersonPress}
|
onItemPress={onPersonPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -235,13 +235,10 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
module). It renders the native search bar + grid keyboard and
|
module). It renders the native search bar + grid keyboard and
|
||||||
forwards typed text into the existing query pipeline via setSearch;
|
forwards typed text into the existing query pipeline via setSearch;
|
||||||
our own results grid renders below. */}
|
our own results grid renders below. */}
|
||||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
|
||||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
|
||||||
margins squeeze the bar's width and clip that trailing hint, so let
|
|
||||||
the native view span the full width and own its own insets. */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
|
marginHorizontal: HORIZONTAL_PADDING,
|
||||||
height: SEARCH_AREA_HEIGHT,
|
height: SEARCH_AREA_HEIGHT,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -283,17 +280,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
{/* Library Search Results */}
|
{/* Library Search Results */}
|
||||||
{isLibraryMode && !loading && (
|
{isLibraryMode && !loading && (
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
{sections.map((section) => (
|
{sections.map((section, index) => (
|
||||||
<TVSearchSection
|
<TVSearchSection
|
||||||
key={section.key}
|
key={section.key}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
items={section.items!}
|
items={section.items!}
|
||||||
orientation={section.orientation || "vertical"}
|
orientation={section.orientation || "vertical"}
|
||||||
// Never auto-focus a result. The native search field owns focus
|
isFirstSection={index === 0}
|
||||||
// while typing; `hasTVPreferredFocus` here would re-grab focus on
|
|
||||||
// every keystroke as results re-render. User navigates down to the
|
|
||||||
// grid manually.
|
|
||||||
isFirstSection={false}
|
|
||||||
onItemPress={onItemPress}
|
onItemPress={onItemPress}
|
||||||
onItemLongPress={onItemLongPress}
|
onItemLongPress={onItemLongPress}
|
||||||
imageUrlGetter={
|
imageUrlGetter={
|
||||||
|
|||||||
@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
|||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
getItemLayout={getItemLayout}
|
getItemLayout={getItemLayout}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
|
contentInset={{
|
||||||
// contentOffset only applies on initial mount; since this FlatList is
|
left: edgePadding,
|
||||||
// reused across searches (stable key), a second search left the inset
|
right: edgePadding,
|
||||||
// without the offset and the grid snapped flush to the left edge.
|
}}
|
||||||
|
contentOffset={{ x: -edgePadding, y: 0 }}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: edgePadding,
|
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,12 +31,8 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const router = useRouter();
|
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
// Read the live (cached) downloads DB inside the query rather than the
|
const router = useRouter();
|
||||||
// provider's downloadedItems snapshot, so refetches after
|
|
||||||
// updateDownloadedItem() reflect the latest state instead of a stale
|
|
||||||
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
|
|
||||||
const { getDownloadedItems } = useDownload();
|
const { getDownloadedItems } = useDownload();
|
||||||
|
|
||||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||||
@@ -104,7 +100,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.setParams({ id: _item.Id });
|
router.setParams({ id: _item.Id });
|
||||||
}}
|
}}
|
||||||
className={`flex flex-col w-44
|
className={`flex flex-col w-44
|
||||||
${item?.Id === _item.Id ? "" : "opacity-50"}
|
${item?.Id === _item.Id ? "" : "opacity-50"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { Switch, View } from "react-native";
|
||||||
import { View } from "react-native";
|
|
||||||
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={t("home.settings.chromecast.title")}>
|
<ListGroup title={"Chromecast"}>
|
||||||
<SettingsSwitchRow
|
<ListItem title={"Enable H265 for Chromecast"}>
|
||||||
title={t("home.settings.chromecast.enable_h265")}
|
<Switch
|
||||||
value={settings.enableH265ForChromecast}
|
value={settings.enableH265ForChromecast}
|
||||||
onValueChange={(enableH265ForChromecast) =>
|
onValueChange={(enableH265ForChromecast) =>
|
||||||
updateSettings({ enableH265ForChromecast })
|
updateSettings({ enableH265ForChromecast })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
|
import { Switch } from "react-native";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -31,65 +32,85 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
<ListGroup
|
<ListGroup
|
||||||
title={t("home.settings.gesture_controls.gesture_controls_title")}
|
title={t("home.settings.gesture_controls.gesture_controls_title")}
|
||||||
>
|
>
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.horizontal_swipe_skip")}
|
title={t("home.settings.gesture_controls.horizontal_swipe_skip")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.horizontal_swipe_skip_description",
|
"home.settings.gesture_controls.horizontal_swipe_skip_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
value={settings.enableHorizontalSwipeSkip}
|
>
|
||||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
<Switch
|
||||||
updateSettings({ enableHorizontalSwipeSkip })
|
value={settings.enableHorizontalSwipeSkip}
|
||||||
}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
/>
|
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||||
|
updateSettings({ enableHorizontalSwipeSkip })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.left_side_brightness")}
|
title={t("home.settings.gesture_controls.left_side_brightness")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.left_side_brightness_description",
|
"home.settings.gesture_controls.left_side_brightness_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
value={settings.enableLeftSideBrightnessSwipe}
|
>
|
||||||
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
<Switch
|
||||||
updateSettings({ enableLeftSideBrightnessSwipe })
|
value={settings.enableLeftSideBrightnessSwipe}
|
||||||
}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
/>
|
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
||||||
|
updateSettings({ enableLeftSideBrightnessSwipe })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.right_side_volume")}
|
title={t("home.settings.gesture_controls.right_side_volume")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.right_side_volume_description",
|
"home.settings.gesture_controls.right_side_volume_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
value={settings.enableRightSideVolumeSwipe}
|
>
|
||||||
onValueChange={(enableRightSideVolumeSwipe) =>
|
<Switch
|
||||||
updateSettings({ enableRightSideVolumeSwipe })
|
value={settings.enableRightSideVolumeSwipe}
|
||||||
}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
/>
|
onValueChange={(enableRightSideVolumeSwipe) =>
|
||||||
|
updateSettings({ enableRightSideVolumeSwipe })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.hide_volume_slider")}
|
title={t("home.settings.gesture_controls.hide_volume_slider")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.hide_volume_slider_description",
|
"home.settings.gesture_controls.hide_volume_slider_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
value={settings.hideVolumeSlider}
|
>
|
||||||
onValueChange={(hideVolumeSlider) =>
|
<Switch
|
||||||
updateSettings({ hideVolumeSlider })
|
value={settings.hideVolumeSlider}
|
||||||
}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
/>
|
onValueChange={(hideVolumeSlider) =>
|
||||||
|
updateSettings({ hideVolumeSlider })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.hide_brightness_slider")}
|
title={t("home.settings.gesture_controls.hide_brightness_slider")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.hide_brightness_slider_description",
|
"home.settings.gesture_controls.hide_brightness_slider_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
value={settings.hideBrightnessSlider}
|
>
|
||||||
onValueChange={(hideBrightnessSlider) =>
|
<Switch
|
||||||
updateSettings({ hideBrightnessSlider })
|
value={settings.hideBrightnessSlider}
|
||||||
}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
/>
|
onValueChange={(hideBrightnessSlider) =>
|
||||||
|
updateSettings({ hideBrightnessSlider })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { SettingsStepperRow } from "@/components/settings/index/SettingsStepperRow";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -26,27 +27,35 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled} {...props}>
|
<DisabledSetting disabled={disabled} {...props}>
|
||||||
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
|
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
|
||||||
<SettingsStepperRow
|
<ListItem
|
||||||
title={t("home.settings.media_controls.forward_skip_length")}
|
title={t("home.settings.media_controls.forward_skip_length")}
|
||||||
disabled={pluginSettings?.forwardSkipTime?.locked}
|
disabled={pluginSettings?.forwardSkipTime?.locked}
|
||||||
value={settings.forwardSkipTime}
|
>
|
||||||
step={5}
|
<Stepper
|
||||||
appendValue={t("home.settings.media_controls.seconds_unit")}
|
value={settings.forwardSkipTime}
|
||||||
min={0}
|
disabled={pluginSettings?.forwardSkipTime?.locked}
|
||||||
max={60}
|
step={5}
|
||||||
onUpdate={(forwardSkipTime) => updateSettings({ forwardSkipTime })}
|
appendValue={t("home.settings.media_controls.seconds_unit")}
|
||||||
/>
|
min={0}
|
||||||
|
max={60}
|
||||||
|
onUpdate={(forwardSkipTime) => updateSettings({ forwardSkipTime })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsStepperRow
|
<ListItem
|
||||||
title={t("home.settings.media_controls.rewind_length")}
|
title={t("home.settings.media_controls.rewind_length")}
|
||||||
disabled={pluginSettings?.rewindSkipTime?.locked}
|
disabled={pluginSettings?.rewindSkipTime?.locked}
|
||||||
value={settings.rewindSkipTime}
|
>
|
||||||
step={5}
|
<Stepper
|
||||||
appendValue={t("home.settings.media_controls.seconds_unit")}
|
value={settings.rewindSkipTime}
|
||||||
min={0}
|
disabled={pluginSettings?.rewindSkipTime?.locked}
|
||||||
max={60}
|
step={5}
|
||||||
onUpdate={(rewindSkipTime) => updateSettings({ rewindSkipTime })}
|
appendValue={t("home.settings.media_controls.seconds_unit")}
|
||||||
/>
|
min={0}
|
||||||
|
max={60}
|
||||||
|
onUpdate={(rewindSkipTime) => updateSettings({ rewindSkipTime })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
import { View } from "react-native";
|
||||||
import { SettingsStepperRow } from "@/components/settings/index/SettingsStepperRow";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
|
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
|
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
|
||||||
{ key: "home.settings.buffer.cache_auto", value: "auto" },
|
{ key: "home.settings.buffer.cache_auto", value: "auto" },
|
||||||
@@ -41,43 +45,56 @@ export const MpvBufferSettings: React.FC = () => {
|
|||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup title={t("home.settings.buffer.title")}>
|
<ListGroup title={t("home.settings.buffer.title")} className='mb-4'>
|
||||||
<SettingsSelectRow
|
<ListItem title={t("home.settings.buffer.cache_mode")}>
|
||||||
title={t("home.settings.buffer.cache_mode")}
|
<PlatformDropdown
|
||||||
valueLabel={currentCacheModeLabel}
|
groups={cacheModeOptions}
|
||||||
groups={cacheModeOptions}
|
trigger={
|
||||||
dropdownTitle={t("home.settings.buffer.cache_mode")}
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
/>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{currentCacheModeLabel}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.buffer.cache_mode")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsStepperRow
|
<ListItem title={t("home.settings.buffer.buffer_duration")}>
|
||||||
title={t("home.settings.buffer.buffer_duration")}
|
<Stepper
|
||||||
value={settings.mpvCacheSeconds ?? 10}
|
value={settings.mpvCacheSeconds ?? 10}
|
||||||
step={5}
|
step={5}
|
||||||
min={5}
|
min={5}
|
||||||
max={120}
|
max={120}
|
||||||
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
|
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
|
||||||
appendValue='s'
|
appendValue='s'
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsStepperRow
|
<ListItem title={t("home.settings.buffer.max_cache_size")}>
|
||||||
title={t("home.settings.buffer.max_cache_size")}
|
<Stepper
|
||||||
value={settings.mpvDemuxerMaxBytes ?? 150}
|
value={settings.mpvDemuxerMaxBytes ?? 150}
|
||||||
step={25}
|
step={25}
|
||||||
min={50}
|
min={50}
|
||||||
max={500}
|
max={500}
|
||||||
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
|
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
|
||||||
appendValue=' MB'
|
appendValue=' MB'
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsStepperRow
|
<ListItem title={t("home.settings.buffer.max_backward_cache")}>
|
||||||
title={t("home.settings.buffer.max_backward_cache")}
|
<Stepper
|
||||||
value={settings.mpvDemuxerMaxBackBytes ?? 50}
|
value={settings.mpvDemuxerMaxBackBytes ?? 50}
|
||||||
step={25}
|
step={25}
|
||||||
min={25}
|
min={25}
|
||||||
max={200}
|
max={200}
|
||||||
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBackBytes: value })}
|
onUpdate={(value) =>
|
||||||
appendValue=' MB'
|
updateSettings({ mpvDemuxerMaxBackBytes: value })
|
||||||
/>
|
}
|
||||||
|
appendValue=' MB'
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
|
import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
|
const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
|
||||||
{ key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
|
{ key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
|
||||||
@@ -43,13 +46,21 @@ export const MpvVoSettings: React.FC = () => {
|
|||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup title={t("home.settings.vo_driver.title")}>
|
<ListGroup title={t("home.settings.vo_driver.title")} className='mb-4'>
|
||||||
<SettingsSelectRow
|
<ListItem title={t("home.settings.vo_driver.vo_mode")}>
|
||||||
title={t("home.settings.vo_driver.vo_mode")}
|
<PlatformDropdown
|
||||||
valueLabel={currentVoDriverLabel}
|
groups={voDriverOptions}
|
||||||
groups={voDriverOptions}
|
trigger={
|
||||||
dropdownTitle={t("home.settings.vo_driver.vo_mode")}
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
/>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{currentVoDriverLabel}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.vo_driver.vo_mode")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -196,10 +196,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
|
||||||
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={autoPlayEpisodeOptions}
|
groups={autoPlayEpisodeOptions}
|
||||||
trigger={
|
trigger={
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Switch, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
|
||||||
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const PlaybackControlsSettings: React.FC = () => {
|
export const PlaybackControlsSettings: React.FC = () => {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
@@ -113,77 +116,138 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled}>
|
<DisabledSetting disabled={disabled}>
|
||||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
||||||
<SettingsSelectRow
|
<ListItem
|
||||||
title={t("home.settings.other.video_orientation")}
|
title={t("home.settings.other.video_orientation")}
|
||||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||||
valueLabel={
|
>
|
||||||
t(
|
<PlatformDropdown
|
||||||
orientationTranslations[
|
groups={orientationOptions}
|
||||||
settings.defaultVideoOrientation as keyof typeof orientationTranslations
|
trigger={
|
||||||
],
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
) || "Unknown Orientation"
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
}
|
{t(
|
||||||
groups={orientationOptions}
|
orientationTranslations[
|
||||||
dropdownTitle={t("home.settings.other.orientation")}
|
settings.defaultVideoOrientation as keyof typeof orientationTranslations
|
||||||
/>
|
],
|
||||||
|
) || "Unknown Orientation"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.orientation")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
value={settings.safeAreaInControlsEnabled}
|
>
|
||||||
onValueChange={(value) =>
|
<Switch
|
||||||
updateSettings({ safeAreaInControlsEnabled: value })
|
value={settings.safeAreaInControlsEnabled}
|
||||||
}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
/>
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ safeAreaInControlsEnabled: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSelectRow
|
<ListItem
|
||||||
title={t("home.settings.other.default_quality")}
|
title={t("home.settings.other.default_quality")}
|
||||||
disabled={pluginSettings?.defaultBitrate?.locked}
|
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||||
valueLabel={settings.defaultBitrate?.key}
|
>
|
||||||
groups={bitrateOptions}
|
<PlatformDropdown
|
||||||
dropdownTitle={t("home.settings.other.default_quality")}
|
groups={bitrateOptions}
|
||||||
/>
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between pl-3 py-1.5 '>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{settings.defaultBitrate?.key}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.default_quality")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSelectRow
|
<ListItem
|
||||||
title={t("home.settings.other.default_playback_speed")}
|
title={t("home.settings.other.default_playback_speed")}
|
||||||
disabled={pluginSettings?.defaultPlaybackSpeed?.locked}
|
disabled={pluginSettings?.defaultPlaybackSpeed?.locked}
|
||||||
valueLabel={
|
>
|
||||||
PLAYBACK_SPEEDS.find(
|
<PlatformDropdown
|
||||||
(s) => s.value === settings.defaultPlaybackSpeed,
|
groups={playbackSpeedOptions}
|
||||||
)?.label ?? "1x"
|
trigger={
|
||||||
}
|
<View className='flex flex-row items-center justify-between pl-3 py-1.5'>
|
||||||
groups={playbackSpeedOptions}
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
dropdownTitle={t("home.settings.other.default_playback_speed")}
|
{PLAYBACK_SPEEDS.find(
|
||||||
/>
|
(s) => s.value === settings.defaultPlaybackSpeed,
|
||||||
|
)?.label ?? "1x"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.default_playback_speed")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
value={settings.disableHapticFeedback}
|
>
|
||||||
onValueChange={(disableHapticFeedback) =>
|
<Switch
|
||||||
updateSettings({ disableHapticFeedback })
|
value={settings.disableHapticFeedback}
|
||||||
}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
/>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
|
updateSettings({ disableHapticFeedback })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.other.auto_play_next_episode")}
|
title={t("home.settings.other.auto_play_next_episode")}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
value={settings.autoPlayNextEpisode}
|
>
|
||||||
onValueChange={(autoPlayNextEpisode) =>
|
<Switch
|
||||||
updateSettings({ autoPlayNextEpisode })
|
value={settings.autoPlayNextEpisode}
|
||||||
}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
/>
|
onValueChange={(autoPlayNextEpisode) =>
|
||||||
|
updateSettings({ autoPlayNextEpisode })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSelectRow
|
<ListItem
|
||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
disabled={
|
disabled={!settings.autoPlayNextEpisode}
|
||||||
!settings.autoPlayNextEpisode ||
|
>
|
||||||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
<PlatformDropdown
|
||||||
}
|
groups={autoPlayEpisodeOptions}
|
||||||
valueLabel={t(settings?.maxAutoPlayEpisodeCount.key)}
|
trigger={
|
||||||
groups={autoPlayEpisodeOptions}
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
dropdownTitle={t("home.settings.other.max_auto_play_episode_count")}
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
/>
|
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,57 +1,54 @@
|
|||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
BottomSheetBackdrop,
|
||||||
useCallback,
|
type BottomSheetBackdropProps,
|
||||||
useImperativeHandle,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Alert, Platform, View } from "react-native";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
type BottomSheetMethods,
|
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@/utils/expoUiBottomSheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { PinInput } from "../inputs/PinInput";
|
import { PinInput } from "../inputs/PinInput";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export type QuickConnectSheetRef = { present: () => void };
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||||
(_props, ref) => {
|
const isTv = Platform.isTV;
|
||||||
const isTv = Platform.isTV;
|
const [api] = useAtom(apiAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
const modalRef = useRef<BottomSheetMethods>(null);
|
const successHapticFeedback = useHaptic("success");
|
||||||
const successHapticFeedback = useHaptic("success");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const snapPoints = useMemo(
|
||||||
const snapPoints = useMemo(
|
() => (Platform.OS === "android" ? ["100%"] : ["40%"]),
|
||||||
() => (Platform.OS === "android" ? ["100%"] : ["40%"]),
|
[],
|
||||||
[],
|
);
|
||||||
);
|
const isAndroid = Platform.OS === "android";
|
||||||
const isAndroid = Platform.OS === "android";
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
useImperativeHandle(
|
const { t } = useTranslation();
|
||||||
ref,
|
|
||||||
() => ({
|
|
||||||
present: () => {
|
|
||||||
setQuickConnectCode("");
|
|
||||||
modalRef.current?.present();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const authorizeQuickConnect = useCallback(async () => {
|
const renderBackdrop = useCallback(
|
||||||
if (!quickConnectCode) return;
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const authorizeQuickConnect = useCallback(async () => {
|
||||||
|
if (quickConnectCode) {
|
||||||
try {
|
try {
|
||||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||||
code: quickConnectCode,
|
code: quickConnectCode,
|
||||||
@@ -64,7 +61,7 @@ export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
|||||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
modalRef.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
} else {
|
} else {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -79,26 +76,39 @@ export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
|||||||
t("home.settings.quick_connect.invalid_code"),
|
t("home.settings.quick_connect.invalid_code"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}
|
||||||
api,
|
}, [api, user, quickConnectCode]);
|
||||||
user,
|
|
||||||
quickConnectCode,
|
|
||||||
t,
|
|
||||||
successHapticFeedback,
|
|
||||||
errorHapticFeedback,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => {
|
||||||
|
// Reset the code when opening the sheet
|
||||||
|
setQuickConnectCode("");
|
||||||
|
bottomSheetModalRef?.current?.present();
|
||||||
|
}}
|
||||||
|
title={t("home.settings.quick_connect.authorize_button")}
|
||||||
|
textColor='blue'
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
return (
|
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={modalRef}
|
ref={bottomSheetModalRef}
|
||||||
enablePanDownToClose
|
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
handleIndicatorStyle={{
|
||||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
||||||
keyboardBlurBehavior='restore'
|
keyboardBlurBehavior='restore'
|
||||||
|
android_keyboardInputMode='adjustResize'
|
||||||
|
topInset={isAndroid ? 0 : undefined}
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
@@ -132,8 +142,6 @@ export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
|||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
);
|
</View>
|
||||||
},
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
QuickConnectSheet.displayName = "QuickConnectSheet";
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getUserImageUrl } from "@/utils/jellyfin/image/getUserImageUrl";
|
|
||||||
|
|
||||||
export const SettingsHero: React.FC<{ onPress: () => void }> = ({
|
|
||||||
onPress,
|
|
||||||
}) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const connected = Boolean(api && user);
|
|
||||||
const imageUrl =
|
|
||||||
api && user?.Id
|
|
||||||
? (getUserImageUrl({
|
|
||||||
serverAddress: api.basePath,
|
|
||||||
userId: user.Id,
|
|
||||||
primaryImageTag: user.PrimaryImageTag,
|
|
||||||
}) ?? undefined)
|
|
||||||
: undefined;
|
|
||||||
const host = api?.basePath?.replace(/^https?:\/\//, "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onPress}
|
|
||||||
className='mx-3 mb-4 rounded-2xl overflow-hidden'
|
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={["#241b33", "#15151a"]}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
>
|
|
||||||
<View className='flex-row items-center p-4'>
|
|
||||||
{imageUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: imageUrl }}
|
|
||||||
style={{ width: 52, height: 52, borderRadius: 26 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LinearGradient
|
|
||||||
colors={["#a855f7", "#6d28d9"]}
|
|
||||||
style={{
|
|
||||||
width: 52,
|
|
||||||
height: 52,
|
|
||||||
borderRadius: 26,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-white text-[22px] font-bold'>
|
|
||||||
{(user?.Name?.[0] ?? "?").toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
)}
|
|
||||||
<View className='flex-1 ml-3'>
|
|
||||||
<Text
|
|
||||||
className='text-white text-[18px] font-bold'
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{user?.Name ?? ""}
|
|
||||||
</Text>
|
|
||||||
<View className='flex-row items-center mt-0.5'>
|
|
||||||
<View
|
|
||||||
className='w-2 h-2 rounded-full mr-1.5'
|
|
||||||
style={{ backgroundColor: connected ? "#30D158" : "#8E8D91" }}
|
|
||||||
/>
|
|
||||||
<Text className='text-[#9899A1] text-[13px]' numberOfLines={1}>
|
|
||||||
{host}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Ionicons name='chevron-forward' size={18} color='#5A5960' />
|
|
||||||
</View>
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type React from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
|
|
||||||
export interface SettingsRowProps {
|
|
||||||
title: string;
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
value?: string;
|
|
||||||
showChevron?: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
isLast?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACCENT = "#a855f7"; // single accent (full theming is a separate sub-project)
|
|
||||||
|
|
||||||
export const SettingsRow: React.FC<SettingsRowProps> = ({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
value,
|
|
||||||
showChevron = true,
|
|
||||||
onPress,
|
|
||||||
isLast = false,
|
|
||||||
}) => {
|
|
||||||
const haptic = useHaptic("light"); // no-op when disableHapticFeedback is set
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
haptic();
|
|
||||||
onPress();
|
|
||||||
}}
|
|
||||||
className={`flex flex-row items-center bg-neutral-900 h-[48px] px-3 ${
|
|
||||||
isLast ? "" : "border-b border-[#ffffff14]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className='h-[29px] w-[29px] rounded-[7px] items-center justify-center mr-3'
|
|
||||||
style={{ backgroundColor: `${ACCENT}29` }}
|
|
||||||
>
|
|
||||||
<Ionicons name={icon} size={17} color='#c79bff' />
|
|
||||||
</View>
|
|
||||||
<Text className='flex-1 text-white text-[15px]' numberOfLines={1}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{value ? (
|
|
||||||
<Text className='text-[#9899A1] text-[15px] ml-2' numberOfLines={1}>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
{showChevron ? (
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-forward'
|
|
||||||
size={17}
|
|
||||||
color='#5A5960'
|
|
||||||
style={{ marginLeft: 4 }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import type React from "react";
|
|
||||||
import { TextInput, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
export const SettingsSearchBar: React.FC<{
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}> = ({ value, onChange }) => (
|
|
||||||
<View className='mx-3 mb-4 h-[38px] rounded-xl bg-neutral-800 flex-row items-center px-3'>
|
|
||||||
<Ionicons name='search' size={16} color='#76767c' />
|
|
||||||
<TextInput
|
|
||||||
value={value}
|
|
||||||
onChangeText={onChange}
|
|
||||||
placeholder={t("home.settings.search_placeholder")}
|
|
||||||
placeholderTextColor='#76767c'
|
|
||||||
className='flex-1 ml-2 text-white text-[15px]'
|
|
||||||
autoCapitalize='none'
|
|
||||||
autoCorrect={false}
|
|
||||||
returnKeyType='search'
|
|
||||||
/>
|
|
||||||
{value.length > 0 ? (
|
|
||||||
<TouchableOpacity onPress={() => onChange("")} hitSlop={8}>
|
|
||||||
<Ionicons name='close-circle' size={18} color='#76767c' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
export const SettingsSection: React.FC<{
|
|
||||||
title?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}> = ({ title, children }) => (
|
|
||||||
<View className='mb-5'>
|
|
||||||
{title ? (
|
|
||||||
<Text className='ml-4 mb-1.5 uppercase text-[#8E8D91] text-[11px] tracking-wide'>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<View className='mx-3 rounded-xl overflow-hidden bg-neutral-900'>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import {
|
|
||||||
type OptionGroup,
|
|
||||||
PlatformDropdown,
|
|
||||||
} from "@/components/PlatformDropdown";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
valueLabel?: string;
|
|
||||||
groups: OptionGroup[];
|
|
||||||
dropdownTitle?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsSelectRow: React.FC<Props> = ({
|
|
||||||
title,
|
|
||||||
valueLabel,
|
|
||||||
groups,
|
|
||||||
dropdownTitle,
|
|
||||||
disabled,
|
|
||||||
}) => (
|
|
||||||
<ListItem title={title} disabled={disabled}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={groups}
|
|
||||||
title={dropdownTitle}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{valueLabel}</Text>
|
|
||||||
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
value: number;
|
|
||||||
step: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
onUpdate: (value: number) => void;
|
|
||||||
appendValue?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsStepperRow: React.FC<Props> = ({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
value,
|
|
||||||
step,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
onUpdate,
|
|
||||||
appendValue,
|
|
||||||
disabled,
|
|
||||||
}) => (
|
|
||||||
<ListItem title={title} subtitle={subtitle} disabled={disabled}>
|
|
||||||
<Stepper
|
|
||||||
value={value}
|
|
||||||
step={step}
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
appendValue={appendValue}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { Switch } from "react-native";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
value: boolean;
|
|
||||||
onValueChange: (value: boolean) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsSwitchRow: React.FC<Props> = ({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
value,
|
|
||||||
onValueChange,
|
|
||||||
disabled,
|
|
||||||
}) => (
|
|
||||||
<ListItem title={title} subtitle={subtitle} disabled={disabled}>
|
|
||||||
<Switch value={value} disabled={disabled} onValueChange={onValueChange} />
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { expect, test } from "bun:test";
|
|
||||||
import { matchesQuery, normalize } from "./searchFilter";
|
|
||||||
|
|
||||||
test("normalize strips accents and lowercases", () => {
|
|
||||||
expect(normalize("Légèreté")).toBe("legerete");
|
|
||||||
expect(normalize(" AUDIO ")).toBe("audio");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matchesQuery matches title case/accent-insensitively", () => {
|
|
||||||
expect(matchesQuery({ title: "Apparence", keywords: [] }, "appar")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
matchesQuery({ title: "Audio", keywords: ["sous-titres"] }, "SOUS"),
|
|
||||||
).toBe(true);
|
|
||||||
expect(matchesQuery({ title: "Music", keywords: [] }, "xyz")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matchesQuery returns true for empty query", () => {
|
|
||||||
expect(matchesQuery({ title: "Anything" }, "")).toBe(true);
|
|
||||||
expect(matchesQuery({ title: "Anything" }, " ")).toBe(true);
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export const normalize = (s: string): string =>
|
|
||||||
s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
|
|
||||||
|
|
||||||
export interface Searchable {
|
|
||||||
title: string;
|
|
||||||
keywords?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const matchesQuery = (item: Searchable, query: string): boolean => {
|
|
||||||
const q = normalize(query);
|
|
||||||
if (!q) return true;
|
|
||||||
const hay = normalize([item.title, ...(item.keywords ?? [])].join(" "));
|
|
||||||
return hay.includes(q);
|
|
||||||
};
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import type { Ionicons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
export type SettingsTarget =
|
|
||||||
| { type: "route"; route: string }
|
|
||||||
| { type: "action"; action: "quickConnect" };
|
|
||||||
|
|
||||||
export interface SettingsEntry {
|
|
||||||
id: string;
|
|
||||||
titleKey: string;
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
target: SettingsTarget;
|
|
||||||
/** extra search terms (English); the title is always searched too */
|
|
||||||
keywords?: string[];
|
|
||||||
/** when set, entry only shows on these platforms */
|
|
||||||
platforms?: ("ios" | "android")[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SettingsSectionDef {
|
|
||||||
id: string;
|
|
||||||
titleKey: string;
|
|
||||||
entries: SettingsEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single source of truth for the Settings index: drives both rendering and search.
|
|
||||||
*
|
|
||||||
* EXTENSIBLE SHELL — to add a setting that lands from an in-flight PR, append one
|
|
||||||
* entry to the right section (and, if it lives inside a sub-page, an entry in
|
|
||||||
* settingsSearchIndex.ts). No screen rewrite needed. Reserved slots (add when the
|
|
||||||
* PR merges): sleep timer #922, sync-play #1612, wake-on-LAN #1539 (advanced);
|
|
||||||
* download location #1486/#1193, clear image cache #1589 (storage/downloads);
|
|
||||||
* double-tap seek #1219/#1289, subtitle background #1543 (playback).
|
|
||||||
*/
|
|
||||||
export const SETTINGS_CATALOG: SettingsSectionDef[] = [
|
|
||||||
{
|
|
||||||
id: "playback",
|
|
||||||
titleKey: "home.settings.categories.playback",
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
id: "playback-controls",
|
|
||||||
titleKey: "home.settings.playback_controls.title",
|
|
||||||
icon: "play",
|
|
||||||
target: { type: "route", route: "/settings/playback-controls/page" },
|
|
||||||
keywords: ["speed", "skip", "autoplay", "orientation"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "audio-subtitles",
|
|
||||||
titleKey: "home.settings.audio_subtitles.title",
|
|
||||||
icon: "chatbox-ellipses",
|
|
||||||
target: { type: "route", route: "/settings/audio-subtitles/page" },
|
|
||||||
keywords: ["subtitle", "audio", "language"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "music",
|
|
||||||
titleKey: "home.settings.music.title",
|
|
||||||
icon: "musical-notes",
|
|
||||||
target: { type: "route", route: "/settings/music/page" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "personalization",
|
|
||||||
titleKey: "home.settings.categories.personalization",
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
id: "appearance",
|
|
||||||
titleKey: "home.settings.appearance.title",
|
|
||||||
icon: "color-palette",
|
|
||||||
target: { type: "route", route: "/settings/appearance/page" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "notifications",
|
|
||||||
titleKey: "home.settings.notifications.title",
|
|
||||||
icon: "notifications",
|
|
||||||
target: { type: "route", route: "/settings/notifications/page" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "advanced",
|
|
||||||
titleKey: "home.settings.categories.advanced",
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
id: "quick-connect",
|
|
||||||
titleKey: "home.settings.quick_connect.quick_connect_title",
|
|
||||||
icon: "key",
|
|
||||||
target: { type: "action", action: "quickConnect" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "pair",
|
|
||||||
titleKey: "pairing.pair_with_phone",
|
|
||||||
icon: "phone-portrait",
|
|
||||||
target: {
|
|
||||||
type: "route",
|
|
||||||
route: "/(auth)/(tabs)/(home)/companion-login",
|
|
||||||
},
|
|
||||||
platforms: ["android"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "plugins",
|
|
||||||
titleKey: "home.settings.plugins.plugins_title",
|
|
||||||
icon: "extension-puzzle",
|
|
||||||
target: { type: "route", route: "/settings/plugins/page" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "network",
|
|
||||||
titleKey: "home.settings.network.title",
|
|
||||||
icon: "wifi",
|
|
||||||
target: { type: "route", route: "/settings/network/page" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "logs",
|
|
||||||
titleKey: "home.settings.logs.logs_title",
|
|
||||||
icon: "document-text",
|
|
||||||
target: { type: "route", route: "/settings/logs/page" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "intro",
|
|
||||||
titleKey: "home.settings.intro.title",
|
|
||||||
icon: "information-circle",
|
|
||||||
target: { type: "route", route: "/settings/intro/page" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
export interface SearchableOption {
|
|
||||||
titleKey: string;
|
|
||||||
parentRoute: string;
|
|
||||||
parentTitleKey: string;
|
|
||||||
keywords?: string[];
|
|
||||||
platforms?: ("ios" | "android")[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal options of sub-pages, for deep search ("index + internal settings").
|
|
||||||
* Populated from a per-sub-page audit of every user-facing ListItem/dropdown row.
|
|
||||||
* Every titleKey/parentTitleKey MUST be a real existing i18n key.
|
|
||||||
*/
|
|
||||||
export const SETTINGS_SEARCH_INDEX: SearchableOption[] = [
|
|
||||||
// --- Playback & Controls -------------------------------------------------
|
|
||||||
// MediaToggles
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.media_controls.forward_skip_length",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["skip", "forward", "seconds"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.media_controls.rewind_length",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["rewind", "back", "seconds"],
|
|
||||||
},
|
|
||||||
// GestureControls
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.horizontal_swipe_skip",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["gesture", "swipe", "skip", "seek"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.left_side_brightness",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["gesture", "swipe", "brightness", "left"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.right_side_volume",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["gesture", "swipe", "volume", "right"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.hide_volume_slider",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["volume", "slider", "hide", "gesture"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.hide_brightness_slider",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["brightness", "slider", "hide", "gesture"],
|
|
||||||
},
|
|
||||||
// PlaybackControlsSettings (home.settings.other.*)
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.video_orientation",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["orientation", "rotate", "landscape", "portrait"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.safe_area_in_controls",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["safe", "area", "notch", "controls", "inset"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.default_quality",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["quality", "bitrate", "resolution"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.default_playback_speed",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["playback", "speed", "rate"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.disable_haptic_feedback",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["haptic", "vibration", "feedback"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.auto_play_next_episode",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["autoplay", "auto", "next", "episode"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.max_auto_play_episode_count",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["autoplay", "auto", "episode", "count", "limit"],
|
|
||||||
},
|
|
||||||
// MpvBufferSettings
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.buffer.cache_mode",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["cache", "buffer", "mode"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.buffer.buffer_duration",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["buffer", "duration", "cache", "seconds"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.buffer.max_cache_size",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["buffer", "cache", "size", "memory"],
|
|
||||||
},
|
|
||||||
// MpvVoSettings (Android only)
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.vo_driver.vo_mode",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["video", "output", "driver", "gpu", "mpv"],
|
|
||||||
platforms: ["android"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Audio & Subtitles ---------------------------------------------------
|
|
||||||
// AudioToggles
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.audio.set_audio_track",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["audio", "track", "remember", "previous"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.audio.audio_language",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["audio", "language", "default"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.audio.transcode_mode.title",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["audio", "transcode", "surround", "stereo", "passthrough"],
|
|
||||||
},
|
|
||||||
// SubtitleToggles
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.subtitles.subtitle_language",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["subtitle", "caption", "language", "default"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.subtitles.subtitle_mode",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["subtitle", "caption", "mode", "forced"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.subtitles.set_subtitle_track",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["subtitle", "caption", "track", "remember", "previous"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.subtitles.subtitle_size",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["subtitle", "size", "caption", "scale", "font"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Music ---------------------------------------------------------------
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.music.prefer_downloaded",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "downloaded", "offline", "local"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.music.lookahead_enabled",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "lookahead", "cache", "prefetch"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.music.lookahead_count",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "lookahead", "cache", "count", "tracks"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.music.max_cache_size",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "cache", "size", "storage"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.storage.clear_music_cache",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "cache", "clear", "storage"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.storage.delete_all_downloaded_songs",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "downloaded", "delete", "songs", "storage"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Appearance ----------------------------------------------------------
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.show_custom_menu_links",
|
|
||||||
parentRoute: "/settings/appearance/page",
|
|
||||||
parentTitleKey: "home.settings.appearance.title",
|
|
||||||
keywords: ["menu", "links", "custom", "navigation"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.appearance.merge_next_up_continue_watching",
|
|
||||||
parentRoute: "/settings/appearance/page",
|
|
||||||
parentTitleKey: "home.settings.appearance.title",
|
|
||||||
keywords: ["continue", "watching", "next", "up", "merge", "home"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.hide_libraries",
|
|
||||||
parentRoute: "/settings/appearance/page",
|
|
||||||
parentTitleKey: "home.settings.appearance.title",
|
|
||||||
keywords: ["hide", "libraries", "library", "home"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.appearance.hide_remote_session_button",
|
|
||||||
parentRoute: "/settings/appearance/page",
|
|
||||||
parentTitleKey: "home.settings.appearance.title",
|
|
||||||
keywords: ["remote", "session", "button", "hide", "cast"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Network -------------------------------------------------------------
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.network.remote_url",
|
|
||||||
parentRoute: "/settings/network/page",
|
|
||||||
parentTitleKey: "home.settings.network.title",
|
|
||||||
keywords: ["remote", "url", "server", "address"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.network.active_url",
|
|
||||||
parentRoute: "/settings/network/page",
|
|
||||||
parentTitleKey: "home.settings.network.title",
|
|
||||||
keywords: ["active", "url", "server", "connection"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.network.auto_switch_enabled",
|
|
||||||
parentRoute: "/settings/network/page",
|
|
||||||
parentTitleKey: "home.settings.network.title",
|
|
||||||
keywords: ["auto", "switch", "local", "wifi", "network"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.network.local_url",
|
|
||||||
parentRoute: "/settings/network/page",
|
|
||||||
parentTitleKey: "home.settings.network.title",
|
|
||||||
keywords: ["local", "url", "lan", "server", "address"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { matchesQuery } from "./searchFilter";
|
|
||||||
import { SETTINGS_CATALOG, type SettingsTarget } from "./settingsCatalog";
|
|
||||||
import { SETTINGS_SEARCH_INDEX } from "./settingsSearchIndex";
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
subtitle?: string;
|
|
||||||
target: SettingsTarget;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSettingsSearch = (query: string): SearchResult[] => {
|
|
||||||
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
|
|
||||||
return useMemo(() => {
|
|
||||||
if (!query.trim()) return [];
|
|
||||||
const results: SearchResult[] = [];
|
|
||||||
for (const section of SETTINGS_CATALOG) {
|
|
||||||
for (const e of section.entries) {
|
|
||||||
if (e.platforms && !e.platforms.includes(os)) continue;
|
|
||||||
const title = t(e.titleKey);
|
|
||||||
if (matchesQuery({ title, keywords: e.keywords }, query)) {
|
|
||||||
results.push({ id: e.id, title, icon: e.icon, target: e.target });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const o of SETTINGS_SEARCH_INDEX) {
|
|
||||||
if (o.platforms && !o.platforms.includes(os)) continue;
|
|
||||||
const title = t(o.titleKey);
|
|
||||||
if (matchesQuery({ title, keywords: o.keywords }, query)) {
|
|
||||||
results.push({
|
|
||||||
id: `${o.parentRoute}#${o.titleKey}`,
|
|
||||||
title,
|
|
||||||
icon: "search",
|
|
||||||
subtitle: t(o.parentTitleKey),
|
|
||||||
target: { type: "route", route: o.parentRoute },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}, [query, os]);
|
|
||||||
};
|
|
||||||
@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import { type SharedValue } from "react-native-reanimated";
|
import { type SharedValue } from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
@@ -75,6 +75,9 @@ interface BottomControlsProps {
|
|||||||
minutes: number;
|
minutes: number;
|
||||||
seconds: number;
|
seconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Chapter props
|
||||||
|
chapterPositions?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BottomControls: FC<BottomControlsProps> = ({
|
export const BottomControls: FC<BottomControlsProps> = ({
|
||||||
@@ -108,10 +111,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
time,
|
time,
|
||||||
|
chapterPositions = [],
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||||
|
|
||||||
// Only expose chapter UI when there are at least two real markers.
|
// Only expose chapter UI when there are at least two real markers.
|
||||||
@@ -142,9 +146,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: insets.right,
|
right:
|
||||||
left: insets.left,
|
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||||
bottom: Math.max(insets.bottom - 17, 0),
|
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||||
|
bottom:
|
||||||
|
(settings?.safeAreaInControlsEnabled ?? true)
|
||||||
|
? Math.max(insets.bottom - 17, 0)
|
||||||
|
: 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
className={"flex flex-col px-2"}
|
className={"flex flex-col px-2"}
|
||||||
@@ -180,6 +188,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||||
|
{hasChapters && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setChapterListVisible(true)}
|
||||||
|
hitSlop={10}
|
||||||
|
className='justify-center mr-4'
|
||||||
|
accessibilityRole='button'
|
||||||
|
accessibilityLabel={t("chapters.open")}
|
||||||
|
>
|
||||||
|
<Ionicons name='bookmarks' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
@@ -211,17 +230,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
onPress={handleNextEpisodeManual}
|
onPress={handleNextEpisodeManual}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasChapters && (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setChapterListVisible(true)}
|
|
||||||
hitSlop={10}
|
|
||||||
className='justify-center ml-4'
|
|
||||||
accessibilityRole='button'
|
|
||||||
accessibilityLabel={t("chapters.open")}
|
|
||||||
>
|
|
||||||
<Ionicons name='bookmarks' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import AudioSlider from "./AudioSlider";
|
import AudioSlider from "./AudioSlider";
|
||||||
import BrightnessSlider from "./BrightnessSlider";
|
import BrightnessSlider from "./BrightnessSlider";
|
||||||
@@ -42,15 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
goToNextChapter,
|
goToNextChapter,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "50%",
|
top: "50%",
|
||||||
left: insets.left,
|
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||||
right: insets.right,
|
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ export const Controls: FC<Props> = ({
|
|||||||
hasNextChapter,
|
hasNextChapter,
|
||||||
goToPreviousChapter,
|
goToPreviousChapter,
|
||||||
goToNextChapter,
|
goToNextChapter,
|
||||||
|
chapterPositions,
|
||||||
} = useChapterNavigation({
|
} = useChapterNavigation({
|
||||||
chapters: item.Chapters,
|
chapters: item.Chapters,
|
||||||
progress,
|
progress,
|
||||||
@@ -365,9 +366,7 @@ export const Controls: FC<Props> = ({
|
|||||||
{ applyLanguagePreferences: true },
|
{ applyLanguagePreferences: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use setParams instead of replace to avoid unmounting/remounting the player,
|
const queryParams = new URLSearchParams({
|
||||||
// which would create a new MPV native view and crash with "mp_initialize already initialized".
|
|
||||||
router.setParams({
|
|
||||||
...(offline && { offline: "true" }),
|
...(offline && { offline: "true" }),
|
||||||
itemId: item.Id ?? "",
|
itemId: item.Id ?? "",
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
@@ -376,17 +375,11 @@ export const Controls: FC<Props> = ({
|
|||||||
bitrateValue: bitrateValue?.toString(),
|
bitrateValue: bitrateValue?.toString(),
|
||||||
playbackPosition:
|
playbackPosition:
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
});
|
}).toString();
|
||||||
|
|
||||||
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
},
|
},
|
||||||
[
|
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
||||||
settings,
|
|
||||||
subtitleIndex,
|
|
||||||
audioIndex,
|
|
||||||
mediaSource,
|
|
||||||
bitrateValue,
|
|
||||||
router,
|
|
||||||
offline,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
const goToPreviousItem = useCallback(() => {
|
||||||
@@ -592,6 +585,7 @@ export const Controls: FC<Props> = ({
|
|||||||
trickPlayUrl={trickPlayUrl}
|
trickPlayUrl={trickPlayUrl}
|
||||||
trickplayInfo={trickplayInfo}
|
trickplayInfo={trickplayInfo}
|
||||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||||
|
chapterPositions={chapterPositions}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.ends_at", { time: getFinishTime() })}
|
{t("player.ends_at")} {getFinishTime()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.ends_at", { time: getFinishTime() })}
|
{t("player.ends_at")} {getFinishTime()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import {
|
import {
|
||||||
HorizontalScroll,
|
HorizontalScroll,
|
||||||
@@ -16,10 +17,10 @@ import {
|
|||||||
SeasonDropdown,
|
SeasonDropdown,
|
||||||
type SeasonIndexState,
|
type SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
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 {
|
import {
|
||||||
getDownloadedEpisodesForSeason,
|
getDownloadedEpisodesForSeason,
|
||||||
getDownloadedSeasonNumbers,
|
getDownloadedSeasonNumbers,
|
||||||
@@ -45,7 +46,8 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||||
};
|
};
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const { settings } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
// Set the initial season index
|
// Set the initial season index
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,11 +59,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Read the live (cached) downloads DB inside the query rather than the
|
|
||||||
// provider's downloadedItems snapshot. The snapshot only refreshes on the
|
|
||||||
// provider refreshKey, so after updateDownloadedItem() invalidates
|
|
||||||
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
|
|
||||||
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
|
|
||||||
const { getDownloadedItems } = useDownload();
|
const { getDownloadedItems } = useDownload();
|
||||||
|
|
||||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||||
@@ -185,9 +182,12 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
paddingTop: insets.top,
|
paddingTop:
|
||||||
paddingLeft: insets.left,
|
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||||
paddingRight: insets.right,
|
paddingLeft:
|
||||||
|
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||||
|
paddingRight:
|
||||||
|
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import type {
|
|||||||
} 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 { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||||
import DropdownView from "./dropdown/DropdownView";
|
import DropdownView from "./dropdown/DropdownView";
|
||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
@@ -57,8 +58,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
showTechnicalInfo = false,
|
showTechnicalInfo = false,
|
||||||
onToggleTechnicalInfo,
|
onToggleTechnicalInfo,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { settings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
const { orientation, lockOrientation } = useOrientation();
|
const { orientation, lockOrientation } = useOrientation();
|
||||||
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
||||||
@@ -97,9 +99,10 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: insets.top,
|
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||||
left: insets.left,
|
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||||
right: insets.right,
|
right:
|
||||||
|
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||||
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { HEADER_LAYOUT } from "./constants";
|
import { HEADER_LAYOUT } from "./constants";
|
||||||
|
|
||||||
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
||||||
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const safeInsets = useControlsSafeAreaInsets();
|
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
|
|
||||||
const opacity = useSharedValue(0);
|
const opacity = useSharedValue(0);
|
||||||
@@ -268,8 +268,14 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
left: Math.max(insets.left, 48) + 20,
|
left: Math.max(insets.left, 48) + 20,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
top:
|
||||||
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
(settings?.safeAreaInControlsEnabled ?? true)
|
||||||
|
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
|
||||||
|
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||||
|
left:
|
||||||
|
(settings?.safeAreaInControlsEnabled ?? true)
|
||||||
|
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
|
||||||
|
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
const textStyle = Platform.isTV
|
const textStyle = Platform.isTV
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import {
|
|
||||||
type EdgeInsets,
|
|
||||||
useSafeAreaInsets,
|
|
||||||
} from "react-native-safe-area-context";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns safe-area insets to apply to in-player controls, honoring the
|
|
||||||
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
|
|
||||||
* returns zero insets so controls can sit flush against the screen edges.
|
|
||||||
*/
|
|
||||||
export const useControlsSafeAreaInsets = (): EdgeInsets => {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { File, Paths } from "expo-file-system";
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
@@ -13,28 +12,36 @@ const useImageStorage = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
|
||||||
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
|
|
||||||
* resolves to an empty payload under RN's New Architecture.
|
|
||||||
*/
|
|
||||||
const image2Base64 = useCallback(async (url?: string | null) => {
|
const image2Base64 = useCallback(async (url?: string | null) => {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
||||||
const tmpFile = new File(
|
let blob: Blob;
|
||||||
Paths.cache,
|
|
||||||
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const downloaded = await File.downloadFileAsync(url, tmpFile, {
|
// Fetch the data from the URL
|
||||||
idempotent: true,
|
const response = await fetch(url);
|
||||||
});
|
blob = await response.blob();
|
||||||
return await downloaded.base64();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Error fetching image:", error);
|
console.warn("Error fetching image:", error);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
|
||||||
if (tmpFile.exists) tmpFile.delete();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a FileReader instance
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
// Convert blob to base64
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (typeof reader.result === "string") {
|
||||||
|
// Extract the base64 string (remove the data URL prefix)
|
||||||
|
const base64 = reader.result.split(",")[1];
|
||||||
|
resolve(base64);
|
||||||
|
} else {
|
||||||
|
reject(new Error("Failed to convert image to base64"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveImage = useCallback(
|
const saveImage = useCallback(
|
||||||
|
|||||||
@@ -109,35 +109,30 @@ export const usePlaybackManager = ({
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive prev/next from the current item's real position in the adjacent
|
|
||||||
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
|
|
||||||
* not guarantee a fixed [prev, current, next] shape — at the first/last
|
|
||||||
* episode it can still return the current item as the first/last entry — so
|
|
||||||
* length-based indexing wrongly surfaces the current episode as "previous".
|
|
||||||
*/
|
|
||||||
const currentIndex = useMemo(
|
|
||||||
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
|
|
||||||
[adjacentItems, item],
|
|
||||||
);
|
|
||||||
|
|
||||||
/** A neighbour is only navigable if it has an actual media file (not a
|
|
||||||
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
|
|
||||||
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
|
|
||||||
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
|
|
||||||
|
|
||||||
const previousItem = useMemo(() => {
|
const previousItem = useMemo(() => {
|
||||||
if (!adjacentItems || currentIndex <= 0) return null;
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
const candidate = adjacentItems[currentIndex - 1];
|
return null;
|
||||||
return isNavigable(candidate) ? candidate : null;
|
}
|
||||||
}, [adjacentItems, currentIndex, item]);
|
|
||||||
|
if (adjacentItems.length === 2) {
|
||||||
|
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjacentItems[0];
|
||||||
|
}, [adjacentItems, item]);
|
||||||
|
|
||||||
/** The next item in the series */
|
/** The next item in the series */
|
||||||
const nextItem = useMemo(() => {
|
const nextItem = useMemo(() => {
|
||||||
if (!adjacentItems || currentIndex < 0) return null;
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
const candidate = adjacentItems[currentIndex + 1];
|
return null;
|
||||||
return isNavigable(candidate) ? candidate : null;
|
}
|
||||||
}, [adjacentItems, currentIndex, item]);
|
|
||||||
|
if (adjacentItems.length === 2) {
|
||||||
|
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjacentItems[2];
|
||||||
|
}, [adjacentItems, item]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reports playback progress.
|
* Reports playback progress.
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
private func setupView() {
|
private func setupView() {
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
backgroundColor = .black
|
backgroundColor = .black
|
||||||
|
configureAudioSession()
|
||||||
|
|
||||||
videoContainer = UIView()
|
videoContainer = UIView()
|
||||||
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -140,26 +141,21 @@ class MpvPlayerView: ExpoView {
|
|||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio Session & Notifications
|
|
||||||
|
|
||||||
private func configureAudioSession() {
|
private func configureAudioSession() {
|
||||||
let session = AVAudioSession.sharedInstance()
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
do {
|
do {
|
||||||
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
|
try audioSession.setCategory(
|
||||||
try session.setActive(true)
|
.playback,
|
||||||
|
mode: .moviePlayback,
|
||||||
|
policy: .longFormAudio,
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
try audioSession.setActive(true)
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to configure audio session: \(error)")
|
print("Failed to configure audio session: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// MARK: - Audio Session & Notifications
|
||||||
/// Deactivate the session AND reset the category — `setActive(false)` alone
|
|
||||||
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
|
|
||||||
/// reactivation (foreground, route change, other modules) re-steals audio.
|
|
||||||
private func tearDownAudioSession() {
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
try? session.setActive(false, options: .notifyOthersOnDeactivation)
|
|
||||||
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupNotifications() {
|
private func setupNotifications() {
|
||||||
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||||
@@ -274,7 +270,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
intendedPlayState = true
|
intendedPlayState = true
|
||||||
configureAudioSession()
|
|
||||||
setupRemoteCommands()
|
setupRemoteCommands()
|
||||||
renderer?.play()
|
renderer?.play()
|
||||||
pipController?.setPlaybackRate(1.0)
|
pipController?.setPlaybackRate(1.0)
|
||||||
@@ -445,7 +440,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
displayLayer.removeFromSuperlayer()
|
displayLayer.removeFromSuperlayer()
|
||||||
clearNowPlayingInfo()
|
clearNowPlayingInfo()
|
||||||
tearDownAudioSession()
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,7 +519,9 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
||||||
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
|
// Audio output is now active - this is the right time to activate audio session and set Now Playing
|
||||||
|
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
|
||||||
|
nowPlayingManager.activateAudioSession()
|
||||||
syncNowPlaying(isPlaying: !isPaused())
|
syncNowPlaying(isPlaying: !isPaused())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,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.3",
|
|
||||||
"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",
|
||||||
@@ -125,10 +124,8 @@
|
|||||||
"@biomejs/biome": "2.4.16",
|
"@biomejs/biome": "2.4.16",
|
||||||
"@react-native-community/cli": "20.1.3",
|
"@react-native-community/cli": "20.1.3",
|
||||||
"@react-native-tvos/config-tv": "0.1.6",
|
"@react-native-tvos/config-tv": "0.1.6",
|
||||||
"@types/bun": "^1.3.14",
|
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.24",
|
"@types/lodash": "4.17.24",
|
||||||
"@types/node": "^24",
|
|
||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
|
|||||||
@@ -4,68 +4,28 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
|
|||||||
|
|
||||||
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
||||||
|
|
||||||
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
|
|
||||||
let cachedDb: DownloadsDatabase | null = null;
|
|
||||||
let cacheVersion = 0;
|
|
||||||
|
|
||||||
// Performance optimization: Cache the flattened items array
|
|
||||||
let cachedItems: DownloadedItem[] | null = null;
|
|
||||||
let itemsCacheVersion = -1;
|
|
||||||
|
|
||||||
// Performance optimization: Index for O(1) item lookups by ID
|
|
||||||
let itemIndex: Map<string, DownloadedItem> | null = null;
|
|
||||||
let indexCacheVersion = -1;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the downloads database from storage
|
* Get the downloads database from storage
|
||||||
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
|
|
||||||
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
|
|
||||||
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
|
|
||||||
* saveDownloadsDatabase() runs and the derived caches stay consistent.
|
|
||||||
*/
|
*/
|
||||||
export function getDownloadsDatabase(): DownloadsDatabase {
|
export function getDownloadsDatabase(): DownloadsDatabase {
|
||||||
// Return cached database if available
|
|
||||||
if (cachedDb !== null) {
|
|
||||||
return cachedDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse from storage and cache the result
|
|
||||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||||
if (file) {
|
if (file) {
|
||||||
cachedDb = JSON.parse(file) as DownloadsDatabase;
|
return JSON.parse(file) as DownloadsDatabase;
|
||||||
return cachedDb;
|
|
||||||
}
|
}
|
||||||
|
return { movies: {}, series: {}, other: {} };
|
||||||
const emptyDb = { movies: {}, series: {}, other: {} };
|
|
||||||
cachedDb = emptyDb;
|
|
||||||
return emptyDb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save the downloads database to storage
|
* Save the downloads database to storage
|
||||||
* PERFORMANCE: Updates cache and invalidates derived caches
|
|
||||||
*/
|
*/
|
||||||
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
||||||
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
||||||
// Update the cache with the new database
|
|
||||||
cachedDb = db;
|
|
||||||
// Invalidate derived caches (items array and index)
|
|
||||||
cachedItems = null;
|
|
||||||
itemIndex = null;
|
|
||||||
cacheVersion++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all downloaded items as a flat array
|
* Get all downloaded items as a flat array
|
||||||
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
|
|
||||||
*/
|
*/
|
||||||
export function getAllDownloadedItems(): DownloadedItem[] {
|
export function getAllDownloadedItems(): DownloadedItem[] {
|
||||||
// Return cached items if available and up-to-date
|
|
||||||
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
|
|
||||||
return cachedItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the items array from the database
|
|
||||||
const db = getDownloadsDatabase();
|
const db = getDownloadsDatabase();
|
||||||
const items: DownloadedItem[] = [];
|
const items: DownloadedItem[] = [];
|
||||||
|
|
||||||
@@ -87,41 +47,34 @@ export function getAllDownloadedItems(): DownloadedItem[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
cachedItems = items;
|
|
||||||
itemsCacheVersion = cacheVersion;
|
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build or refresh the item index for O(1) lookups
|
* Get a downloaded item by its ID
|
||||||
*/
|
*/
|
||||||
function ensureItemIndex(): void {
|
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||||
if (itemIndex !== null && indexCacheVersion === cacheVersion) {
|
const db = getDownloadsDatabase();
|
||||||
return; // Index is up-to-date
|
|
||||||
|
if (db.movies[id]) {
|
||||||
|
return db.movies[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build new index from all items
|
for (const series of Object.values(db.series)) {
|
||||||
itemIndex = new Map<string, DownloadedItem>();
|
for (const season of Object.values(series.seasons)) {
|
||||||
const items = getAllDownloadedItems();
|
for (const episode of Object.values(season.episodes)) {
|
||||||
|
if (episode.item.Id === id) {
|
||||||
for (const item of items) {
|
return episode;
|
||||||
if (item.item.Id) {
|
}
|
||||||
itemIndex.set(item.item.Id, item);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
indexCacheVersion = cacheVersion;
|
if (db.other?.[id]) {
|
||||||
}
|
return db.other[id];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
return undefined;
|
||||||
* Get a downloaded item by its ID
|
|
||||||
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
|
|
||||||
*/
|
|
||||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
|
||||||
ensureItemIndex();
|
|
||||||
return itemIndex!.get(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -268,5 +221,4 @@ export function updateDownloadedItem(
|
|||||||
*/
|
*/
|
||||||
export function clearAllDownloadedItems(): void {
|
export function clearAllDownloadedItems(): void {
|
||||||
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
||||||
// saveDownloadsDatabase already invalidates caches
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import type * as NotificationsType from "expo-notifications";
|
import type * as NotificationsType from "expo-notifications";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
// Conditionally import expo-notifications only on non-TV platforms
|
// Conditionally import expo-notifications only on non-TV platforms
|
||||||
const Notifications = Platform.isTV
|
const Notifications = Platform.isTV
|
||||||
@@ -68,14 +67,6 @@ export async function sendDownloadNotification(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (Platform.isTV || !Notifications) return;
|
if (Platform.isTV || !Notifications) return;
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = storage.getString("settings");
|
|
||||||
const s = raw ? JSON.parse(raw) : {};
|
|
||||||
if (s.notificationsEnabled === false || s.notifyDownloads === false) return;
|
|
||||||
} catch {
|
|
||||||
// ignore parse errors; fall through to sending (defaults are enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Notifications.scheduleNotificationAsync({
|
await Notifications.scheduleNotificationAsync({
|
||||||
content: {
|
content: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { router } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { AppState, type AppStateStatus } from "react-native";
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||||
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||||
@@ -28,6 +28,20 @@ const LIBRARY_CHANGE_QUERY_KEYS = [
|
|||||||
["episodes"],
|
["episodes"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// Query keys that depend on per-user playback state (resume position, played
|
||||||
|
// status, favorites) and should be refreshed when the server reports a
|
||||||
|
// `UserDataChanged`. Scoped to the progression-based sections so finishing an
|
||||||
|
// episode does not pointlessly refetch "recently added" or suggestions.
|
||||||
|
const USER_DATA_CHANGE_QUERY_KEYS = [
|
||||||
|
["home", "continueAndNextUp"],
|
||||||
|
["home", "resumeItems"],
|
||||||
|
["home", "nextUp-all"],
|
||||||
|
["home", "heroItems"],
|
||||||
|
["resumeItems"],
|
||||||
|
["nextUp-all"],
|
||||||
|
["nextUp"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
interface WebSocketMessage {
|
interface WebSocketMessage {
|
||||||
MessageType: string;
|
MessageType: string;
|
||||||
Data: any;
|
Data: any;
|
||||||
@@ -38,10 +52,30 @@ interface WebSocketProviderProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler invoked for every message of a given `MessageType`. Receives the
|
||||||
|
* message `Data` payload and the full message.
|
||||||
|
*/
|
||||||
|
type WebSocketMessageHandler = (data: any, message: WebSocketMessage) => void;
|
||||||
|
|
||||||
interface WebSocketContextType {
|
interface WebSocketContextType {
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
|
/**
|
||||||
|
* @deprecated Prefer `subscribe`. `lastMessage` only keeps the most recent
|
||||||
|
* message, so bursts arriving in the same tick are coalesced and lost. Kept
|
||||||
|
* for `useWebsockets` (GeneralCommand handling) until it is migrated.
|
||||||
|
*/
|
||||||
lastMessage: WebSocketMessage | null;
|
lastMessage: WebSocketMessage | null;
|
||||||
|
/**
|
||||||
|
* Subscribe to a given message type. The handler is called synchronously for
|
||||||
|
* every matching message (no coalescing, unlike `lastMessage`). Returns an
|
||||||
|
* unsubscribe function to call on cleanup.
|
||||||
|
*/
|
||||||
|
subscribe: (
|
||||||
|
messageType: string,
|
||||||
|
handler: WebSocketMessageHandler,
|
||||||
|
) => () => void;
|
||||||
sendMessage: (message: any) => void;
|
sendMessage: (message: any) => void;
|
||||||
clearLastMessage: () => void;
|
clearLastMessage: () => void;
|
||||||
}
|
}
|
||||||
@@ -54,7 +88,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
const deviceId = useMemo(() => {
|
const deviceId = useMemo(() => {
|
||||||
return getOrSetDeviceId();
|
return getOrSetDeviceId();
|
||||||
@@ -63,6 +96,52 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const userDataChangeDebounceRef = useRef<ReturnType<
|
||||||
|
typeof setTimeout
|
||||||
|
> | null>(null);
|
||||||
|
|
||||||
|
// Pub/sub registry: messageType -> set of handlers. Stored in a ref so
|
||||||
|
// subscribing/dispatching never triggers a re-render.
|
||||||
|
const listenersRef = useRef<Map<string, Set<WebSocketMessageHandler>>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(messageType: string, handler: WebSocketMessageHandler) => {
|
||||||
|
const listeners = listenersRef.current;
|
||||||
|
let handlers = listeners.get(messageType);
|
||||||
|
if (!handlers) {
|
||||||
|
handlers = new Set();
|
||||||
|
listeners.set(messageType, handlers);
|
||||||
|
}
|
||||||
|
handlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
handlers?.delete(handler);
|
||||||
|
if (handlers && handlers.size === 0) {
|
||||||
|
listeners.delete(messageType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispatchMessage = useCallback((message: WebSocketMessage) => {
|
||||||
|
const handlers = listenersRef.current.get(message.MessageType);
|
||||||
|
if (!handlers || handlers.size === 0) return;
|
||||||
|
// Copy to tolerate handlers that unsubscribe during dispatch.
|
||||||
|
for (const handler of [...handlers]) {
|
||||||
|
// Isolate each handler so one throwing subscriber can't abort the rest
|
||||||
|
// (and isn't misreported as a parse failure by the outer onmessage catch).
|
||||||
|
try {
|
||||||
|
handler(message.Data, message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error handling WebSocket message type "${message.MessageType}":`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const connectWebSocket = useCallback(() => {
|
const connectWebSocket = useCallback(() => {
|
||||||
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
||||||
@@ -113,7 +192,10 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
newWebSocket.onmessage = (e) => {
|
newWebSocket.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(e.data);
|
const message = JSON.parse(e.data);
|
||||||
setLastMessage(message); // Store the last message in context
|
// Legacy single-slot state, still consumed by useWebsockets.
|
||||||
|
setLastMessage(message);
|
||||||
|
// Pub/sub: deliver to every subscriber without coalescing.
|
||||||
|
dispatchMessage(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error parsing WebSocket message:", error);
|
console.error("Error parsing WebSocket message:", error);
|
||||||
}
|
}
|
||||||
@@ -126,7 +208,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
}
|
}
|
||||||
newWebSocket.close();
|
newWebSocket.close();
|
||||||
};
|
};
|
||||||
}, [api, deviceId, isNetworkConnected]);
|
}, [api, deviceId, isNetworkConnected, dispatchMessage]);
|
||||||
|
|
||||||
const handleLibraryChanged = useCallback(
|
const handleLibraryChanged = useCallback(
|
||||||
(data: any) => {
|
(data: any) => {
|
||||||
@@ -157,47 +239,77 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleUserDataChanged = useCallback(
|
||||||
if (!lastMessage) {
|
(data: any) => {
|
||||||
return;
|
// Jellyfin sends UserDataChanged when playback position, played status
|
||||||
}
|
// or favorites change (e.g. finishing an episode). Only the
|
||||||
if (lastMessage.MessageType === "Play") {
|
// progression-based home sections care about it.
|
||||||
handlePlayCommand(lastMessage.Data);
|
if (!((data?.UserDataList?.length ?? 0) > 0)) {
|
||||||
} else if (lastMessage.MessageType === "LibraryChanged") {
|
return;
|
||||||
handleLibraryChanged(lastMessage.Data);
|
}
|
||||||
}
|
|
||||||
}, [lastMessage, router, handleLibraryChanged]);
|
// Finishing an item can emit several UserDataChanged messages, so
|
||||||
|
// debounce to invalidate the affected sections only once.
|
||||||
|
if (userDataChangeDebounceRef.current) {
|
||||||
|
clearTimeout(userDataChangeDebounceRef.current);
|
||||||
|
}
|
||||||
|
userDataChangeDebounceRef.current = setTimeout(() => {
|
||||||
|
for (const queryKey of USER_DATA_CHANGE_QUERY_KEYS) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...queryKey] });
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh library-dependent queries when the server reports a change.
|
||||||
|
useEffect(
|
||||||
|
() => subscribe("LibraryChanged", handleLibraryChanged),
|
||||||
|
[subscribe, handleLibraryChanged],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh "Continue Watching" / "Next Up" when playback state changes.
|
||||||
|
useEffect(
|
||||||
|
() => subscribe("UserDataChanged", handleUserDataChanged),
|
||||||
|
[subscribe, handleUserDataChanged],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (libraryChangeDebounceRef.current) {
|
if (libraryChangeDebounceRef.current) {
|
||||||
clearTimeout(libraryChangeDebounceRef.current);
|
clearTimeout(libraryChangeDebounceRef.current);
|
||||||
}
|
}
|
||||||
|
if (userDataChangeDebounceRef.current) {
|
||||||
|
clearTimeout(userDataChangeDebounceRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePlayCommand = useCallback(
|
const handlePlayCommand = useCallback((data: any) => {
|
||||||
(data: any) => {
|
if (!data?.ItemIds?.length) {
|
||||||
if (!data?.ItemIds?.length) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const itemId = data.ItemIds[0];
|
const itemId = data.ItemIds[0];
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/player/direct-player",
|
pathname: "/(auth)/player/direct-player",
|
||||||
params: {
|
params: {
|
||||||
itemId: itemId,
|
itemId: itemId,
|
||||||
playCommand: data.PlayCommand || "PlayNow",
|
playCommand: data.PlayCommand || "PlayNow",
|
||||||
audioIndex: data.AudioStreamIndex?.toString(),
|
audioIndex: data.AudioStreamIndex?.toString(),
|
||||||
subtitleIndex: data.SubtitleStreamIndex?.toString(),
|
subtitleIndex: data.SubtitleStreamIndex?.toString(),
|
||||||
mediaSourceId: data.MediaSourceId || "",
|
mediaSourceId: data.MediaSourceId || "",
|
||||||
bitrateValue: "",
|
bitrateValue: "",
|
||||||
offline: "false",
|
offline: "false",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
}, []);
|
||||||
[router],
|
|
||||||
|
// Server-initiated "Play me this item" remote command.
|
||||||
|
useEffect(
|
||||||
|
() => subscribe("Play", handlePlayCommand),
|
||||||
|
[subscribe, handlePlayCommand],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -267,7 +379,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<WebSocketContext.Provider
|
<WebSocketContext.Provider
|
||||||
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
|
value={{
|
||||||
|
ws,
|
||||||
|
isConnected,
|
||||||
|
lastMessage,
|
||||||
|
subscribe,
|
||||||
|
sendMessage,
|
||||||
|
clearLastMessage,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</WebSocketContext.Provider>
|
</WebSocketContext.Provider>
|
||||||
|
|||||||
@@ -119,9 +119,6 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Settings",
|
"settings_title": "Settings",
|
||||||
"log_out_button": "Log Out",
|
"log_out_button": "Log Out",
|
||||||
"search_placeholder": "Search settings",
|
|
||||||
"search_results": "Results",
|
|
||||||
"search_no_results": "No matching settings",
|
|
||||||
"switch_user": {
|
"switch_user": {
|
||||||
"title": "Switch User",
|
"title": "Switch User",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
@@ -129,25 +126,7 @@
|
|||||||
"current": "current"
|
"current": "current"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories",
|
"title": "Categories"
|
||||||
"playback": "Playback",
|
|
||||||
"personalization": "Personalization",
|
|
||||||
"advanced": "Advanced"
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"title": "Notifications",
|
|
||||||
"disabled_title": "Notifications are off",
|
|
||||||
"disabled_description": "Allow notifications to get alerts about your downloads and more.",
|
|
||||||
"enable_button": "Enable notifications",
|
|
||||||
"events_title": "Notify me about",
|
|
||||||
"master": "Enable notifications",
|
|
||||||
"downloads": "Download completed / failed"
|
|
||||||
},
|
|
||||||
"account": {
|
|
||||||
"title": "Account",
|
|
||||||
"copy_token": "Copy token",
|
|
||||||
"copied": "Copied to clipboard",
|
|
||||||
"copy_failed": "Couldn't copy to clipboard"
|
|
||||||
},
|
},
|
||||||
"playback_controls": {
|
"playback_controls": {
|
||||||
"title": "Playback & Controls"
|
"title": "Playback & Controls"
|
||||||
@@ -220,10 +199,6 @@
|
|||||||
"rewind_length": "Rewind Length",
|
"rewind_length": "Rewind Length",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
"chromecast": {
|
|
||||||
"title": "Chromecast",
|
|
||||||
"enable_h265": "Enable H265 for Chromecast"
|
|
||||||
},
|
|
||||||
"buffer": {
|
"buffer": {
|
||||||
"title": "Buffer Settings",
|
"title": "Buffer Settings",
|
||||||
"cache_mode": "Cache Mode",
|
"cache_mode": "Cache Mode",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
type SortOrder,
|
type SortOrder,
|
||||||
SubtitlePlaybackMode,
|
SubtitlePlaybackMode,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { t } from "i18next";
|
|
||||||
import { atom, useAtom, useAtomValue } from "jotai";
|
import { atom, useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||||
@@ -122,46 +121,6 @@ export interface MaxAutoPlayEpisodeCount {
|
|||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The plugin may send object-typed settings as plain primitives.
|
|
||||||
* Resolve to the proper option object from the available choices.
|
|
||||||
*/
|
|
||||||
const normalizePluginValue = (
|
|
||||||
settingsKey: keyof Settings,
|
|
||||||
value: unknown,
|
|
||||||
): unknown => {
|
|
||||||
if (typeof value !== "object" || value === null) {
|
|
||||||
const defaultVal = defaultValues[settingsKey];
|
|
||||||
if (
|
|
||||||
typeof defaultVal === "object" &&
|
|
||||||
defaultVal !== null &&
|
|
||||||
"key" in defaultVal &&
|
|
||||||
"value" in defaultVal
|
|
||||||
) {
|
|
||||||
// defaultBitrate needs a lookup because its keys are human-readable
|
|
||||||
// (e.g. "8 Mb/s") that can't be derived from the raw value (e.g. 8000000).
|
|
||||||
// Other { key, value } settings like maxAutoPlayEpisodeCount work with
|
|
||||||
// the fallback because their keys are just String(value) (e.g. "5").
|
|
||||||
if (settingsKey === "defaultBitrate") {
|
|
||||||
const match = BITRATES.find(
|
|
||||||
(b) => b.key === value || b.value === value,
|
|
||||||
);
|
|
||||||
if (match) return match;
|
|
||||||
}
|
|
||||||
// maxAutoPlayEpisodeCount: 0 is invalid (breaks autoplay), clamp to -1
|
|
||||||
// -1 key must match the translated dropdown label so the UI shows "Disabled"
|
|
||||||
if (
|
|
||||||
settingsKey === "maxAutoPlayEpisodeCount" &&
|
|
||||||
(value === 0 || value === -1)
|
|
||||||
) {
|
|
||||||
return { key: t("home.settings.other.disabled"), value: -1 };
|
|
||||||
}
|
|
||||||
return { key: String(value), value };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HomeSectionLatestResolver = {
|
export type HomeSectionLatestResolver = {
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -294,12 +253,6 @@ export type Settings = {
|
|||||||
openSubtitlesApiKey?: string;
|
openSubtitlesApiKey?: string;
|
||||||
// TV-only: Inactivity timeout for auto-logout
|
// TV-only: Inactivity timeout for auto-logout
|
||||||
inactivityTimeout: InactivityTimeout;
|
inactivityTimeout: InactivityTimeout;
|
||||||
// Settings-redo additions (SP1)
|
|
||||||
notificationsEnabled: boolean;
|
|
||||||
notifyDownloads: boolean;
|
|
||||||
defaultLandingTab: "(home)" | "(search)" | "(favorites)" | "(libraries)";
|
|
||||||
downloadOnWifiOnly: boolean;
|
|
||||||
cellularBitrate?: Bitrate;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Lockable<T> {
|
export interface Lockable<T> {
|
||||||
@@ -401,11 +354,6 @@ export const defaultValues: Settings = {
|
|||||||
audioTranscodeMode: AudioTranscodeMode.Auto,
|
audioTranscodeMode: AudioTranscodeMode.Auto,
|
||||||
// TV-only: Inactivity timeout (disabled by default)
|
// TV-only: Inactivity timeout (disabled by default)
|
||||||
inactivityTimeout: InactivityTimeout.Disabled,
|
inactivityTimeout: InactivityTimeout.Disabled,
|
||||||
notificationsEnabled: true,
|
|
||||||
notifyDownloads: true,
|
|
||||||
defaultLandingTab: "(home)",
|
|
||||||
downloadOnWifiOnly: false,
|
|
||||||
cellularBitrate: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSettings = (): Partial<Settings> => {
|
const loadSettings = (): Partial<Settings> => {
|
||||||
@@ -479,37 +427,61 @@ export const useSettings = () => {
|
|||||||
[_setPluginSettings],
|
[_setPluginSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshStreamyfinPluginSettings = useCallback(async () => {
|
const refreshStreamyfinPluginSettings = useCallback(
|
||||||
if (!api) {
|
async (forceOverride = false) => {
|
||||||
return;
|
if (!api) {
|
||||||
}
|
return;
|
||||||
const newPluginSettings = await api.getStreamyfinPluginConfig().then(
|
|
||||||
({ data }) => {
|
|
||||||
writeInfoLog("Got plugin settings", data?.settings);
|
|
||||||
return data?.settings;
|
|
||||||
},
|
|
||||||
(_err) => undefined,
|
|
||||||
);
|
|
||||||
setPluginSettings(newPluginSettings);
|
|
||||||
|
|
||||||
// Locked/unlocked values are handled by the settings memo, which
|
|
||||||
// applies locked values at runtime without overwriting user storage.
|
|
||||||
// We only handle auto-enabling Streamystats here.
|
|
||||||
if (newPluginSettings && _settings) {
|
|
||||||
const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl;
|
|
||||||
if (streamyStatsUrl?.value && _settings.searchEngine !== "Streamystats") {
|
|
||||||
const newSettings = {
|
|
||||||
...defaultValues,
|
|
||||||
..._settings,
|
|
||||||
searchEngine: "Streamystats",
|
|
||||||
} as Settings;
|
|
||||||
setSettings(newSettings);
|
|
||||||
saveSettings(newSettings);
|
|
||||||
}
|
}
|
||||||
}
|
const newPluginSettings = await api.getStreamyfinPluginConfig().then(
|
||||||
|
({ data }) => {
|
||||||
|
writeInfoLog("Got plugin settings", data?.settings);
|
||||||
|
return data?.settings;
|
||||||
|
},
|
||||||
|
(_err) => undefined,
|
||||||
|
);
|
||||||
|
setPluginSettings(newPluginSettings);
|
||||||
|
|
||||||
return newPluginSettings;
|
// Apply plugin values to settings
|
||||||
}, [api, _settings]);
|
if (newPluginSettings && _settings) {
|
||||||
|
const updates: Partial<Settings> = {};
|
||||||
|
for (const [key, setting] of Object.entries(newPluginSettings)) {
|
||||||
|
if (setting && !setting.locked && setting.value !== undefined) {
|
||||||
|
const settingsKey = key as keyof Settings;
|
||||||
|
const effectiveValue = getEffectiveSettingValue(
|
||||||
|
_settings,
|
||||||
|
settingsKey,
|
||||||
|
);
|
||||||
|
// Apply if forceOverride is true, or if neither persisted settings
|
||||||
|
// nor app defaults provide a meaningful value.
|
||||||
|
if (forceOverride || !hasMeaningfulSettingValue(effectiveValue)) {
|
||||||
|
(updates as any)[settingsKey] = setting.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-enable Streamystats if server URL is provided
|
||||||
|
const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl;
|
||||||
|
if (
|
||||||
|
streamyStatsUrl?.value &&
|
||||||
|
_settings.searchEngine !== "Streamystats"
|
||||||
|
) {
|
||||||
|
updates.searchEngine = "Streamystats";
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
const newSettings = {
|
||||||
|
...defaultValues,
|
||||||
|
..._settings,
|
||||||
|
...updates,
|
||||||
|
} as Settings;
|
||||||
|
setSettings(newSettings);
|
||||||
|
saveSettings(newSettings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPluginSettings;
|
||||||
|
},
|
||||||
|
[api, _settings],
|
||||||
|
);
|
||||||
|
|
||||||
const updateSettings = (update: Partial<Settings>) => {
|
const updateSettings = (update: Partial<Settings>) => {
|
||||||
if (!_settings) {
|
if (!_settings) {
|
||||||
@@ -540,13 +512,8 @@ export const useSettings = () => {
|
|||||||
Partial<Settings>
|
Partial<Settings>
|
||||||
>((acc, [key, setting]) => {
|
>((acc, [key, setting]) => {
|
||||||
if (setting) {
|
if (setting) {
|
||||||
let { value } = setting;
|
const { value, locked } = setting;
|
||||||
const { locked } = setting;
|
|
||||||
const settingsKey = key as keyof Settings;
|
const settingsKey = key as keyof Settings;
|
||||||
|
|
||||||
// Normalize object-typed settings from plugin (plain primitive → { key, value })
|
|
||||||
value = normalizePluginValue(settingsKey, value);
|
|
||||||
|
|
||||||
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
|
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
|
||||||
|
|
||||||
(acc as any)[settingsKey] = locked
|
(acc as any)[settingsKey] = locked
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TV-safe re-exports of `@expo/ui/community/bottom-sheet`.
|
|
||||||
*
|
|
||||||
* `@expo/ui` resolves its native bridge at module load via
|
|
||||||
* `requireNativeModule('ExpoUI')`, which does not exist on tvOS — a static
|
|
||||||
* top-level import would crash the whole expo-router route tree. We `require()`
|
|
||||||
* it lazily and only off-TV; on TV the exports are undefined, which is fine
|
|
||||||
* because every call site early-returns on `Platform.isTV`.
|
|
||||||
*/
|
|
||||||
type BottomSheetMod = typeof import("@expo/ui/community/bottom-sheet");
|
|
||||||
|
|
||||||
const mod: BottomSheetMod = Platform.isTV
|
|
||||||
? ({} as BottomSheetMod)
|
|
||||||
: (require("@expo/ui/community/bottom-sheet") as BottomSheetMod);
|
|
||||||
|
|
||||||
export const BottomSheetModal = mod.BottomSheetModal;
|
|
||||||
export const BottomSheetView = mod.BottomSheetView;
|
|
||||||
export const BottomSheetScrollView = mod.BottomSheetScrollView;
|
|
||||||
export const BottomSheetTextInput = mod.BottomSheetTextInput;
|
|
||||||
|
|
||||||
export type { BottomSheetMethods } from "@expo/ui/community/bottom-sheet";
|
|
||||||
@@ -27,7 +27,6 @@ export function startPairingListener(
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on("error", (err) => {
|
socket.on("error", (err) => {
|
||||||
if (!active) return;
|
|
||||||
if (__DEV__) console.error("[PairingService] Socket error:", err);
|
if (__DEV__) console.error("[PairingService] Socket error:", err);
|
||||||
onError?.(err.message);
|
onError?.(err.message);
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|||||||
Reference in New Issue
Block a user