mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-29 17:20:30 +01:00
Compare commits
6 Commits
feat/tv-se
...
refactor/j
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f8eb31b9b | ||
|
|
881e71ce1a | ||
|
|
deae5f12b2 | ||
|
|
c2a6e33d74 | ||
|
|
cf2bab57bb | ||
|
|
f97852ae98 |
@@ -6,6 +6,4 @@
|
|||||||
|
|
||||||
## Detail
|
## Detail
|
||||||
|
|
||||||
**Updated 2026-06-29**: This limitation no longer applies. The hook was rewritten to use a `Proxy` (not `Object.create`). It overrides only `invalidateQueries` (network-aware) / `forceInvalidateQueries`, and binds every other method to the real `queryClient` target (`value.bind(target)`). So private-field methods like `getQueriesData`, `setQueryData`, and `removeQueries` work correctly through it now — no need to fall back to a separate `useQueryClient`. (Confirmed when adding `queryClient.removeQueries` to `clearAllJellyseerData` in `hooks/useJellyseerr.ts`.)
|
The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`.
|
||||||
|
|
||||||
Historical (pre-2026-06): the hook used `Object.create(queryClient)`, which broke methods relying on JavaScript private fields; back then only `invalidateQueries` was safe.
|
|
||||||
|
|||||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
- Use TypeScript for ALL files (no .js files)
|
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||||
- Use descriptive English names for variables, functions, and components
|
- Use descriptive English names for variables, functions, and components
|
||||||
- Prefer functional React components with hooks
|
- Prefer functional React components with hooks
|
||||||
- Use Jotai atoms for global state management
|
- Use Jotai atoms for global state management
|
||||||
|
|||||||
2
.github/workflows/detect-duplicate.yml
vendored
2
.github/workflows/detect-duplicate.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 🔍 Detect duplicate issues
|
- name: 🔍 Detect duplicate issues
|
||||||
run: bun scripts/detect-duplicate-issue.mjs
|
run: bun scripts/detect-duplicate-issue.ts
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,10 +12,6 @@ web-build/
|
|||||||
# Platform-specific Build Directories
|
# Platform-specific Build Directories
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
/iostv
|
|
||||||
/iosmobile
|
|
||||||
/androidmobile
|
|
||||||
/androidtv
|
|
||||||
|
|
||||||
# Gradle caches (top-level + per-module native projects)
|
# Gradle caches (top-level + per-module native projects)
|
||||||
**/.gradle/
|
**/.gradle/
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
- Use TypeScript for all files (no .js)
|
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||||
- Use functional React components with hooks
|
- Use functional React components with hooks
|
||||||
- Use Jotai atoms for global state, React Query for server state
|
- Use Jotai atoms for global state, React Query for server state
|
||||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Directory, Paths } from "expo-file-system";
|
import { Directory, Paths } from "expo-file-system";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
||||||
@@ -22,7 +21,6 @@ import {
|
|||||||
TVSettingsToggle,
|
TVSettingsToggle,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
||||||
import { APP_LANGUAGES } from "@/i18n";
|
import { APP_LANGUAGES } from "@/i18n";
|
||||||
@@ -52,7 +50,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
|||||||
export default function SettingsTV() {
|
export default function SettingsTV() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -61,51 +59,6 @@ export default function SettingsTV() {
|
|||||||
const { showUserSwitchModal } = useTVUserSwitchModal();
|
const { showUserSwitchModal } = useTVUserSwitchModal();
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
|
|
||||||
useJellyseerr();
|
|
||||||
|
|
||||||
// Jellyseerr connection state
|
|
||||||
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
|
|
||||||
settings.jellyseerrServerUrl || "",
|
|
||||||
);
|
|
||||||
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
|
|
||||||
|
|
||||||
const isJellyseerrLocked =
|
|
||||||
pluginSettings?.jellyseerrServerUrl?.locked === true;
|
|
||||||
const isJellyseerrConnected = !!jellyseerrApi;
|
|
||||||
|
|
||||||
const handleJellyseerrUrlBlur = useCallback(() => {
|
|
||||||
const url = jellyseerrServerUrl.trim();
|
|
||||||
updateSettings({ jellyseerrServerUrl: url || undefined });
|
|
||||||
}, [jellyseerrServerUrl, updateSettings]);
|
|
||||||
|
|
||||||
const jellyseerrLoginMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const url = jellyseerrServerUrl.trim();
|
|
||||||
if (!url) throw new Error("Missing server url");
|
|
||||||
if (!user?.Name) throw new Error("Missing user info");
|
|
||||||
const tempApi = new JellyseerrApi(url);
|
|
||||||
const testResult = await tempApi.test();
|
|
||||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
|
||||||
return tempApi.login(user.Name, jellyseerrPassword);
|
|
||||||
},
|
|
||||||
onSuccess: (loggedInUser) => {
|
|
||||||
setJellyseerrUser(loggedInUser);
|
|
||||||
updateSettings({ jellyseerrServerUrl: jellyseerrServerUrl.trim() });
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error(t("jellyseerr.failed_to_login"));
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
setJellyseerrPassword("");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDisconnectJellyseerr = useCallback(() => {
|
|
||||||
clearAllJellyseerData();
|
|
||||||
setJellyseerrServerUrl("");
|
|
||||||
setJellyseerrPassword("");
|
|
||||||
}, [clearAllJellyseerData]);
|
|
||||||
|
|
||||||
// Local state for OpenSubtitles API key (only commit on blur)
|
// Local state for OpenSubtitles API key (only commit on blur)
|
||||||
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||||
@@ -924,72 +877,6 @@ export default function SettingsTV() {
|
|||||||
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
|
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* seerr Section */}
|
|
||||||
<TVSectionHeader title='seerr' />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#9CA3AF",
|
|
||||||
fontSize: typography.callout - 2,
|
|
||||||
marginBottom: 16,
|
|
||||||
marginLeft: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
|
||||||
</Text>
|
|
||||||
<TVSettingsTextInput
|
|
||||||
label={t("home.settings.plugins.jellyseerr.server_url")}
|
|
||||||
value={jellyseerrServerUrl}
|
|
||||||
placeholder={t(
|
|
||||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
|
||||||
)}
|
|
||||||
onChangeText={setJellyseerrServerUrl}
|
|
||||||
onBlur={handleJellyseerrUrlBlur}
|
|
||||||
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
|
|
||||||
/>
|
|
||||||
{!isJellyseerrConnected && !isJellyseerrLocked && (
|
|
||||||
<>
|
|
||||||
<TVSettingsTextInput
|
|
||||||
label={t("home.settings.plugins.jellyseerr.password")}
|
|
||||||
value={jellyseerrPassword}
|
|
||||||
placeholder={t(
|
|
||||||
"home.settings.plugins.jellyseerr.password_placeholder",
|
|
||||||
{ username: user?.Name },
|
|
||||||
)}
|
|
||||||
onChangeText={setJellyseerrPassword}
|
|
||||||
secureTextEntry
|
|
||||||
disabled={jellyseerrLoginMutation.isPending}
|
|
||||||
/>
|
|
||||||
<TVSettingsOptionButton
|
|
||||||
label={
|
|
||||||
jellyseerrLoginMutation.isPending
|
|
||||||
? t("common.connecting")
|
|
||||||
: t("common.connect")
|
|
||||||
}
|
|
||||||
value=''
|
|
||||||
onPress={() => jellyseerrLoginMutation.mutate()}
|
|
||||||
disabled={jellyseerrLoginMutation.isPending}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<TVSettingsRow
|
|
||||||
label={
|
|
||||||
isJellyseerrConnected
|
|
||||||
? t("common.connected")
|
|
||||||
: t("common.not_connected")
|
|
||||||
}
|
|
||||||
value=''
|
|
||||||
showChevron={false}
|
|
||||||
/>
|
|
||||||
{isJellyseerrConnected && !isJellyseerrLocked && (
|
|
||||||
<TVSettingsOptionButton
|
|
||||||
label={t(
|
|
||||||
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
|
|
||||||
)}
|
|
||||||
value=''
|
|
||||||
onPress={handleDisconnectJellyseerr}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Storage Section */}
|
{/* Storage Section */}
|
||||||
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
|
|||||||
@@ -7,12 +7,7 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import {
|
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
|
||||||
useIsFocused,
|
|
||||||
useLocalSearchParams,
|
|
||||||
useNavigation,
|
|
||||||
useSegments,
|
|
||||||
} from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { orderBy, uniqBy } from "lodash";
|
import { orderBy, uniqBy } from "lodash";
|
||||||
import {
|
import {
|
||||||
@@ -25,13 +20,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
Alert,
|
|
||||||
Platform,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -52,10 +41,7 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
|||||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||||
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import {
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
useJellyseerr,
|
|
||||||
validateJellyseerrSession,
|
|
||||||
} from "@/hooks/useJellyseerr";
|
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -120,40 +106,8 @@ export default function SearchPage() {
|
|||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const isFocused = useIsFocused();
|
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
// Prompt the user to connect when a Jellyseerr server is configured but no
|
|
||||||
// session exists yet (only once per focus, and only while the tab is focused).
|
|
||||||
const jellyseerrAlertedRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
|
|
||||||
if (jellyseerrAlertedRef.current) return;
|
|
||||||
jellyseerrAlertedRef.current = true;
|
|
||||||
Alert.alert(
|
|
||||||
t("jellyseerr.connect_to_jellyseerr"),
|
|
||||||
t("jellyseerr.connect_in_settings"),
|
|
||||||
);
|
|
||||||
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
|
|
||||||
|
|
||||||
// Validate the Jellyseerr session when switching to Discover; warn if expired.
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
searchType !== "Discover" ||
|
|
||||||
!jellyseerrApi ||
|
|
||||||
!settings?.jellyseerrServerUrl
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
|
|
||||||
if (status.valid) return;
|
|
||||||
Alert.alert(
|
|
||||||
t("jellyseerr.session_expired"),
|
|
||||||
t("jellyseerr.session_expired_connect_again"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
|
|
||||||
|
|
||||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||||
useState<JellyseerrSearchSort>(
|
useState<JellyseerrSearchSort>(
|
||||||
JellyseerrSearchSort[
|
JellyseerrSearchSort[
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVOptionCard } from "@/components/tv";
|
import { TVOptionCard } from "@/components/tv";
|
||||||
import {
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
useScaledTVTypography,
|
|
||||||
useTVRelativeScale,
|
|
||||||
} from "@/constants/TVTypography";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||||
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
|
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
|
||||||
@@ -25,7 +22,6 @@ export default function TVOptionModal() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const modalState = useAtomValue(tvOptionModalAtom);
|
const modalState = useAtomValue(tvOptionModalAtom);
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const relativeScale = useTVRelativeScale();
|
|
||||||
|
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const firstCardRef = useRef<View>(null);
|
const firstCardRef = useRef<View>(null);
|
||||||
@@ -101,15 +97,8 @@ export default function TVOptionModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { title, options } = modalState;
|
const { title, options } = modalState;
|
||||||
// Honor the caller-provided card size (e.g. wider cards for long root-folder
|
const scaledCardWidth = scaleSize(160);
|
||||||
// paths) and grow it in step with the user's text-scale setting so larger
|
const scaledCardHeight = scaleSize(75);
|
||||||
// fonts don't get clipped.
|
|
||||||
const scaledCardWidth = scaleSize(
|
|
||||||
(modalState.cardWidth ?? 160) * relativeScale,
|
|
||||||
);
|
|
||||||
const scaledCardHeight = scaleSize(
|
|
||||||
(modalState.cardHeight ?? 75) * relativeScale,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
|||||||
@@ -15,12 +15,11 @@ import {
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
|
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
|
||||||
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
|
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
|
||||||
import { TVButton } from "@/components/tv";
|
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||||
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
|
||||||
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
|
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
|
||||||
import type {
|
import type {
|
||||||
QualityProfile,
|
QualityProfile,
|
||||||
@@ -36,7 +35,6 @@ export default function TVRequestModalPage() {
|
|||||||
const modalState = useAtomValue(tvRequestModalAtom);
|
const modalState = useAtomValue(tvRequestModalAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
const { showOptions } = useTVOptionModal();
|
|
||||||
|
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
||||||
@@ -45,6 +43,10 @@ export default function TVRequestModalPage() {
|
|||||||
userId: jellyseerrUser?.id,
|
userId: jellyseerrUser?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [activeSelector, setActiveSelector] = useState<
|
||||||
|
"profile" | "folder" | "user" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
@@ -240,14 +242,17 @@ export default function TVRequestModalPage() {
|
|||||||
// Handlers
|
// Handlers
|
||||||
const handleProfileChange = useCallback((profileId: number) => {
|
const handleProfileChange = useCallback((profileId: number) => {
|
||||||
setRequestOverrides((prev) => ({ ...prev, profileId }));
|
setRequestOverrides((prev) => ({ ...prev, profileId }));
|
||||||
|
setActiveSelector(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFolderChange = useCallback((rootFolder: string) => {
|
const handleFolderChange = useCallback((rootFolder: string) => {
|
||||||
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
|
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
|
||||||
|
setActiveSelector(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleUserChange = useCallback((userId: number) => {
|
const handleUserChange = useCallback((userId: number) => {
|
||||||
setRequestOverrides((prev) => ({ ...prev, userId }));
|
setRequestOverrides((prev) => ({ ...prev, userId }));
|
||||||
|
setActiveSelector(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTagToggle = useCallback(
|
const handleTagToggle = useCallback(
|
||||||
@@ -348,37 +353,18 @@ export default function TVRequestModalPage() {
|
|||||||
<TVRequestOptionRow
|
<TVRequestOptionRow
|
||||||
label={t("jellyseerr.quality_profile")}
|
label={t("jellyseerr.quality_profile")}
|
||||||
value={selectedProfileName}
|
value={selectedProfileName}
|
||||||
onPress={() =>
|
onPress={() => setActiveSelector("profile")}
|
||||||
showOptions({
|
|
||||||
title: t("jellyseerr.quality_profile"),
|
|
||||||
options: qualityProfileOptions,
|
|
||||||
onSelect: handleProfileChange,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
hasTVPreferredFocus
|
hasTVPreferredFocus
|
||||||
/>
|
/>
|
||||||
<TVRequestOptionRow
|
<TVRequestOptionRow
|
||||||
label={t("jellyseerr.root_folder")}
|
label={t("jellyseerr.root_folder")}
|
||||||
value={selectedFolderName}
|
value={selectedFolderName}
|
||||||
onPress={() =>
|
onPress={() => setActiveSelector("folder")}
|
||||||
showOptions({
|
|
||||||
title: t("jellyseerr.root_folder"),
|
|
||||||
options: rootFolderOptions,
|
|
||||||
onSelect: handleFolderChange,
|
|
||||||
cardWidth: 280,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<TVRequestOptionRow
|
<TVRequestOptionRow
|
||||||
label={t("jellyseerr.request_as")}
|
label={t("jellyseerr.request_as")}
|
||||||
value={selectedUserName}
|
value={selectedUserName}
|
||||||
onPress={() =>
|
onPress={() => setActiveSelector("user")}
|
||||||
showOptions({
|
|
||||||
title: t("jellyseerr.request_as"),
|
|
||||||
options: userOptions,
|
|
||||||
onSelect: handleUserChange,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{tagItems.length > 0 && (
|
{tagItems.length > 0 && (
|
||||||
@@ -423,6 +409,33 @@ export default function TVRequestModalPage() {
|
|||||||
</TVFocusGuideView>
|
</TVFocusGuideView>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Sub-selectors */}
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "profile"}
|
||||||
|
title={t("jellyseerr.quality_profile")}
|
||||||
|
options={qualityProfileOptions}
|
||||||
|
onSelect={handleProfileChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
/>
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "folder"}
|
||||||
|
title={t("jellyseerr.root_folder")}
|
||||||
|
options={rootFolderOptions}
|
||||||
|
onSelect={handleFolderChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
cardWidth={280}
|
||||||
|
/>
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "user"}
|
||||||
|
title={t("jellyseerr.request_as")}
|
||||||
|
options={userOptions}
|
||||||
|
onSelect={handleUserChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
MediaType,
|
MediaType,
|
||||||
} from "@/utils/jellyseerr/server/constants/media";
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
|
||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
interface TVSeasonToggleCardProps {
|
interface TVSeasonToggleCardProps {
|
||||||
@@ -50,7 +49,6 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
|||||||
hasTVPreferredFocus,
|
hasTVPreferredFocus,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||||
|
|
||||||
@@ -121,10 +119,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.seasonTitle,
|
styles.seasonTitle,
|
||||||
{
|
{ color: focused ? "#000000" : "#FFFFFF" },
|
||||||
fontSize: typography.callout,
|
|
||||||
color: focused ? "#000000" : "#FFFFFF",
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@@ -137,7 +132,6 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
styles.episodeCount,
|
styles.episodeCount,
|
||||||
{
|
{
|
||||||
fontSize: typography.callout - 4,
|
|
||||||
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
|
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -257,15 +251,14 @@ export default function TVSeasonSelectModalPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (modalState.hasAdvancedRequestPermission) {
|
if (modalState.hasAdvancedRequestPermission) {
|
||||||
// Replace this sheet with the advanced request modal so it takes our
|
// Close this modal and open the advanced request modal
|
||||||
// place in the stack instead of stacking on top (which breaks focus).
|
router.back();
|
||||||
showRequestModal({
|
showRequestModal({
|
||||||
requestBody: body,
|
requestBody: body,
|
||||||
title: modalState.title,
|
title: modalState.title,
|
||||||
id: modalState.mediaId,
|
id: modalState.mediaId,
|
||||||
mediaType: MediaType.TV,
|
mediaType: MediaType.TV,
|
||||||
onRequested: modalState.onRequested,
|
onRequested: modalState.onRequested,
|
||||||
replace: true,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -408,7 +401,7 @@ const styles = StyleSheet.create({
|
|||||||
gap: 16,
|
gap: 16,
|
||||||
},
|
},
|
||||||
seasonCard: {
|
seasonCard: {
|
||||||
width: scaleSize(220),
|
width: 160,
|
||||||
paddingVertical: 16,
|
paddingVertical: 16,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@@ -422,10 +415,7 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
seasonInfo: {
|
seasonInfo: {
|
||||||
// Note: no `flex: 1` here — the card is an auto-height column, so flex:1
|
flex: 1,
|
||||||
// (flexBasis: 0) would collapse this box and hide the text. Let it size to
|
|
||||||
// its content instead.
|
|
||||||
alignSelf: "stretch",
|
|
||||||
},
|
},
|
||||||
seasonTitle: {
|
seasonTitle: {
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
@@ -436,7 +426,9 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
},
|
},
|
||||||
episodeCount: {},
|
episodeCount: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
statusBadge: {
|
statusBadge: {
|
||||||
width: 22,
|
width: 22,
|
||||||
height: 22,
|
height: 22,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { useScaledTVSizes } from "@/constants/TVSizes";
|
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import {
|
import {
|
||||||
@@ -167,7 +166,6 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
isFirstSlide = false,
|
isFirstSlide = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
@@ -240,7 +238,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
marginLeft: sizes.padding.horizontal,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{slideTitle}
|
{slideTitle}
|
||||||
@@ -251,7 +249,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: sizes.padding.horizontal,
|
paddingHorizontal: SCALE_PADDING,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
gap: 20,
|
gap: 20,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { useScaledTVSizes } from "@/constants/TVSizes";
|
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||||
@@ -234,7 +233,6 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -245,7 +243,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
marginLeft: sizes.padding.horizontal,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -256,7 +254,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: sizes.padding.horizontal,
|
paddingHorizontal: SCALE_PADDING,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
gap: 20,
|
gap: 20,
|
||||||
}}
|
}}
|
||||||
@@ -287,7 +285,6 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -298,7 +295,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
marginLeft: sizes.padding.horizontal,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -309,7 +306,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: sizes.padding.horizontal,
|
paddingHorizontal: SCALE_PADDING,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
gap: 20,
|
gap: 20,
|
||||||
}}
|
}}
|
||||||
@@ -340,7 +337,6 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
|||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -351,7 +347,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
marginLeft: sizes.padding.horizontal,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -362,7 +358,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: sizes.padding.horizontal,
|
paddingHorizontal: SCALE_PADDING,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
gap: 20,
|
gap: 20,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
|
|||||||
import { TVSearchSection } from "./TVSearchSection";
|
import { TVSearchSection } from "./TVSearchSection";
|
||||||
import { TVSearchTabBadges } from "./TVSearchTabBadges";
|
import { TVSearchTabBadges } from "./TVSearchTabBadges";
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = 60;
|
||||||
const TOP_PADDING = 100;
|
const TOP_PADDING = 100;
|
||||||
// Height of the native search bar itself. The tvOS grid keyboard presents as
|
// Height of the native search bar itself. The tvOS grid keyboard presents as
|
||||||
// its own overlay when the field is focused, so we only reserve the bar height
|
// its own overlay when the field is focused, so we only reserve the bar height
|
||||||
@@ -162,7 +163,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
discoverSliders,
|
discoverSliders,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -251,7 +251,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
marginHorizontal: sizes.padding.horizontal,
|
marginHorizontal: HORIZONTAL_PADDING,
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -280,15 +280,12 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
keyboardDismissMode='on-drag'
|
keyboardDismissMode='on-drag'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
// Top padding so the focus-scale/shadow on the first row (filter
|
|
||||||
// badges) isn't clipped against the ScrollView's top edge.
|
|
||||||
paddingTop: 16,
|
|
||||||
paddingBottom: insets.bottom + 60,
|
paddingBottom: insets.bottom + 60,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Search Type Tab Badges */}
|
{/* Search Type Tab Badges */}
|
||||||
{showDiscover && (
|
{showDiscover && (
|
||||||
<View style={{ marginHorizontal: sizes.padding.horizontal }}>
|
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
|
||||||
<TVSearchTabBadges
|
<TVSearchTabBadges
|
||||||
searchType={searchType}
|
searchType={searchType}
|
||||||
setSearchType={setSearchType}
|
setSearchType={setSearchType}
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
export default {
|
const MediaTypes = {
|
||||||
Audio: "Audio",
|
Audio: "Audio",
|
||||||
Video: "Video",
|
Video: "Video",
|
||||||
Photo: "Photo",
|
Photo: "Photo",
|
||||||
Book: "Book",
|
Book: "Book",
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
|
||||||
|
|
||||||
|
export default MediaTypes;
|
||||||
@@ -55,20 +55,6 @@ export type ScaledTVTypography = {
|
|||||||
callout: number;
|
callout: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the user's text-scale factor relative to the Default scale (1.0 at
|
|
||||||
* Default, >1 for Large/ExtraLarge, <1 for Small). Use it to scale containers
|
|
||||||
* (e.g. option-card width/height) in step with the scaled font so larger text
|
|
||||||
* settings don't overflow fixed boxes.
|
|
||||||
*/
|
|
||||||
export const useTVRelativeScale = (): number => {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const scale =
|
|
||||||
scaleMultipliers[settings.tvTypographyScale] ??
|
|
||||||
scaleMultipliers[TVTypographyScale.Default];
|
|
||||||
return scale / scaleMultipliers[TVTypographyScale.Default];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook that returns scaled TV typography values based on user settings.
|
* Hook that returns scaled TV typography values based on user settings.
|
||||||
* Use this instead of the static TVTypography constant for dynamic scaling.
|
* Use this instead of the static TVTypography constant for dynamic scaling.
|
||||||
|
|||||||
@@ -70,35 +70,6 @@ export const clearJellyseerrStorageData = () => {
|
|||||||
storage.remove(JELLYSEERR_COOKIES);
|
storage.remove(JELLYSEERR_COOKIES);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JellyseerrSessionStatus =
|
|
||||||
| { valid: true }
|
|
||||||
| { valid: false; reason: "no_session" | "expired" };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether the persisted Jellyseerr session (user + cookies) is still
|
|
||||||
* valid by hitting the server status endpoint. Clears local session data if the
|
|
||||||
* request fails (expired/revoked cookie).
|
|
||||||
*/
|
|
||||||
export async function validateJellyseerrSession(
|
|
||||||
serverUrl: string,
|
|
||||||
): Promise<JellyseerrSessionStatus> {
|
|
||||||
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
|
|
||||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
|
||||||
|
|
||||||
if (!user || !cookies) {
|
|
||||||
return { valid: false, reason: "no_session" };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = new JellyseerrApi(serverUrl);
|
|
||||||
await api.axios.get(Endpoints.API_V1 + Endpoints.STATUS);
|
|
||||||
return { valid: true };
|
|
||||||
} catch {
|
|
||||||
clearJellyseerrStorageData();
|
|
||||||
return { valid: false, reason: "expired" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum Endpoints {
|
export enum Endpoints {
|
||||||
STATUS = "/status",
|
STATUS = "/status",
|
||||||
API_V1 = "/api/v1",
|
API_V1 = "/api/v1",
|
||||||
@@ -479,8 +450,7 @@ export const useJellyseerr = () => {
|
|||||||
clearJellyseerrStorageData();
|
clearJellyseerrStorageData();
|
||||||
setJellyseerrUser(undefined);
|
setJellyseerrUser(undefined);
|
||||||
updateSettings({ jellyseerrServerUrl: undefined });
|
updateSettings({ jellyseerrServerUrl: undefined });
|
||||||
queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
|
}, []);
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
const requestMedia = useCallback(
|
const requestMedia = useCallback(
|
||||||
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||||
|
|||||||
@@ -11,12 +11,6 @@ interface ShowRequestModalParams {
|
|||||||
id: number;
|
id: number;
|
||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
onRequested: () => void;
|
onRequested: () => void;
|
||||||
/**
|
|
||||||
* Replace the current route instead of pushing. Use when opening the request
|
|
||||||
* modal from another modal (e.g. the season selector) so the new sheet takes
|
|
||||||
* its place rather than stacking on top of it (which breaks TV focus).
|
|
||||||
*/
|
|
||||||
replace?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTVRequestModal = () => {
|
export const useTVRequestModal = () => {
|
||||||
@@ -31,11 +25,7 @@ export const useTVRequestModal = () => {
|
|||||||
mediaType: params.mediaType,
|
mediaType: params.mediaType,
|
||||||
onRequested: params.onRequested,
|
onRequested: params.onRequested,
|
||||||
});
|
});
|
||||||
if (params.replace) {
|
router.push("/(auth)/tv-request-modal");
|
||||||
router.replace("/(auth)/tv-request-modal");
|
|
||||||
} else {
|
|
||||||
router.push("/(auth)/tv-request-modal");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[router],
|
[router],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,13 +17,13 @@
|
|||||||
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
||||||
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"typecheck": "node scripts/typecheck.js",
|
"typecheck": "bun scripts/typecheck.ts",
|
||||||
"check": "biome check . --max-diagnostics 1000",
|
"check": "biome check . --max-diagnostics 1000",
|
||||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"doctor": "expo-doctor",
|
"doctor": "expo-doctor",
|
||||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
"i18n:check": "bun scripts/check-i18n-keys.ts",
|
||||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
|
||||||
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
|
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
|
||||||
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
|
* bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
|
||||||
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
|
* bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
|
||||||
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
* bun scripts/check-i18n-keys.ts --json # machine-readable output
|
||||||
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
* bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -34,9 +34,20 @@ import {
|
|||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { extname, join, relative } from "node:path";
|
import { extname, join, relative } from "node:path";
|
||||||
|
|
||||||
|
type LocaleTree = { [key: string]: LocaleTree | string };
|
||||||
|
|
||||||
|
interface I18nConfig {
|
||||||
|
localesDir: string;
|
||||||
|
sourceLocale: string;
|
||||||
|
srcDirs: string[];
|
||||||
|
srcExtensions: string[];
|
||||||
|
excludeDirs: string[];
|
||||||
|
ignoreUnused: string[];
|
||||||
|
}
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const flag = (name, def) => {
|
const flag = (name: string, def: string | boolean): string | boolean => {
|
||||||
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||||
if (!a) return def;
|
if (!a) return def;
|
||||||
const [, v] = a.split("=");
|
const [, v] = a.split("=");
|
||||||
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
|
|||||||
|
|
||||||
// ---- config ----
|
// ---- config ----
|
||||||
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG: I18nConfig = {
|
||||||
localesDir: "translations",
|
localesDir: "translations",
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
||||||
@@ -69,29 +80,36 @@ const DEFAULT_CONFIG = {
|
|||||||
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||||
ignoreUnused: [],
|
ignoreUnused: [],
|
||||||
};
|
};
|
||||||
const config = existsSync(CONFIG_PATH)
|
const config: I18nConfig = existsSync(CONFIG_PATH)
|
||||||
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
? {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
|
||||||
|
}
|
||||||
: DEFAULT_CONFIG;
|
: DEFAULT_CONFIG;
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
const flatten = (obj, prefix = "", out = {}) => {
|
const flatten = (
|
||||||
|
obj: LocaleTree,
|
||||||
|
prefix = "",
|
||||||
|
out: Record<string, string> = {},
|
||||||
|
): Record<string, string> => {
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
const key = prefix ? `${prefix}.${k}` : k;
|
const key = prefix ? `${prefix}.${k}` : k;
|
||||||
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||||
else out[key] = v;
|
else out[key] = v as string;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
const globMatch = (key, pattern) => {
|
const globMatch = (key: string, pattern: string): boolean => {
|
||||||
if (pattern.endsWith(".*"))
|
if (pattern.endsWith(".*"))
|
||||||
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
||||||
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
||||||
return key === pattern;
|
return key === pattern;
|
||||||
};
|
};
|
||||||
|
|
||||||
const walk = (dir, files = []) => {
|
const walk = (dir: string, files: string[] = []): string[] => {
|
||||||
let entries;
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = readdirSync(dir);
|
entries = readdirSync(dir);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
|
|||||||
}
|
}
|
||||||
for (const name of entries) {
|
for (const name of entries) {
|
||||||
const full = join(dir, name);
|
const full = join(dir, name);
|
||||||
let st;
|
let st: ReturnType<typeof statSync>;
|
||||||
try {
|
try {
|
||||||
st = statSync(full);
|
st = statSync(full);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
|
|||||||
// ---- load source keys ----
|
// ---- load source keys ----
|
||||||
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||||
const sourceKeys = Object.keys(
|
const sourceKeys = Object.keys(
|
||||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
|
||||||
);
|
);
|
||||||
const sourceKeySet = new Set(sourceKeys);
|
const sourceKeySet = new Set(sourceKeys);
|
||||||
|
|
||||||
@@ -129,16 +147,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
|
|||||||
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
||||||
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
||||||
|
|
||||||
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||||
const dynamicPrefixes = new Set();
|
const dynamicPrefixes = new Set<string>();
|
||||||
const fullyDynamic = []; // { file, line }
|
const fullyDynamic: Array<{ file: string; line: number }> = [];
|
||||||
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
||||||
|
|
||||||
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
||||||
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
||||||
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
||||||
// `://` inside string URLs intact.
|
// `://` inside string URLs intact.
|
||||||
const stripComments = (src) =>
|
const stripComments = (src: string): string =>
|
||||||
src
|
src
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||||
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||||
@@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes];
|
|||||||
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
||||||
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
||||||
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
||||||
const literalUsed = (key) =>
|
const literalUsed = (key: string): boolean =>
|
||||||
codeBlob.includes(`"${key}"`) ||
|
codeBlob.includes(`"${key}"`) ||
|
||||||
codeBlob.includes(`'${key}'`) ||
|
codeBlob.includes(`'${key}'`) ||
|
||||||
codeBlob.includes(`\`${key}\``);
|
codeBlob.includes(`\`${key}\``);
|
||||||
const isUsed = (key) =>
|
const isUsed = (key: string): boolean =>
|
||||||
literalUsed(key) ||
|
literalUsed(key) ||
|
||||||
prefixList.some((p) => key.startsWith(p)) ||
|
prefixList.some((p) => key.startsWith(p)) ||
|
||||||
config.ignoreUnused.some((g) => globMatch(key, g));
|
config.ignoreUnused.some((g) => globMatch(key, g));
|
||||||
@@ -191,25 +209,22 @@ const missing = [...usedStatic]
|
|||||||
// keys are static literals in practice; revisit if dynamic key constants become common.
|
// keys are static literals in practice; revisit if dynamic key constants become common.
|
||||||
|
|
||||||
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
||||||
const removeKey = (obj, parts) => {
|
const removeKey = (obj: LocaleTree, parts: string[]): void => {
|
||||||
const [head, ...rest] = parts;
|
const [head, ...rest] = parts;
|
||||||
if (!(head in obj)) return;
|
if (!(head in obj)) return;
|
||||||
if (rest.length === 0) {
|
if (rest.length === 0) {
|
||||||
delete obj[head];
|
delete obj[head];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeKey(obj[head], rest);
|
const child = obj[head];
|
||||||
if (
|
if (!child || typeof child !== "object") return;
|
||||||
obj[head] &&
|
removeKey(child, rest);
|
||||||
typeof obj[head] === "object" &&
|
if (Object.keys(child).length === 0) delete obj[head];
|
||||||
Object.keys(obj[head]).length === 0
|
|
||||||
)
|
|
||||||
delete obj[head];
|
|
||||||
};
|
};
|
||||||
if (FIX_UNUSED && unused.length) {
|
if (FIX_UNUSED && unused.length) {
|
||||||
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
||||||
// the keys from them automatically on the next sync once they disappear from the source.
|
// the keys from them automatically on the next sync once they disappear from the source.
|
||||||
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
|
const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
|
||||||
for (const key of unused) removeKey(data, key.split("."));
|
for (const key of unused) removeKey(data, key.split("."));
|
||||||
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -259,7 +274,7 @@ if (JSON_OUT) {
|
|||||||
);
|
);
|
||||||
for (const k of unused) console.log(` - ${k}`);
|
for (const k of unused) console.log(` - ${k}`);
|
||||||
console.log(
|
console.log(
|
||||||
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
|
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
||||||
@@ -21,8 +21,14 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
interface Issue {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
|
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
|
||||||
const numEnv = (name, def) => {
|
const numEnv = (name: string, def: number): number => {
|
||||||
const raw = process.env[name];
|
const raw = process.env[name];
|
||||||
if (raw === undefined || raw === "") return def;
|
if (raw === undefined || raw === "") return def;
|
||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
@@ -51,9 +57,9 @@ const STOP = new Set(
|
|||||||
).split(/\s+/),
|
).split(/\s+/),
|
||||||
);
|
);
|
||||||
|
|
||||||
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
|
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
|
||||||
|
|
||||||
const tokens = (s) =>
|
const tokens = (s: string | null): string[] =>
|
||||||
(s || "")
|
(s || "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
||||||
@@ -65,7 +71,7 @@ const tokens = (s) =>
|
|||||||
.map(stem)
|
.map(stem)
|
||||||
.filter((w) => w.length > 2);
|
.filter((w) => w.length > 2);
|
||||||
|
|
||||||
const jaccard = (a, b) => {
|
const jaccard = (a: string[], b: string[]): number => {
|
||||||
const A = new Set(a);
|
const A = new Set(a);
|
||||||
const B = new Set(b);
|
const B = new Set(b);
|
||||||
if (!A.size || !B.size) return 0;
|
if (!A.size || !B.size) return 0;
|
||||||
@@ -76,14 +82,14 @@ const jaccard = (a, b) => {
|
|||||||
|
|
||||||
const newTitle = tokens(TITLE);
|
const newTitle = tokens(TITLE);
|
||||||
const newBody = tokens(BODY);
|
const newBody = tokens(BODY);
|
||||||
const score = (o) =>
|
const score = (o: Issue): number =>
|
||||||
0.6 * jaccard(newTitle, tokens(o.title)) +
|
0.6 * jaccard(newTitle, tokens(o.title)) +
|
||||||
0.4 * jaccard(newBody, tokens(o.body));
|
0.4 * jaccard(newBody, tokens(o.body));
|
||||||
|
|
||||||
// fetch open issues (excluding PRs and the new issue itself)
|
// fetch open issues (excluding PRs and the new issue itself)
|
||||||
let issues;
|
let issues: Issue[];
|
||||||
if (process.env.DUP_FIXTURE) {
|
if (process.env.DUP_FIXTURE) {
|
||||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
|
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
|
||||||
} else {
|
} else {
|
||||||
const raw = execFileSync(
|
const raw = execFileSync(
|
||||||
"gh",
|
"gh",
|
||||||
@@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) {
|
|||||||
issues = raw
|
issues = raw
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((l) => JSON.parse(l));
|
.map((l) => JSON.parse(l) as Issue);
|
||||||
}
|
}
|
||||||
|
|
||||||
const matches = issues
|
const matches = issues
|
||||||
@@ -123,7 +129,7 @@ if (!matches.length) {
|
|||||||
// Neutralise other issues' titles before echoing them back: break @mentions and
|
// Neutralise other issues' titles before echoing them back: break @mentions and
|
||||||
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
|
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
|
||||||
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
|
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
|
||||||
const safeTitle = (t) =>
|
const safeTitle = (t: string): string =>
|
||||||
(t || "")
|
(t || "")
|
||||||
.replace(/@/g, "@")
|
.replace(/@/g, "@")
|
||||||
.replace(/[`<>|*_~[\]]/g, " ")
|
.replace(/[`<>|*_~[\]]/g, " ")
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
const _fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
const process = require("node:process");
|
|
||||||
const { execSync } = require("node:child_process");
|
|
||||||
|
|
||||||
const root = process.cwd();
|
|
||||||
// const tvosPath = path.join(root, 'iostv');
|
|
||||||
// const iosPath = path.join(root, 'iosmobile');
|
|
||||||
// const androidPath = path.join(root, 'androidmobile');
|
|
||||||
// const androidTVPath = path.join(root, 'androidtv');
|
|
||||||
// const device = process.argv[2];
|
|
||||||
// const platform = process.argv[2];
|
|
||||||
const isTV = process.env.EXPO_TV || false;
|
|
||||||
|
|
||||||
const paths = new Map([
|
|
||||||
["tvos", path.join(root, "iostv")],
|
|
||||||
["ios", path.join(root, "iosmobile")],
|
|
||||||
["android", path.join(root, "androidmobile")],
|
|
||||||
["androidtv", path.join(root, "androidtv")],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// const platformPath = paths.get(platform);
|
|
||||||
|
|
||||||
if (isTV) {
|
|
||||||
stdout = execSync(
|
|
||||||
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
|
|
||||||
);
|
|
||||||
console.log(stdout.toString());
|
|
||||||
stdout = execSync(
|
|
||||||
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
|
|
||||||
"androidtv",
|
|
||||||
)} android`,
|
|
||||||
);
|
|
||||||
console.log(stdout.toString());
|
|
||||||
} else {
|
|
||||||
stdout = execSync(
|
|
||||||
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
|
|
||||||
);
|
|
||||||
console.log(stdout.toString());
|
|
||||||
stdout = execSync(
|
|
||||||
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
|
|
||||||
);
|
|
||||||
console.log(stdout.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// target = "";
|
|
||||||
// switch (platform) {
|
|
||||||
// case "tvos":
|
|
||||||
// target = "ios";
|
|
||||||
// break;
|
|
||||||
// case "ios":
|
|
||||||
// target = "ios";
|
|
||||||
// break;
|
|
||||||
// case "android":
|
|
||||||
// target = "android";
|
|
||||||
// break;
|
|
||||||
// case "androidtv":
|
|
||||||
// target = "android";
|
|
||||||
// break;
|
|
||||||
// }
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
const { execFileSync } = require("node:child_process");
|
import { execFileSync } from "node:child_process";
|
||||||
const process = require("node:process");
|
import { createRequire } from "node:module";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
// Enhanced ANSI color codes and styles
|
// Enhanced ANSI color codes and styles
|
||||||
const colors = {
|
const colors = {
|
||||||
@@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
|
|||||||
|
|
||||||
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||||
|
|
||||||
function log(message, color = "") {
|
function log(message: string, color = "") {
|
||||||
if (useColor && color) {
|
if (useColor && color) {
|
||||||
console.log(`${color}${message}${colors.reset}`);
|
console.log(`${color}${message}${colors.reset}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -40,7 +43,7 @@ function log(message, color = "") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatError(errorLine) {
|
function formatError(errorLine: string): string {
|
||||||
if (!useColor) return errorLine;
|
if (!useColor) return errorLine;
|
||||||
|
|
||||||
// Color file paths in cyan
|
// Color file paths in cyan
|
||||||
@@ -70,12 +73,15 @@ function formatError(errorLine) {
|
|||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseErrorsAndCreateSummary(errorOutput) {
|
function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||||
|
formattedErrors: string[];
|
||||||
|
errorsByFile: Map<string, number>;
|
||||||
|
} {
|
||||||
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
||||||
const errorsByFile = new Map();
|
const errorsByFile = new Map<string, number>();
|
||||||
const formattedErrors = [];
|
const formattedErrors: string[] = [];
|
||||||
|
|
||||||
let currentError = [];
|
let currentError: string[] = [];
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
@@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
|||||||
if (!errorsByFile.has(filePath)) {
|
if (!errorsByFile.has(filePath)) {
|
||||||
errorsByFile.set(filePath, 0);
|
errorsByFile.set(filePath, 0);
|
||||||
}
|
}
|
||||||
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
|
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
|
||||||
|
|
||||||
// Start new error
|
// Start new error
|
||||||
currentError.push(formatError(line));
|
currentError.push(formatError(line));
|
||||||
@@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
|||||||
return { formattedErrors, errorsByFile };
|
return { formattedErrors, errorsByFile };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createErrorSummaryTable(errorsByFile) {
|
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
|
||||||
if (errorsByFile.size === 0) return "";
|
if (errorsByFile.size === 0) return "";
|
||||||
|
|
||||||
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
||||||
@@ -136,7 +142,7 @@ function createErrorSummaryTable(errorsByFile) {
|
|||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runTypeCheck() {
|
function runTypeCheck(): { ok: boolean } {
|
||||||
const extraArgs = process.argv.slice(2);
|
const extraArgs = process.argv.slice(2);
|
||||||
|
|
||||||
// Prefer local TypeScript binary when available
|
// Prefer local TypeScript binary when available
|
||||||
@@ -150,16 +156,13 @@ function runTypeCheck() {
|
|||||||
"false",
|
"false",
|
||||||
...extraArgs,
|
...extraArgs,
|
||||||
];
|
];
|
||||||
let execArgs = null;
|
let execArgs: { cmd: string; args: string[] };
|
||||||
try {
|
try {
|
||||||
const tscBin = require.resolve("typescript/bin/tsc");
|
const tscBin = require.resolve("typescript/bin/tsc");
|
||||||
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
||||||
} catch {
|
} catch {
|
||||||
// fallback to PATH tsc
|
// fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved)
|
||||||
execArgs = {
|
execArgs = { cmd: "tsc", args: runnerArgs };
|
||||||
cmd: "tsc",
|
|
||||||
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -183,7 +186,21 @@ function runTypeCheck() {
|
|||||||
);
|
);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
const execError = error as { stderr?: string; stdout?: string };
|
||||||
|
const errorOutput = [execError.stdout, execError.stderr]
|
||||||
|
.filter((chunk): chunk is string => Boolean(chunk))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// No compiler output = tsc never ran (e.g. binary missing). Don't let a
|
||||||
|
// launch failure fall through to the "passed" branch and green-light CI.
|
||||||
|
if (!errorOutput) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
log(
|
||||||
|
`❌ ${colors.bold}TypeScript check failed to start${colors.reset} ${colors.gray}${message}${colors.reset}`,
|
||||||
|
colors.red,
|
||||||
|
);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
||||||
// that generates a large volume of known type errors
|
// that generates a large volume of known type errors
|
||||||
@@ -505,11 +505,7 @@
|
|||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"seeAll": "See all",
|
"seeAll": "See all"
|
||||||
"connect": "Connect",
|
|
||||||
"connecting": "Connecting…",
|
|
||||||
"connected": "Connected",
|
|
||||||
"not_connected": "Not connected"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
@@ -736,10 +732,6 @@
|
|||||||
"request_button": "Request",
|
"request_button": "Request",
|
||||||
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
|
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
|
||||||
"failed_to_login": "Failed to log in",
|
"failed_to_login": "Failed to log in",
|
||||||
"connect_to_jellyseerr": "Connect to Jellyseerr",
|
|
||||||
"connect_in_settings": "Jellyseerr is available. Connect in Settings to enable request features.",
|
|
||||||
"session_expired": "Session expired",
|
|
||||||
"session_expired_connect_again": "Your Jellyseerr session has expired. Please reconnect in Settings.",
|
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
|
|||||||
@@ -505,11 +505,7 @@
|
|||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"seeAll": "See all",
|
"seeAll": "See all"
|
||||||
"connect": "Anslut",
|
|
||||||
"connecting": "Ansluter…",
|
|
||||||
"connected": "Ansluten",
|
|
||||||
"not_connected": "Inte ansluten"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Sök...",
|
"search": "Sök...",
|
||||||
@@ -736,10 +732,6 @@
|
|||||||
"request_button": "Önska",
|
"request_button": "Önska",
|
||||||
"are_you_sure_you_want_to_request_all_seasons": "Är du säker på att du vill begära alla säsonger?",
|
"are_you_sure_you_want_to_request_all_seasons": "Är du säker på att du vill begära alla säsonger?",
|
||||||
"failed_to_login": "Inloggningen Misslyckades",
|
"failed_to_login": "Inloggningen Misslyckades",
|
||||||
"connect_to_jellyseerr": "Anslut till Jellyseerr",
|
|
||||||
"connect_in_settings": "Jellyseerr är tillgängligt. Anslut i Inställningar för att aktivera förfrågningsfunktioner.",
|
|
||||||
"session_expired": "Sessionen har gått ut",
|
|
||||||
"session_expired_connect_again": "Din Jellyseerr-session har gått ut. Anslut igen i Inställningar.",
|
|
||||||
"cast": "Roller",
|
"cast": "Roller",
|
||||||
"details": "Detaljer",
|
"details": "Detaljer",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
import { generateDeviceProfile } from "./native";
|
import type {
|
||||||
|
DeviceProfile,
|
||||||
/**
|
SubtitleProfile,
|
||||||
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
*/
|
import { type AudioTranscodeModeType, generateDeviceProfile } from "./native";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download-specific subtitle profiles.
|
* Download-specific subtitle profiles.
|
||||||
* These are more permissive than streaming profiles since we can embed subtitles.
|
* These are more permissive than streaming profiles since we can embed subtitles.
|
||||||
*/
|
*/
|
||||||
const downloadSubtitleProfiles = [
|
const downloadSubtitleProfiles: SubtitleProfile[] = [
|
||||||
// Official formats
|
// Official formats
|
||||||
{ Format: "vtt", Method: "Encode" },
|
{ Format: "vtt", Method: "Encode" },
|
||||||
{ Format: "webvtt", Method: "Encode" },
|
{ Format: "webvtt", Method: "Encode" },
|
||||||
@@ -46,11 +46,10 @@ const downloadSubtitleProfiles = [
|
|||||||
/**
|
/**
|
||||||
* Generates a device profile optimized for downloads.
|
* Generates a device profile optimized for downloads.
|
||||||
* Uses the same audio codec logic as streaming but with download-specific bitrate limits.
|
* Uses the same audio codec logic as streaming but with download-specific bitrate limits.
|
||||||
*
|
|
||||||
* @param {AudioTranscodeModeType} [audioMode="auto"] - Audio transcoding mode
|
|
||||||
* @returns {Object} Jellyfin device profile for downloads
|
|
||||||
*/
|
*/
|
||||||
export const generateDownloadProfile = (audioMode = "auto") => {
|
export const generateDownloadProfile = (
|
||||||
|
audioMode: AudioTranscodeModeType = "auto",
|
||||||
|
): DeviceProfile => {
|
||||||
// Get the base profile with proper audio codec configuration
|
// Get the base profile with proper audio codec configuration
|
||||||
const baseProfile = generateDeviceProfile({ audioMode });
|
const baseProfile = generateDeviceProfile({ audioMode });
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import MediaTypes from "../../constants/MediaTypes";
|
import MediaTypes from "../../constants/MediaTypes";
|
||||||
import { getSubtitleProfiles } from "./subtitles";
|
import { getSubtitleProfiles } from "./subtitles";
|
||||||
@@ -193,7 +194,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
SubtitleProfiles: getSubtitleProfiles(),
|
SubtitleProfiles: getSubtitleProfiles(),
|
||||||
};
|
} satisfies DeviceProfile;
|
||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
import type { SubtitleProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
|
||||||
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
|
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
|
||||||
// because MPV cannot load them externally over HTTP
|
// because MPV cannot load them externally over HTTP
|
||||||
@@ -13,7 +14,7 @@ const IMAGE_BASED_FORMATS = [
|
|||||||
"pgssub",
|
"pgssub",
|
||||||
"teletext",
|
"teletext",
|
||||||
"vobsub",
|
"vobsub",
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
// Text-based formats - these can be loaded externally by MPV
|
// Text-based formats - these can be loaded externally by MPV
|
||||||
const TEXT_BASED_FORMATS = [
|
const TEXT_BASED_FORMATS = [
|
||||||
@@ -37,10 +38,10 @@ const TEXT_BASED_FORMATS = [
|
|||||||
"text",
|
"text",
|
||||||
"vplayer",
|
"vplayer",
|
||||||
"xsub",
|
"xsub",
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const getSubtitleProfiles = () => {
|
export const getSubtitleProfiles = (): SubtitleProfile[] => {
|
||||||
const profiles = [];
|
const profiles: SubtitleProfile[] = [];
|
||||||
|
|
||||||
// Image-based formats: Embed or Encode (burn-in), NOT External
|
// Image-based formats: Embed or Encode (burn-in), NOT External
|
||||||
for (const format of IMAGE_BASED_FORMATS) {
|
for (const format of IMAGE_BASED_FORMATS) {
|
||||||
@@ -58,4 +59,4 @@ export const getSubtitleProfiles = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Export for use in player filtering
|
// Export for use in player filtering
|
||||||
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;
|
export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;
|
||||||
19
utils/profiles/trackplayer.d.ts
vendored
19
utils/profiles/trackplayer.d.ts
vendored
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type PlatformType = "ios" | "android";
|
|
||||||
|
|
||||||
export interface TrackPlayerProfileOptions {
|
|
||||||
/** Target platform */
|
|
||||||
platform?: PlatformType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateTrackPlayerProfile(
|
|
||||||
options?: TrackPlayerProfileOptions,
|
|
||||||
): any;
|
|
||||||
|
|
||||||
declare const _default: any;
|
|
||||||
export default _default;
|
|
||||||
@@ -3,23 +3,25 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
import type {
|
||||||
|
CodecProfile,
|
||||||
|
DeviceProfile,
|
||||||
|
DirectPlayProfile,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import MediaTypes from "../../constants/MediaTypes";
|
import MediaTypes from "../../constants/MediaTypes";
|
||||||
|
import type { PlatformType } from "./native";
|
||||||
|
|
||||||
/**
|
export interface TrackPlayerProfileOptions {
|
||||||
* @typedef {"ios" | "android"} PlatformType
|
/** Target platform */
|
||||||
*
|
platform?: PlatformType;
|
||||||
* @typedef {Object} TrackPlayerProfileOptions
|
}
|
||||||
* @property {PlatformType} [platform] - Target platform
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audio direct play profiles for react-native-track-player.
|
* Audio direct play profiles for react-native-track-player.
|
||||||
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
|
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
|
||||||
*
|
|
||||||
* @param {PlatformType} platform
|
|
||||||
*/
|
*/
|
||||||
const getDirectPlayProfile = (platform) => {
|
const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
|
||||||
if (platform === "ios") {
|
if (platform === "ios") {
|
||||||
// iOS AVPlayer supported formats
|
// iOS AVPlayer supported formats
|
||||||
return {
|
return {
|
||||||
@@ -39,10 +41,8 @@ const getDirectPlayProfile = (platform) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Audio codec profiles for react-native-track-player.
|
* Audio codec profiles for react-native-track-player.
|
||||||
*
|
|
||||||
* @param {PlatformType} platform
|
|
||||||
*/
|
*/
|
||||||
const getCodecProfile = (platform) => {
|
const getCodecProfile = (platform: PlatformType): CodecProfile => {
|
||||||
if (platform === "ios") {
|
if (platform === "ios") {
|
||||||
// iOS AVPlayer codec constraints
|
// iOS AVPlayer codec constraints
|
||||||
return {
|
return {
|
||||||
@@ -64,12 +64,11 @@ const getCodecProfile = (platform) => {
|
|||||||
* This profile is specifically for standalone audio playback using:
|
* This profile is specifically for standalone audio playback using:
|
||||||
* - AVPlayer on iOS
|
* - AVPlayer on iOS
|
||||||
* - ExoPlayer on Android
|
* - ExoPlayer on Android
|
||||||
*
|
|
||||||
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
|
|
||||||
* @returns {Object} Jellyfin device profile for track player
|
|
||||||
*/
|
*/
|
||||||
export const generateTrackPlayerProfile = (options = {}) => {
|
export const generateTrackPlayerProfile = (
|
||||||
const platform = options.platform || Platform.OS;
|
options: TrackPlayerProfileOptions = {},
|
||||||
|
): DeviceProfile => {
|
||||||
|
const platform = (options.platform || Platform.OS) as PlatformType;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Name: "Track Player",
|
Name: "Track Player",
|
||||||
Reference in New Issue
Block a user