mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-09 15:38:39 +01:00
Compare commits
6 Commits
fix/tv-see
...
fix/save-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
768c875039 | ||
|
|
b7bae0072f | ||
|
|
410b337bdc | ||
|
|
55d2b240e1 | ||
|
|
1685571406 | ||
|
|
36ed7539a2 |
1
.github/workflows/linting.yml
vendored
1
.github/workflows/linting.yml
vendored
@@ -97,6 +97,7 @@ jobs:
|
|||||||
- "check"
|
- "check"
|
||||||
- "format"
|
- "format"
|
||||||
- "typecheck"
|
- "typecheck"
|
||||||
|
- "i18n:check"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
|
|||||||
60
.github/workflows/trivy-scan.yml
vendored
Normal file
60
.github/workflows/trivy-scan.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: 🛡️ Trivy Security Scan
|
||||||
|
|
||||||
|
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
|
||||||
|
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
|
||||||
|
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
|
||||||
|
# upload needs a write token that fork PRs don't get).
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
schedule:
|
||||||
|
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: trivy-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trivy:
|
||||||
|
name: 🔎 Filesystem scan
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write # upload SARIF to code scanning
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
# Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
|
||||||
|
# instead of a fresh immutable entry per run, still refreshing the DB every week.
|
||||||
|
- name: 🗓️ Compute weekly Trivy cache key
|
||||||
|
id: trivy-cache-key
|
||||||
|
run: echo "value=trivy-db-${{ runner.os }}-$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: 💾 Cache Trivy vulnerability DB
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/trivy
|
||||||
|
key: ${{ steps.trivy-cache-key.outputs.value }}
|
||||||
|
restore-keys: trivy-db-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: 🔎 Run Trivy filesystem scan
|
||||||
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: .
|
||||||
|
scanners: vuln,secret,misconfig
|
||||||
|
ignore-unfixed: true
|
||||||
|
severity: CRITICAL,HIGH
|
||||||
|
format: sarif
|
||||||
|
output: trivy-results.sarif
|
||||||
|
|
||||||
|
- name: 📤 Upload results to code scanning
|
||||||
|
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-results.sarif
|
||||||
|
category: trivy-fs
|
||||||
@@ -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 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,81 +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") ||
|
|
||||||
"Enter your Jellyseerr server URL to enable discover and request features."}
|
|
||||||
</Text>
|
|
||||||
<TVSettingsTextInput
|
|
||||||
label={
|
|
||||||
t("home.settings.plugins.jellyseerr.server_url") || "Server URL"
|
|
||||||
}
|
|
||||||
value={jellyseerrServerUrl}
|
|
||||||
placeholder={
|
|
||||||
t("home.settings.plugins.jellyseerr.server_url_placeholder") ||
|
|
||||||
"https://jellyseerr.example.com"
|
|
||||||
}
|
|
||||||
onChangeText={setJellyseerrServerUrl}
|
|
||||||
onBlur={handleJellyseerrUrlBlur}
|
|
||||||
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
|
|
||||||
/>
|
|
||||||
{!isJellyseerrConnected && !isJellyseerrLocked && (
|
|
||||||
<>
|
|
||||||
<TVSettingsTextInput
|
|
||||||
label={
|
|
||||||
t("home.settings.plugins.jellyseerr.password") || "Password"
|
|
||||||
}
|
|
||||||
value={jellyseerrPassword}
|
|
||||||
placeholder={
|
|
||||||
t("home.settings.plugins.jellyseerr.password_placeholder", {
|
|
||||||
username: user?.Name,
|
|
||||||
}) || `Jellyfin password`
|
|
||||||
}
|
|
||||||
onChangeText={setJellyseerrPassword}
|
|
||||||
secureTextEntry
|
|
||||||
disabled={jellyseerrLoginMutation.isPending}
|
|
||||||
/>
|
|
||||||
<TVSettingsOptionButton
|
|
||||||
label={
|
|
||||||
jellyseerrLoginMutation.isPending
|
|
||||||
? t("common.connecting", "Connecting...") || "Connecting..."
|
|
||||||
: t("common.connect", "Connect") || "Connect"
|
|
||||||
}
|
|
||||||
value=''
|
|
||||||
onPress={() => jellyseerrLoginMutation.mutate()}
|
|
||||||
disabled={jellyseerrLoginMutation.isPending}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<TVSettingsRow
|
|
||||||
label={
|
|
||||||
isJellyseerrConnected
|
|
||||||
? t("common.connected", "Connected") || "Connected"
|
|
||||||
: t("common.not_connected", "Not connected") || "Not connected"
|
|
||||||
}
|
|
||||||
value=''
|
|
||||||
showChevron={false}
|
|
||||||
/>
|
|
||||||
{isJellyseerrConnected && !isJellyseerrLocked && (
|
|
||||||
<TVSettingsOptionButton
|
|
||||||
label={
|
|
||||||
t(
|
|
||||||
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
|
|
||||||
) || "Disconnect"
|
|
||||||
}
|
|
||||||
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,44 +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();
|
||||||
|
|
||||||
// Alert when seerr server is configured but user hasn't connected (only when 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", "Connect to Jellyseerr"),
|
|
||||||
t(
|
|
||||||
"jellyseerr.connect_in_settings",
|
|
||||||
"Jellyseerr is available. Connect in Settings to enable request features.",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
|
|
||||||
|
|
||||||
// Validate jellyseerr session when switching to Discover
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
searchType !== "Discover" ||
|
|
||||||
!jellyseerrApi ||
|
|
||||||
!settings?.jellyseerrServerUrl
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
|
|
||||||
if (status.valid) return;
|
|
||||||
Alert.alert(
|
|
||||||
t("jellyseerr.session_expired", "Session expired"),
|
|
||||||
t(
|
|
||||||
"jellyseerr.session_expired_connect_again",
|
|
||||||
"Your Jellyseerr session has expired. Please reconnect in Settings.",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
|
|
||||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||||
useState<JellyseerrSearchSort>(
|
useState<JellyseerrSearchSort>(
|
||||||
JellyseerrSearchSort[
|
JellyseerrSearchSort[
|
||||||
|
|||||||
@@ -69,17 +69,23 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
|
|||||||
[isAndroid],
|
[isAndroid],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isPresentedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
} else {
|
} else if (isPresentedRef.current) {
|
||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
isPresentedRef.current = false;
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback(
|
const handleSheetChanges = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (index === -1) {
|
if (index >= 0) {
|
||||||
|
isPresentedRef.current = true;
|
||||||
|
} else if (index === -1 && isPresentedRef.current) {
|
||||||
|
isPresentedRef.current = false;
|
||||||
resetState();
|
resetState();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -23,6 +22,8 @@ import type {
|
|||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
interface TVDiscoverPosterProps {
|
interface TVDiscoverPosterProps {
|
||||||
item: MovieResult | TvResult;
|
item: MovieResult | TvResult;
|
||||||
isFirstItem?: boolean;
|
isFirstItem?: boolean;
|
||||||
@@ -33,7 +34,6 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
isFirstItem = false,
|
isFirstItem = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
@@ -50,8 +50,6 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
|
||||||
const posterWidth = sizes.posters.poster;
|
|
||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||||
@@ -73,7 +71,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
width: posterWidth,
|
width: 210,
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.6 : 0,
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
@@ -83,9 +81,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: posterWidth,
|
width: 210,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: sizes.gaps.small,
|
borderRadius: 24,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
}}
|
}}
|
||||||
@@ -142,12 +140,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{year != null && (
|
{year && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: sizes.gaps.small,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{year}
|
{year}
|
||||||
@@ -168,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();
|
||||||
|
|
||||||
@@ -234,14 +231,14 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
if (!flatData || flatData.length === 0) return null;
|
if (!flatData || flatData.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: sizes.gaps.small,
|
marginBottom: 16,
|
||||||
marginLeft: sizes.padding.scale,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{slideTitle}
|
{slideTitle}
|
||||||
@@ -252,9 +249,9 @@ 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.scale,
|
paddingHorizontal: SCALE_PADDING,
|
||||||
paddingVertical: sizes.padding.scale,
|
paddingVertical: SCALE_PADDING,
|
||||||
gap: sizes.gaps.item,
|
gap: 20,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
|||||||
<View style={styles.buttonContainer}>
|
<View style={styles.buttonContainer}>
|
||||||
<TVSubmitButton
|
<TVSubmitButton
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
label={t("login.login")}
|
label={t("login.login_button")}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={!password}
|
disabled={!password}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -15,21 +14,20 @@ import type {
|
|||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
interface TVJellyseerrPosterProps {
|
interface TVJellyseerrPosterProps {
|
||||||
item: MovieResult | TvResult;
|
item: MovieResult | TvResult;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
isFirstItem?: boolean;
|
isFirstItem?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||||
item,
|
item,
|
||||||
onPress,
|
onPress,
|
||||||
isFirstItem = false,
|
isFirstItem = false,
|
||||||
disabled = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
@@ -45,22 +43,18 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
|
||||||
const posterWidth = sizes.posters.poster;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
hasTVPreferredFocus={isFirstItem && !disabled}
|
hasTVPreferredFocus={isFirstItem}
|
||||||
disabled={disabled}
|
|
||||||
focusable={!disabled}
|
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
width: posterWidth,
|
width: 210,
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.6 : 0,
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
@@ -70,9 +64,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: posterWidth,
|
width: 210,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: sizes.gaps.small,
|
borderRadius: 24,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
}}
|
}}
|
||||||
@@ -123,13 +117,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: sizes.gaps.small,
|
marginTop: 12,
|
||||||
}}
|
}}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{year != null && (
|
{year && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
@@ -148,16 +142,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
interface TVJellyseerrPersonPosterProps {
|
interface TVJellyseerrPersonPosterProps {
|
||||||
item: PersonResult;
|
item: PersonResult;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||||
item,
|
item,
|
||||||
onPress,
|
onPress,
|
||||||
disabled = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation();
|
useTVFocusAnimation();
|
||||||
@@ -166,21 +157,13 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
|
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const avatarSize = Math.round(sizes.posters.poster * 0.67);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
|
||||||
onPress={onPress}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
disabled={disabled}
|
|
||||||
focusable={!disabled}
|
|
||||||
>
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
width: avatarSize,
|
width: 160,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
@@ -191,9 +174,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: avatarSize,
|
width: 140,
|
||||||
height: avatarSize,
|
height: 140,
|
||||||
borderRadius: avatarSize / 2,
|
borderRadius: 70,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
borderWidth: focused ? 3 : 0,
|
borderWidth: focused ? 3 : 0,
|
||||||
@@ -215,11 +198,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
|
||||||
name='person'
|
|
||||||
size={Math.round(avatarSize * 0.35)}
|
|
||||||
color='rgba(255,255,255,0.4)'
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -228,7 +207,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: sizes.gaps.small,
|
marginTop: 12,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
@@ -244,7 +223,6 @@ interface TVJellyseerrMovieSectionProps {
|
|||||||
title: string;
|
title: string;
|
||||||
items: MovieResult[];
|
items: MovieResult[];
|
||||||
isFirstSection?: boolean;
|
isFirstSection?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
onItemPress: (item: MovieResult) => void;
|
onItemPress: (item: MovieResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,22 +230,20 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
isFirstSection = false,
|
isFirstSection = false,
|
||||||
disabled = false,
|
|
||||||
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 (
|
||||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: sizes.gaps.small,
|
marginBottom: 16,
|
||||||
marginLeft: sizes.padding.scale,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -278,9 +254,9 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: sizes.padding.scale,
|
paddingHorizontal: SCALE_PADDING,
|
||||||
paddingVertical: sizes.padding.scale,
|
paddingVertical: SCALE_PADDING,
|
||||||
gap: sizes.gaps.item,
|
gap: 20,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
@@ -288,7 +264,6 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
item={item}
|
item={item}
|
||||||
onPress={() => onItemPress(item)}
|
onPress={() => onItemPress(item)}
|
||||||
isFirstItem={isFirstSection && index === 0}
|
isFirstItem={isFirstSection && index === 0}
|
||||||
disabled={disabled}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -300,7 +275,6 @@ interface TVJellyseerrTvSectionProps {
|
|||||||
title: string;
|
title: string;
|
||||||
items: TvResult[];
|
items: TvResult[];
|
||||||
isFirstSection?: boolean;
|
isFirstSection?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
onItemPress: (item: TvResult) => void;
|
onItemPress: (item: TvResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,22 +282,20 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
isFirstSection = false,
|
isFirstSection = false,
|
||||||
disabled = false,
|
|
||||||
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 (
|
||||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: sizes.gaps.small,
|
marginBottom: 16,
|
||||||
marginLeft: sizes.padding.scale,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -334,9 +306,9 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: sizes.padding.scale,
|
paddingHorizontal: SCALE_PADDING,
|
||||||
paddingVertical: sizes.padding.scale,
|
paddingVertical: SCALE_PADDING,
|
||||||
gap: sizes.gaps.item,
|
gap: 20,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
@@ -344,7 +316,6 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
item={item}
|
item={item}
|
||||||
onPress={() => onItemPress(item)}
|
onPress={() => onItemPress(item)}
|
||||||
isFirstItem={isFirstSection && index === 0}
|
isFirstItem={isFirstSection && index === 0}
|
||||||
disabled={disabled}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -356,7 +327,6 @@ interface TVJellyseerrPersonSectionProps {
|
|||||||
title: string;
|
title: string;
|
||||||
items: PersonResult[];
|
items: PersonResult[];
|
||||||
isFirstSection?: boolean;
|
isFirstSection?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
onItemPress: (item: PersonResult) => void;
|
onItemPress: (item: PersonResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,22 +334,20 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
|||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
isFirstSection: _isFirstSection = false,
|
isFirstSection: _isFirstSection = false,
|
||||||
disabled = false,
|
|
||||||
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 (
|
||||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: sizes.gaps.small,
|
marginBottom: 16,
|
||||||
marginLeft: sizes.padding.scale,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -390,16 +358,15 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: sizes.padding.scale,
|
paddingHorizontal: SCALE_PADDING,
|
||||||
paddingVertical: sizes.padding.scale,
|
paddingVertical: SCALE_PADDING,
|
||||||
gap: sizes.gaps.item,
|
gap: 20,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<TVJellyseerrPersonPoster
|
<TVJellyseerrPersonPoster
|
||||||
item={item}
|
item={item}
|
||||||
onPress={() => onItemPress(item)}
|
onPress={() => onItemPress(item)}
|
||||||
disabled={disabled}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -417,7 +384,6 @@ export interface TVJellyseerrSearchResultsProps {
|
|||||||
onMoviePress: (item: MovieResult) => void;
|
onMoviePress: (item: MovieResult) => void;
|
||||||
onTvPress: (item: TvResult) => void;
|
onTvPress: (item: TvResult) => void;
|
||||||
onPersonPress: (item: PersonResult) => void;
|
onPersonPress: (item: PersonResult) => void;
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TVJellyseerrSearchResults: React.FC<
|
export const TVJellyseerrSearchResults: React.FC<
|
||||||
@@ -432,10 +398,8 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
onMoviePress,
|
onMoviePress,
|
||||||
onTvPress,
|
onTvPress,
|
||||||
onPersonPress,
|
onPersonPress,
|
||||||
disabled = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
@@ -446,7 +410,7 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: 24,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -454,9 +418,7 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
>
|
>
|
||||||
{t("search.no_results_found_for")}
|
{t("search.no_results_found_for")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
||||||
style={{ fontSize: typography.body, color: "rgba(255,255,255,0.6)" }}
|
|
||||||
>
|
|
||||||
"{searchQuery}"
|
"{searchQuery}"
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -473,21 +435,18 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
title={t("search.request_movies")}
|
title={t("search.request_movies")}
|
||||||
items={movieResults}
|
items={movieResults}
|
||||||
isFirstSection={false}
|
isFirstSection={false}
|
||||||
disabled={disabled}
|
|
||||||
onItemPress={onMoviePress}
|
onItemPress={onMoviePress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrTvSection
|
<TVJellyseerrTvSection
|
||||||
title={t("search.request_series")}
|
title={t("search.request_series")}
|
||||||
items={tvResults}
|
items={tvResults}
|
||||||
isFirstSection={false}
|
isFirstSection={false}
|
||||||
disabled={disabled}
|
|
||||||
onItemPress={onTvPress}
|
onItemPress={onTvPress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrPersonSection
|
<TVJellyseerrPersonSection
|
||||||
title={t("search.actors")}
|
title={t("search.actors")}
|
||||||
items={personResults}
|
items={personResults}
|
||||||
isFirstSection={false}
|
isFirstSection={false}
|
||||||
disabled={disabled}
|
|
||||||
onItemPress={onPersonPress}
|
onItemPress={onPersonPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, TextInput, View } from "react-native";
|
import { ScrollView, 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 { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||||
@@ -166,7 +166,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
|
||||||
|
|
||||||
// Image URL getter for music items
|
// Image URL getter for music items
|
||||||
const getImageUrl = useMemo(() => {
|
const getImageUrl = useMemo(() => {
|
||||||
@@ -232,51 +231,26 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
paddingTop: insets.top + TOP_PADDING,
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
|
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
|
||||||
TextInput fallback on Android TV (the native module is Apple-only). */}
|
module). It renders the native search bar + grid keyboard and
|
||||||
{Platform.OS === "ios" ? (
|
forwards typed text into the existing query pipeline via setSearch;
|
||||||
<View
|
our own results grid renders below. */}
|
||||||
style={{
|
{/* No horizontal margin here: the native tvOS search bar centers itself
|
||||||
marginBottom: 24,
|
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
||||||
height: SEARCH_AREA_HEIGHT,
|
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
|
||||||
{/* No horizontal margin here: the native tvOS search bar centers
|
style={{
|
||||||
itself and renders a trailing "Hold to Dictate" hint. */}
|
marginBottom: 24,
|
||||||
<TvSearchView
|
height: SEARCH_AREA_HEIGHT,
|
||||||
style={{ width: "100%", height: "100%" }}
|
}}
|
||||||
placeholder={t("search.search")}
|
>
|
||||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
<TvSearchView
|
||||||
/>
|
style={{ width: "100%", height: "100%" }}
|
||||||
</View>
|
placeholder={t("search.search")}
|
||||||
) : (
|
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||||
<View
|
/>
|
||||||
style={{
|
</View>
|
||||||
marginHorizontal: HORIZONTAL_PADDING,
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
style={{
|
|
||||||
height: 56,
|
|
||||||
width: "100%",
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
fontSize: 28,
|
|
||||||
color: "#fff",
|
|
||||||
}}
|
|
||||||
placeholder={t("search.search")}
|
|
||||||
placeholderTextColor='rgba(255,255,255,0.4)'
|
|
||||||
onChangeText={setSearch}
|
|
||||||
defaultValue=''
|
|
||||||
autoFocus={false}
|
|
||||||
onFocus={() => setIsSearchFocused(true)}
|
|
||||||
onBlur={() => setIsSearchFocused(false)}
|
|
||||||
hasTVPreferredFocus
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -294,7 +268,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
searchType={searchType}
|
searchType={searchType}
|
||||||
setSearchType={setSearchType}
|
setSearchType={setSearchType}
|
||||||
showDiscover={showDiscover}
|
showDiscover={showDiscover}
|
||||||
disabled={isSearchFocused}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -321,7 +294,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
// every keystroke as results re-render. User navigates down to the
|
// every keystroke as results re-render. User navigates down to the
|
||||||
// grid manually.
|
// grid manually.
|
||||||
isFirstSection={false}
|
isFirstSection={false}
|
||||||
disabled={isSearchFocused}
|
|
||||||
onItemPress={onItemPress}
|
onItemPress={onItemPress}
|
||||||
onItemLongPress={onItemLongPress}
|
onItemLongPress={onItemLongPress}
|
||||||
imageUrlGetter={
|
imageUrlGetter={
|
||||||
@@ -345,7 +317,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
loading={jellyseerrLoading}
|
loading={jellyseerrLoading}
|
||||||
noResults={jellyseerrNoResults}
|
noResults={jellyseerrNoResults}
|
||||||
searchQuery={debouncedSearch}
|
searchQuery={debouncedSearch}
|
||||||
disabled={isSearchFocused}
|
|
||||||
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
||||||
onTvPress={onJellyseerrTvPress || (() => {})}
|
onTvPress={onJellyseerrTvPress || (() => {})}
|
||||||
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
||||||
|
|||||||
@@ -70,30 +70,6 @@ export const clearJellyseerrStorageData = () => {
|
|||||||
storage.remove(JELLYSEERR_COOKIES);
|
storage.remove(JELLYSEERR_COOKIES);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JellyseerrSessionStatus =
|
|
||||||
| { valid: true }
|
|
||||||
| { valid: false; reason: "no_session" | "expired" };
|
|
||||||
|
|
||||||
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",
|
||||||
@@ -474,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) => {
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
|
||||||
<application>
|
<application>
|
||||||
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
<receiver
|
||||||
|
android:name=".TvRecommendationsReceiver"
|
||||||
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
|
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -61,61 +61,31 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
fun clear(context: Context): Boolean {
|
fun clear(context: Context): Boolean {
|
||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
|
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
||||||
|
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
|
if (programIds != null) {
|
||||||
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
|
||||||
if (allProgramIds != null) {
|
|
||||||
var deletedPrograms = 0
|
var deletedPrograms = 0
|
||||||
val channelKeys = allProgramIds.keys()
|
val keys = programIds.keys()
|
||||||
while (channelKeys.hasNext()) {
|
while (keys.hasNext()) {
|
||||||
val channelIdStr = channelKeys.next()
|
val key = keys.next()
|
||||||
val programIdsJson = allProgramIds.optString(channelIdStr)
|
val programId = programIds.optLong(key, -1L)
|
||||||
if (programIdsJson.isBlank()) continue
|
if (programId > 0L) {
|
||||||
|
contentResolver.delete(
|
||||||
try {
|
TvContractCompat.buildPreviewProgramUri(programId),
|
||||||
val programIds = JSONObject(programIdsJson)
|
null,
|
||||||
val keys = programIds.keys()
|
null
|
||||||
while (keys.hasNext()) {
|
)
|
||||||
val providerId = keys.next()
|
deletedPrograms += 1
|
||||||
val programId = programIds.optLong(providerId, -1L)
|
|
||||||
if (programId > 0L) {
|
|
||||||
deletePreviewProgram(contentResolver, programId)
|
|
||||||
deletedPrograms += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify the channel
|
|
||||||
val channelId = channelIdStr.toLongOrNull() ?: -1L
|
|
||||||
if (channelId > 0L) {
|
|
||||||
try {
|
|
||||||
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove per-channel pref
|
|
||||||
prefs.edit().remove("programIds_$channelIdStr").apply()
|
|
||||||
}
|
}
|
||||||
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
|
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also handle legacy format (flat { providerId: programId }) for migration
|
if (channelId > 0L) {
|
||||||
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
||||||
if (legacyProgramIds != null) {
|
Log.d(TAG, "clear(): notified channel $channelId")
|
||||||
val keys = legacyProgramIds.keys()
|
|
||||||
while (keys.hasNext()) {
|
|
||||||
val key = keys.next()
|
|
||||||
val programId = legacyProgramIds.optLong(key, -1L)
|
|
||||||
if (programId > 0L) {
|
|
||||||
deletePreviewProgram(contentResolver, programId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
@@ -126,262 +96,126 @@ internal object TvRecommendationsPublisher {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a single preview program from the TvProvider.
|
|
||||||
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
|
|
||||||
*/
|
|
||||||
fun deletePreviewProgram(context: Context, programId: Long) {
|
|
||||||
try {
|
|
||||||
context.contentResolver.delete(
|
|
||||||
TvContractCompat.buildPreviewProgramUri(programId),
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
|
|
||||||
|
|
||||||
// Also remove from stored programIds prefs
|
|
||||||
removeProgramFromPrefs(context, programId)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
|
|
||||||
try {
|
|
||||||
contentResolver.delete(
|
|
||||||
TvContractCompat.buildPreviewProgramUri(programId),
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeProgramFromPrefs(context: Context, programId: Long) {
|
|
||||||
val prefs = preferences(context)
|
|
||||||
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
|
|
||||||
try {
|
|
||||||
val programIds = JSONObject(programIdsJson)
|
|
||||||
val keys = programIds.keys()
|
|
||||||
while (keys.hasNext()) {
|
|
||||||
val key = keys.next()
|
|
||||||
if (programIds.optLong(key, -1L) == programId) {
|
|
||||||
programIds.remove(key)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prefs.edit().putString(KEY_PROGRAM_IDS, programIds.toString()).apply()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun synchronize(context: Context, payload: JSONObject): Boolean {
|
private fun synchronize(context: Context, payload: JSONObject): Boolean {
|
||||||
val sections = payload.optJSONArray("sections") ?: JSONArray()
|
val sections = payload.optJSONArray("sections") ?: JSONArray()
|
||||||
if (sections.length() == 0) {
|
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
|
||||||
Log.w(TAG, "synchronize(): no sections in payload")
|
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
||||||
return false
|
val items = firstSection?.optJSONArray("items") ?: JSONArray()
|
||||||
}
|
|
||||||
|
|
||||||
val prefs = preferences(context)
|
|
||||||
val allNextProgramIds = JSONObject()
|
|
||||||
var totalActive = 0
|
|
||||||
var totalDeleted = 0
|
|
||||||
|
|
||||||
for (sectionIndex in 0 until sections.length()) {
|
|
||||||
val section = sections.optJSONObject(sectionIndex) ?: continue
|
|
||||||
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
|
||||||
val items = section.optJSONArray("items") ?: JSONArray()
|
|
||||||
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
val channelId = getOrCreateChannel(context, sectionTitle)
|
|
||||||
if (channelId <= 0L) {
|
|
||||||
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per Android docs: check channel.isBrowsable() and request if needed.
|
|
||||||
if (!isChannelBrowsable(context, channelId)) {
|
|
||||||
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
|
|
||||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
val prefKey = "programIds_$channelId"
|
|
||||||
val previousProgramIds = prefs.getString(prefKey, null)
|
|
||||||
?.let(::JSONObject)
|
|
||||||
?: JSONObject()
|
|
||||||
val nextProgramIds = JSONObject()
|
|
||||||
val activeProviderIds = mutableSetOf<String>()
|
|
||||||
|
|
||||||
for (index in 0 until items.length()) {
|
|
||||||
val item = items.optJSONObject(index) ?: continue
|
|
||||||
val providerId = item.optString("id")
|
|
||||||
if (providerId.isBlank()) continue
|
|
||||||
|
|
||||||
val programId = upsertPreviewProgram(
|
|
||||||
context = context,
|
|
||||||
channelId = channelId,
|
|
||||||
item = item,
|
|
||||||
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
|
||||||
weight = index
|
|
||||||
)
|
|
||||||
|
|
||||||
if (programId > 0L) {
|
|
||||||
activeProviderIds += providerId
|
|
||||||
nextProgramIds.put(providerId, programId)
|
|
||||||
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var deletedPrograms = 0
|
|
||||||
val previousKeys = previousProgramIds.keys()
|
|
||||||
while (previousKeys.hasNext()) {
|
|
||||||
val providerId = previousKeys.next()
|
|
||||||
if (activeProviderIds.contains(providerId)) continue
|
|
||||||
|
|
||||||
val programId = previousProgramIds.optLong(providerId, -1L)
|
|
||||||
if (programId > 0L) {
|
|
||||||
deletePreviewProgram(context, programId)
|
|
||||||
deletedPrograms += 1
|
|
||||||
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
|
|
||||||
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
|
|
||||||
totalActive += activeProviderIds.size
|
|
||||||
totalDeleted += deletedPrograms
|
|
||||||
|
|
||||||
logProviderState(context, channelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store all channel program IDs for clear() to use
|
|
||||||
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
|
|
||||||
|
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
|
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
val channelId = getOrCreateChannel(context, sectionTitle)
|
||||||
|
if (channelId <= 0L) {
|
||||||
|
Log.w(TAG, "synchronize(): failed to get or create preview channel")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
|
||||||
|
|
||||||
|
val previousProgramIds = preferences(context)
|
||||||
|
.getString(KEY_PROGRAM_IDS, null)
|
||||||
|
?.let(::JSONObject)
|
||||||
|
?: JSONObject()
|
||||||
|
val nextProgramIds = JSONObject()
|
||||||
|
val activeProviderIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
for (index in 0 until items.length()) {
|
||||||
|
val item = items.optJSONObject(index) ?: continue
|
||||||
|
val providerId = item.optString("id")
|
||||||
|
if (providerId.isBlank()) continue
|
||||||
|
|
||||||
|
val programId = upsertPreviewProgram(
|
||||||
|
context = context,
|
||||||
|
channelId = channelId,
|
||||||
|
item = item,
|
||||||
|
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
||||||
|
weight = index
|
||||||
|
)
|
||||||
|
|
||||||
|
if (programId > 0L) {
|
||||||
|
activeProviderIds += providerId
|
||||||
|
nextProgramIds.put(providerId, programId)
|
||||||
|
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedPrograms = 0
|
||||||
|
val previousKeys = previousProgramIds.keys()
|
||||||
|
while (previousKeys.hasNext()) {
|
||||||
|
val providerId = previousKeys.next()
|
||||||
|
if (activeProviderIds.contains(providerId)) continue
|
||||||
|
|
||||||
|
val programId = previousProgramIds.optLong(providerId, -1L)
|
||||||
|
if (programId > 0L) {
|
||||||
|
context.contentResolver.delete(
|
||||||
|
TvContractCompat.buildPreviewProgramUri(programId),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
deletedPrograms += 1
|
||||||
|
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences(context)
|
||||||
|
.edit()
|
||||||
|
.putLong(KEY_CHANNEL_ID, channelId)
|
||||||
|
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
logProviderState(context, channelId)
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
|
||||||
)
|
)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Query provider to check if a channel is browsable.
|
|
||||||
* Per Android docs: "check channel.isBrowsable() before updating programs."
|
|
||||||
*/
|
|
||||||
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
|
|
||||||
return try {
|
|
||||||
context.contentResolver.query(
|
|
||||||
TvContractCompat.buildChannelUri(channelId),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)?.use { cursor ->
|
|
||||||
if (cursor.moveToFirst()) {
|
|
||||||
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
|
|
||||||
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} ?: false
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
|
|
||||||
true // Assume browsable if we can't check, to avoid blocking updates
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query provider to verify a channel actually exists.
|
|
||||||
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
|
|
||||||
* we must first check whether the channel was deleted by the system
|
|
||||||
* or if the update simply failed for another reason.
|
|
||||||
*/
|
|
||||||
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
|
|
||||||
return try {
|
|
||||||
context.contentResolver.query(
|
|
||||||
TvContractCompat.buildChannelUri(channelId),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)?.use { cursor ->
|
|
||||||
cursor.moveToFirst()
|
|
||||||
} ?: false
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (existingChannelId > 0L) {
|
if (existingChannelId > 0L) {
|
||||||
// Query provider first to verify channel actually exists (prevents recreate bug)
|
val updated = Channel.Builder()
|
||||||
val exists = channelExistsInProvider(context, existingChannelId)
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
|
.setDisplayName(displayName)
|
||||||
|
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||||
|
.build()
|
||||||
|
|
||||||
if (exists) {
|
val updatedRows = contentResolver.update(
|
||||||
// Channel exists — update it in place, never recreate
|
TvContractCompat.buildChannelUri(existingChannelId),
|
||||||
val updated = Channel.Builder()
|
updated.toContentValues(),
|
||||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
null,
|
||||||
.setDisplayName(displayName)
|
null
|
||||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
)
|
||||||
.build()
|
|
||||||
|
|
||||||
try {
|
if (updatedRows > 0) {
|
||||||
val updatedRows = contentResolver.update(
|
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
||||||
TvContractCompat.buildChannelUri(existingChannelId),
|
storeChannelLogo(context, existingChannelId)
|
||||||
updated.toContentValues(),
|
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
||||||
null,
|
return existingChannelId
|
||||||
null
|
|
||||||
)
|
|
||||||
|
|
||||||
if (updatedRows > 0) {
|
|
||||||
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
|
||||||
storeChannelLogo(context, existingChannelId)
|
|
||||||
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
|
||||||
return existingChannelId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
|
|
||||||
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
|
|
||||||
return existingChannelId
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
|
|
||||||
return existingChannelId
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel truly doesn't exist in provider — recreate
|
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
|
||||||
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
|
|
||||||
prefs.edit().remove(KEY_CHANNEL_ID).apply()
|
prefs.edit().remove(KEY_CHANNEL_ID).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new channel
|
|
||||||
val channel = Channel.Builder()
|
val channel = Channel.Builder()
|
||||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
.setDisplayName(displayName)
|
.setDisplayName(displayName)
|
||||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val channelUri = try {
|
val channelUri = contentResolver.insert(
|
||||||
contentResolver.insert(
|
TvContractCompat.Channels.CONTENT_URI,
|
||||||
TvContractCompat.Channels.CONTENT_URI,
|
channel.toContentValues()
|
||||||
channel.toContentValues()
|
) ?: return -1L
|
||||||
)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
|
|
||||||
null
|
|
||||||
} ?: return -1L
|
|
||||||
|
|
||||||
val channelId = ContentUris.parseId(channelUri)
|
val channelId = ContentUris.parseId(channelUri)
|
||||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||||
@@ -415,62 +249,42 @@ internal object TvRecommendationsPublisher {
|
|||||||
builder.setDescription(it)
|
builder.setDescription(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per Android docs: use unique URIs for all images to avoid stale cache
|
|
||||||
imageUrl.takeIf { it.isNotBlank() }?.let {
|
imageUrl.takeIf { it.isNotBlank() }?.let {
|
||||||
val uniqueImageUrl = appendCacheBuster(it)
|
val imageUri = Uri.parse(it)
|
||||||
val imageUri = Uri.parse(uniqueImageUrl)
|
|
||||||
builder.setPosterArtUri(imageUri)
|
builder.setPosterArtUri(imageUri)
|
||||||
builder.setThumbnailUri(imageUri)
|
builder.setThumbnailUri(imageUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val contentValues = builder.build().toContentValues()
|
val contentValues = builder.build().toContentValues()
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (previousProgramId > 0L) {
|
if (previousProgramId > 0L) {
|
||||||
try {
|
val updatedRows = contentResolver.update(
|
||||||
val updatedRows = contentResolver.update(
|
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
||||||
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
contentValues,
|
||||||
contentValues,
|
null,
|
||||||
null,
|
null
|
||||||
null
|
)
|
||||||
)
|
|
||||||
|
|
||||||
if (updatedRows > 0) {
|
if (updatedRows > 0) {
|
||||||
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
||||||
return previousProgramId
|
return previousProgramId
|
||||||
}
|
|
||||||
|
|
||||||
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
||||||
}
|
}
|
||||||
|
|
||||||
val insertedUri = try {
|
val insertedUri = contentResolver.insert(
|
||||||
contentResolver.insert(
|
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
contentValues
|
||||||
contentValues
|
) ?: return -1L
|
||||||
)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
|
|
||||||
null
|
|
||||||
} ?: return -1L
|
|
||||||
|
|
||||||
val programId = ContentUris.parseId(insertedUri)
|
val programId = ContentUris.parseId(insertedUri)
|
||||||
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
||||||
return programId
|
return programId
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Append a cache-busting parameter to ensure unique URIs when images change.
|
|
||||||
* Per Android docs: "Use unique Uris for all images... the old image will
|
|
||||||
* continue to appear if you don't change the Uri."
|
|
||||||
*/
|
|
||||||
private fun appendCacheBuster(imageUrl: String): String {
|
|
||||||
val separator = if (imageUrl.contains("?")) "&" else "?"
|
|
||||||
return "$imageUrl${separator}_t=${System.currentTimeMillis()}"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
data = Uri.parse(deepLink)
|
data = Uri.parse(deepLink)
|
||||||
@@ -492,17 +306,13 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
private fun storeChannelLogo(context: Context, channelId: Long) {
|
private fun storeChannelLogo(context: Context, channelId: Long) {
|
||||||
val bitmap = applicationIconBitmap(context) ?: return
|
val bitmap = applicationIconBitmap(context) ?: return
|
||||||
try {
|
val outputStream = context.contentResolver.openOutputStream(
|
||||||
val outputStream = context.contentResolver.openOutputStream(
|
TvContractCompat.buildChannelLogoUri(channelId)
|
||||||
TvContractCompat.buildChannelLogoUri(channelId)
|
) ?: return
|
||||||
) ?: return
|
|
||||||
|
|
||||||
outputStream.use { stream ->
|
outputStream.use { stream ->
|
||||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||||
stream.flush()
|
stream.flush()
|
||||||
}
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,14 +341,9 @@ internal object TvRecommendationsPublisher {
|
|||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChannelId(context: Context): Long {
|
|
||||||
return preferences(context).getLong(KEY_CHANNEL_ID, -1L)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun preferences(context: Context): SharedPreferences {
|
private fun preferences(context: Context): SharedPreferences {
|
||||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logProviderState(context: Context, channelId: Long) {
|
private fun logProviderState(context: Context, channelId: Long) {
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
@@ -567,8 +372,8 @@ internal object TvRecommendationsPublisher {
|
|||||||
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
||||||
}
|
}
|
||||||
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
||||||
} catch (error: SecurityException) {
|
} catch (error: Exception) {
|
||||||
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import { requireNativeView } from "expo";
|
import { requireNativeView } from "expo";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { View } from "react-native";
|
import type { View } from "react-native";
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
import type { TvSearchViewProps } from "./TvSearchView.types";
|
import type { TvSearchViewProps } from "./TvSearchView.types";
|
||||||
|
|
||||||
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
|
|
||||||
// On Android the component is never rendered, but we must avoid calling
|
|
||||||
// `requireNativeView` at module-scope because it would crash on import.
|
|
||||||
const NativeView: React.ComponentType<
|
const NativeView: React.ComponentType<
|
||||||
TvSearchViewProps & React.RefAttributes<View>
|
TvSearchViewProps & React.RefAttributes<View>
|
||||||
> =
|
> = requireNativeView("TvSearchModule");
|
||||||
Platform.OS === "ios"
|
|
||||||
? requireNativeView("TvSearchModule")
|
|
||||||
: ((() => null) as any);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forwards its ref to the underlying native view so it can be used as a
|
* Forwards its ref to the underlying native view so it can be used as a
|
||||||
|
|||||||
@@ -22,7 +22,9 @@
|
|||||||
"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",
|
||||||
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
|
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
||||||
|
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
||||||
|
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -247,6 +247,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
}, [api, secret, headers, jellyfin]);
|
}, [api, secret, headers, jellyfin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await refreshStreamyfinPluginSettings();
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
store.set(apiAtom, api);
|
store.set(apiAtom, api);
|
||||||
}, [api]);
|
}, [api]);
|
||||||
@@ -547,20 +553,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Refresh plugin settings
|
// Refresh plugin settings
|
||||||
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
await refreshStreamyfinPluginSettings();
|
||||||
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
|
|
||||||
const jellyseerrApi = new JellyseerrApi(
|
|
||||||
recentPluginSettings.jellyseerrServerUrl.value,
|
|
||||||
);
|
|
||||||
await jellyseerrApi.test().then((result) => {
|
|
||||||
if (result.isValid && result.requiresPass) {
|
|
||||||
jellyseerrApi
|
|
||||||
.login(username, password)
|
|
||||||
.then(setJellyseerrUser)
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|||||||
273
scripts/check-i18n-keys.mjs
Normal file
273
scripts/check-i18n-keys.mjs
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* i18n key checker for Streamyfin.
|
||||||
|
*
|
||||||
|
* Detects:
|
||||||
|
* - MISSING keys: a static `t("a.b.c")` / `i18nKey="a.b.c"` referenced in the code
|
||||||
|
* that does not exist in the source locale (translations/en.json). These are bugs —
|
||||||
|
* the app renders the raw key. Always fails CI.
|
||||||
|
* - UNUSED (dead) keys: a key in the source locale that is referenced nowhere in the
|
||||||
|
* code, neither statically nor via a detected dynamic prefix (`t(`a.b.${x}`)`).
|
||||||
|
* These are dead weight that also clutter every locale on Crowdin.
|
||||||
|
*
|
||||||
|
* Dynamic usage is handled conservatively:
|
||||||
|
* - `t(`prefix.${x}`)` -> every key starting with `prefix.` is considered used.
|
||||||
|
* - `t(`${x}`)` -> fully dynamic, reported for manual review, never used to
|
||||||
|
* whitelist keys (in Streamyfin these are user-defined section
|
||||||
|
* titles, not translation keys).
|
||||||
|
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun scripts/check-i18n-keys.mjs # 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.mjs --unused=off # ignore unused entirely
|
||||||
|
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
||||||
|
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { extname, join, relative } from "node:path";
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const flag = (name, def) => {
|
||||||
|
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||||
|
if (!a) return def;
|
||||||
|
const [, v] = a.split("=");
|
||||||
|
return v === undefined ? true : v;
|
||||||
|
};
|
||||||
|
const UNUSED_MODE = String(flag("unused", "error")); // error | warn | off
|
||||||
|
const JSON_OUT = !!flag("json", false);
|
||||||
|
const FIX_UNUSED = !!flag("fix-unused", false);
|
||||||
|
|
||||||
|
// ---- config ----
|
||||||
|
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
localesDir: "translations",
|
||||||
|
sourceLocale: "en",
|
||||||
|
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
||||||
|
// (e.g. packages/, key constants in utils/atoms) are not wrongly flagged as dead.
|
||||||
|
srcDirs: ["."],
|
||||||
|
srcExtensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
||||||
|
excludeDirs: [
|
||||||
|
"node_modules",
|
||||||
|
"ios",
|
||||||
|
"android",
|
||||||
|
".expo",
|
||||||
|
".git",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"translations",
|
||||||
|
"scripts",
|
||||||
|
],
|
||||||
|
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||||
|
ignoreUnused: [],
|
||||||
|
};
|
||||||
|
const config = existsSync(CONFIG_PATH)
|
||||||
|
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
||||||
|
: DEFAULT_CONFIG;
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
const flatten = (obj, prefix = "", out = {}) => {
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
const key = prefix ? `${prefix}.${k}` : k;
|
||||||
|
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||||
|
else out[key] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const globMatch = (key, pattern) => {
|
||||||
|
if (pattern.endsWith(".*"))
|
||||||
|
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
||||||
|
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
||||||
|
return key === pattern;
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = (dir, files = []) => {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
for (const name of entries) {
|
||||||
|
const full = join(dir, name);
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = statSync(full);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
if (config.excludeDirs.includes(name)) continue;
|
||||||
|
walk(full, files);
|
||||||
|
} else if (config.srcExtensions.includes(extname(name))) {
|
||||||
|
files.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- load source keys ----
|
||||||
|
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||||
|
const sourceKeys = Object.keys(
|
||||||
|
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
||||||
|
);
|
||||||
|
const sourceKeySet = new Set(sourceKeys);
|
||||||
|
|
||||||
|
// ---- scan code ----
|
||||||
|
const STATIC_RE = /\bt\(\s*(['"])((?:\\.|(?!\1).)+?)\1/g; // t("a.b") / t('a.b')
|
||||||
|
const TPL_STATIC_RE = /\bt\(\s*`([^`$]+)`/g; // t(`a.b`) no interpolation
|
||||||
|
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 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 dynamicPrefixes = new Set();
|
||||||
|
const fullyDynamic = []; // { file, line }
|
||||||
|
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
|
||||||
|
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
||||||
|
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
||||||
|
// `://` inside string URLs intact.
|
||||||
|
const stripComments = (src) =>
|
||||||
|
src
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||||
|
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||||
|
|
||||||
|
const files = config.srcDirs.flatMap((d) =>
|
||||||
|
walk(join(ROOT, d === "." ? "" : d) || ROOT),
|
||||||
|
);
|
||||||
|
for (const file of files) {
|
||||||
|
const text = readFileSync(file, "utf8");
|
||||||
|
const clean = stripComments(text);
|
||||||
|
codeBlob += `\n${clean}`;
|
||||||
|
for (const m of clean.matchAll(STATIC_RE)) usedStatic.add(m[2]);
|
||||||
|
for (const m of clean.matchAll(TPL_STATIC_RE)) usedStatic.add(m[1]);
|
||||||
|
for (const m of clean.matchAll(I18NKEY_RE)) usedStatic.add(m[2]);
|
||||||
|
for (const m of clean.matchAll(TPL_DYN_RE)) {
|
||||||
|
const prefix = m[1];
|
||||||
|
if (prefix?.includes(".")) dynamicPrefixes.add(prefix);
|
||||||
|
else {
|
||||||
|
const idx = clean.slice(0, m.index).split("\n").length;
|
||||||
|
fullyDynamic.push({ file: relative(ROOT, file), line: idx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixList = [...dynamicPrefixes];
|
||||||
|
// A key counts as used if its EXACT delimited literal ("k" / 'k' / `k`) appears anywhere 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
|
||||||
|
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
||||||
|
const literalUsed = (key) =>
|
||||||
|
codeBlob.includes(`"${key}"`) ||
|
||||||
|
codeBlob.includes(`'${key}'`) ||
|
||||||
|
codeBlob.includes(`\`${key}\``);
|
||||||
|
const isUsed = (key) =>
|
||||||
|
literalUsed(key) ||
|
||||||
|
prefixList.some((p) => key.startsWith(p)) ||
|
||||||
|
config.ignoreUnused.some((g) => globMatch(key, g));
|
||||||
|
|
||||||
|
// ---- compute ----
|
||||||
|
const unused = sourceKeys.filter((k) => !isUsed(k)).sort();
|
||||||
|
// Static references are always validated, even under a dynamic prefix: a dynamic prefix only
|
||||||
|
// affects the UNUSED calculation, never MISSING.
|
||||||
|
const missing = [...usedStatic]
|
||||||
|
.filter((k) => KEY_SHAPE.test(k) && !sourceKeySet.has(k))
|
||||||
|
.sort();
|
||||||
|
// Known limitation: only keys seen in a static t("…") / i18nKey="…" / t(`…`) call are
|
||||||
|
// validated for MISSING. A key stored as a bare string constant and resolved via t(variable)
|
||||||
|
// counts as USED (via literalUsed → not flagged unused) but its existence in en.json is not
|
||||||
|
// checked here — static analysis can't resolve which key a runtime variable holds. Streamyfin
|
||||||
|
// keys are static literals in practice; revisit if dynamic key constants become common.
|
||||||
|
|
||||||
|
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
||||||
|
const removeKey = (obj, parts) => {
|
||||||
|
const [head, ...rest] = parts;
|
||||||
|
if (!(head in obj)) return;
|
||||||
|
if (rest.length === 0) {
|
||||||
|
delete obj[head];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeKey(obj[head], rest);
|
||||||
|
if (
|
||||||
|
obj[head] &&
|
||||||
|
typeof obj[head] === "object" &&
|
||||||
|
Object.keys(obj[head]).length === 0
|
||||||
|
)
|
||||||
|
delete obj[head];
|
||||||
|
};
|
||||||
|
if (FIX_UNUSED && unused.length) {
|
||||||
|
// 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.
|
||||||
|
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
|
||||||
|
for (const key of unused) removeKey(data, key.split("."));
|
||||||
|
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||||
|
console.log(
|
||||||
|
`🧹 Removed ${unused.length} dead key(s) from ${config.sourceLocale}.json (Crowdin will sync the other locales).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- report ----
|
||||||
|
if (JSON_OUT) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
sourceKeys: sourceKeys.length,
|
||||||
|
missing,
|
||||||
|
unused,
|
||||||
|
dynamicPrefixes: prefixList,
|
||||||
|
fullyDynamic,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`🔑 i18n key check — source: ${relative(ROOT, sourcePath)} (${sourceKeys.length} keys), scanned ${files.length} files`,
|
||||||
|
);
|
||||||
|
if (prefixList.length)
|
||||||
|
console.log(
|
||||||
|
` dynamic prefixes treated as used: ${prefixList.map((p) => `${p}*`).join(", ")}`,
|
||||||
|
);
|
||||||
|
if (fullyDynamic.length)
|
||||||
|
console.log(
|
||||||
|
` ⚠️ ${fullyDynamic.length} fully-dynamic t(\`\${…}\`) call(s) (not key-based, manual review): ${fullyDynamic.map((d) => `${d.file}:${d.line}`).join(", ")}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
console.log(
|
||||||
|
`\n❌ MISSING keys (used in code, absent from ${config.sourceLocale}.json) — ${missing.length}:`,
|
||||||
|
);
|
||||||
|
for (const k of missing) console.log(` - ${k}`);
|
||||||
|
} else console.log("\n✅ No missing keys.");
|
||||||
|
|
||||||
|
if (UNUSED_MODE !== "off") {
|
||||||
|
if (unused.length) {
|
||||||
|
console.log(
|
||||||
|
`\n${UNUSED_MODE === "error" ? "❌" : "⚠️ "} UNUSED keys (in ${config.sourceLocale}.json, referenced nowhere) — ${unused.length}:`,
|
||||||
|
);
|
||||||
|
for (const k of unused) console.log(` - ${k}`);
|
||||||
|
console.log(
|
||||||
|
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
||||||
|
);
|
||||||
|
} else console.log("\n✅ No unused keys.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail =
|
||||||
|
missing.length > 0 || (UNUSED_MODE === "error" && unused.length > 0);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
46
scripts/i18n-keys.config.json
Normal file
46
scripts/i18n-keys.config.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"localesDir": "translations",
|
||||||
|
"sourceLocale": "en",
|
||||||
|
"srcDirs": [
|
||||||
|
"app",
|
||||||
|
"components",
|
||||||
|
"hooks",
|
||||||
|
"providers",
|
||||||
|
"utils",
|
||||||
|
"modules",
|
||||||
|
"packages",
|
||||||
|
"constants"
|
||||||
|
],
|
||||||
|
"srcExtensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
||||||
|
"excludeDirs": [
|
||||||
|
"node_modules",
|
||||||
|
"ios",
|
||||||
|
"android",
|
||||||
|
".expo",
|
||||||
|
".git",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"translations"
|
||||||
|
],
|
||||||
|
"_ignoreUnusedNote": "Keys present in en.json but referenced by no t() call — allow-listed so the unused-key cleanup keeps them. Two kinds: (1) ORPHANS of features that already exist but render hardcoded strings or use other keys — watchlists.* (add/remove, handled by WatchlistSheet via hooks), pin.* (confirm step of the existing PIN setup flow), player.* (in-player subtitle search, swipe-down hint, stop-playback confirm); these should be wired to the live UI or removed in a follow-up i18n pass, not assumed to be future work. (2) GENUINELY PLANNED, tracked by issues: home.settings.other.show_large_home_carousel (#1702 media-bar), home.settings.logs.delete_all_logs (#1703 iOS logs fix), home.suggested_episodes (#1704).",
|
||||||
|
"ignoreUnused": [
|
||||||
|
"watchlists.add_to_watchlist",
|
||||||
|
"watchlists.remove_from_watchlist",
|
||||||
|
"watchlists.create_one_first",
|
||||||
|
"watchlists.no_compatible_watchlists",
|
||||||
|
"pin.confirm_pin",
|
||||||
|
"pin.pins_dont_match",
|
||||||
|
"player.search_subtitles",
|
||||||
|
"player.subtitle_search",
|
||||||
|
"player.subtitle_download_hint",
|
||||||
|
"player.subtitle_tracks",
|
||||||
|
"player.using_jellyfin_server",
|
||||||
|
"player.swipe_down_settings",
|
||||||
|
"player.stopPlayback",
|
||||||
|
"player.stopPlayingTitle",
|
||||||
|
"player.stopPlayingConfirm",
|
||||||
|
"home.settings.other.show_large_home_carousel",
|
||||||
|
"home.settings.logs.delete_all_logs",
|
||||||
|
"home.suggested_episodes"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "None",
|
"None": "None",
|
||||||
"OnlyForced": "Only forced"
|
"OnlyForced": "Only forced"
|
||||||
},
|
},
|
||||||
"text_color": "Text color",
|
|
||||||
"background_color": "Background color",
|
|
||||||
"outline_color": "Outline color",
|
|
||||||
"outline_thickness": "Outline thickness",
|
|
||||||
"background_opacity": "Background opacity",
|
|
||||||
"outline_opacity": "Outline opacity",
|
|
||||||
"bold_text": "Bold text",
|
|
||||||
"colors": {
|
|
||||||
"Black": "Black",
|
|
||||||
"Gray": "Gray",
|
|
||||||
"Silver": "Silver",
|
|
||||||
"White": "White",
|
|
||||||
"Maroon": "Maroon",
|
|
||||||
"Red": "Red",
|
|
||||||
"Fuchsia": "Fuchsia",
|
|
||||||
"Yellow": "Yellow",
|
|
||||||
"Olive": "Olive",
|
|
||||||
"Green": "Green",
|
|
||||||
"Teal": "Teal",
|
|
||||||
"Lime": "Lime",
|
|
||||||
"Purple": "Purple",
|
|
||||||
"Navy": "Navy",
|
|
||||||
"Blue": "Blue",
|
|
||||||
"Aqua": "Aqua"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "None",
|
|
||||||
"Thin": "Thin",
|
|
||||||
"Normal": "Normal",
|
|
||||||
"Thick": "Thick"
|
|
||||||
},
|
|
||||||
"subtitle_color": "Subtitle color",
|
|
||||||
"subtitle_background_color": "Background color",
|
|
||||||
"subtitle_font": "Subtitle font",
|
|
||||||
"ksplayer_title": "KSPlayer settings",
|
|
||||||
"hardware_decode": "Hardware decoding",
|
|
||||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "API key",
|
"opensubtitles_api_key": "API key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
|
||||||
"title": "VLC subtitle settings",
|
|
||||||
"hint": "Customize subtitle appearance for the VLC player. Changes take effect on next playback.",
|
|
||||||
"text_color": "Text color",
|
|
||||||
"background_color": "Background color",
|
|
||||||
"background_opacity": "Background opacity",
|
|
||||||
"outline_color": "Outline color",
|
|
||||||
"outline_opacity": "Outline opacity",
|
|
||||||
"outline_thickness": "Outline thickness",
|
|
||||||
"bold": "Bold text",
|
|
||||||
"margin": "Bottom margin"
|
|
||||||
},
|
|
||||||
"video_player": {
|
|
||||||
"title": "Video player",
|
|
||||||
"video_player": "Video player",
|
|
||||||
"video_player_description": "Choose which video player to use on iOS.",
|
|
||||||
"ksplayer": "KSPlayer",
|
|
||||||
"vlc": "VLC"
|
|
||||||
},
|
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Other",
|
"other_title": "Other",
|
||||||
"video_orientation": "Video orientation",
|
"video_orientation": "Video orientation",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Unknown"
|
"UNKNOWN": "Unknown"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Safe area in controls",
|
"safe_area_in_controls": "Safe area in controls",
|
||||||
"video_player": "Video player",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "Show custom menu links",
|
"show_custom_menu_links": "Show custom menu links",
|
||||||
"show_large_home_carousel": "Show large home carousel (beta)",
|
"show_large_home_carousel": "Show large home carousel (beta)",
|
||||||
"hide_libraries": "Hide libraries",
|
"hide_libraries": "Hide libraries",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Max auto-play episode count",
|
"max_auto_play_episode_count": "Max auto-play episode count",
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Downloads"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -413,23 +349,18 @@
|
|||||||
"read_more_about_marlin": "Read more about Marlin.",
|
"read_more_about_marlin": "Read more about Marlin.",
|
||||||
"save_button": "Save",
|
"save_button": "Save",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Saved",
|
"saved": "Saved"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh settings from server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for search",
|
"enable_search": "Use for search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||||
"read_more_about_streamystats": "Read more about Streamystats.",
|
"read_more_about_streamystats": "Read more about Streamystats.",
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Home sections",
|
|
||||||
"enable_movie_recommendations": "Movie recommendations",
|
"enable_movie_recommendations": "Movie recommendations",
|
||||||
"enable_series_recommendations": "Series recommendations",
|
"enable_series_recommendations": "Series recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted watchlists",
|
"enable_promoted_watchlists": "Promoted watchlists",
|
||||||
@@ -445,8 +376,7 @@
|
|||||||
"refresh_from_server": "Refresh settings from server"
|
"refresh_from_server": "Refresh settings from server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable watchlist integration",
|
"watchlist_enabler": "Enable watchlist integration"
|
||||||
"watchlist_button": "Toggle watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +387,6 @@
|
|||||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
"delete_all_downloaded_files": "Delete all downloaded files",
|
||||||
"music_cache_title": "Music cache",
|
"music_cache_title": "Music cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"enable_music_cache": "Enable music cache",
|
|
||||||
"clear_music_cache": "Clear music cache",
|
"clear_music_cache": "Clear music cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -467,8 +396,6 @@
|
|||||||
"clear_all_cache": "Clear all cache",
|
"clear_all_cache": "Clear all cache",
|
||||||
"clear_all_cache_confirm": "Clear all cache?",
|
"clear_all_cache_confirm": "Clear all cache?",
|
||||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
"clear_all_cache_success": "Cache cleared",
|
|
||||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
|
||||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +417,12 @@
|
|||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Error deleting files",
|
"error_deleting_files": "Error deleting files"
|
||||||
"background_downloads_enabled": "Background downloads enabled",
|
|
||||||
"background_downloads_disabled": "Background downloads disabled"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity timeout",
|
"title": "Inactivity timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -508,6 +432,10 @@
|
|||||||
"4_hours": "4 hours",
|
"4_hours": "4 hours",
|
||||||
"24_hours": "24 hours"
|
"24_hours": "24 hours"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"sessions_title": "Sessions"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -518,10 +446,7 @@
|
|||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"series": "Series",
|
"series": "Series",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"queue": "Queue",
|
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
"queue_hint": "Queue and downloads will be lost on app restart",
|
|
||||||
"no_items_in_queue": "No items in queue",
|
|
||||||
"no_downloaded_items": "No downloaded items",
|
"no_downloaded_items": "No downloaded items",
|
||||||
"delete_all_movies_button": "Delete all movies",
|
"delete_all_movies_button": "Delete all movies",
|
||||||
"delete_all_series_button": "Delete all series",
|
"delete_all_series_button": "Delete all series",
|
||||||
@@ -546,13 +471,8 @@
|
|||||||
"failed_to_delete_all_series": "Failed to delete all series",
|
"failed_to_delete_all_series": "Failed to delete all series",
|
||||||
"deleted_media_successfully": "Deleted other media successfully!",
|
"deleted_media_successfully": "Deleted other media successfully!",
|
||||||
"failed_to_delete_media": "Failed to delete other media",
|
"failed_to_delete_media": "Failed to delete other media",
|
||||||
"download_deleted": "Download deleted",
|
|
||||||
"download_cancelled": "Download cancelled",
|
"download_cancelled": "Download cancelled",
|
||||||
"could_not_delete_download": "Could not delete download",
|
"could_not_delete_download": "Could not delete download",
|
||||||
"download_paused": "Download paused",
|
|
||||||
"could_not_pause_download": "Could not pause download",
|
|
||||||
"download_resumed": "Download resumed",
|
|
||||||
"could_not_resume_download": "Could not resume download",
|
|
||||||
"download_completed": "Download completed",
|
"download_completed": "Download completed",
|
||||||
"download_failed": "Download failed",
|
"download_failed": "Download failed",
|
||||||
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
||||||
@@ -562,10 +482,7 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All downloads deleted successfully",
|
"all_files_deleted": "All downloads deleted successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
|
||||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
|
||||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||||
"go_to_downloads": "Go to Downloads",
|
|
||||||
"file_deleted": "{{item}} deleted"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,17 +500,17 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh",
|
"episodes": "Episodes",
|
||||||
"loading": "Loading..."
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
@@ -692,10 +609,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from server: {{message}}",
|
"message_from_server": "Message from server: {{message}}",
|
||||||
"next_episode": "Next episode",
|
"next_episode": "Next episode",
|
||||||
"refresh_tracks": "Refresh tracks",
|
|
||||||
"audio_tracks": "Audio tracks:",
|
|
||||||
"playback_state": "Playback state:",
|
|
||||||
"index": "Index:",
|
|
||||||
"continue_watching": "Continue watching",
|
"continue_watching": "Continue watching",
|
||||||
"go_back": "Go back",
|
"go_back": "Go back",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -724,7 +637,8 @@
|
|||||||
"stopPlayback": "Stop playback",
|
"stopPlayback": "Stop playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -762,7 +676,6 @@
|
|||||||
"show_more": "Show more",
|
"show_more": "Show more",
|
||||||
"show_less": "Show less",
|
"show_less": "Show less",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical details",
|
"technical_details": "Technical details",
|
||||||
@@ -785,7 +698,8 @@
|
|||||||
"resume_playback": "Resume playback",
|
"resume_playback": "Resume playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from start",
|
"play_from_start": "Play from start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
@@ -822,11 +736,7 @@
|
|||||||
"report_issue_button": "Report issue",
|
"report_issue_button": "Report issue",
|
||||||
"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 Login",
|
"failed_to_login": "Failed to log in",
|
||||||
"connect_to_jellyseerr": "Connect to Jellyseerr",
|
|
||||||
"session_expired": "Session expired",
|
|
||||||
"session_expired_connect_again": "Your Jellyseerr session has expired. Please reconnect in Settings.",
|
|
||||||
"connect_in_settings": "Jellyseerr is available. Connect in Settings to enable request features.",
|
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -893,13 +803,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "Songs"
|
"tracks": "Songs"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently added",
|
"recently_added": "Recently added",
|
||||||
"recently_played": "Recently played",
|
"recently_played": "Recently played",
|
||||||
"frequently_played": "Frequently played",
|
"frequently_played": "Frequently played",
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top songs",
|
"top_tracks": "Top songs",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1033,7 +939,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with phone",
|
"pair_with_phone": "Pair with phone",
|
||||||
"pair_with_phone_title": "Log in on TV",
|
"pair_with_phone_title": "Log in on TV",
|
||||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
|
||||||
"waiting_for_phone": "Waiting for phone...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const getDownloadUrl = async ({
|
|||||||
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
|
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
|
||||||
console.log("Downloading item directly");
|
console.log("Downloading item directly");
|
||||||
return {
|
return {
|
||||||
url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`,
|
url: `${api.basePath}/Items/${mediaSource.Id}/Download?api_key=${api.accessToken}`,
|
||||||
mediaSource: streamDetails?.mediaSource ?? null,
|
mediaSource: streamDetails?.mediaSource ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user