mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-06 14:08:30 +01:00
Compare commits
8 Commits
feat/unifi
...
cleanup/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f95faacece | ||
|
|
77d3bc2563 | ||
|
|
89fb0d9624 | ||
|
|
7564463065 | ||
|
|
f7cd413882 | ||
|
|
d7fbe992ae | ||
|
|
13973dc53a | ||
|
|
8eeb571f33 |
@@ -143,14 +143,6 @@ interface ModalOptions {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
|
||||||
- Simple content modal
|
|
||||||
- Modal with custom snap points
|
|
||||||
- Complex component in modal
|
|
||||||
- Success/error modals triggered from functions
|
|
||||||
|
|
||||||
## Default Styling
|
## Default Styling
|
||||||
|
|
||||||
The modal uses these default styles (can be overridden via options):
|
The modal uses these default styles (can be overridden via options):
|
||||||
|
|||||||
@@ -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,4 +1,3 @@
|
|||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./mmkv";
|
export * from "./mmkv";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
export * from "./string";
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ declare global {
|
|||||||
bytesToReadable(decimals?: number): string;
|
bytesToReadable(decimals?: number): string;
|
||||||
secondsToMilliseconds(): number;
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number;
|
minutesToMilliseconds(): number;
|
||||||
hoursToMilliseconds(): number;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
|
|||||||
return this.valueOf() * (60).secondsToMilliseconds();
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
};
|
};
|
||||||
|
|
||||||
Number.prototype.hoursToMilliseconds = function () {
|
|
||||||
return this.valueOf() * (60).minutesToMilliseconds();
|
|
||||||
};
|
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
declare global {
|
|
||||||
interface String {
|
|
||||||
toTitle(): string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String.prototype.toTitle = function () {
|
|
||||||
return this.replaceAll("_", " ").replace(
|
|
||||||
/\w\S*/g,
|
|
||||||
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export {};
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
/**
|
|
||||||
* Example Usage of Global Modal
|
|
||||||
*
|
|
||||||
* This file demonstrates how to use the global modal system from anywhere in your app.
|
|
||||||
* You can delete this file after understanding how it works.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 1: Simple Content Modal
|
|
||||||
*/
|
|
||||||
export const SimpleModalExample = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const handleOpenModal = () => {
|
|
||||||
showModal(
|
|
||||||
<View className='p-6'>
|
|
||||||
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
|
||||||
<Text className='text-white mb-4'>
|
|
||||||
This is a simple modal with just some text content.
|
|
||||||
</Text>
|
|
||||||
<Text className='text-neutral-400'>
|
|
||||||
Swipe down or tap outside to close.
|
|
||||||
</Text>
|
|
||||||
</View>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenModal}
|
|
||||||
className='bg-purple-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 2: Modal with Custom Snap Points
|
|
||||||
*/
|
|
||||||
export const CustomSnapPointsExample = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const handleOpenModal = () => {
|
|
||||||
showModal(
|
|
||||||
<View className='p-6' style={{ minHeight: 400 }}>
|
|
||||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
|
||||||
Custom Snap Points
|
|
||||||
</Text>
|
|
||||||
<Text className='text-white mb-4'>
|
|
||||||
This modal has custom snap points (25%, 50%, 90%).
|
|
||||||
</Text>
|
|
||||||
<View className='bg-neutral-800 p-4 rounded-lg'>
|
|
||||||
<Text className='text-white'>
|
|
||||||
Try dragging the modal to different heights!
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>,
|
|
||||||
{
|
|
||||||
snapPoints: ["25%", "50%", "90%"],
|
|
||||||
enableDynamicSizing: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenModal}
|
|
||||||
className='bg-blue-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 3: Complex Component in Modal
|
|
||||||
*/
|
|
||||||
const SettingsModalContent = () => {
|
|
||||||
const { hideModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const settings = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: "Notifications",
|
|
||||||
icon: "notifications-outline" as const,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Auto-play",
|
|
||||||
icon: "play-outline" as const,
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='p-6'>
|
|
||||||
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
|
||||||
|
|
||||||
{settings.map((setting, index) => (
|
|
||||||
<View
|
|
||||||
key={setting.id}
|
|
||||||
className={`flex-row items-center justify-between py-4 ${
|
|
||||||
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className='flex-row items-center gap-3'>
|
|
||||||
<Ionicons name={setting.icon} size={24} color='white' />
|
|
||||||
<Text className='text-white text-lg'>{setting.title}</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={`w-12 h-7 rounded-full ${
|
|
||||||
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
|
||||||
setting.enabled ? "translate-x-6" : "translate-x-1"
|
|
||||||
}`}
|
|
||||||
style={{ marginTop: 4 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={hideModal}
|
|
||||||
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold text-center'>Close</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ComplexModalExample = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const handleOpenModal = () => {
|
|
||||||
showModal(<SettingsModalContent />);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenModal}
|
|
||||||
className='bg-green-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Complex Component</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 4: Modal Triggered from Function (e.g., API response)
|
|
||||||
*/
|
|
||||||
export const useShowSuccessModal = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
return (message: string) => {
|
|
||||||
showModal(
|
|
||||||
<View className='p-6 items-center'>
|
|
||||||
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
|
||||||
<Ionicons name='checkmark' size={48} color='white' />
|
|
||||||
</View>
|
|
||||||
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
|
||||||
<Text className='text-white text-center'>{message}</Text>
|
|
||||||
</View>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main Demo Component
|
|
||||||
*/
|
|
||||||
export const GlobalModalDemo = () => {
|
|
||||||
const showSuccess = useShowSuccessModal();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='p-6 gap-4'>
|
|
||||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
|
||||||
Global Modal Examples
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<SimpleModalExample />
|
|
||||||
<CustomSnapPointsExample />
|
|
||||||
<ComplexModalExample />
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => showSuccess("Operation completed successfully!")}
|
|
||||||
className='bg-orange-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Image } from "expo-image";
|
|
||||||
import { View } from "react-native";
|
|
||||||
|
|
||||||
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
|
||||||
if (!url)
|
|
||||||
return (
|
|
||||||
<View className='p-4 rounded-xl overflow-hidden '>
|
|
||||||
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='p-4 rounded-xl overflow-hidden '>
|
|
||||||
<Image
|
|
||||||
source={{ uri: url }}
|
|
||||||
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { View, type ViewProps } from "react-native";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
width: "32%",
|
|
||||||
}}
|
|
||||||
className='flex flex-col'
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
aspectRatio: "10/15",
|
|
||||||
}}
|
|
||||||
className='w-full bg-neutral-800 mb-2 rounded-lg'
|
|
||||||
/>
|
|
||||||
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
|
||||||
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
|
||||||
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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'>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
|
||||||
|
|
||||||
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
|
||||||
import type { ComponentProps } from "react";
|
|
||||||
|
|
||||||
export function TabBarIcon({
|
|
||||||
style,
|
|
||||||
...rest
|
|
||||||
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
|
||||||
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
type MoviePosterProps = {
|
|
||||||
item: BaseItemDto;
|
|
||||||
showProgress?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EpisodePoster: React.FC<MoviePosterProps> = ({
|
|
||||||
item,
|
|
||||||
showProgress = false,
|
|
||||||
}) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
|
|
||||||
const url = useMemo(() => {
|
|
||||||
if (item.Type === "Episode") {
|
|
||||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
|
||||||
}
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const [progress, _setProgress] = useState(
|
|
||||||
item.UserData?.PlayedPercentage || 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const blurhash = useMemo(() => {
|
|
||||||
const key = item.ImageTags?.Primary as string;
|
|
||||||
return item.ImageBlurHashes?.Primary?.[key];
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
|
|
||||||
<Image
|
|
||||||
placeholder={{
|
|
||||||
blurhash,
|
|
||||||
}}
|
|
||||||
key={item.Id}
|
|
||||||
id={item.Id}
|
|
||||||
source={
|
|
||||||
url
|
|
||||||
? {
|
|
||||||
uri: url,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
contentFit='cover'
|
|
||||||
style={{
|
|
||||||
aspectRatio: "10/15",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<WatchedIndicator item={item} />
|
|
||||||
{showProgress && progress > 0 && (
|
|
||||||
<View className='h-1 bg-red-600 w-full' />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { Image } from "expo-image";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
type PosterProps = {
|
|
||||||
id?: string;
|
|
||||||
showProgress?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
|
|
||||||
const url = useMemo(
|
|
||||||
() => `${api?.basePath}/Items/${id}/Images/Primary`,
|
|
||||||
[id],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!url || !id)
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className='border border-neutral-900'
|
|
||||||
style={{
|
|
||||||
aspectRatio: "10/15",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='rounded-lg overflow-hidden border border-neutral-900'>
|
|
||||||
<Image
|
|
||||||
key={id}
|
|
||||||
id={id}
|
|
||||||
source={{
|
|
||||||
uri: url,
|
|
||||||
}}
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
contentFit='cover'
|
|
||||||
style={{
|
|
||||||
aspectRatio: "10/15",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ParentPoster;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
export const Dashboard = () => {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
|
|
||||||
<ListItem
|
|
||||||
className={sessions.length !== 0 ? "bg-purple-900" : ""}
|
|
||||||
onPress={() => router.push("/settings/dashboard/sessions")}
|
|
||||||
title={t("home.settings.dashboard.sessions_title")}
|
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function DownloadSettings() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function DownloadSettings() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,23 +115,30 @@ export const JellyseerrSettings = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||||
<Text className='text-xs text-red-600 mb-2'>
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='mb-2'>
|
<View className='flex flex-col shrink mb-2'>
|
||||||
<ServerUrlField
|
<Text className='text-xs text-gray-600'>
|
||||||
value={jellyseerrServerUrl}
|
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||||
onChangeText={setjellyseerrServerUrl}
|
</Text>
|
||||||
onResolved={(url) => setResolvedUrl(url)}
|
</View>
|
||||||
probe={jellyseerrProbe}
|
<Input
|
||||||
label={t("home.settings.plugins.jellyseerr.server_url")}
|
className='border border-neutral-800 mb-2'
|
||||||
hint={t("home.settings.plugins.jellyseerr.server_url_hint")}
|
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
"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}
|
editable={!loginToJellyseerrMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export const LANGUAGES: DefaultLanguageOption[] = [
|
|
||||||
{ label: "English", value: "eng" },
|
|
||||||
{ label: "Spanish", value: "spa" },
|
|
||||||
{ label: "Chinese (Mandarin)", value: "cmn" },
|
|
||||||
{ label: "Hindi", value: "hin" },
|
|
||||||
{ label: "Arabic", value: "ara" },
|
|
||||||
{ label: "French", value: "fra" },
|
|
||||||
{ label: "Russian", value: "rus" },
|
|
||||||
{ label: "Portuguese", value: "por" },
|
|
||||||
{ label: "Japanese", value: "jpn" },
|
|
||||||
{ label: "German", value: "deu" },
|
|
||||||
{ label: "Italian", value: "ita" },
|
|
||||||
{ label: "Korean", value: "kor" },
|
|
||||||
{ label: "Turkish", value: "tur" },
|
|
||||||
{ label: "Dutch", value: "nld" },
|
|
||||||
{ label: "Polish", value: "pol" },
|
|
||||||
{ label: "Vietnamese", value: "vie" },
|
|
||||||
{ label: "Thai", value: "tha" },
|
|
||||||
{ label: "Indonesian", value: "ind" },
|
|
||||||
{ label: "Greek", value: "ell" },
|
|
||||||
{ label: "Swedish", value: "swe" },
|
|
||||||
{ label: "Danish", value: "dan" },
|
|
||||||
{ label: "Norwegian", value: "nor" },
|
|
||||||
{ label: "Finnish", value: "fin" },
|
|
||||||
{ label: "Czech", value: "ces" },
|
|
||||||
{ label: "Hungarian", value: "hun" },
|
|
||||||
{ label: "Romanian", value: "ron" },
|
|
||||||
{ label: "Ukrainian", value: "ukr" },
|
|
||||||
{ label: "Hebrew", value: "heb" },
|
|
||||||
{ label: "Bengali", value: "ben" },
|
|
||||||
{ label: "Punjabi", value: "pan" },
|
|
||||||
{ label: "Tagalog", value: "tgl" },
|
|
||||||
{ label: "Swahili", value: "swa" },
|
|
||||||
{ label: "Malay", value: "msa" },
|
|
||||||
{ label: "Persian", value: "fas" },
|
|
||||||
{ label: "Urdu", value: "urd" },
|
|
||||||
];
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
|
|
||||||
export const useControlsVisibility = (timeout = 3000) => {
|
|
||||||
const opacity = useSharedValue(1);
|
|
||||||
|
|
||||||
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const showControls = useCallback(() => {
|
|
||||||
opacity.value = 1;
|
|
||||||
if (hideControlsTimerRef.current) {
|
|
||||||
clearTimeout(hideControlsTimerRef.current);
|
|
||||||
}
|
|
||||||
hideControlsTimerRef.current = setTimeout(() => {
|
|
||||||
opacity.value = 0;
|
|
||||||
}, timeout);
|
|
||||||
}, [timeout]);
|
|
||||||
|
|
||||||
const hideControls = useCallback(() => {
|
|
||||||
opacity.value = 0;
|
|
||||||
if (hideControlsTimerRef.current) {
|
|
||||||
clearTimeout(hideControlsTimerRef.current);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (hideControlsTimerRef.current) {
|
|
||||||
clearTimeout(hideControlsTimerRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { opacity, showControls, hideControls };
|
|
||||||
};
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
|
|
||||||
export const useDownloadedFileOpener = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
|
||||||
|
|
||||||
const openFile = useCallback(
|
|
||||||
async (item: BaseItemDto) => {
|
|
||||||
if (!item.Id) {
|
|
||||||
writeToLog("ERROR", "Attempted to open a file without an ID.");
|
|
||||||
console.error("Attempted to open a file without an ID.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error opening file", error);
|
|
||||||
console.error("Error opening file:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setOfflineSettings, setPlayUrl, router],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { openFile };
|
|
||||||
};
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import type * as ImageColorsType from "react-native-image-colors";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
// Conditionally import react-native-image-colors only on non-TV platforms
|
|
||||||
const ImageColors = Platform.isTV
|
|
||||||
? null
|
|
||||||
: (require("react-native-image-colors") as typeof ImageColorsType);
|
|
||||||
|
|
||||||
import {
|
|
||||||
adjustToNearBlack,
|
|
||||||
calculateTextColor,
|
|
||||||
isCloseToBlack,
|
|
||||||
itemThemeColorAtom,
|
|
||||||
} from "@/utils/atoms/primaryColor";
|
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook to extract and manage image colors for a given item.
|
|
||||||
*
|
|
||||||
* @param item - The BaseItemDto object representing the item.
|
|
||||||
* @param disabled - A boolean flag to disable color extraction.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export const useImageColors = ({
|
|
||||||
item,
|
|
||||||
url,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
item?: BaseItemDto | null;
|
|
||||||
url?: string | null;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
|
||||||
|
|
||||||
const isTv = Platform.isTV;
|
|
||||||
|
|
||||||
const source = useMemo(() => {
|
|
||||||
if (!api) return;
|
|
||||||
if (url) return { uri: url };
|
|
||||||
if (item)
|
|
||||||
return getItemImage({
|
|
||||||
item,
|
|
||||||
api,
|
|
||||||
variant: "Primary",
|
|
||||||
quality: 80,
|
|
||||||
width: 300,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}, [api, item, url]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isTv) return;
|
|
||||||
if (disabled) return;
|
|
||||||
if (source?.uri) {
|
|
||||||
const _primary = storage.getString(`${source.uri}-primary`);
|
|
||||||
const _text = storage.getString(`${source.uri}-text`);
|
|
||||||
|
|
||||||
if (_primary && _text) {
|
|
||||||
setPrimaryColor({
|
|
||||||
primary: _primary,
|
|
||||||
text: _text,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract colors from the image
|
|
||||||
if (!ImageColors?.getColors) return;
|
|
||||||
|
|
||||||
ImageColors.getColors(source.uri, {
|
|
||||||
fallback: "#fff",
|
|
||||||
cache: false,
|
|
||||||
})
|
|
||||||
.then((colors: ImageColorsType.ImageColorsResult) => {
|
|
||||||
let primary = "#fff";
|
|
||||||
let text = "#000";
|
|
||||||
let backup = "#fff";
|
|
||||||
|
|
||||||
// Select the appropriate color based on the platform
|
|
||||||
if (colors.platform === "android") {
|
|
||||||
primary = colors.dominant;
|
|
||||||
backup = colors.vibrant;
|
|
||||||
} else if (colors.platform === "ios") {
|
|
||||||
primary = colors.detail;
|
|
||||||
backup = colors.primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust the primary color if it's too close to black
|
|
||||||
if (primary && isCloseToBlack(primary)) {
|
|
||||||
if (backup && !isCloseToBlack(backup)) primary = backup;
|
|
||||||
primary = adjustToNearBlack(primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the text color based on the primary color
|
|
||||||
if (primary) text = calculateTextColor(primary);
|
|
||||||
|
|
||||||
setPrimaryColor({
|
|
||||||
primary,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cache the colors in storage
|
|
||||||
if (source.uri && primary) {
|
|
||||||
storage.set(`${source.uri}-primary`, primary);
|
|
||||||
storage.set(`${source.uri}-text`, text);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error: any) => {
|
|
||||||
console.error("Error getting colors", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isTv, source?.uri, setPrimaryColor, disabled]);
|
|
||||||
|
|
||||||
if (isTv) return;
|
|
||||||
};
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -213,7 +213,7 @@ public class MpvPlayerModule: Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Defines events that the view can send to JavaScript
|
// Defines events that the view can send to JavaScript
|
||||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
let onProgress = EventDispatcher()
|
let onProgress = EventDispatcher()
|
||||||
let onError = EventDispatcher()
|
let onError = EventDispatcher()
|
||||||
let onTracksReady = EventDispatcher()
|
let onTracksReady = EventDispatcher()
|
||||||
let onPictureInPictureChange = EventDispatcher()
|
|
||||||
|
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
private var cachedPosition: Double = 0
|
private var cachedPosition: Double = 0
|
||||||
@@ -638,9 +637,6 @@ extension MpvPlayerView: PiPControllerDelegate {
|
|||||||
print("PiP did start: \(didStartPictureInPicture)")
|
print("PiP did start: \(didStartPictureInPicture)")
|
||||||
// Ensure current time is synced when PiP starts
|
// Ensure current time is synced when PiP starts
|
||||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||||
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
|
|
||||||
// is `false` when AVKit reports a failure to start, so reflect that.
|
|
||||||
onPictureInPictureChange(["isActive": didStartPictureInPicture])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||||
@@ -659,9 +655,6 @@ extension MpvPlayerView: PiPControllerDelegate {
|
|||||||
if _isZoomedToFill {
|
if _isZoomedToFill {
|
||||||
displayLayer.videoGravity = .resizeAspectFill
|
displayLayer.videoGravity = .resizeAspectFill
|
||||||
}
|
}
|
||||||
// Notify JS that PiP has fully stopped so the controls overlay can
|
|
||||||
// be re-mounted when the user returns to full screen.
|
|
||||||
onPictureInPictureChange(["isActive": false])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -116,7 +108,7 @@
|
|||||||
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
|
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
|
||||||
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
||||||
"downloads_feature_title": "Downloads",
|
"downloads_feature_title": "Downloads",
|
||||||
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
|
"downloads_feature_description": "Download movies and tv-shows to view offline.",
|
||||||
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
|
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
|
||||||
"centralised_settings_plugin_title": "Centralised Settings Plugin",
|
"centralised_settings_plugin_title": "Centralised Settings Plugin",
|
||||||
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
||||||
@@ -392,7 +384,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugins",
|
"plugins_title": "Plugins",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
|
||||||
"server_url": "Server URL",
|
"server_url": "Server URL",
|
||||||
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
||||||
"server_url_placeholder": "Seerr URL",
|
"server_url_placeholder": "Seerr URL",
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* Convert bits to megabits or gigabits
|
|
||||||
*
|
|
||||||
* Return nice looking string
|
|
||||||
* If under 1000Mb, return XXXMB, else return X.XGB
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
|
|
||||||
if (!bits) return "0MB";
|
|
||||||
|
|
||||||
const megabits = bits / 1000000;
|
|
||||||
|
|
||||||
if (megabits < 1000) {
|
|
||||||
return `${Math.round(megabits)}MB`;
|
|
||||||
}
|
|
||||||
const gigabits = megabits / 1000;
|
|
||||||
return `${gigabits.toFixed(1)}GB`;
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import {
|
|
||||||
BaseItemKind,
|
|
||||||
CollectionType,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a ColletionType to a BaseItemKind (also called ItemType)
|
|
||||||
*
|
|
||||||
* CollectionTypes
|
|
||||||
* readonly Unknown: "unknown";
|
|
||||||
readonly Movies: "movies";
|
|
||||||
readonly Tvshows: "tvshows";
|
|
||||||
readonly Trailers: "trailers";
|
|
||||||
readonly Homevideos: "homevideos";
|
|
||||||
readonly Boxsets: "boxsets";
|
|
||||||
readonly Books: "books";
|
|
||||||
readonly Photos: "photos";
|
|
||||||
readonly Livetv: "livetv";
|
|
||||||
readonly Playlists: "playlists";
|
|
||||||
readonly Folders: "folders";
|
|
||||||
*/
|
|
||||||
export const colletionTypeToItemType = (
|
|
||||||
collectionType?: CollectionType | null,
|
|
||||||
): BaseItemKind | undefined => {
|
|
||||||
if (!collectionType) return undefined;
|
|
||||||
|
|
||||||
switch (collectionType) {
|
|
||||||
case CollectionType.Movies:
|
|
||||||
return BaseItemKind.Movie;
|
|
||||||
case CollectionType.Tvshows:
|
|
||||||
return BaseItemKind.Series;
|
|
||||||
case CollectionType.Homevideos:
|
|
||||||
return BaseItemKind.Video;
|
|
||||||
case CollectionType.Books:
|
|
||||||
return BaseItemKind.Book;
|
|
||||||
case CollectionType.Playlists:
|
|
||||||
return BaseItemKind.Playlist;
|
|
||||||
case CollectionType.Folders:
|
|
||||||
return BaseItemKind.Folder;
|
|
||||||
case CollectionType.Photos:
|
|
||||||
return BaseItemKind.Photo;
|
|
||||||
case CollectionType.Trailers:
|
|
||||||
return BaseItemKind.Trailer;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
|
|
||||||
export interface SubtitleTrack {
|
|
||||||
index: number;
|
|
||||||
name: string;
|
|
||||||
uri: string;
|
|
||||||
language: string;
|
|
||||||
default: boolean;
|
|
||||||
forced: boolean;
|
|
||||||
autoSelect: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function parseM3U8ForSubtitles(
|
|
||||||
url: string,
|
|
||||||
): Promise<SubtitleTrack[]> {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(url, { responseType: "text" });
|
|
||||||
const lines = response.data.split(/\r?\n/);
|
|
||||||
const subtitleTracks: SubtitleTrack[] = [];
|
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
lines.forEach((line: string) => {
|
|
||||||
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
|
|
||||||
const attributes = parseAttributes(line);
|
|
||||||
const track: SubtitleTrack = {
|
|
||||||
index: index++,
|
|
||||||
name: attributes.NAME || "",
|
|
||||||
uri: attributes.URI || "",
|
|
||||||
language: attributes.LANGUAGE || "",
|
|
||||||
default: attributes.DEFAULT === "YES",
|
|
||||||
forced: attributes.FORCED === "YES",
|
|
||||||
autoSelect: attributes.AUTOSELECT === "YES",
|
|
||||||
};
|
|
||||||
subtitleTracks.push(track);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return subtitleTracks;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch or parse the M3U8 file:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAttributes(line: string): { [key: string]: string } {
|
|
||||||
const attributes: { [key: string]: string } = {};
|
|
||||||
const regex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g;
|
|
||||||
|
|
||||||
for (const match of line.matchAll(regex)) {
|
|
||||||
const key = match[1];
|
|
||||||
const value = match[2] ?? match[3]; // quoted or unquoted
|
|
||||||
attributes[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return attributes;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import type { Settings } from "../../atoms/settings";
|
|
||||||
import { generateDeviceProfile } from "../../profiles/native";
|
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
|
||||||
|
|
||||||
interface PostCapabilitiesParams {
|
|
||||||
api: Api | null | undefined;
|
|
||||||
itemId: string | null | undefined;
|
|
||||||
sessionId: string | null | undefined;
|
|
||||||
deviceProfile: Settings["deviceProfile"];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks a media item as not played for a specific user.
|
|
||||||
*
|
|
||||||
* @param params - The parameters for marking an item as not played
|
|
||||||
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
|
||||||
*/
|
|
||||||
export const postCapabilities = async ({
|
|
||||||
api,
|
|
||||||
itemId,
|
|
||||||
sessionId,
|
|
||||||
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
|
|
||||||
if (!api || !itemId || !sessionId) {
|
|
||||||
throw new Error("Missing parameters for marking item as not played");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const d = api.axiosInstance.post(
|
|
||||||
`${api.basePath}/Sessions/Capabilities/Full`,
|
|
||||||
{
|
|
||||||
playableMediaTypes: ["Audio", "Video"],
|
|
||||||
supportedCommands: [
|
|
||||||
"PlayState",
|
|
||||||
"Play",
|
|
||||||
"ToggleFullscreen",
|
|
||||||
"DisplayMessage",
|
|
||||||
"Mute",
|
|
||||||
"Unmute",
|
|
||||||
"SetVolume",
|
|
||||||
"ToggleMute",
|
|
||||||
],
|
|
||||||
supportsMediaControl: true,
|
|
||||||
id: sessionId,
|
|
||||||
DeviceProfile: generateDeviceProfile(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return d;
|
|
||||||
} catch (_error) {
|
|
||||||
throw new Error("Failed to mark as not played");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
|
||||||
|
|
||||||
interface NextUpParams {
|
|
||||||
itemId?: string | null;
|
|
||||||
userId?: string | null;
|
|
||||||
api?: Api | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the next up episodes for a series or all series for a user.
|
|
||||||
*
|
|
||||||
* @param params - The parameters for fetching next up episodes
|
|
||||||
* @returns A promise that resolves to an array of BaseItemDto representing the next up episodes
|
|
||||||
*/
|
|
||||||
export const nextUp = async ({
|
|
||||||
itemId,
|
|
||||||
userId,
|
|
||||||
api,
|
|
||||||
}: NextUpParams): Promise<BaseItemDto[]> => {
|
|
||||||
if (!userId || !api) {
|
|
||||||
console.error("Invalid parameters for nextUp: missing userId or api");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.axiosInstance.get<{ Items: BaseItemDto[] }>(
|
|
||||||
`${api.basePath}/Shows/NextUp`,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
SeriesId: itemId || undefined,
|
|
||||||
UserId: userId,
|
|
||||||
Fields: "MediaSourceCount",
|
|
||||||
},
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data.Items;
|
|
||||||
} catch (_error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves an item by its ID from the API.
|
|
||||||
*
|
|
||||||
* @param api - The Jellyfin API instance.
|
|
||||||
* @param itemId - The ID of the item to retrieve.
|
|
||||||
* @returns The item object or undefined if no item matches the ID.
|
|
||||||
*/
|
|
||||||
export const getItemById = async (
|
|
||||||
api?: Api | null | undefined,
|
|
||||||
itemId?: string | null | undefined,
|
|
||||||
): Promise<BaseItemDto | undefined> => {
|
|
||||||
if (!api || !itemId) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const itemData = await getUserLibraryApi(api).getItem({ itemId });
|
|
||||||
|
|
||||||
const item = itemData.data;
|
|
||||||
if (!item) {
|
|
||||||
console.error("No items found with the specified ID:", itemId);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to retrieve the item:", error);
|
|
||||||
throw new Error(`Failed to retrieve the item due to an error: ${error}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -72,21 +72,6 @@ export const readFromLog = (): LogEntry[] => {
|
|||||||
return logs ? JSON.parse(logs) : [];
|
return logs ? JSON.parse(logs) : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearLogs = () => {
|
|
||||||
storage.remove("logs");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dumpDownloadDiagnostics = (extra: any = {}) => {
|
|
||||||
const diagnostics = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
processes: extra?.processes || [],
|
|
||||||
nativeTasks: extra?.nativeTasks || [],
|
|
||||||
focusedProcess: extra?.focusedProcess || null,
|
|
||||||
};
|
|
||||||
writeDebugLog("Download diagnostics", diagnostics);
|
|
||||||
return diagnostics;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useLog() {
|
export function useLog() {
|
||||||
const context = useContext(LogContext);
|
const context = useContext(LogContext);
|
||||||
if (context === null) {
|
if (context === null) {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
// seconds to ticks util
|
|
||||||
|
|
||||||
export function secondsToTicks(seconds: number): number {
|
|
||||||
return seconds * 10000000;
|
|
||||||
}
|
|
||||||
@@ -203,27 +203,6 @@ export async function hasAccountCredential(
|
|||||||
return stored !== null;
|
return stored !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete all credentials for all accounts on all servers.
|
|
||||||
*/
|
|
||||||
export async function clearAllCredentials(): Promise<void> {
|
|
||||||
const previousServers = getPreviousServers();
|
|
||||||
|
|
||||||
for (const server of previousServers) {
|
|
||||||
for (const account of server.accounts) {
|
|
||||||
const key = credentialKey(server.address, account.userId);
|
|
||||||
await SecureStore.deleteItemAsync(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear all accounts from servers
|
|
||||||
const clearedServers = previousServers.map((server) => ({
|
|
||||||
...server,
|
|
||||||
accounts: [],
|
|
||||||
}));
|
|
||||||
storage.set("previousServers", JSON.stringify(clearedServers));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add or update an account in a server's accounts list.
|
* Add or update an account in a server's accounts list.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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