mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 21:48:31 +01:00
Compare commits
3 Commits
feat/unifi
...
fix/tv-see
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36d18e2bec | ||
|
|
326956dfda | ||
|
|
7528274249 |
@@ -3,7 +3,7 @@ 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 { useMemo, useState } from "react";
|
import { useCallback, 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";
|
||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
TVSettingsToggle,
|
TVSettingsToggle,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { useJellyseerrConnect } from "@/hooks/useJellyseerrConnect";
|
||||||
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";
|
||||||
@@ -59,6 +61,37 @@ export default function SettingsTV() {
|
|||||||
const { showUserSwitchModal } = useTVUserSwitchModal();
|
const { showUserSwitchModal } = useTVUserSwitchModal();
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { jellyseerrApi, clearAllJellyseerData } = useJellyseerr();
|
||||||
|
const { connecting: jellyseerrConnecting, connect: jellyseerrConnect } =
|
||||||
|
useJellyseerrConnect();
|
||||||
|
|
||||||
|
// Jellyseerr state
|
||||||
|
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
|
||||||
|
settings.jellyseerrServerUrl || "",
|
||||||
|
);
|
||||||
|
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
|
||||||
|
const { pluginSettings } = useSettings();
|
||||||
|
|
||||||
|
const isJellyseerrLocked =
|
||||||
|
pluginSettings?.jellyseerrServerUrl?.locked === true;
|
||||||
|
const isJellyseerrConnected = !!jellyseerrApi;
|
||||||
|
|
||||||
|
const handleJellyseerrUrlBlur = useCallback(() => {
|
||||||
|
const url = jellyseerrServerUrl.trim();
|
||||||
|
updateSettings({ jellyseerrServerUrl: url || undefined });
|
||||||
|
}, [jellyseerrServerUrl, updateSettings]);
|
||||||
|
|
||||||
|
const handleJellyseerrConnect = useCallback(async () => {
|
||||||
|
const url = jellyseerrServerUrl.trim();
|
||||||
|
if (!url) return;
|
||||||
|
await jellyseerrConnect(url, jellyseerrPassword);
|
||||||
|
}, [jellyseerrServerUrl, jellyseerrPassword, jellyseerrConnect]);
|
||||||
|
|
||||||
|
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(
|
||||||
@@ -883,6 +916,81 @@ 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 || jellyseerrConnecting}
|
||||||
|
/>
|
||||||
|
{!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={jellyseerrConnecting}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={
|
||||||
|
jellyseerrConnecting
|
||||||
|
? t("common.connecting", "Connecting...") || "Connecting..."
|
||||||
|
: t("common.connect", "Connect") || "Connect"
|
||||||
|
}
|
||||||
|
value=''
|
||||||
|
onPress={handleJellyseerrConnect}
|
||||||
|
disabled={jellyseerrConnecting}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<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
|
||||||
|
|||||||
@@ -11,15 +11,12 @@ import {
|
|||||||
} from "react-native";
|
} 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 { toast } from "sonner-native";
|
||||||
import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { reachabilityProbe } from "@/utils/serverUrl/probes/reachability";
|
|
||||||
|
|
||||||
export default function MarlinSearchPage() {
|
export default function MarlinSearchPage() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -32,7 +29,6 @@ export default function MarlinSearchPage() {
|
|||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
const urlResolver = useServerUrlResolver(reachabilityProbe);
|
|
||||||
|
|
||||||
const onSave = (val: string) => {
|
const onSave = (val: string) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -131,17 +127,8 @@ export default function MarlinSearchPage() {
|
|||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
onChangeText={(text) => setValue(text)}
|
onChangeText={(text) => setValue(text)}
|
||||||
onBlur={() => {
|
|
||||||
const candidate = value.trim();
|
|
||||||
if (candidate) {
|
|
||||||
urlResolver.resolve(candidate).then((r) => {
|
|
||||||
if (r.ok) setValue(r.url);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<ServerUrlStatusText state={urlResolver} className='mt-1' />
|
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
|
|||||||
@@ -11,14 +11,11 @@ import {
|
|||||||
} from "react-native";
|
} 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 { toast } from "sonner-native";
|
||||||
import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { reachabilityProbe } from "@/utils/serverUrl/probes/reachability";
|
|
||||||
|
|
||||||
export default function StreamystatsPage() {
|
export default function StreamystatsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -35,7 +32,6 @@ export default function StreamystatsPage() {
|
|||||||
|
|
||||||
// Local state for all editable fields
|
// Local state for all editable fields
|
||||||
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
|
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
|
||||||
const urlResolver = useServerUrlResolver(reachabilityProbe);
|
|
||||||
const [useForSearch, setUseForSearch] = useState<boolean>(
|
const [useForSearch, setUseForSearch] = useState<boolean>(
|
||||||
settings?.searchEngine === "Streamystats",
|
settings?.searchEngine === "Streamystats",
|
||||||
);
|
);
|
||||||
@@ -156,20 +152,9 @@ export default function StreamystatsPage() {
|
|||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
onChangeText={setUrl}
|
onChangeText={setUrl}
|
||||||
onBlur={() => {
|
|
||||||
const candidate = url.trim();
|
|
||||||
if (candidate) {
|
|
||||||
urlResolver.resolve(candidate).then((r) => {
|
|
||||||
if (r.ok) setUrl(r.url);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<View className='px-4 mt-1'>
|
|
||||||
<ServerUrlStatusText state={urlResolver} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
|
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
import { useCallback, useRef } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
|
|
||||||
import type { ResolveOptions } from "@/utils/serverUrl/resolve";
|
|
||||||
import type { ServerProbe } from "@/utils/serverUrl/types";
|
|
||||||
import { Input } from "./Input";
|
|
||||||
import { ServerUrlStatusText } from "./ServerUrlStatusText";
|
|
||||||
import { Text } from "./Text";
|
|
||||||
|
|
||||||
interface ServerUrlFieldProps {
|
|
||||||
/** Raw user input (controlled). */
|
|
||||||
value: string;
|
|
||||||
onChangeText: (text: string) => void;
|
|
||||||
/** Service-specific validator. Pass a stable (module-level) reference. */
|
|
||||||
probe: ServerProbe;
|
|
||||||
/** Called with the canonical URL once a candidate validates. */
|
|
||||||
onResolved?: (url: string, meta?: Record<string, unknown>) => void;
|
|
||||||
label?: string;
|
|
||||||
hint?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
editable?: boolean;
|
|
||||||
resolveOptions?: ResolveOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified server-URL input: the user types a loose address (`media.example.com`,
|
|
||||||
* `https://…`, `host:port`); on blur it auto-resolves via the given probe,
|
|
||||||
* adopts the canonical URL into the field, and persists it. A small status line
|
|
||||||
* (checking / resolved / error) shows underneath.
|
|
||||||
*/
|
|
||||||
export function ServerUrlField({
|
|
||||||
value,
|
|
||||||
onChangeText,
|
|
||||||
probe,
|
|
||||||
onResolved,
|
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
placeholder,
|
|
||||||
editable = true,
|
|
||||||
resolveOptions,
|
|
||||||
}: ServerUrlFieldProps) {
|
|
||||||
const resolver = useServerUrlResolver(probe, resolveOptions);
|
|
||||||
const lastResolvedInput = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const runResolve = useCallback(async () => {
|
|
||||||
const input = value.trim();
|
|
||||||
if (!input) {
|
|
||||||
resolver.reset();
|
|
||||||
lastResolvedInput.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastResolvedInput.current = input;
|
|
||||||
const result = await resolver.resolve(input);
|
|
||||||
if (result.ok) {
|
|
||||||
onChangeText(result.url); // adopt the canonical URL into the field
|
|
||||||
onResolved?.(result.url, result.meta);
|
|
||||||
}
|
|
||||||
}, [value, resolver, onChangeText, onResolved]);
|
|
||||||
|
|
||||||
const handleBlur = useCallback(() => {
|
|
||||||
const input = value.trim();
|
|
||||||
if (input && input !== lastResolvedInput.current) runResolve();
|
|
||||||
}, [value, runResolve]);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(text: string) => {
|
|
||||||
onChangeText(text);
|
|
||||||
// Editing invalidates a previous result; drop the stale status.
|
|
||||||
if (resolver.status !== "idle") resolver.reset();
|
|
||||||
lastResolvedInput.current = null;
|
|
||||||
},
|
|
||||||
[onChangeText, resolver],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
{label ? <Text className='font-bold mb-1'>{label}</Text> : null}
|
|
||||||
{hint ? <Text className='text-xs text-gray-500 mb-2'>{hint}</Text> : null}
|
|
||||||
|
|
||||||
<Input
|
|
||||||
value={value}
|
|
||||||
onChangeText={handleChange}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onSubmitEditing={runResolve}
|
|
||||||
placeholder={placeholder}
|
|
||||||
editable={editable}
|
|
||||||
extraClassName='border border-neutral-800'
|
|
||||||
keyboardType='url'
|
|
||||||
autoCapitalize='none'
|
|
||||||
autoCorrect={false}
|
|
||||||
returnKeyType='go'
|
|
||||||
textContentType='URL'
|
|
||||||
clearButtonMode='never'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ServerUrlStatusText state={resolver} className='mt-2' />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ActivityIndicator, View } from "react-native";
|
|
||||||
import type { ServerUrlResolverState } from "@/hooks/useServerUrlResolver";
|
|
||||||
import { Text } from "./Text";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact status line for the server-URL resolver, for screens whose layout
|
|
||||||
* (e.g. ListItem rows) doesn't fit the full `ServerUrlField`. Renders nothing
|
|
||||||
* while idle.
|
|
||||||
*/
|
|
||||||
export function ServerUrlStatusText({
|
|
||||||
state,
|
|
||||||
className = "",
|
|
||||||
}: {
|
|
||||||
state: ServerUrlResolverState;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (state.status === "idle") return null;
|
|
||||||
|
|
||||||
if (state.status === "resolving") {
|
|
||||||
return (
|
|
||||||
<View className={`flex-row items-center ${className}`}>
|
|
||||||
<ActivityIndicator size='small' color='#9ca3af' />
|
|
||||||
<Text className='text-xs text-neutral-400 ml-2'>
|
|
||||||
{t("server_url.resolving")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.status === "ok") {
|
|
||||||
return (
|
|
||||||
<Text className={`text-xs text-green-500 ${className}`}>
|
|
||||||
{t("server_url.resolved", { url: state.resolvedUrl })}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const message =
|
|
||||||
state.reason === "wrong-service"
|
|
||||||
? t("server_url.wrong_service")
|
|
||||||
: state.reason === "invalid"
|
|
||||||
? t("server_url.invalid_url")
|
|
||||||
: t("server_url.unreachable");
|
|
||||||
|
|
||||||
return <Text className={`text-xs text-red-500 ${className}`}>{message}</Text>;
|
|
||||||
}
|
|
||||||
@@ -11,13 +11,10 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { sendCredentialsToTV } from "@/utils/pairingService";
|
import { sendCredentialsToTV } from "@/utils/pairingService";
|
||||||
import { jellyfinProbe } from "@/utils/serverUrl/probes/jellyfin";
|
|
||||||
|
|
||||||
type ScreenState =
|
type ScreenState =
|
||||||
| "scanning"
|
| "scanning"
|
||||||
@@ -52,7 +49,6 @@ export const CompanionLoginScreen: React.FC = () => {
|
|||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const serverResolver = useServerUrlResolver(jellyfinProbe);
|
|
||||||
|
|
||||||
// Pre-fill server URL and username from current session
|
// Pre-fill server URL and username from current session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -409,16 +405,7 @@ export const CompanionLoginScreen: React.FC = () => {
|
|||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
keyboardType='url'
|
keyboardType='url'
|
||||||
returnKeyType='next'
|
returnKeyType='next'
|
||||||
onBlur={() => {
|
|
||||||
const candidate = serverUrl.trim();
|
|
||||||
if (candidate) {
|
|
||||||
serverResolver.resolve(candidate).then((r) => {
|
|
||||||
if (r.ok) setServerUrl(r.url);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<ServerUrlStatusText state={serverResolver} className='mt-2' />
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='mb-5'>
|
<View className='mb-5'>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 {
|
||||||
@@ -22,8 +23,6 @@ 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;
|
||||||
@@ -34,6 +33,7 @@ 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,6 +50,8 @@ 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",
|
||||||
@@ -71,7 +73,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
width: 210,
|
width: posterWidth,
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.6 : 0,
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
@@ -81,9 +83,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 210,
|
width: posterWidth,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: 24,
|
borderRadius: sizes.gaps.small,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
}}
|
}}
|
||||||
@@ -140,12 +142,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{year && (
|
{year != null && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: sizes.gaps.small,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{year}
|
{year}
|
||||||
@@ -166,6 +168,7 @@ 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();
|
||||||
|
|
||||||
@@ -231,14 +234,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: 24 }}>
|
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: sizes.gaps.small,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: sizes.padding.scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{slideTitle}
|
{slideTitle}
|
||||||
@@ -249,9 +252,9 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: SCALE_PADDING,
|
paddingHorizontal: sizes.padding.scale,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: sizes.padding.scale,
|
||||||
gap: 20,
|
gap: sizes.gaps.item,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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";
|
||||||
@@ -14,8 +15,6 @@ 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;
|
||||||
@@ -28,6 +27,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
isFirstItem = false,
|
isFirstItem = 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 });
|
||||||
@@ -43,6 +43,8 @@ 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}
|
||||||
@@ -54,7 +56,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
width: 210,
|
width: posterWidth,
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.6 : 0,
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
@@ -64,9 +66,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 210,
|
width: posterWidth,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: 24,
|
borderRadius: sizes.gaps.small,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
}}
|
}}
|
||||||
@@ -117,13 +119,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: 12,
|
marginTop: sizes.gaps.small,
|
||||||
}}
|
}}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{year && (
|
{year != null && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
@@ -149,6 +151,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
onPress,
|
onPress,
|
||||||
}) => {
|
}) => {
|
||||||
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();
|
||||||
@@ -157,13 +160,15 @@ 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 onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
|
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
width: 160,
|
width: avatarSize,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
@@ -174,9 +179,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 140,
|
width: avatarSize,
|
||||||
height: 140,
|
height: avatarSize,
|
||||||
borderRadius: 70,
|
borderRadius: avatarSize / 2,
|
||||||
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,
|
||||||
@@ -198,7 +203,11 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
|
<Ionicons
|
||||||
|
name='person'
|
||||||
|
size={Math.round(avatarSize * 0.35)}
|
||||||
|
color='rgba(255,255,255,0.4)'
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -207,7 +216,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: 12,
|
marginTop: sizes.gaps.small,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
@@ -233,17 +242,18 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: sizes.gaps.small,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: sizes.padding.scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -254,9 +264,9 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: SCALE_PADDING,
|
paddingHorizontal: sizes.padding.scale,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: sizes.padding.scale,
|
||||||
gap: 20,
|
gap: sizes.gaps.item,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
@@ -285,17 +295,18 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: sizes.gaps.small,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: sizes.padding.scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -306,9 +317,9 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: SCALE_PADDING,
|
paddingHorizontal: sizes.padding.scale,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: sizes.padding.scale,
|
||||||
gap: 20,
|
gap: sizes.gaps.item,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
@@ -337,17 +348,18 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
|||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: sizes.gaps.small,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: sizes.padding.scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -358,9 +370,9 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: SCALE_PADDING,
|
paddingHorizontal: sizes.padding.scale,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: sizes.padding.scale,
|
||||||
gap: 20,
|
gap: sizes.gaps.item,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
@@ -400,6 +412,7 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
onPersonPress,
|
onPersonPress,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
@@ -410,7 +423,7 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 24,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -418,7 +431,9 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
>
|
>
|
||||||
{t("search.no_results_found_for")}
|
{t("search.no_results_found_for")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
<Text
|
||||||
|
style={{ fontSize: typography.body, color: "rgba(255,255,255,0.6)" }}
|
||||||
|
>
|
||||||
"{searchQuery}"
|
"{searchQuery}"
|
||||||
</Text>
|
</Text>
|
||||||
</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 } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, View } from "react-native";
|
import { Platform, ScrollView, TextInput, 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,6 +166,7 @@ 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(() => {
|
||||||
@@ -231,26 +232,51 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
paddingTop: insets.top + TOP_PADDING,
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
|
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
|
||||||
module). It renders the native search bar + grid keyboard and
|
TextInput fallback on Android TV (the native module is Apple-only). */}
|
||||||
forwards typed text into the existing query pipeline via setSearch;
|
{Platform.OS === "ios" ? (
|
||||||
our own results grid renders below. */}
|
<View
|
||||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
style={{
|
||||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
marginBottom: 24,
|
||||||
margins squeeze the bar's width and clip that trailing hint, so let
|
height: SEARCH_AREA_HEIGHT,
|
||||||
the native view span the full width and own its own insets. */}
|
}}
|
||||||
<View
|
>
|
||||||
style={{
|
{/* No horizontal margin here: the native tvOS search bar centers
|
||||||
marginBottom: 24,
|
itself and renders a trailing "Hold to Dictate" hint. */}
|
||||||
height: SEARCH_AREA_HEIGHT,
|
<TvSearchView
|
||||||
}}
|
style={{ width: "100%", height: "100%" }}
|
||||||
>
|
placeholder={t("search.search")}
|
||||||
<TvSearchView
|
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||||
style={{ width: "100%", height: "100%" }}
|
/>
|
||||||
placeholder={t("search.search")}
|
</View>
|
||||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
) : (
|
||||||
/>
|
<View
|
||||||
</View>
|
style={{
|
||||||
|
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
|
||||||
@@ -268,6 +294,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
searchType={searchType}
|
searchType={searchType}
|
||||||
setSearchType={setSearchType}
|
setSearchType={setSearchType}
|
||||||
showDiscover={showDiscover}
|
showDiscover={showDiscover}
|
||||||
|
disabled={isSearchFocused}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -294,6 +321,7 @@ 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={
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ import { toast } from "sonner-native";
|
|||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { jellyseerrProbe } from "@/utils/serverUrl/probes/jellyseerr";
|
|
||||||
import { resolveServerUrl } from "@/utils/serverUrl/resolve";
|
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Input } from "../common/Input";
|
import { Input } from "../common/Input";
|
||||||
import { ServerUrlField } from "../common/ServerUrlField";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
@@ -29,44 +26,26 @@ export const JellyseerrSettings = () => {
|
|||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<string>(
|
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
|
||||||
settings?.jellyseerrServerUrl ?? "",
|
string | undefined
|
||||||
);
|
>(settings?.jellyseerrServerUrl || undefined);
|
||||||
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>(
|
|
||||||
settings?.jellyseerrServerUrl ?? undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const loginToJellyseerrMutation = useMutation({
|
const loginToJellyseerrMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
||||||
|
throw new Error("Missing server url");
|
||||||
if (!user?.Name)
|
if (!user?.Name)
|
||||||
throw new Error("Missing required information for login");
|
throw new Error("Missing required information for login");
|
||||||
|
const jellyseerrTempApi = new JellyseerrApi(
|
||||||
// Prefer the already-resolved URL; otherwise resolve the raw input now
|
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
|
||||||
// (covers tapping Login before the field's on-blur resolve settled).
|
);
|
||||||
let finalUrl = resolvedUrl || settings?.jellyseerrServerUrl || "";
|
|
||||||
if (!finalUrl && jellyseerrServerUrl) {
|
|
||||||
const resolved = await resolveServerUrl(
|
|
||||||
jellyseerrServerUrl,
|
|
||||||
jellyseerrProbe,
|
|
||||||
);
|
|
||||||
if (!resolved.ok) throw new Error("Invalid server url");
|
|
||||||
finalUrl = resolved.url;
|
|
||||||
}
|
|
||||||
if (!finalUrl) throw new Error("Missing server url");
|
|
||||||
|
|
||||||
const jellyseerrTempApi = new JellyseerrApi(finalUrl);
|
|
||||||
const testResult = await jellyseerrTempApi.test();
|
const testResult = await jellyseerrTempApi.test();
|
||||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||||
const loggedInUser = await jellyseerrTempApi.login(
|
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
||||||
user.Name,
|
|
||||||
jellyseerrPassword || "",
|
|
||||||
);
|
|
||||||
return { user: loggedInUser, url: finalUrl };
|
|
||||||
},
|
},
|
||||||
onSuccess: ({ user: loggedInUser, url }) => {
|
onSuccess: (user) => {
|
||||||
setJellyseerrUser(loggedInUser);
|
setJellyseerrUser(user);
|
||||||
setResolvedUrl(url);
|
updateSettings({ jellyseerrServerUrl });
|
||||||
updateSettings({ jellyseerrServerUrl: url });
|
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(t("jellyseerr.failed_to_login"));
|
toast.error(t("jellyseerr.failed_to_login"));
|
||||||
@@ -80,8 +59,7 @@ export const JellyseerrSettings = () => {
|
|||||||
clearAllJellyseerData().finally(() => {
|
clearAllJellyseerData().finally(() => {
|
||||||
setJellyseerrUser(undefined);
|
setJellyseerrUser(undefined);
|
||||||
setJellyseerrPassword(undefined);
|
setJellyseerrPassword(undefined);
|
||||||
setjellyseerrServerUrl("");
|
setjellyseerrServerUrl(undefined);
|
||||||
setResolvedUrl(undefined);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,20 +118,30 @@ export const JellyseerrSettings = () => {
|
|||||||
<Text className='text-xs text-red-600 mb-2'>
|
<Text className='text-xs text-red-600 mb-2'>
|
||||||
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='mb-2'>
|
<Text className='font-bold mb-1'>
|
||||||
<ServerUrlField
|
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||||
value={jellyseerrServerUrl}
|
</Text>
|
||||||
onChangeText={setjellyseerrServerUrl}
|
<View className='flex flex-col shrink mb-2'>
|
||||||
onResolved={(url) => setResolvedUrl(url)}
|
<Text className='text-xs text-gray-600'>
|
||||||
probe={jellyseerrProbe}
|
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||||
label={t("home.settings.plugins.jellyseerr.server_url")}
|
</Text>
|
||||||
hint={t("home.settings.plugins.jellyseerr.server_url_hint")}
|
|
||||||
placeholder={t(
|
|
||||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
|
||||||
)}
|
|
||||||
editable={!loginToJellyseerrMutation.isPending}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
<Input
|
||||||
|
className='border border-neutral-800 mb-2'
|
||||||
|
placeholder={t(
|
||||||
|
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||||
|
)}
|
||||||
|
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
||||||
|
defaultValue={
|
||||||
|
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||||
|
}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
onChangeText={setjellyseerrServerUrl}
|
||||||
|
editable={!loginToJellyseerrMutation.isPending}
|
||||||
|
/>
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-bold mb-2'>
|
<Text className='font-bold mb-2'>
|
||||||
{t("home.settings.plugins.jellyseerr.password")}
|
{t("home.settings.plugins.jellyseerr.password")}
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ import {
|
|||||||
type LocalNetworkConfig,
|
type LocalNetworkConfig,
|
||||||
updateServerLocalConfig,
|
updateServerLocalConfig,
|
||||||
} from "@/utils/secureCredentials";
|
} from "@/utils/secureCredentials";
|
||||||
import { jellyfinProbe } from "@/utils/serverUrl/probes/jellyfin";
|
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { ServerUrlField } from "../common/ServerUrlField";
|
import { Input } from "../common/Input";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
@@ -163,12 +162,13 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className=''>
|
<View className=''>
|
||||||
<ServerUrlField
|
<Input
|
||||||
|
placeholder={t("home.settings.network.local_url_placeholder")}
|
||||||
value={config.localUrl}
|
value={config.localUrl}
|
||||||
onChangeText={handleLocalUrlChange}
|
onChangeText={handleLocalUrlChange}
|
||||||
onResolved={(url) => saveConfig({ ...config, localUrl: url })}
|
keyboardType='url'
|
||||||
probe={jellyfinProbe}
|
autoCapitalize='none'
|
||||||
placeholder={t("home.settings.network.local_url_placeholder")}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import type {
|
|||||||
TvDetails,
|
TvDetails,
|
||||||
} from "@/utils/jellyseerr/server/models/Tv";
|
} from "@/utils/jellyseerr/server/models/Tv";
|
||||||
import { writeErrorLog } from "@/utils/log";
|
import { writeErrorLog } from "@/utils/log";
|
||||||
import { isVersionBelow } from "@/utils/serverUrl/semver";
|
|
||||||
|
|
||||||
interface SearchParams {
|
interface SearchParams {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -142,13 +141,10 @@ export class JellyseerrApi {
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
const { status, headers, data } = response;
|
const { status, headers, data } = response;
|
||||||
if (inRange(status, 200, 299)) {
|
if (inRange(status, 200, 299)) {
|
||||||
if (data.version && isVersionBelow(data.version, "2.0.0")) {
|
if (data.version < "2.0.0") {
|
||||||
const error = t(
|
const error = t(
|
||||||
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
||||||
);
|
);
|
||||||
writeErrorLog(
|
|
||||||
`Jellyseerr version ${data.version} is below the required 2.0.0`,
|
|
||||||
);
|
|
||||||
toast.error(error);
|
toast.error(error);
|
||||||
throw Error(error);
|
throw Error(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
type ResolveFailureReason,
|
|
||||||
type ResolveOptions,
|
|
||||||
type ResolveResult,
|
|
||||||
resolveServerUrl,
|
|
||||||
} from "@/utils/serverUrl/resolve";
|
|
||||||
import type { ServerProbe } from "@/utils/serverUrl/types";
|
|
||||||
|
|
||||||
export type ServerUrlResolverState =
|
|
||||||
| { status: "idle" }
|
|
||||||
| { status: "resolving" }
|
|
||||||
| { status: "ok"; resolvedUrl: string; meta?: Record<string, unknown> }
|
|
||||||
| { status: "error"; reason: ResolveFailureReason };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stateful wrapper around `resolveServerUrl` for screens.
|
|
||||||
*
|
|
||||||
* `resolve(input)` cancels any in-flight resolution, drives the state machine
|
|
||||||
* (idle → resolving → ok | error) and returns the raw result. Pass a stable
|
|
||||||
* (module-level) probe; memoize `options` if you supply one.
|
|
||||||
*/
|
|
||||||
export function useServerUrlResolver(
|
|
||||||
probe: ServerProbe,
|
|
||||||
options?: ResolveOptions,
|
|
||||||
) {
|
|
||||||
const [state, setState] = useState<ServerUrlResolverState>({
|
|
||||||
status: "idle",
|
|
||||||
});
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
const resolve = useCallback(
|
|
||||||
async (input: string): Promise<ResolveResult> => {
|
|
||||||
abortRef.current?.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
abortRef.current = controller;
|
|
||||||
setState({ status: "resolving" });
|
|
||||||
|
|
||||||
const result = await resolveServerUrl(input, probe, {
|
|
||||||
...options,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ignore results from a resolution that was superseded/cancelled.
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setState(
|
|
||||||
result.ok
|
|
||||||
? { status: "ok", resolvedUrl: result.url, meta: result.meta }
|
|
||||||
: { status: "error", reason: result.reason },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
[probe, options],
|
|
||||||
);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
abortRef.current?.abort();
|
|
||||||
setState({ status: "idle" });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => () => abortRef.current?.abort(), []);
|
|
||||||
|
|
||||||
return { ...state, resolve, reset };
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
<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
|
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
||||||
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,31 +61,61 @@ 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
|
||||||
|
|
||||||
if (programIds != null) {
|
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
|
||||||
|
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||||
|
if (allProgramIds != null) {
|
||||||
var deletedPrograms = 0
|
var deletedPrograms = 0
|
||||||
val keys = programIds.keys()
|
val channelKeys = allProgramIds.keys()
|
||||||
while (keys.hasNext()) {
|
while (channelKeys.hasNext()) {
|
||||||
val key = keys.next()
|
val channelIdStr = channelKeys.next()
|
||||||
val programId = programIds.optLong(key, -1L)
|
val programIdsJson = allProgramIds.optString(channelIdStr)
|
||||||
if (programId > 0L) {
|
if (programIdsJson.isBlank()) continue
|
||||||
contentResolver.delete(
|
|
||||||
TvContractCompat.buildPreviewProgramUri(programId),
|
try {
|
||||||
null,
|
val programIds = JSONObject(programIdsJson)
|
||||||
null
|
val keys = programIds.keys()
|
||||||
)
|
while (keys.hasNext()) {
|
||||||
deletedPrograms += 1
|
val providerId = keys.next()
|
||||||
|
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)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channelId > 0L) {
|
// Also handle legacy format (flat { providerId: programId }) for migration
|
||||||
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||||
Log.d(TAG, "clear(): notified channel $channelId")
|
if (legacyProgramIds != null) {
|
||||||
|
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()
|
||||||
@@ -96,126 +126,262 @@ 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()
|
||||||
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
|
if (sections.length() == 0) {
|
||||||
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
Log.w(TAG, "synchronize(): no sections in payload")
|
||||||
val items = firstSection?.optJSONArray("items") ?: JSONArray()
|
|
||||||
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
|
val prefs = preferences(context)
|
||||||
|
val allNextProgramIds = JSONObject()
|
||||||
|
var totalActive = 0
|
||||||
|
var totalDeleted = 0
|
||||||
|
|
||||||
val previousProgramIds = preferences(context)
|
for (sectionIndex in 0 until sections.length()) {
|
||||||
.getString(KEY_PROGRAM_IDS, null)
|
val section = sections.optJSONObject(sectionIndex) ?: continue
|
||||||
?.let(::JSONObject)
|
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
||||||
?: JSONObject()
|
val items = section.optJSONArray("items") ?: JSONArray()
|
||||||
val nextProgramIds = JSONObject()
|
|
||||||
val activeProviderIds = mutableSetOf<String>()
|
|
||||||
|
|
||||||
for (index in 0 until items.length()) {
|
Log.d(
|
||||||
val item = items.optJSONObject(index) ?: continue
|
TAG,
|
||||||
val providerId = item.optString("id")
|
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
|
||||||
if (providerId.isBlank()) continue
|
|
||||||
|
|
||||||
val programId = upsertPreviewProgram(
|
|
||||||
context = context,
|
|
||||||
channelId = channelId,
|
|
||||||
item = item,
|
|
||||||
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
|
||||||
weight = index
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (programId > 0L) {
|
val channelId = getOrCreateChannel(context, sectionTitle)
|
||||||
activeProviderIds += providerId
|
if (channelId <= 0L) {
|
||||||
nextProgramIds.put(providerId, programId)
|
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
|
||||||
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
continue
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var deletedPrograms = 0
|
// Per Android docs: check channel.isBrowsable() and request if needed.
|
||||||
val previousKeys = previousProgramIds.keys()
|
if (!isChannelBrowsable(context, channelId)) {
|
||||||
while (previousKeys.hasNext()) {
|
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
|
||||||
val providerId = previousKeys.next()
|
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||||
if (activeProviderIds.contains(providerId)) continue
|
}
|
||||||
|
|
||||||
val programId = previousProgramIds.optLong(providerId, -1L)
|
val prefKey = "programIds_$channelId"
|
||||||
if (programId > 0L) {
|
val previousProgramIds = prefs.getString(prefKey, null)
|
||||||
context.contentResolver.delete(
|
?.let(::JSONObject)
|
||||||
TvContractCompat.buildPreviewProgramUri(programId),
|
?: JSONObject()
|
||||||
null,
|
val nextProgramIds = JSONObject()
|
||||||
null
|
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
|
||||||
)
|
)
|
||||||
deletedPrograms += 1
|
|
||||||
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
preferences(context)
|
// Store all channel program IDs for clear() to use
|
||||||
.edit()
|
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
|
||||||
.putLong(KEY_CHANNEL_ID, channelId)
|
|
||||||
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
|
|
||||||
.apply()
|
|
||||||
|
|
||||||
logProviderState(context, channelId)
|
|
||||||
|
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
|
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted 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) {
|
||||||
val updated = Channel.Builder()
|
// Query provider first to verify channel actually exists (prevents recreate bug)
|
||||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
val exists = channelExistsInProvider(context, existingChannelId)
|
||||||
.setDisplayName(displayName)
|
|
||||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val updatedRows = contentResolver.update(
|
if (exists) {
|
||||||
TvContractCompat.buildChannelUri(existingChannelId),
|
// Channel exists — update it in place, never recreate
|
||||||
updated.toContentValues(),
|
val updated = Channel.Builder()
|
||||||
null,
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
null
|
.setDisplayName(displayName)
|
||||||
)
|
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||||
|
.build()
|
||||||
|
|
||||||
if (updatedRows > 0) {
|
try {
|
||||||
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
val updatedRows = contentResolver.update(
|
||||||
storeChannelLogo(context, existingChannelId)
|
TvContractCompat.buildChannelUri(existingChannelId),
|
||||||
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
updated.toContentValues(),
|
||||||
return existingChannelId
|
null,
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
|
// Channel truly doesn't exist in provider — recreate
|
||||||
|
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 = contentResolver.insert(
|
val channelUri = try {
|
||||||
TvContractCompat.Channels.CONTENT_URI,
|
contentResolver.insert(
|
||||||
channel.toContentValues()
|
TvContractCompat.Channels.CONTENT_URI,
|
||||||
) ?: return -1L
|
channel.toContentValues()
|
||||||
|
)
|
||||||
|
} 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)
|
||||||
@@ -249,42 +415,62 @@ 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 imageUri = Uri.parse(it)
|
val uniqueImageUrl = appendCacheBuster(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) {
|
||||||
val updatedRows = contentResolver.update(
|
try {
|
||||||
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
val updatedRows = contentResolver.update(
|
||||||
contentValues,
|
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
||||||
null,
|
contentValues,
|
||||||
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 = contentResolver.insert(
|
val insertedUri = try {
|
||||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
contentResolver.insert(
|
||||||
contentValues
|
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||||
) ?: return -1L
|
contentValues
|
||||||
|
)
|
||||||
|
} 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)
|
||||||
@@ -306,13 +492,17 @@ 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
|
||||||
val outputStream = context.contentResolver.openOutputStream(
|
try {
|
||||||
TvContractCompat.buildChannelLogoUri(channelId)
|
val outputStream = context.contentResolver.openOutputStream(
|
||||||
) ?: return
|
TvContractCompat.buildChannelLogoUri(channelId)
|
||||||
|
) ?: 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,9 +531,14 @@ 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
|
||||||
|
|
||||||
@@ -372,8 +567,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: Exception) {
|
} catch (error: SecurityException) {
|
||||||
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
{
|
{
|
||||||
"server_url": {
|
|
||||||
"resolving": "Checking…",
|
|
||||||
"resolved": "→ {{url}}",
|
|
||||||
"connected": "Connected to {{url}}",
|
|
||||||
"unreachable": "Server unreachable",
|
|
||||||
"wrong_service": "Reachable, but not the expected server",
|
|
||||||
"invalid_url": "Enter a valid address"
|
|
||||||
},
|
|
||||||
"login": {
|
"login": {
|
||||||
"username_required": "Username Is Required",
|
"username_required": "Username Is Required",
|
||||||
"error_title": "Error",
|
"error_title": "Error",
|
||||||
@@ -600,7 +592,8 @@
|
|||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh",
|
||||||
|
"loading": "Loading..."
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generic server-URL candidate generator.
|
|
||||||
*
|
|
||||||
* Turns loose user input (`media.uruk.dev`, `https://media.uruk.dev`,
|
|
||||||
* `host:8096`, `http://10.0.0.5:3000/path`) into an ordered list of full URLs
|
|
||||||
* to probe — https first, http as fallback — while preserving any explicit
|
|
||||||
* port and path. Service-agnostic: unlike the Jellyfin SDK's `getAddressCandidates`
|
|
||||||
* it adds no Jellyfin-specific ports, so it suits Jellyseerr/Streamystats/etc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// scheme? host (port)? (path/query/hash)?
|
|
||||||
const URL_RE = /^(?:(https?):\/\/)?([^/:\s?#]+)(?::(\d+))?([/?#].*)?$/i;
|
|
||||||
|
|
||||||
export interface ParsedServerInput {
|
|
||||||
scheme?: "http" | "https";
|
|
||||||
host: string;
|
|
||||||
port?: string;
|
|
||||||
/** Normalized path+query+hash, without a trailing slash; "" when none. */
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePath(path?: string): string {
|
|
||||||
if (!path || path === "/") return "";
|
|
||||||
return path.replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse loose user input. Returns null when it can't be understood. */
|
|
||||||
export function parseServerInput(input: string): ParsedServerInput | null {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
|
|
||||||
const match = URL_RE.exec(trimmed);
|
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
const [, scheme, host, port, rawPath] = match;
|
|
||||||
return {
|
|
||||||
scheme: scheme ? (scheme.toLowerCase() as "http" | "https") : undefined,
|
|
||||||
host: host.toLowerCase(),
|
|
||||||
port,
|
|
||||||
path: normalizePath(rawPath),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUrl(
|
|
||||||
scheme: "http" | "https",
|
|
||||||
host: string,
|
|
||||||
port: string | undefined,
|
|
||||||
path: string,
|
|
||||||
): string {
|
|
||||||
return `${scheme}://${host}${port ? `:${port}` : ""}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ordered, de-duplicated candidate URLs for the given input.
|
|
||||||
*
|
|
||||||
* - Explicit scheme AND port → trusted as-is (single candidate).
|
|
||||||
* - Otherwise https is tried before http (prefer secure), keeping any port/path.
|
|
||||||
*
|
|
||||||
* @returns [] when the input can't be parsed.
|
|
||||||
*/
|
|
||||||
export function getServerUrlCandidates(input: string): string[] {
|
|
||||||
const parsed = parseServerInput(input);
|
|
||||||
if (!parsed) return [];
|
|
||||||
|
|
||||||
const { scheme, host, port, path } = parsed;
|
|
||||||
|
|
||||||
// Fully specified: don't second-guess the user.
|
|
||||||
if (scheme && port) return [buildUrl(scheme, host, port, path)];
|
|
||||||
|
|
||||||
// Secure-first; the typed scheme (if any) is still covered by this set.
|
|
||||||
const candidates = (["https", "http"] as const).map((s) =>
|
|
||||||
buildUrl(s, host, port, path),
|
|
||||||
);
|
|
||||||
return Array.from(new Set(candidates));
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export {
|
|
||||||
getServerUrlCandidates,
|
|
||||||
type ParsedServerInput,
|
|
||||||
parseServerInput,
|
|
||||||
} from "./candidates";
|
|
||||||
export { jellyfinProbe } from "./probes/jellyfin";
|
|
||||||
export { jellyseerrProbe } from "./probes/jellyseerr";
|
|
||||||
export { reachabilityProbe } from "./probes/reachability";
|
|
||||||
export {
|
|
||||||
type ResolveFailureReason,
|
|
||||||
type ResolveOptions,
|
|
||||||
type ResolveResult,
|
|
||||||
resolveServerUrl,
|
|
||||||
} from "./resolve";
|
|
||||||
export { isVersionBelow } from "./semver";
|
|
||||||
export type { ServerProbe, ServerProbeOutcome } from "./types";
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import type { ServerProbe } from "../types";
|
|
||||||
|
|
||||||
/** Public, unauthenticated Jellyfin endpoint; `ProductName` confirms the service. */
|
|
||||||
const PRODUCT_NAME = "Jellyfin Server";
|
|
||||||
|
|
||||||
export const jellyfinProbe: ServerProbe = async (url, signal) => {
|
|
||||||
try {
|
|
||||||
const { status, data } = await axios.get(`${url}/System/Info/Public`, {
|
|
||||||
signal,
|
|
||||||
timeout: 8000, // backstop; the resolver aborts via signal first
|
|
||||||
});
|
|
||||||
|
|
||||||
if (status < 200 || status >= 300) return { status: "unreachable" };
|
|
||||||
if (data?.ProductName !== PRODUCT_NAME) return { status: "wrong-service" };
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: "ok",
|
|
||||||
meta: { version: data?.Version, serverName: data?.ServerName },
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { status: "unreachable" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import type { ServerProbe } from "../types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Probe for a Jellyseerr server. `/api/v1/status` is jellyseerr/overseerr
|
|
||||||
* specific and unauthenticated, so it both proves reachability and confirms we
|
|
||||||
* hit the right service. The minimum-version requirement is enforced at login
|
|
||||||
* time (see JellyseerrApi.test) — not surfaced here, to keep the field UI clean.
|
|
||||||
*/
|
|
||||||
export const jellyseerrProbe: ServerProbe = async (url, signal) => {
|
|
||||||
try {
|
|
||||||
const { status, data } = await axios.get(`${url}/api/v1/status`, {
|
|
||||||
signal,
|
|
||||||
timeout: 8000, // backstop; the resolver aborts via signal first
|
|
||||||
});
|
|
||||||
|
|
||||||
if (status < 200 || status >= 300) return { status: "unreachable" };
|
|
||||||
|
|
||||||
// A JSON body carrying version/commitTag identifies a real jellyseerr.
|
|
||||||
const looksLikeJellyseerr =
|
|
||||||
!!data &&
|
|
||||||
typeof data === "object" &&
|
|
||||||
(typeof data.version === "string" || "commitTag" in data);
|
|
||||||
if (!looksLikeJellyseerr) return { status: "wrong-service" };
|
|
||||||
|
|
||||||
return { status: "ok", meta: { version: data.version } };
|
|
||||||
} catch {
|
|
||||||
return { status: "unreachable" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import type { ServerProbe } from "../types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal probe for services without a known/unauthenticated health endpoint
|
|
||||||
* (e.g. Marlin Search, Streamystats). Any HTTP response — even 4xx — proves the
|
|
||||||
* host is up and speaking HTTP at this protocol/port, which is enough to pick
|
|
||||||
* https vs http. It cannot detect a "wrong service".
|
|
||||||
*/
|
|
||||||
export const reachabilityProbe: ServerProbe = async (url, signal) => {
|
|
||||||
try {
|
|
||||||
await axios.get(url, {
|
|
||||||
signal,
|
|
||||||
timeout: 8000,
|
|
||||||
validateStatus: () => true, // any status = the server answered
|
|
||||||
});
|
|
||||||
return { status: "ok" };
|
|
||||||
} catch (error) {
|
|
||||||
// A delivered response that still threw counts as reachable.
|
|
||||||
if ((error as { response?: unknown })?.response) return { status: "ok" };
|
|
||||||
return { status: "unreachable" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { getServerUrlCandidates } from "./candidates";
|
|
||||||
import type { ServerProbe, ServerProbeOutcome } from "./types";
|
|
||||||
|
|
||||||
export type ResolveFailureReason =
|
|
||||||
| "empty"
|
|
||||||
| "invalid"
|
|
||||||
| "wrong-service"
|
|
||||||
| "unreachable";
|
|
||||||
|
|
||||||
export type ResolveResult =
|
|
||||||
| { ok: true; url: string; meta?: Record<string, unknown> }
|
|
||||||
| { ok: false; reason: ResolveFailureReason };
|
|
||||||
|
|
||||||
export interface ResolveOptions {
|
|
||||||
/** Per-candidate probe timeout in ms. Default 5000. */
|
|
||||||
timeoutMs?: number;
|
|
||||||
/** Abort the whole resolution (cancels every in-flight probe). */
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Order in which to surface a failure when no candidate validated:
|
|
||||||
// the more specific/actionable the reason, the earlier it is reported.
|
|
||||||
const FAILURE_PRIORITY = [
|
|
||||||
"wrong-service",
|
|
||||||
"unreachable",
|
|
||||||
] as const satisfies ReadonlyArray<ResolveFailureReason>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve loose user input to a single working, canonical server URL.
|
|
||||||
*
|
|
||||||
* Generates candidates (https-first), probes them in parallel with a per-candidate
|
|
||||||
* timeout, and returns the first candidate (in preference order) the probe
|
|
||||||
* accepted. When none work, the most actionable failure is returned.
|
|
||||||
*/
|
|
||||||
export async function resolveServerUrl(
|
|
||||||
input: string,
|
|
||||||
probe: ServerProbe,
|
|
||||||
options: ResolveOptions = {},
|
|
||||||
): Promise<ResolveResult> {
|
|
||||||
const { timeoutMs = 5000, signal } = options;
|
|
||||||
|
|
||||||
if (!input.trim()) return { ok: false, reason: "empty" };
|
|
||||||
|
|
||||||
const candidates = getServerUrlCandidates(input);
|
|
||||||
if (candidates.length === 0) return { ok: false, reason: "invalid" };
|
|
||||||
|
|
||||||
const outcomes = await Promise.all(
|
|
||||||
candidates.map((url) => runProbe(url, probe, timeoutMs, signal)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Prefer the first candidate (https-first) that validated.
|
|
||||||
for (let i = 0; i < candidates.length; i++) {
|
|
||||||
const outcome = outcomes[i];
|
|
||||||
if (outcome.status === "ok") {
|
|
||||||
return { ok: true, url: candidates[i], meta: outcome.meta };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing validated: report the most useful failure.
|
|
||||||
for (const reason of FAILURE_PRIORITY) {
|
|
||||||
const hit = outcomes.find((outcome) => outcome.status === reason);
|
|
||||||
if (hit) {
|
|
||||||
return { ok: false, reason };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ok: false, reason: "unreachable" };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runProbe(
|
|
||||||
url: string,
|
|
||||||
probe: ServerProbe,
|
|
||||||
timeoutMs: number,
|
|
||||||
parentSignal?: AbortSignal,
|
|
||||||
): Promise<ServerProbeOutcome> {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const abort = () => controller.abort();
|
|
||||||
parentSignal?.addEventListener("abort", abort);
|
|
||||||
const timer = setTimeout(abort, timeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await probe(url, controller.signal);
|
|
||||||
} catch {
|
|
||||||
return { status: "unreachable" };
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
parentSignal?.removeEventListener("abort", abort);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Strict numeric "below" comparison for dotted versions.
|
|
||||||
*
|
|
||||||
* Avoids the string-comparison bug (`"1.9.9" < "2.0.0"` works by luck but
|
|
||||||
* `"2.10.0" < "2.0.0"` is wrongly true). Non-numeric/pre-release suffixes on a
|
|
||||||
* segment are ignored (e.g. `2.0.0-beta` → 2.0.0).
|
|
||||||
*/
|
|
||||||
export function isVersionBelow(version: string, minimum: string): boolean {
|
|
||||||
const parse = (v: string) =>
|
|
||||||
v.split(".").map((segment) => Number.parseInt(segment, 10) || 0);
|
|
||||||
|
|
||||||
const a = parse(version);
|
|
||||||
const b = parse(minimum);
|
|
||||||
const length = Math.max(a.length, b.length);
|
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
const x = a[i] ?? 0;
|
|
||||||
const y = b[i] ?? 0;
|
|
||||||
if (x !== y) return x < y;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/** Result of probing a single candidate URL for a specific service. */
|
|
||||||
export type ServerProbeOutcome =
|
|
||||||
| { status: "ok"; meta?: Record<string, unknown> }
|
|
||||||
| { status: "wrong-service" }
|
|
||||||
| { status: "unreachable" };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates one fully-qualified candidate URL for a given service.
|
|
||||||
* Implementations must resolve (never reject) — map errors to "unreachable".
|
|
||||||
* The provided signal is aborted on timeout or cancellation.
|
|
||||||
*/
|
|
||||||
export type ServerProbe = (
|
|
||||||
url: string,
|
|
||||||
signal: AbortSignal,
|
|
||||||
) => Promise<ServerProbeOutcome>;
|
|
||||||
Reference in New Issue
Block a user