mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-06 14:08:30 +01:00
Compare commits
10 Commits
feat/setti
...
feature/sy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
613ad1effc | ||
|
|
2df63eb63c | ||
|
|
ab42e8a576 | ||
|
|
0e93cd5385 | ||
|
|
96b4121c1f | ||
|
|
f7033e7abb | ||
|
|
0d796d01b8 | ||
|
|
27c400a54a | ||
|
|
261f7cc0cd | ||
|
|
d06daef933 |
@@ -7,6 +7,9 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
|
||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
const SyncPlayButtonComponent = Platform.isTV
|
||||||
|
? null
|
||||||
|
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
@@ -33,6 +36,7 @@ export default function IndexLayout() {
|
|||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast background='transparent' />
|
<Chromecast.Chromecast background='transparent' />
|
||||||
|
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
|
||||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</>
|
</>
|
||||||
@@ -243,28 +247,6 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => <HeaderBackButton />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name='settings/account/page'
|
|
||||||
options={{
|
|
||||||
title: t("home.settings.account.title"),
|
|
||||||
headerShown: !Platform.isTV,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
headerLeft: () => <HeaderBackButton />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='settings/notifications/page'
|
|
||||||
options={{
|
|
||||||
title: t("home.settings.notifications.title"),
|
|
||||||
headerShown: !Platform.isTV,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
headerLeft: () => <HeaderBackButton />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,44 +1,38 @@
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
import { SettingsHero } from "@/components/settings/index/SettingsHero";
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
import { SettingsRow } from "@/components/settings/index/SettingsRow";
|
|
||||||
import { SettingsSearchBar } from "@/components/settings/index/SettingsSearchBar";
|
|
||||||
import { SettingsSection } from "@/components/settings/index/SettingsSection";
|
|
||||||
import {
|
|
||||||
SETTINGS_CATALOG,
|
|
||||||
type SettingsTarget,
|
|
||||||
} from "@/components/settings/index/settingsCatalog";
|
|
||||||
import { useSettingsSearch } from "@/components/settings/index/useSettingsSearch";
|
|
||||||
import {
|
|
||||||
QuickConnectSheet,
|
|
||||||
type QuickConnectSheetRef,
|
|
||||||
} from "@/components/settings/QuickConnect";
|
|
||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// TV-specific settings component
|
||||||
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
||||||
|
|
||||||
|
// Mobile settings component
|
||||||
function SettingsMobile() {
|
function SettingsMobile() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [_user] = useAtom(userAtom);
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const navigation = useNavigation();
|
|
||||||
const quickConnectRef = useRef<QuickConnectSheetRef>(null);
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
|
|
||||||
const results = useSettingsSearch(query);
|
|
||||||
const searching = query.trim().length > 0;
|
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => logout()}>
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Text className='text-red-600 px-2'>
|
<Text className='text-red-600 px-2'>
|
||||||
{t("home.settings.log_out_button")}
|
{t("home.settings.log_out_button")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -47,95 +41,98 @@ function SettingsMobile() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTarget = (target: SettingsTarget) => {
|
|
||||||
if (target.type === "action") {
|
|
||||||
if (target.action === "quickConnect") {
|
|
||||||
quickConnectRef.current?.present();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(target.route as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
keyboardShouldPersistTaps='handled'
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingTop: 8,
|
|
||||||
paddingBottom: 32,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!searching && (
|
<View
|
||||||
<SettingsHero
|
className='p-4 flex flex-col'
|
||||||
onPress={() => router.push("/settings/account/page" as any)}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
/>
|
>
|
||||||
)}
|
<View className='mb-4'>
|
||||||
<SettingsSearchBar value={query} onChange={setQuery} />
|
<UserInfo />
|
||||||
|
|
||||||
{searching ? (
|
|
||||||
<SettingsSection title={t("home.settings.search_results")}>
|
|
||||||
{results.length === 0 ? (
|
|
||||||
<View className='px-4 py-3'>
|
|
||||||
<Text className='text-[#9899A1]'>
|
|
||||||
{t("home.settings.search_no_results")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
|
||||||
results.map((r, i) => (
|
<QuickConnect className='mb-4' />
|
||||||
<SettingsRow
|
|
||||||
key={r.id}
|
{Platform.OS !== "ios" && (
|
||||||
title={r.title}
|
<View className='mb-4'>
|
||||||
icon={r.icon}
|
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||||
value={r.subtitle}
|
<ListItem
|
||||||
onPress={() => handleTarget(r.target)}
|
onPress={() =>
|
||||||
isLast={i === results.length - 1}
|
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||||
|
}
|
||||||
|
title={t("pairing.pair_with_phone")}
|
||||||
|
textColor='blue'
|
||||||
/>
|
/>
|
||||||
))
|
</ListGroup>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</SettingsSection>
|
|
||||||
) : (
|
<View className='mb-4'>
|
||||||
<>
|
|
||||||
<View className='mx-3 mb-5'>
|
|
||||||
<AppLanguageSelector />
|
<AppLanguageSelector />
|
||||||
</View>
|
</View>
|
||||||
{SETTINGS_CATALOG.map((section) => {
|
|
||||||
const entries = section.entries.filter(
|
<View className='mb-4'>
|
||||||
(e) => !e.platforms || e.platforms.includes(os),
|
<ListGroup title={t("home.settings.categories.title")}>
|
||||||
);
|
<ListItem
|
||||||
if (entries.length === 0) return null;
|
onPress={() => router.push("/settings/playback-controls/page")}
|
||||||
return (
|
showArrow
|
||||||
<SettingsSection key={section.id} title={t(section.titleKey)}>
|
title={t("home.settings.playback_controls.title")}
|
||||||
{entries.map((e, i) => (
|
|
||||||
<SettingsRow
|
|
||||||
key={e.id}
|
|
||||||
title={t(e.titleKey)}
|
|
||||||
icon={e.icon}
|
|
||||||
onPress={() => handleTarget(e.target)}
|
|
||||||
isLast={i === entries.length - 1}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<ListItem
|
||||||
</SettingsSection>
|
onPress={() => router.push("/settings/audio-subtitles/page")}
|
||||||
);
|
showArrow
|
||||||
})}
|
title={t("home.settings.audio_subtitles.title")}
|
||||||
<SettingsSection>
|
/>
|
||||||
<View className='p-3'>
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/music/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.music.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/appearance/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.appearance.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/plugins/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.plugins.plugins_title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/intro/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.intro.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/network/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.network.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.logs.logs_title")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
|
||||||
<StorageSettings />
|
<StorageSettings />
|
||||||
</View>
|
</View>
|
||||||
</SettingsSection>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<QuickConnectSheet ref={quickConnectRef} />
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
|
// Use TV settings component on TV platforms
|
||||||
if (Platform.isTV && SettingsTV) {
|
if (Platform.isTV && SettingsTV) {
|
||||||
return <SettingsTV />;
|
return <SettingsTV />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SettingsMobile />;
|
return <SettingsMobile />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import * as Application from "expo-application";
|
|
||||||
import { setStringAsync } from "expo-clipboard";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Alert, ScrollView } from "react-native";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
export default function AccountPage() {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const [revealed, setRevealed] = useState(false);
|
|
||||||
const success = useHaptic("success");
|
|
||||||
const version = Application.nativeApplicationVersion ?? "N/A";
|
|
||||||
const token = api?.accessToken ?? "";
|
|
||||||
const masked = token ? `•••• •••• •••• ${token.slice(-4)}` : "";
|
|
||||||
|
|
||||||
const copyToken = async () => {
|
|
||||||
if (!token) return;
|
|
||||||
try {
|
|
||||||
await setStringAsync(token);
|
|
||||||
success();
|
|
||||||
Alert.alert(t("home.settings.account.copied"));
|
|
||||||
} catch {
|
|
||||||
Alert.alert(t("home.settings.account.copy_failed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
|
||||||
<ListGroup title={t("home.settings.user_info.user_info_title")}>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.user_info.user")}
|
|
||||||
value={user?.Name}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.user_info.server")}
|
|
||||||
value={api?.basePath}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.user_info.token")}
|
|
||||||
value={revealed ? token : masked}
|
|
||||||
onPress={() => setRevealed((r) => !r)}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.account.copy_token")}
|
|
||||||
textColor='blue'
|
|
||||||
onPress={copyToken}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.user_info.app_version")}
|
|
||||||
value={version}
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import * as Notifications from "expo-notifications";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Linking, ScrollView, Switch, View } from "react-native";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
const [perm, setPerm] =
|
|
||||||
useState<Notifications.NotificationPermissionsStatus | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Notifications.getPermissionsAsync().then(setPerm);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const requestPermission = async () => {
|
|
||||||
const res = await Notifications.requestPermissionsAsync();
|
|
||||||
setPerm(res);
|
|
||||||
// Only bounce to system settings when the OS will not prompt again.
|
|
||||||
if (!res.granted && res.canAskAgain === false) {
|
|
||||||
Linking.openSettings();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!settings || perm === null) return null;
|
|
||||||
|
|
||||||
if (!perm.granted) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 items-center justify-center px-8'>
|
|
||||||
<Ionicons name='notifications-off-outline' size={56} color='#5A5960' />
|
|
||||||
<Text className='text-white text-lg font-semibold mt-4 text-center'>
|
|
||||||
{t("home.settings.notifications.disabled_title")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-[#9899A1] text-center mt-2'>
|
|
||||||
{t("home.settings.notifications.disabled_description")}
|
|
||||||
</Text>
|
|
||||||
<Button color='purple' className='mt-6' onPress={requestPermission}>
|
|
||||||
{t("home.settings.notifications.enable_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
|
||||||
<ListGroup title={t("home.settings.notifications.events_title")}>
|
|
||||||
<ListItem title={t("home.settings.notifications.master")}>
|
|
||||||
<Switch
|
|
||||||
value={settings.notificationsEnabled}
|
|
||||||
onValueChange={(v) => updateSettings({ notificationsEnabled: v })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.notifications.downloads")}>
|
|
||||||
<Switch
|
|
||||||
value={settings.notifyDownloads}
|
|
||||||
disabled={!settings.notificationsEnabled}
|
|
||||||
onValueChange={(v) => updateSettings({ notifyDownloads: v })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -20,16 +20,18 @@ export default function PlaybackControlsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className='p-4'
|
className='p-4 flex flex-col'
|
||||||
style={{ gap: 16, paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
|
<View className='mb-4'>
|
||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<MediaToggles />
|
<MediaToggles className='mb-4' />
|
||||||
<GestureControls />
|
<GestureControls className='mb-4' />
|
||||||
<PlaybackControlsSettings />
|
<PlaybackControlsSettings />
|
||||||
<MpvBufferSettings />
|
<MpvBufferSettings />
|
||||||
<MpvVoSettings />
|
<MpvVoSettings />
|
||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
|
</View>
|
||||||
{!Platform.isTV && <ChromecastSettings />}
|
{!Platform.isTV && <ChromecastSettings />}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ import {
|
|||||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
|
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
|
||||||
|
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import {
|
import {
|
||||||
@@ -49,10 +51,10 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
|||||||
import { useInactivity } from "@/providers/InactivityProvider";
|
import { useInactivity } from "@/providers/InactivityProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
import type { PlayerControls } from "@/providers/SyncPlay/types";
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import {
|
import {
|
||||||
@@ -77,6 +79,11 @@ export default function DirectPlayerPage() {
|
|||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [isPipMode, setIsPipMode] = useState(false);
|
const [isPipMode, setIsPipMode] = useState(false);
|
||||||
|
|
||||||
|
// Keep the global WebSocket open while in PiP so SyncPlay commands
|
||||||
|
// (and any other server pushes) keep flowing while iOS treats the
|
||||||
|
// app as backgrounded. See `WebSocketProvider.acquireKeepAlive`.
|
||||||
|
useKeepWebSocketAlive(isPipMode);
|
||||||
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
|
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
|
||||||
"default",
|
"default",
|
||||||
);
|
);
|
||||||
@@ -128,6 +135,7 @@ export default function DirectPlayerPage() {
|
|||||||
bitrateValue: bitrateValueStr,
|
bitrateValue: bitrateValueStr,
|
||||||
offline: offlineStr,
|
offline: offlineStr,
|
||||||
playbackPosition: playbackPositionFromUrl,
|
playbackPosition: playbackPositionFromUrl,
|
||||||
|
syncPlay: syncPlayStr,
|
||||||
} = useLocalSearchParams<{
|
} = useLocalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
@@ -137,9 +145,23 @@ export default function DirectPlayerPage() {
|
|||||||
offline: string;
|
offline: string;
|
||||||
/** Playback position in ticks. */
|
/** Playback position in ticks. */
|
||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
|
/** Whether playback was initiated by SyncPlay */
|
||||||
|
syncPlay?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// When opened via SyncPlay, don't auto-play - let SyncPlay commands control playback
|
||||||
|
const openedViaSyncPlay = syncPlayStr === "true";
|
||||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||||
|
|
||||||
|
// SyncPlay integration
|
||||||
|
const syncPlay = useSyncPlay();
|
||||||
|
const {
|
||||||
|
isEnabled: isSyncPlayEnabled,
|
||||||
|
controller: syncPlayController,
|
||||||
|
setPlayerControls,
|
||||||
|
notifyBuffering,
|
||||||
|
} = syncPlay;
|
||||||
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
|
|
||||||
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||||
@@ -415,8 +437,72 @@ export default function DirectPlayerPage() {
|
|||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [stream, api, offline]);
|
}, [stream, api, offline]);
|
||||||
|
|
||||||
|
// SyncPlay: Connect player controls when video is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVideoLoaded || !videoRef.current || offline) {
|
||||||
|
setPlayerControls(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls: PlayerControls = {
|
||||||
|
play: () => videoRef.current?.play(),
|
||||||
|
pause: () => videoRef.current?.pause(),
|
||||||
|
seekTo: (positionMs: number) => {
|
||||||
|
const positionSec = positionMs / 1000;
|
||||||
|
console.log(
|
||||||
|
`PlayerControls.seekTo: ${positionMs}ms = ${positionSec}s, videoRef exists: ${!!videoRef.current}`,
|
||||||
|
);
|
||||||
|
videoRef.current?.seekTo(positionSec);
|
||||||
|
},
|
||||||
|
setSpeed: (speed: number) => videoRef.current?.setSpeed?.(speed),
|
||||||
|
getSpeed: () => currentPlaybackSpeed,
|
||||||
|
getCurrentPosition: () => progress.get(),
|
||||||
|
isPlaying: () => isPlaying,
|
||||||
|
isBuffering: () => isBuffering,
|
||||||
|
};
|
||||||
|
|
||||||
|
setPlayerControls(controls);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setPlayerControls(null);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
isVideoLoaded,
|
||||||
|
offline,
|
||||||
|
isPlaying,
|
||||||
|
isBuffering,
|
||||||
|
currentPlaybackSpeed,
|
||||||
|
progress,
|
||||||
|
setPlayerControls,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// SyncPlay: Report buffering/ready state to server.
|
||||||
|
//
|
||||||
|
// CRITICAL: We must report `buffering` to the server *during* initial
|
||||||
|
// load (before `isVideoLoaded`), otherwise the server treats us as ready
|
||||||
|
// and proceeds without waiting for us. jellyfin-web reports this for
|
||||||
|
// free via the HTML5 video element's `waiting` event; for us, the
|
||||||
|
// initial load itself is the buffering window.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSyncPlayEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocallyReady = isVideoLoaded && !isBuffering;
|
||||||
|
// notifyBuffering routes through the debouncer in PlaybackCore so
|
||||||
|
// re-renders during a stall don't spam the server.
|
||||||
|
notifyBuffering(!isLocallyReady);
|
||||||
|
}, [isSyncPlayEnabled, isVideoLoaded, isBuffering, notifyBuffering]);
|
||||||
|
|
||||||
const togglePlay = async () => {
|
const togglePlay = async () => {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
// Route through SyncPlay when active
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
syncPlayController.playPause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
@@ -439,21 +525,15 @@ export default function DirectPlayerPage() {
|
|||||||
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(progress.get());
|
const currentTimeInTicks = msToTicks(progress.get());
|
||||||
await getPlaystateApi(api).onPlaybackStopped({
|
await getPlaystateApi(api).reportPlaybackStopped({
|
||||||
itemId: item.Id,
|
playbackStopInfo: {
|
||||||
mediaSourceId: mediaSourceId,
|
ItemId: item.Id,
|
||||||
positionTicks: currentTimeInTicks,
|
MediaSourceId: mediaSourceId,
|
||||||
playSessionId: stream.sessionId,
|
PositionTicks: currentTimeInTicks,
|
||||||
|
PlaySessionId: stream.sessionId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}, [
|
}, [api, item, mediaSourceId, stream, progress, offline]);
|
||||||
api,
|
|
||||||
item,
|
|
||||||
mediaSourceId,
|
|
||||||
stream,
|
|
||||||
progress,
|
|
||||||
offline,
|
|
||||||
revalidateProgressCache,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
// Update URL with final playback position before stopping
|
// Update URL with final playback position before stopping
|
||||||
@@ -471,9 +551,10 @@ export default function DirectPlayerPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||||
return () => {
|
return () => {
|
||||||
|
reportPlaybackStopped();
|
||||||
beforeRemoveListener();
|
beforeRemoveListener();
|
||||||
};
|
};
|
||||||
}, [navigation, stop]);
|
}, [navigation, stop, reportPlaybackStopped]);
|
||||||
|
|
||||||
const currentPlayStateInfo = useCallback(():
|
const currentPlayStateInfo = useCallback(():
|
||||||
| PlaybackProgressInfo
|
| PlaybackProgressInfo
|
||||||
@@ -650,10 +731,12 @@ export default function DirectPlayerPage() {
|
|||||||
const startPos = ticksToSeconds(startTicks);
|
const startPos = ticksToSeconds(startTicks);
|
||||||
|
|
||||||
// Build source config - headers only needed for online streaming
|
// Build source config - headers only needed for online streaming
|
||||||
|
// When opened via SyncPlay, don't auto-play - SyncPlay commands control playback
|
||||||
|
const shouldAutoplay = !openedViaSyncPlay;
|
||||||
const source: MpvVideoSource = {
|
const source: MpvVideoSource = {
|
||||||
url: stream.url,
|
url: stream.url,
|
||||||
startPosition: startPos,
|
startPosition: startPos,
|
||||||
autoplay: true,
|
autoplay: shouldAutoplay,
|
||||||
initialSubtitleId,
|
initialSubtitleId,
|
||||||
initialAudioId,
|
initialAudioId,
|
||||||
// Pass cache/buffer settings from user preferences
|
// Pass cache/buffer settings from user preferences
|
||||||
@@ -848,6 +931,41 @@ export default function DirectPlayerPage() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// PiP playback controls. When SyncPlay is active, the native side
|
||||||
|
// is told to *delegate* these via `syncPlayDelegated`, so the OS
|
||||||
|
// play/pause/skip buttons emit these events instead of poking MPV
|
||||||
|
// directly. We route them through the SyncPlay controller so the
|
||||||
|
// server broadcasts a command to every group member (including us).
|
||||||
|
const _onPipPlayRequest = useCallback(() => {
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
console.log("SyncPlay: PiP play → controller.playPause()");
|
||||||
|
syncPlayController.playPause();
|
||||||
|
}
|
||||||
|
}, [isSyncPlayEnabled, syncPlayController]);
|
||||||
|
|
||||||
|
const _onPipPauseRequest = useCallback(() => {
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
console.log("SyncPlay: PiP pause → controller.playPause()");
|
||||||
|
syncPlayController.playPause();
|
||||||
|
}
|
||||||
|
}, [isSyncPlayEnabled, syncPlayController]);
|
||||||
|
|
||||||
|
const _onPipSkipRequest = useCallback(
|
||||||
|
(e: {
|
||||||
|
nativeEvent: { targetSeconds: number; intervalSeconds: number };
|
||||||
|
}) => {
|
||||||
|
if (!isSyncPlayEnabled || !syncPlayController) return;
|
||||||
|
const { targetSeconds } = e.nativeEvent;
|
||||||
|
// SyncPlay seek takes ticks (1 s = 10_000_000 ticks).
|
||||||
|
const ticks = Math.max(0, Math.round(targetSeconds * 10_000_000));
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: PiP skip → controller.seek(${targetSeconds}s = ${ticks} ticks)`,
|
||||||
|
);
|
||||||
|
syncPlayController.seek(ticks);
|
||||||
|
},
|
||||||
|
[isSyncPlayEnabled, syncPlayController],
|
||||||
|
);
|
||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
// Add useEffect to handle mounting
|
// Add useEffect to handle mounting
|
||||||
@@ -872,10 +990,21 @@ export default function DirectPlayerPage() {
|
|||||||
videoRef.current?.pause?.();
|
videoRef.current?.pause?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const seek = useCallback((position: number) => {
|
const seek = useCallback(
|
||||||
|
(position: number) => {
|
||||||
|
// Route through SyncPlay when active. `position` is in ms; the
|
||||||
|
// controller takes ticks (1 ms = 10000 ticks).
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
console.log("SyncPlay: seek requested via SyncPlay", position);
|
||||||
|
syncPlayController.seek(Math.round(position * 10000));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// MPV expects seconds, convert from ms
|
// MPV expects seconds, convert from ms
|
||||||
videoRef.current?.seekTo?.(position / 1000);
|
videoRef.current?.seekTo?.(position / 1000);
|
||||||
}, []);
|
},
|
||||||
|
[isSyncPlayEnabled, syncPlayController],
|
||||||
|
);
|
||||||
|
|
||||||
// TV audio track change handler
|
// TV audio track change handler
|
||||||
const handleAudioIndexChange = useCallback(
|
const handleAudioIndexChange = useCallback(
|
||||||
@@ -1015,44 +1144,6 @@ export default function DirectPlayerPage() {
|
|||||||
}
|
}
|
||||||
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
|
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
|
||||||
|
|
||||||
// TV: Navigate to previous item
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
|
||||||
if (!previousItem || !settings) return;
|
|
||||||
|
|
||||||
const {
|
|
||||||
mediaSource: newMediaSource,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
|
||||||
} = getDefaultPlaySettings(previousItem, settings, {
|
|
||||||
indexes: {
|
|
||||||
// Use the live selection, not the stale URL params (see goToNextItem).
|
|
||||||
subtitleIndex: currentSubtitleIndex,
|
|
||||||
audioIndex: currentAudioIndex,
|
|
||||||
},
|
|
||||||
source: stream?.mediaSource ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: previousItem.Id ?? "",
|
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: newMediaSource?.Id ?? "",
|
|
||||||
bitrateValue: bitrateValue?.toString() ?? "",
|
|
||||||
playbackPosition:
|
|
||||||
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
|
||||||
}, [
|
|
||||||
previousItem,
|
|
||||||
settings,
|
|
||||||
currentSubtitleIndex,
|
|
||||||
currentAudioIndex,
|
|
||||||
stream?.mediaSource,
|
|
||||||
bitrateValue,
|
|
||||||
router,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
||||||
const addSubtitleFile = useCallback(async (path: string) => {
|
const addSubtitleFile = useCallback(async (path: string) => {
|
||||||
await videoRef.current?.addSubtitleFile?.(path, true);
|
await videoRef.current?.addSubtitleFile?.(path, true);
|
||||||
@@ -1082,45 +1173,25 @@ export default function DirectPlayerPage() {
|
|||||||
return [];
|
return [];
|
||||||
}, [isMounted]);
|
}, [isMounted]);
|
||||||
|
|
||||||
// TV: Navigate to next item
|
/*
|
||||||
const goToNextItem = useCallback(() => {
|
* Item-level navigation (next / previous). Wraps SyncPlay dispatch,
|
||||||
if (!nextItem || !settings || isPlaybackStopped) return;
|
* platform-appropriate local navigation (replace on TV), and offline
|
||||||
|
* param injection in a single hook so the in-player buttons and any
|
||||||
|
* future entry points (autoplay overlay, episode picker, etc.) share
|
||||||
|
* one implementation.
|
||||||
|
*/
|
||||||
const {
|
const {
|
||||||
mediaSource: newMediaSource,
|
goToNextItem: dispatchNextItem,
|
||||||
audioIndex: defaultAudioIndex,
|
goToPreviousItem: dispatchPreviousItem,
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
} = usePlayerItemNavigation({
|
||||||
} = getDefaultPlaySettings(nextItem, settings, {
|
|
||||||
indexes: {
|
|
||||||
// Use the live selection (updated when the user changes tracks
|
|
||||||
// mid-playback), not the stale URL params the episode started with.
|
|
||||||
subtitleIndex: currentSubtitleIndex,
|
|
||||||
audioIndex: currentAudioIndex,
|
|
||||||
},
|
|
||||||
source: stream?.mediaSource ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: nextItem.Id ?? "",
|
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: newMediaSource?.Id ?? "",
|
|
||||||
bitrateValue: bitrateValue?.toString() ?? "",
|
|
||||||
playbackPosition:
|
|
||||||
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
|
||||||
}, [
|
|
||||||
nextItem,
|
nextItem,
|
||||||
settings,
|
previousItem,
|
||||||
currentSubtitleIndex,
|
mediaSource: stream?.mediaSource,
|
||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
stream?.mediaSource,
|
currentSubtitleIndex,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
isDisabled: isPlaybackStopped,
|
||||||
isPlaybackStopped,
|
});
|
||||||
]);
|
|
||||||
|
|
||||||
// Apply subtitle settings when video loads
|
// Apply subtitle settings when video loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1267,6 +1338,10 @@ export default function DirectPlayerPage() {
|
|||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
onPlaybackStateChange={onPlaybackStateChanged}
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
onPictureInPictureChange={_onPictureInPictureChange}
|
onPictureInPictureChange={_onPictureInPictureChange}
|
||||||
|
syncPlayDelegated={isSyncPlayEnabled}
|
||||||
|
onPipPlayRequest={_onPipPlayRequest}
|
||||||
|
onPipPauseRequest={_onPipPauseRequest}
|
||||||
|
onPipSkipRequest={_onPipSkipRequest}
|
||||||
onLoad={() => setIsVideoLoaded(true)}
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
@@ -1321,8 +1396,8 @@ export default function DirectPlayerPage() {
|
|||||||
onSubtitleIndexChange={handleSubtitleIndexChange}
|
onSubtitleIndexChange={handleSubtitleIndexChange}
|
||||||
previousItem={previousItem}
|
previousItem={previousItem}
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
goToPreviousItem={goToPreviousItem}
|
goToPreviousItem={dispatchPreviousItem}
|
||||||
goToNextItem={goToNextItem}
|
goToNextItem={dispatchNextItem}
|
||||||
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
|
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
|
||||||
addSubtitleFile={addSubtitleFile}
|
addSubtitleFile={addSubtitleFile}
|
||||||
showTechnicalInfo={showTechnicalInfo}
|
showTechnicalInfo={showTechnicalInfo}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
|||||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||||
|
import { SyncPlayProvider } from "@/providers/SyncPlay";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
@@ -409,6 +410,7 @@ function Layout() {
|
|||||||
<PlaySettingsProvider>
|
<PlaySettingsProvider>
|
||||||
<LogProvider>
|
<LogProvider>
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
|
<SyncPlayProvider>
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
<MusicPlayerProvider>
|
<MusicPlayerProvider>
|
||||||
<GlobalModalProvider>
|
<GlobalModalProvider>
|
||||||
@@ -446,7 +448,8 @@ function Layout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
title: "",
|
title: "",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent:
|
||||||
|
Platform.OS === "ios",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name='+not-found' />
|
<Stack.Screen name='+not-found' />
|
||||||
@@ -536,6 +539,7 @@ function Layout() {
|
|||||||
</GlobalModalProvider>
|
</GlobalModalProvider>
|
||||||
</MusicPlayerProvider>
|
</MusicPlayerProvider>
|
||||||
</DownloadProvider>
|
</DownloadProvider>
|
||||||
|
</SyncPlayProvider>
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
</LogProvider>
|
</LogProvider>
|
||||||
</PlaySettingsProvider>
|
</PlaySettingsProvider>
|
||||||
|
|||||||
22
bun.lock
22
bun.lock
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
@@ -31,7 +30,6 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.15",
|
"expo-build-properties": "~56.0.15",
|
||||||
"expo-camera": "~56.0.7",
|
"expo-camera": "~56.0.7",
|
||||||
"expo-clipboard": "~56.0.3",
|
|
||||||
"expo-constants": "~56.0.16",
|
"expo-constants": "~56.0.16",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.16",
|
"expo-dev-client": "~56.0.16",
|
||||||
@@ -104,10 +102,8 @@
|
|||||||
"@biomejs/biome": "2.4.16",
|
"@biomejs/biome": "2.4.16",
|
||||||
"@react-native-community/cli": "20.1.3",
|
"@react-native-community/cli": "20.1.3",
|
||||||
"@react-native-tvos/config-tv": "0.1.6",
|
"@react-native-tvos/config-tv": "0.1.6",
|
||||||
"@types/bun": "^1.3.14",
|
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.24",
|
"@types/lodash": "4.17.24",
|
||||||
"@types/node": "^24",
|
|
||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
@@ -590,8 +586,6 @@
|
|||||||
|
|
||||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
|
||||||
|
|
||||||
"@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
|
"@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
|
||||||
|
|
||||||
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
|
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
|
||||||
@@ -608,7 +602,7 @@
|
|||||||
|
|
||||||
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@24.13.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg=="],
|
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
|
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
|
||||||
|
|
||||||
@@ -748,8 +742,6 @@
|
|||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
|
||||||
|
|
||||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
|
|
||||||
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
|
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
|
||||||
@@ -962,8 +954,6 @@
|
|||||||
|
|
||||||
"expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="],
|
"expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="],
|
||||||
|
|
||||||
"expo-clipboard": ["expo-clipboard@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-8mCdhmAomm0yBIonJFjAhKUXvSkc2avdNh4+rBwoe7DSWF2AC4w3uy+pa419rvVFbTyVxOBmh83UHAbUwD6qAg=="],
|
|
||||||
|
|
||||||
"expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="],
|
"expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="],
|
||||||
|
|
||||||
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
||||||
@@ -1836,7 +1826,7 @@
|
|||||||
|
|
||||||
"ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="],
|
"ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
|
|
||||||
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
|
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
|
||||||
|
|
||||||
@@ -2040,8 +2030,6 @@
|
|||||||
|
|
||||||
"brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
"bun-types/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
|
||||||
|
|
||||||
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
@@ -2140,8 +2128,6 @@
|
|||||||
|
|
||||||
"nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
"nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||||
|
|
||||||
"node-vibrant/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
|
||||||
|
|
||||||
"npm-package-arg/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
"npm-package-arg/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||||
|
|
||||||
"parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
|
"parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
|
||||||
@@ -2260,8 +2246,6 @@
|
|||||||
|
|
||||||
"ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
"ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
||||||
|
|
||||||
"bun-types/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
|
||||||
|
|
||||||
"chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
"chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
"chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||||
@@ -2318,8 +2302,6 @@
|
|||||||
|
|
||||||
"metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
"metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||||
|
|
||||||
"node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
|
||||||
|
|
||||||
"patch-package/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
"patch-package/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||||
|
|
||||||
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
|
||||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -67,47 +66,44 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
// Single source of truth for all player navigation — SyncPlay,
|
||||||
|
// offline-vs-stream resolution, and the autoplay counter reset all
|
||||||
|
// live inside `playItem`.
|
||||||
|
const { playItem } = usePlayerItemNavigation();
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
// Use colors prop if provided, otherwise fallback to global atom
|
||||||
const effectiveColors = colors || globalColorAtom;
|
const effectiveColors = colors || globalColorAtom;
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const startWidth = useSharedValue(0);
|
const startWidth = useSharedValue(0);
|
||||||
const targetWidth = useSharedValue(0);
|
const targetWidth = useSharedValue(0);
|
||||||
const endColor = useSharedValue(effectiveColors);
|
const endColor = useSharedValue(effectiveColors);
|
||||||
const startColor = useSharedValue(effectiveColors);
|
const startColor = useSharedValue(effectiveColors);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string) => {
|
(opts: Parameters<typeof playItem>[1]) => {
|
||||||
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
|
void playItem(item, opts);
|
||||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
|
||||||
}
|
|
||||||
router.push(`/player/direct-player?${q}`);
|
|
||||||
},
|
},
|
||||||
[router, isOffline],
|
[item, playItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNormalPlayFlow = useCallback(async () => {
|
const handleNormalPlayFlow = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
// Default play options derived from the page's track / source / bitrate
|
||||||
itemId: item.Id!,
|
// pickers. `playItem` handles SyncPlay broadcasting and offline-vs-online
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
// routing; we just need to pick a destination (device vs Chromecast).
|
||||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
const defaultOpts = {
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
audioIndex: selectedOptions.audioIndex,
|
||||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
subtitleIndex: selectedOptions.subtitleIndex,
|
||||||
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
|
||||||
offline: isOffline ? "true" : "false",
|
bitrateValue: selectedOptions.bitrate?.value,
|
||||||
});
|
};
|
||||||
|
|
||||||
const queryString = queryParams.toString();
|
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
goToPlayer(queryString);
|
goToPlayer(defaultOpts);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +266,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
goToPlayer(queryString);
|
goToPlayer(defaultOpts);
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
@@ -280,35 +276,24 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
client,
|
client,
|
||||||
settings,
|
|
||||||
api,
|
api,
|
||||||
user,
|
user,
|
||||||
router,
|
|
||||||
showActionSheetWithOptions,
|
showActionSheetWithOptions,
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
goToPlayer,
|
goToPlayer,
|
||||||
isOffline,
|
|
||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
|
||||||
|
|
||||||
// Check if item is downloaded
|
// Check if item is downloaded
|
||||||
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||||
|
|
||||||
// If already in offline mode, play downloaded file directly
|
// If already in offline mode, play downloaded file directly
|
||||||
if (isOffline && downloadedItem) {
|
if (isOffline && downloadedItem) {
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer({ forceOffline: true });
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,13 +316,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
hideModal();
|
hideModal();
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer({ forceOffline: true });
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
}}
|
}}
|
||||||
color='purple'
|
color='purple'
|
||||||
>
|
>
|
||||||
@@ -374,13 +353,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
{
|
{
|
||||||
text: t("player.downloaded_file_yes"),
|
text: t("player.downloaded_file_yes"),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer({ forceOffline: true });
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
},
|
},
|
||||||
isPreferred: true,
|
isPreferred: true,
|
||||||
},
|
},
|
||||||
@@ -404,13 +377,12 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
handleNormalPlayFlow();
|
handleNormalPlayFlow();
|
||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
lightHapticFeedback,
|
isOffline,
|
||||||
handleNormalPlayFlow,
|
handleNormalPlayFlow,
|
||||||
goToPlayer,
|
goToPlayer,
|
||||||
t,
|
t,
|
||||||
showModal,
|
showModal,
|
||||||
hideModal,
|
hideModal,
|
||||||
effectiveColors,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
|
|||||||
@@ -34,13 +34,12 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||||
const isDisabled = disabled || disabledByAdmin;
|
const isDisabled = disabled || disabledByAdmin;
|
||||||
const hasSubtitle = Boolean(effectiveSubtitle);
|
|
||||||
if (onPress)
|
if (onPress)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
@@ -59,7 +58,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...viewProps}
|
{...viewProps}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { Switch, View } from "react-native";
|
||||||
import { View } from "react-native";
|
|
||||||
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={t("home.settings.chromecast.title")}>
|
<ListGroup title={"Chromecast"}>
|
||||||
<SettingsSwitchRow
|
<ListItem title={"Enable H265 for Chromecast"}>
|
||||||
title={t("home.settings.chromecast.enable_h265")}
|
<Switch
|
||||||
value={settings.enableH265ForChromecast}
|
value={settings.enableH265ForChromecast}
|
||||||
onValueChange={(enableH265ForChromecast) =>
|
onValueChange={(enableH265ForChromecast) =>
|
||||||
updateSettings({ enableH265ForChromecast })
|
updateSettings({ enableH265ForChromecast })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
|
import { Switch } from "react-native";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -31,65 +32,85 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
<ListGroup
|
<ListGroup
|
||||||
title={t("home.settings.gesture_controls.gesture_controls_title")}
|
title={t("home.settings.gesture_controls.gesture_controls_title")}
|
||||||
>
|
>
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.horizontal_swipe_skip")}
|
title={t("home.settings.gesture_controls.horizontal_swipe_skip")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.horizontal_swipe_skip_description",
|
"home.settings.gesture_controls.horizontal_swipe_skip_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
value={settings.enableHorizontalSwipeSkip}
|
value={settings.enableHorizontalSwipeSkip}
|
||||||
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||||
updateSettings({ enableHorizontalSwipeSkip })
|
updateSettings({ enableHorizontalSwipeSkip })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.left_side_brightness")}
|
title={t("home.settings.gesture_controls.left_side_brightness")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.left_side_brightness_description",
|
"home.settings.gesture_controls.left_side_brightness_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
value={settings.enableLeftSideBrightnessSwipe}
|
value={settings.enableLeftSideBrightnessSwipe}
|
||||||
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
||||||
updateSettings({ enableLeftSideBrightnessSwipe })
|
updateSettings({ enableLeftSideBrightnessSwipe })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.right_side_volume")}
|
title={t("home.settings.gesture_controls.right_side_volume")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.right_side_volume_description",
|
"home.settings.gesture_controls.right_side_volume_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
value={settings.enableRightSideVolumeSwipe}
|
value={settings.enableRightSideVolumeSwipe}
|
||||||
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
onValueChange={(enableRightSideVolumeSwipe) =>
|
onValueChange={(enableRightSideVolumeSwipe) =>
|
||||||
updateSettings({ enableRightSideVolumeSwipe })
|
updateSettings({ enableRightSideVolumeSwipe })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.hide_volume_slider")}
|
title={t("home.settings.gesture_controls.hide_volume_slider")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.hide_volume_slider_description",
|
"home.settings.gesture_controls.hide_volume_slider_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
value={settings.hideVolumeSlider}
|
value={settings.hideVolumeSlider}
|
||||||
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
onValueChange={(hideVolumeSlider) =>
|
onValueChange={(hideVolumeSlider) =>
|
||||||
updateSettings({ hideVolumeSlider })
|
updateSettings({ hideVolumeSlider })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.hide_brightness_slider")}
|
title={t("home.settings.gesture_controls.hide_brightness_slider")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.hide_brightness_slider_description",
|
"home.settings.gesture_controls.hide_brightness_slider_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
value={settings.hideBrightnessSlider}
|
value={settings.hideBrightnessSlider}
|
||||||
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
onValueChange={(hideBrightnessSlider) =>
|
onValueChange={(hideBrightnessSlider) =>
|
||||||
updateSettings({ hideBrightnessSlider })
|
updateSettings({ hideBrightnessSlider })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { SettingsStepperRow } from "@/components/settings/index/SettingsStepperRow";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -26,27 +27,35 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled} {...props}>
|
<DisabledSetting disabled={disabled} {...props}>
|
||||||
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
|
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
|
||||||
<SettingsStepperRow
|
<ListItem
|
||||||
title={t("home.settings.media_controls.forward_skip_length")}
|
title={t("home.settings.media_controls.forward_skip_length")}
|
||||||
disabled={pluginSettings?.forwardSkipTime?.locked}
|
disabled={pluginSettings?.forwardSkipTime?.locked}
|
||||||
|
>
|
||||||
|
<Stepper
|
||||||
value={settings.forwardSkipTime}
|
value={settings.forwardSkipTime}
|
||||||
|
disabled={pluginSettings?.forwardSkipTime?.locked}
|
||||||
step={5}
|
step={5}
|
||||||
appendValue={t("home.settings.media_controls.seconds_unit")}
|
appendValue={t("home.settings.media_controls.seconds_unit")}
|
||||||
min={0}
|
min={0}
|
||||||
max={60}
|
max={60}
|
||||||
onUpdate={(forwardSkipTime) => updateSettings({ forwardSkipTime })}
|
onUpdate={(forwardSkipTime) => updateSettings({ forwardSkipTime })}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsStepperRow
|
<ListItem
|
||||||
title={t("home.settings.media_controls.rewind_length")}
|
title={t("home.settings.media_controls.rewind_length")}
|
||||||
disabled={pluginSettings?.rewindSkipTime?.locked}
|
disabled={pluginSettings?.rewindSkipTime?.locked}
|
||||||
|
>
|
||||||
|
<Stepper
|
||||||
value={settings.rewindSkipTime}
|
value={settings.rewindSkipTime}
|
||||||
|
disabled={pluginSettings?.rewindSkipTime?.locked}
|
||||||
step={5}
|
step={5}
|
||||||
appendValue={t("home.settings.media_controls.seconds_unit")}
|
appendValue={t("home.settings.media_controls.seconds_unit")}
|
||||||
min={0}
|
min={0}
|
||||||
max={60}
|
max={60}
|
||||||
onUpdate={(rewindSkipTime) => updateSettings({ rewindSkipTime })}
|
onUpdate={(rewindSkipTime) => updateSettings({ rewindSkipTime })}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
import { View } from "react-native";
|
||||||
import { SettingsStepperRow } from "@/components/settings/index/SettingsStepperRow";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
|
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
|
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
|
||||||
{ key: "home.settings.buffer.cache_auto", value: "auto" },
|
{ key: "home.settings.buffer.cache_auto", value: "auto" },
|
||||||
@@ -41,16 +45,24 @@ export const MpvBufferSettings: React.FC = () => {
|
|||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup title={t("home.settings.buffer.title")}>
|
<ListGroup title={t("home.settings.buffer.title")} className='mb-4'>
|
||||||
<SettingsSelectRow
|
<ListItem title={t("home.settings.buffer.cache_mode")}>
|
||||||
title={t("home.settings.buffer.cache_mode")}
|
<PlatformDropdown
|
||||||
valueLabel={currentCacheModeLabel}
|
|
||||||
groups={cacheModeOptions}
|
groups={cacheModeOptions}
|
||||||
dropdownTitle={t("home.settings.buffer.cache_mode")}
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{currentCacheModeLabel}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.buffer.cache_mode")}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsStepperRow
|
<ListItem title={t("home.settings.buffer.buffer_duration")}>
|
||||||
title={t("home.settings.buffer.buffer_duration")}
|
<Stepper
|
||||||
value={settings.mpvCacheSeconds ?? 10}
|
value={settings.mpvCacheSeconds ?? 10}
|
||||||
step={5}
|
step={5}
|
||||||
min={5}
|
min={5}
|
||||||
@@ -58,9 +70,10 @@ export const MpvBufferSettings: React.FC = () => {
|
|||||||
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
|
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
|
||||||
appendValue='s'
|
appendValue='s'
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsStepperRow
|
<ListItem title={t("home.settings.buffer.max_cache_size")}>
|
||||||
title={t("home.settings.buffer.max_cache_size")}
|
<Stepper
|
||||||
value={settings.mpvDemuxerMaxBytes ?? 150}
|
value={settings.mpvDemuxerMaxBytes ?? 150}
|
||||||
step={25}
|
step={25}
|
||||||
min={50}
|
min={50}
|
||||||
@@ -68,16 +81,20 @@ export const MpvBufferSettings: React.FC = () => {
|
|||||||
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
|
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
|
||||||
appendValue=' MB'
|
appendValue=' MB'
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsStepperRow
|
<ListItem title={t("home.settings.buffer.max_backward_cache")}>
|
||||||
title={t("home.settings.buffer.max_backward_cache")}
|
<Stepper
|
||||||
value={settings.mpvDemuxerMaxBackBytes ?? 50}
|
value={settings.mpvDemuxerMaxBackBytes ?? 50}
|
||||||
step={25}
|
step={25}
|
||||||
min={25}
|
min={25}
|
||||||
max={200}
|
max={200}
|
||||||
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBackBytes: value })}
|
onUpdate={(value) =>
|
||||||
|
updateSettings({ mpvDemuxerMaxBackBytes: value })
|
||||||
|
}
|
||||||
appendValue=' MB'
|
appendValue=' MB'
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
|
import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
|
const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
|
||||||
{ key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
|
{ key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
|
||||||
@@ -43,13 +46,21 @@ export const MpvVoSettings: React.FC = () => {
|
|||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup title={t("home.settings.vo_driver.title")}>
|
<ListGroup title={t("home.settings.vo_driver.title")} className='mb-4'>
|
||||||
<SettingsSelectRow
|
<ListItem title={t("home.settings.vo_driver.vo_mode")}>
|
||||||
title={t("home.settings.vo_driver.vo_mode")}
|
<PlatformDropdown
|
||||||
valueLabel={currentVoDriverLabel}
|
|
||||||
groups={voDriverOptions}
|
groups={voDriverOptions}
|
||||||
dropdownTitle={t("home.settings.vo_driver.vo_mode")}
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{currentVoDriverLabel}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.vo_driver.vo_mode")}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Switch, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
|
||||||
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const PlaybackControlsSettings: React.FC = () => {
|
export const PlaybackControlsSettings: React.FC = () => {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
@@ -113,77 +116,141 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled}>
|
<DisabledSetting disabled={disabled}>
|
||||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
||||||
<SettingsSelectRow
|
<ListItem
|
||||||
title={t("home.settings.other.video_orientation")}
|
title={t("home.settings.other.video_orientation")}
|
||||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||||
valueLabel={
|
>
|
||||||
t(
|
<PlatformDropdown
|
||||||
|
groups={orientationOptions}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{t(
|
||||||
orientationTranslations[
|
orientationTranslations[
|
||||||
settings.defaultVideoOrientation as keyof typeof orientationTranslations
|
settings.defaultVideoOrientation as keyof typeof orientationTranslations
|
||||||
],
|
],
|
||||||
) || "Unknown Orientation"
|
) || "Unknown Orientation"}
|
||||||
}
|
</Text>
|
||||||
groups={orientationOptions}
|
<Ionicons
|
||||||
dropdownTitle={t("home.settings.other.orientation")}
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.orientation")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
value={settings.safeAreaInControlsEnabled}
|
value={settings.safeAreaInControlsEnabled}
|
||||||
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ safeAreaInControlsEnabled: value })
|
updateSettings({ safeAreaInControlsEnabled: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSelectRow
|
<ListItem
|
||||||
title={t("home.settings.other.default_quality")}
|
title={t("home.settings.other.default_quality")}
|
||||||
disabled={pluginSettings?.defaultBitrate?.locked}
|
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||||
valueLabel={settings.defaultBitrate?.key}
|
>
|
||||||
|
<PlatformDropdown
|
||||||
groups={bitrateOptions}
|
groups={bitrateOptions}
|
||||||
dropdownTitle={t("home.settings.other.default_quality")}
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between pl-3 py-1.5 '>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{settings.defaultBitrate?.key}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.default_quality")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSelectRow
|
<ListItem
|
||||||
title={t("home.settings.other.default_playback_speed")}
|
title={t("home.settings.other.default_playback_speed")}
|
||||||
disabled={pluginSettings?.defaultPlaybackSpeed?.locked}
|
disabled={pluginSettings?.defaultPlaybackSpeed?.locked}
|
||||||
valueLabel={
|
>
|
||||||
PLAYBACK_SPEEDS.find(
|
<PlatformDropdown
|
||||||
(s) => s.value === settings.defaultPlaybackSpeed,
|
|
||||||
)?.label ?? "1x"
|
|
||||||
}
|
|
||||||
groups={playbackSpeedOptions}
|
groups={playbackSpeedOptions}
|
||||||
dropdownTitle={t("home.settings.other.default_playback_speed")}
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between pl-3 py-1.5'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{PLAYBACK_SPEEDS.find(
|
||||||
|
(s) => s.value === settings.defaultPlaybackSpeed,
|
||||||
|
)?.label ?? "1x"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.default_playback_speed")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
value={settings.disableHapticFeedback}
|
value={settings.disableHapticFeedback}
|
||||||
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
onValueChange={(disableHapticFeedback) =>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
updateSettings({ disableHapticFeedback })
|
updateSettings({ disableHapticFeedback })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.other.auto_play_next_episode")}
|
title={t("home.settings.other.auto_play_next_episode")}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
value={settings.autoPlayNextEpisode}
|
value={settings.autoPlayNextEpisode}
|
||||||
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
onValueChange={(autoPlayNextEpisode) =>
|
onValueChange={(autoPlayNextEpisode) =>
|
||||||
updateSettings({ autoPlayNextEpisode })
|
updateSettings({ autoPlayNextEpisode })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSelectRow
|
<ListItem
|
||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
disabled={
|
disabled={
|
||||||
!settings.autoPlayNextEpisode ||
|
!settings.autoPlayNextEpisode ||
|
||||||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
||||||
}
|
}
|
||||||
valueLabel={t(settings?.maxAutoPlayEpisodeCount.key)}
|
>
|
||||||
|
<PlatformDropdown
|
||||||
groups={autoPlayEpisodeOptions}
|
groups={autoPlayEpisodeOptions}
|
||||||
dropdownTitle={t("home.settings.other.max_auto_play_episode_count")}
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,35 +1,31 @@
|
|||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
BottomSheetBackdrop,
|
||||||
useCallback,
|
type BottomSheetBackdropProps,
|
||||||
useImperativeHandle,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Alert, Platform, View } from "react-native";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
type BottomSheetMethods,
|
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@/utils/expoUiBottomSheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { PinInput } from "../inputs/PinInput";
|
import { PinInput } from "../inputs/PinInput";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export type QuickConnectSheetRef = { present: () => void };
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||||
(_props, ref) => {
|
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||||
const modalRef = useRef<BottomSheetMethods>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
const snapPoints = useMemo(
|
const snapPoints = useMemo(
|
||||||
@@ -37,21 +33,22 @@ export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const isAndroid = Platform.OS === "android";
|
const isAndroid = Platform.OS === "android";
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useImperativeHandle(
|
const renderBackdrop = useCallback(
|
||||||
ref,
|
(props: BottomSheetBackdropProps) => (
|
||||||
() => ({
|
<BottomSheetBackdrop
|
||||||
present: () => {
|
{...props}
|
||||||
setQuickConnectCode("");
|
disappearsOnIndex={-1}
|
||||||
modalRef.current?.present();
|
appearsOnIndex={0}
|
||||||
},
|
/>
|
||||||
}),
|
),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const authorizeQuickConnect = useCallback(async () => {
|
const authorizeQuickConnect = useCallback(async () => {
|
||||||
if (!quickConnectCode) return;
|
if (quickConnectCode) {
|
||||||
try {
|
try {
|
||||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||||
code: quickConnectCode,
|
code: quickConnectCode,
|
||||||
@@ -64,7 +61,7 @@ export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
|||||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
modalRef.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
} else {
|
} else {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -79,26 +76,39 @@ export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
|||||||
t("home.settings.quick_connect.invalid_code"),
|
t("home.settings.quick_connect.invalid_code"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}
|
||||||
api,
|
}, [api, user, quickConnectCode]);
|
||||||
user,
|
|
||||||
quickConnectCode,
|
|
||||||
t,
|
|
||||||
successHapticFeedback,
|
|
||||||
errorHapticFeedback,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => {
|
||||||
|
// Reset the code when opening the sheet
|
||||||
|
setQuickConnectCode("");
|
||||||
|
bottomSheetModalRef?.current?.present();
|
||||||
|
}}
|
||||||
|
title={t("home.settings.quick_connect.authorize_button")}
|
||||||
|
textColor='blue'
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={modalRef}
|
ref={bottomSheetModalRef}
|
||||||
enablePanDownToClose
|
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
handleIndicatorStyle={{
|
||||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
||||||
keyboardBlurBehavior='restore'
|
keyboardBlurBehavior='restore'
|
||||||
|
android_keyboardInputMode='adjustResize'
|
||||||
|
topInset={isAndroid ? 0 : undefined}
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
@@ -132,8 +142,6 @@ export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
|||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
},
|
};
|
||||||
);
|
|
||||||
|
|
||||||
QuickConnectSheet.displayName = "QuickConnectSheet";
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getUserImageUrl } from "@/utils/jellyfin/image/getUserImageUrl";
|
|
||||||
|
|
||||||
export const SettingsHero: React.FC<{ onPress: () => void }> = ({
|
|
||||||
onPress,
|
|
||||||
}) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const connected = Boolean(api && user);
|
|
||||||
const imageUrl =
|
|
||||||
api && user?.Id
|
|
||||||
? (getUserImageUrl({
|
|
||||||
serverAddress: api.basePath,
|
|
||||||
userId: user.Id,
|
|
||||||
primaryImageTag: user.PrimaryImageTag,
|
|
||||||
}) ?? undefined)
|
|
||||||
: undefined;
|
|
||||||
const host = api?.basePath?.replace(/^https?:\/\//, "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onPress}
|
|
||||||
className='mx-3 mb-4 rounded-2xl overflow-hidden'
|
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={["#241b33", "#15151a"]}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
>
|
|
||||||
<View className='flex-row items-center p-4'>
|
|
||||||
{imageUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: imageUrl }}
|
|
||||||
style={{ width: 52, height: 52, borderRadius: 26 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LinearGradient
|
|
||||||
colors={["#a855f7", "#6d28d9"]}
|
|
||||||
style={{
|
|
||||||
width: 52,
|
|
||||||
height: 52,
|
|
||||||
borderRadius: 26,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-white text-[22px] font-bold'>
|
|
||||||
{(user?.Name?.[0] ?? "?").toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
)}
|
|
||||||
<View className='flex-1 ml-3'>
|
|
||||||
<Text
|
|
||||||
className='text-white text-[18px] font-bold'
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{user?.Name ?? ""}
|
|
||||||
</Text>
|
|
||||||
<View className='flex-row items-center mt-0.5'>
|
|
||||||
<View
|
|
||||||
className='w-2 h-2 rounded-full mr-1.5'
|
|
||||||
style={{ backgroundColor: connected ? "#30D158" : "#8E8D91" }}
|
|
||||||
/>
|
|
||||||
<Text className='text-[#9899A1] text-[13px]' numberOfLines={1}>
|
|
||||||
{host}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Ionicons name='chevron-forward' size={18} color='#5A5960' />
|
|
||||||
</View>
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type React from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
|
|
||||||
export interface SettingsRowProps {
|
|
||||||
title: string;
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
value?: string;
|
|
||||||
showChevron?: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
isLast?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACCENT = "#a855f7"; // single accent (full theming is a separate sub-project)
|
|
||||||
|
|
||||||
export const SettingsRow: React.FC<SettingsRowProps> = ({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
value,
|
|
||||||
showChevron = true,
|
|
||||||
onPress,
|
|
||||||
isLast = false,
|
|
||||||
}) => {
|
|
||||||
const haptic = useHaptic("light"); // no-op when disableHapticFeedback is set
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
haptic();
|
|
||||||
onPress();
|
|
||||||
}}
|
|
||||||
className={`flex flex-row items-center bg-neutral-900 h-[48px] px-3 ${
|
|
||||||
isLast ? "" : "border-b border-[#ffffff14]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className='h-[29px] w-[29px] rounded-[7px] items-center justify-center mr-3'
|
|
||||||
style={{ backgroundColor: `${ACCENT}29` }}
|
|
||||||
>
|
|
||||||
<Ionicons name={icon} size={17} color='#c79bff' />
|
|
||||||
</View>
|
|
||||||
<Text className='flex-1 text-white text-[15px]' numberOfLines={1}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{value ? (
|
|
||||||
<Text className='text-[#9899A1] text-[15px] ml-2' numberOfLines={1}>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
{showChevron ? (
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-forward'
|
|
||||||
size={17}
|
|
||||||
color='#5A5960'
|
|
||||||
style={{ marginLeft: 4 }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import type React from "react";
|
|
||||||
import { TextInput, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
export const SettingsSearchBar: React.FC<{
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}> = ({ value, onChange }) => (
|
|
||||||
<View className='mx-3 mb-4 h-[38px] rounded-xl bg-neutral-800 flex-row items-center px-3'>
|
|
||||||
<Ionicons name='search' size={16} color='#76767c' />
|
|
||||||
<TextInput
|
|
||||||
value={value}
|
|
||||||
onChangeText={onChange}
|
|
||||||
placeholder={t("home.settings.search_placeholder")}
|
|
||||||
placeholderTextColor='#76767c'
|
|
||||||
className='flex-1 ml-2 text-white text-[15px]'
|
|
||||||
autoCapitalize='none'
|
|
||||||
autoCorrect={false}
|
|
||||||
returnKeyType='search'
|
|
||||||
/>
|
|
||||||
{value.length > 0 ? (
|
|
||||||
<TouchableOpacity onPress={() => onChange("")} hitSlop={8}>
|
|
||||||
<Ionicons name='close-circle' size={18} color='#76767c' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
export const SettingsSection: React.FC<{
|
|
||||||
title?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}> = ({ title, children }) => (
|
|
||||||
<View className='mb-5'>
|
|
||||||
{title ? (
|
|
||||||
<Text className='ml-4 mb-1.5 uppercase text-[#8E8D91] text-[11px] tracking-wide'>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<View className='mx-3 rounded-xl overflow-hidden bg-neutral-900'>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import {
|
|
||||||
type OptionGroup,
|
|
||||||
PlatformDropdown,
|
|
||||||
} from "@/components/PlatformDropdown";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
valueLabel?: string;
|
|
||||||
groups: OptionGroup[];
|
|
||||||
dropdownTitle?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsSelectRow: React.FC<Props> = ({
|
|
||||||
title,
|
|
||||||
valueLabel,
|
|
||||||
groups,
|
|
||||||
dropdownTitle,
|
|
||||||
disabled,
|
|
||||||
}) => (
|
|
||||||
<ListItem title={title} disabled={disabled}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={groups}
|
|
||||||
title={dropdownTitle}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{valueLabel}</Text>
|
|
||||||
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
value: number;
|
|
||||||
step: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
onUpdate: (value: number) => void;
|
|
||||||
appendValue?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsStepperRow: React.FC<Props> = ({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
value,
|
|
||||||
step,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
onUpdate,
|
|
||||||
appendValue,
|
|
||||||
disabled,
|
|
||||||
}) => (
|
|
||||||
<ListItem title={title} subtitle={subtitle} disabled={disabled}>
|
|
||||||
<Stepper
|
|
||||||
value={value}
|
|
||||||
step={step}
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
appendValue={appendValue}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { Switch } from "react-native";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
value: boolean;
|
|
||||||
onValueChange: (value: boolean) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsSwitchRow: React.FC<Props> = ({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
value,
|
|
||||||
onValueChange,
|
|
||||||
disabled,
|
|
||||||
}) => (
|
|
||||||
<ListItem title={title} subtitle={subtitle} disabled={disabled}>
|
|
||||||
<Switch value={value} disabled={disabled} onValueChange={onValueChange} />
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { expect, test } from "bun:test";
|
|
||||||
import { matchesQuery, normalize } from "./searchFilter";
|
|
||||||
|
|
||||||
test("normalize strips accents and lowercases", () => {
|
|
||||||
expect(normalize("Légèreté")).toBe("legerete");
|
|
||||||
expect(normalize(" AUDIO ")).toBe("audio");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matchesQuery matches title case/accent-insensitively", () => {
|
|
||||||
expect(matchesQuery({ title: "Apparence", keywords: [] }, "appar")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
matchesQuery({ title: "Audio", keywords: ["sous-titres"] }, "SOUS"),
|
|
||||||
).toBe(true);
|
|
||||||
expect(matchesQuery({ title: "Music", keywords: [] }, "xyz")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matchesQuery returns true for empty query", () => {
|
|
||||||
expect(matchesQuery({ title: "Anything" }, "")).toBe(true);
|
|
||||||
expect(matchesQuery({ title: "Anything" }, " ")).toBe(true);
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export const normalize = (s: string): string =>
|
|
||||||
s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
|
|
||||||
|
|
||||||
export interface Searchable {
|
|
||||||
title: string;
|
|
||||||
keywords?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const matchesQuery = (item: Searchable, query: string): boolean => {
|
|
||||||
const q = normalize(query);
|
|
||||||
if (!q) return true;
|
|
||||||
const hay = normalize([item.title, ...(item.keywords ?? [])].join(" "));
|
|
||||||
return hay.includes(q);
|
|
||||||
};
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import type { Ionicons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
export type SettingsTarget =
|
|
||||||
| { type: "route"; route: string }
|
|
||||||
| { type: "action"; action: "quickConnect" };
|
|
||||||
|
|
||||||
export interface SettingsEntry {
|
|
||||||
id: string;
|
|
||||||
titleKey: string;
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
target: SettingsTarget;
|
|
||||||
/** extra search terms (English); the title is always searched too */
|
|
||||||
keywords?: string[];
|
|
||||||
/** when set, entry only shows on these platforms */
|
|
||||||
platforms?: ("ios" | "android")[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SettingsSectionDef {
|
|
||||||
id: string;
|
|
||||||
titleKey: string;
|
|
||||||
entries: SettingsEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single source of truth for the Settings index: drives both rendering and search.
|
|
||||||
*
|
|
||||||
* EXTENSIBLE SHELL — to add a setting that lands from an in-flight PR, append one
|
|
||||||
* entry to the right section (and, if it lives inside a sub-page, an entry in
|
|
||||||
* settingsSearchIndex.ts). No screen rewrite needed. Reserved slots (add when the
|
|
||||||
* PR merges): sleep timer #922, sync-play #1612, wake-on-LAN #1539 (advanced);
|
|
||||||
* download location #1486/#1193, clear image cache #1589 (storage/downloads);
|
|
||||||
* double-tap seek #1219/#1289, subtitle background #1543 (playback).
|
|
||||||
*/
|
|
||||||
export const SETTINGS_CATALOG: SettingsSectionDef[] = [
|
|
||||||
{
|
|
||||||
id: "playback",
|
|
||||||
titleKey: "home.settings.categories.playback",
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
id: "playback-controls",
|
|
||||||
titleKey: "home.settings.playback_controls.title",
|
|
||||||
icon: "play",
|
|
||||||
target: { type: "route", route: "/settings/playback-controls/page" },
|
|
||||||
keywords: ["speed", "skip", "autoplay", "orientation"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "audio-subtitles",
|
|
||||||
titleKey: "home.settings.audio_subtitles.title",
|
|
||||||
icon: "chatbox-ellipses",
|
|
||||||
target: { type: "route", route: "/settings/audio-subtitles/page" },
|
|
||||||
keywords: ["subtitle", "audio", "language"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "music",
|
|
||||||
titleKey: "home.settings.music.title",
|
|
||||||
icon: "musical-notes",
|
|
||||||
target: { type: "route", route: "/settings/music/page" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "personalization",
|
|
||||||
titleKey: "home.settings.categories.personalization",
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
id: "appearance",
|
|
||||||
titleKey: "home.settings.appearance.title",
|
|
||||||
icon: "color-palette",
|
|
||||||
target: { type: "route", route: "/settings/appearance/page" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "notifications",
|
|
||||||
titleKey: "home.settings.notifications.title",
|
|
||||||
icon: "notifications",
|
|
||||||
target: { type: "route", route: "/settings/notifications/page" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "advanced",
|
|
||||||
titleKey: "home.settings.categories.advanced",
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
id: "quick-connect",
|
|
||||||
titleKey: "home.settings.quick_connect.quick_connect_title",
|
|
||||||
icon: "key",
|
|
||||||
target: { type: "action", action: "quickConnect" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "pair",
|
|
||||||
titleKey: "pairing.pair_with_phone",
|
|
||||||
icon: "phone-portrait",
|
|
||||||
target: {
|
|
||||||
type: "route",
|
|
||||||
route: "/(auth)/(tabs)/(home)/companion-login",
|
|
||||||
},
|
|
||||||
platforms: ["android"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "plugins",
|
|
||||||
titleKey: "home.settings.plugins.plugins_title",
|
|
||||||
icon: "extension-puzzle",
|
|
||||||
target: { type: "route", route: "/settings/plugins/page" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "network",
|
|
||||||
titleKey: "home.settings.network.title",
|
|
||||||
icon: "wifi",
|
|
||||||
target: { type: "route", route: "/settings/network/page" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "logs",
|
|
||||||
titleKey: "home.settings.logs.logs_title",
|
|
||||||
icon: "document-text",
|
|
||||||
target: { type: "route", route: "/settings/logs/page" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "intro",
|
|
||||||
titleKey: "home.settings.intro.title",
|
|
||||||
icon: "information-circle",
|
|
||||||
target: { type: "route", route: "/settings/intro/page" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
export interface SearchableOption {
|
|
||||||
titleKey: string;
|
|
||||||
parentRoute: string;
|
|
||||||
parentTitleKey: string;
|
|
||||||
keywords?: string[];
|
|
||||||
platforms?: ("ios" | "android")[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal options of sub-pages, for deep search ("index + internal settings").
|
|
||||||
* Populated from a per-sub-page audit of every user-facing ListItem/dropdown row.
|
|
||||||
* Every titleKey/parentTitleKey MUST be a real existing i18n key.
|
|
||||||
*/
|
|
||||||
export const SETTINGS_SEARCH_INDEX: SearchableOption[] = [
|
|
||||||
// --- Playback & Controls -------------------------------------------------
|
|
||||||
// MediaToggles
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.media_controls.forward_skip_length",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["skip", "forward", "seconds"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.media_controls.rewind_length",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["rewind", "back", "seconds"],
|
|
||||||
},
|
|
||||||
// GestureControls
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.horizontal_swipe_skip",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["gesture", "swipe", "skip", "seek"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.left_side_brightness",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["gesture", "swipe", "brightness", "left"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.right_side_volume",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["gesture", "swipe", "volume", "right"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.hide_volume_slider",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["volume", "slider", "hide", "gesture"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.hide_brightness_slider",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["brightness", "slider", "hide", "gesture"],
|
|
||||||
},
|
|
||||||
// PlaybackControlsSettings (home.settings.other.*)
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.video_orientation",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["orientation", "rotate", "landscape", "portrait"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.safe_area_in_controls",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["safe", "area", "notch", "controls", "inset"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.default_quality",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["quality", "bitrate", "resolution"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.default_playback_speed",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["playback", "speed", "rate"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.disable_haptic_feedback",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["haptic", "vibration", "feedback"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.auto_play_next_episode",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["autoplay", "auto", "next", "episode"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.max_auto_play_episode_count",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["autoplay", "auto", "episode", "count", "limit"],
|
|
||||||
},
|
|
||||||
// MpvBufferSettings
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.buffer.cache_mode",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["cache", "buffer", "mode"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.buffer.buffer_duration",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["buffer", "duration", "cache", "seconds"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.buffer.max_cache_size",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["buffer", "cache", "size", "memory"],
|
|
||||||
},
|
|
||||||
// MpvVoSettings (Android only)
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.vo_driver.vo_mode",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["video", "output", "driver", "gpu", "mpv"],
|
|
||||||
platforms: ["android"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Audio & Subtitles ---------------------------------------------------
|
|
||||||
// AudioToggles
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.audio.set_audio_track",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["audio", "track", "remember", "previous"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.audio.audio_language",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["audio", "language", "default"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.audio.transcode_mode.title",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["audio", "transcode", "surround", "stereo", "passthrough"],
|
|
||||||
},
|
|
||||||
// SubtitleToggles
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.subtitles.subtitle_language",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["subtitle", "caption", "language", "default"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.subtitles.subtitle_mode",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["subtitle", "caption", "mode", "forced"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.subtitles.set_subtitle_track",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["subtitle", "caption", "track", "remember", "previous"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.subtitles.subtitle_size",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["subtitle", "size", "caption", "scale", "font"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Music ---------------------------------------------------------------
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.music.prefer_downloaded",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "downloaded", "offline", "local"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.music.lookahead_enabled",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "lookahead", "cache", "prefetch"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.music.lookahead_count",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "lookahead", "cache", "count", "tracks"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.music.max_cache_size",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "cache", "size", "storage"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.storage.clear_music_cache",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "cache", "clear", "storage"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.storage.delete_all_downloaded_songs",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "downloaded", "delete", "songs", "storage"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Appearance ----------------------------------------------------------
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.show_custom_menu_links",
|
|
||||||
parentRoute: "/settings/appearance/page",
|
|
||||||
parentTitleKey: "home.settings.appearance.title",
|
|
||||||
keywords: ["menu", "links", "custom", "navigation"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.appearance.merge_next_up_continue_watching",
|
|
||||||
parentRoute: "/settings/appearance/page",
|
|
||||||
parentTitleKey: "home.settings.appearance.title",
|
|
||||||
keywords: ["continue", "watching", "next", "up", "merge", "home"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.hide_libraries",
|
|
||||||
parentRoute: "/settings/appearance/page",
|
|
||||||
parentTitleKey: "home.settings.appearance.title",
|
|
||||||
keywords: ["hide", "libraries", "library", "home"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.appearance.hide_remote_session_button",
|
|
||||||
parentRoute: "/settings/appearance/page",
|
|
||||||
parentTitleKey: "home.settings.appearance.title",
|
|
||||||
keywords: ["remote", "session", "button", "hide", "cast"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Network -------------------------------------------------------------
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.network.remote_url",
|
|
||||||
parentRoute: "/settings/network/page",
|
|
||||||
parentTitleKey: "home.settings.network.title",
|
|
||||||
keywords: ["remote", "url", "server", "address"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.network.active_url",
|
|
||||||
parentRoute: "/settings/network/page",
|
|
||||||
parentTitleKey: "home.settings.network.title",
|
|
||||||
keywords: ["active", "url", "server", "connection"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.network.auto_switch_enabled",
|
|
||||||
parentRoute: "/settings/network/page",
|
|
||||||
parentTitleKey: "home.settings.network.title",
|
|
||||||
keywords: ["auto", "switch", "local", "wifi", "network"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.network.local_url",
|
|
||||||
parentRoute: "/settings/network/page",
|
|
||||||
parentTitleKey: "home.settings.network.title",
|
|
||||||
keywords: ["local", "url", "lan", "server", "address"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { matchesQuery } from "./searchFilter";
|
|
||||||
import { SETTINGS_CATALOG, type SettingsTarget } from "./settingsCatalog";
|
|
||||||
import { SETTINGS_SEARCH_INDEX } from "./settingsSearchIndex";
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
subtitle?: string;
|
|
||||||
target: SettingsTarget;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSettingsSearch = (query: string): SearchResult[] => {
|
|
||||||
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
|
|
||||||
return useMemo(() => {
|
|
||||||
if (!query.trim()) return [];
|
|
||||||
const results: SearchResult[] = [];
|
|
||||||
for (const section of SETTINGS_CATALOG) {
|
|
||||||
for (const e of section.entries) {
|
|
||||||
if (e.platforms && !e.platforms.includes(os)) continue;
|
|
||||||
const title = t(e.titleKey);
|
|
||||||
if (matchesQuery({ title, keywords: e.keywords }, query)) {
|
|
||||||
results.push({ id: e.id, title, icon: e.icon, target: e.target });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const o of SETTINGS_SEARCH_INDEX) {
|
|
||||||
if (o.platforms && !o.platforms.includes(os)) continue;
|
|
||||||
const title = t(o.titleKey);
|
|
||||||
if (matchesQuery({ title, keywords: o.keywords }, query)) {
|
|
||||||
results.push({
|
|
||||||
id: `${o.parentRoute}#${o.titleKey}`,
|
|
||||||
title,
|
|
||||||
icon: "search",
|
|
||||||
subtitle: t(o.parentTitleKey),
|
|
||||||
target: { type: "route", route: o.parentRoute },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}, [query, os]);
|
|
||||||
};
|
|
||||||
259
components/syncplay/GroupSelectionMenu.tsx
Normal file
259
components/syncplay/GroupSelectionMenu.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* GroupSelectionMenu
|
||||||
|
*
|
||||||
|
* Content rendered inside the SyncPlay bottom sheet (the sheet itself is
|
||||||
|
* owned by SyncPlayButton). Calls `onClose` after successful actions to
|
||||||
|
* dismiss the parent sheet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
import type { GroupInfoDto } from "@/providers/SyncPlay/types";
|
||||||
|
|
||||||
|
interface GroupSelectionMenuProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupSelectionMenu({ onClose }: GroupSelectionMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isEnabled,
|
||||||
|
groupInfo,
|
||||||
|
canCreateGroups,
|
||||||
|
joinGroup,
|
||||||
|
createGroup,
|
||||||
|
leaveGroup,
|
||||||
|
getGroups,
|
||||||
|
resumeGroupPlayback,
|
||||||
|
} = useSyncPlay();
|
||||||
|
|
||||||
|
const [groups, setGroups] = useState<GroupInfoDto[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const fetchedGroups = await getGroups();
|
||||||
|
if (!cancelled) {
|
||||||
|
setGroups(fetchedGroups);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch groups", error);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [getGroups]);
|
||||||
|
|
||||||
|
const handleJoinGroup = useCallback(
|
||||||
|
async (groupId: string) => {
|
||||||
|
try {
|
||||||
|
await joinGroup(groupId);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to join group", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[joinGroup, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateGroup = useCallback(async () => {
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
await createGroup();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create group", error);
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}, [createGroup, onClose]);
|
||||||
|
|
||||||
|
const handleLeaveGroup = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await leaveGroup();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to leave group", error);
|
||||||
|
}
|
||||||
|
}, [leaveGroup, onClose]);
|
||||||
|
|
||||||
|
// Jump (back) into the group's current item. Mirrors jellyfin-web's
|
||||||
|
// "Resume playback" menu entry — close the sheet and navigate to
|
||||||
|
// the player; SyncPlayProvider handles the re-follow + URL build.
|
||||||
|
const handleResumePlayback = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await resumeGroupPlayback();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to resume group playback", error);
|
||||||
|
}
|
||||||
|
}, [resumeGroupPlayback, onClose]);
|
||||||
|
|
||||||
|
const containerStyle = {
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: Math.max(16, insets.bottom),
|
||||||
|
paddingTop: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEnabled && groupInfo) {
|
||||||
|
return (
|
||||||
|
<View style={containerStyle}>
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex-row items-center mb-2'>
|
||||||
|
<Ionicons name='people' size={24} color='#00a4dc' />
|
||||||
|
<Text className='font-bold text-xl text-neutral-100 ml-2'>
|
||||||
|
{t("syncplay.title")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='text-neutral-400'>{t("syncplay.my_group")}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='bg-neutral-800 rounded-xl p-4 mb-4'>
|
||||||
|
<View className='flex-row items-center justify-between mb-3'>
|
||||||
|
<Text className='text-neutral-100 font-semibold text-lg'>
|
||||||
|
{groupInfo.GroupName}
|
||||||
|
</Text>
|
||||||
|
<View className='bg-[#00a4dc] px-2 py-1 rounded'>
|
||||||
|
<Text className='text-white text-xs font-medium'>
|
||||||
|
{groupInfo.State}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{groupInfo.Participants && groupInfo.Participants.length > 0 && (
|
||||||
|
<View className='flex-row items-center'>
|
||||||
|
<Ionicons name='person' size={16} color='#9ca3af' />
|
||||||
|
<Text className='text-neutral-400 ml-2'>
|
||||||
|
{groupInfo.Participants.length} {t("syncplay.members")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='mb-3'>
|
||||||
|
<Button onPress={handleResumePlayback} color='black'>
|
||||||
|
<View className='flex-row items-center justify-center'>
|
||||||
|
<Ionicons name='play-circle-outline' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold ml-2'>
|
||||||
|
{t("syncplay.resume_playback")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button onPress={handleLeaveGroup} color='red'>
|
||||||
|
<View className='flex-row items-center justify-center'>
|
||||||
|
<Ionicons name='exit-outline' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold ml-2'>
|
||||||
|
{t("syncplay.leave_group")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={containerStyle}>
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex-row items-center mb-2'>
|
||||||
|
<Ionicons name='people-outline' size={24} color='white' />
|
||||||
|
<Text className='font-bold text-xl text-neutral-100 ml-2'>
|
||||||
|
{t("syncplay.title")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='text-neutral-400'>{t("syncplay.join_group")}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<View className='py-8 items-center'>
|
||||||
|
<ActivityIndicator color='#00a4dc' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && groups.length > 0 && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<Text className='text-neutral-400 text-sm mb-2 ml-1'>
|
||||||
|
{t("syncplay.available_groups")}
|
||||||
|
</Text>
|
||||||
|
<View className='bg-neutral-800 rounded-xl overflow-hidden'>
|
||||||
|
{groups.map((group, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={group.GroupId ?? index}
|
||||||
|
onPress={() => group.GroupId && handleJoinGroup(group.GroupId)}
|
||||||
|
className={`flex-row items-center p-4 ${
|
||||||
|
index < groups.length - 1 ? "border-b border-neutral-700" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='w-10 h-10 bg-[#00a4dc]/20 rounded-full items-center justify-center mr-3'>
|
||||||
|
<Ionicons name='people' size={20} color='#00a4dc' />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text className='text-neutral-100 font-medium'>
|
||||||
|
{group.GroupName}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm'>
|
||||||
|
{group.Participants?.length ?? 0} {t("syncplay.members")} •{" "}
|
||||||
|
{group.State}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Ionicons name='chevron-forward' size={20} color='#9ca3af' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && groups.length === 0 && (
|
||||||
|
<View className='bg-neutral-800/50 rounded-xl p-6 mb-4 items-center'>
|
||||||
|
<Ionicons name='people-outline' size={40} color='#6b7280' />
|
||||||
|
<Text className='text-neutral-400 text-center mt-3'>
|
||||||
|
{t("syncplay.available_groups")}: 0{"\n"}
|
||||||
|
{t("syncplay.create_new_group")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCreateGroups && (
|
||||||
|
<Button
|
||||||
|
onPress={handleCreateGroup}
|
||||||
|
color='purple'
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center justify-center'>
|
||||||
|
{isCreating ? (
|
||||||
|
<ActivityIndicator size='small' color='white' />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name='add' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold ml-2'>
|
||||||
|
{t("syncplay.create_new_group")}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
components/syncplay/SyncPlayActionIcon.tsx
Normal file
206
components/syncplay/SyncPlayActionIcon.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlayActionIcon
|
||||||
|
*
|
||||||
|
* In-button SyncPlay status indicator — drops into the player's
|
||||||
|
* play/pause button slot and replaces the normal play/pause/loader
|
||||||
|
* graphic while SyncPlay is mid-transition. Mirrors jellyfin-web's
|
||||||
|
* `#syncPlayIcon` element (see `showIcon()` in
|
||||||
|
* `jellyfin-web/src/controllers/playback/video/index.js`).
|
||||||
|
*
|
||||||
|
* Icon vocabulary (matched 1:1 with jellyfin-web's `showIcon` switch):
|
||||||
|
*
|
||||||
|
* action primary secondary pulse spin
|
||||||
|
* --------------- ------------- ----------------- ---------- ----
|
||||||
|
* schedule-play sync play (centered) infinite yes
|
||||||
|
* unpause play-circle — one-shot no
|
||||||
|
* pause pause-circle — one-shot no
|
||||||
|
* seek refresh — infinite no
|
||||||
|
* buffering clock — infinite no
|
||||||
|
* wait-pause clock pause (shifted) infinite no
|
||||||
|
* wait-unpause clock play (shifted) infinite no
|
||||||
|
*
|
||||||
|
* Material → Ionicons mapping used here:
|
||||||
|
* sync → sync, schedule → time-outline, update → refresh-outline,
|
||||||
|
* play_arrow → play, pause → pause,
|
||||||
|
* play_circle_outline → play-circle-outline,
|
||||||
|
* pause_circle_outline → pause-circle-outline.
|
||||||
|
*
|
||||||
|
* When no SyncPlay action is active the component renders `fallback`
|
||||||
|
* so callers can keep the normal play/pause/loader graphic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { type ReactNode, useEffect } from "react";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
cancelAnimation,
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withSequence,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import type { SyncPlayOsdAction } from "@/providers/SyncPlay";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
|
||||||
|
// SyncPlay cyan (matches jellyfin-web's `.syncPlayIconCircle` color)
|
||||||
|
const SYNC_PLAY_COLOR = "#00a4dc";
|
||||||
|
|
||||||
|
type IoniconName = keyof typeof Ionicons.glyphMap;
|
||||||
|
|
||||||
|
type SecondaryPosition = "centered" | "shifted";
|
||||||
|
|
||||||
|
interface SecondaryIcon {
|
||||||
|
icon: IoniconName;
|
||||||
|
position: SecondaryPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OsdConfig {
|
||||||
|
/** Primary icon — fills the available size. */
|
||||||
|
icon: IoniconName;
|
||||||
|
/** Optional smaller overlay (~42% size). */
|
||||||
|
secondary?: SecondaryIcon;
|
||||||
|
/** Wrapper-level scale animation. */
|
||||||
|
pulse: "infinite" | "oneshot";
|
||||||
|
/** Rotate the primary icon continuously (secondary stays still). */
|
||||||
|
spin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG: Record<SyncPlayOsdAction, OsdConfig> = {
|
||||||
|
"schedule-play": {
|
||||||
|
icon: "sync",
|
||||||
|
secondary: { icon: "play", position: "centered" },
|
||||||
|
pulse: "infinite",
|
||||||
|
spin: true,
|
||||||
|
},
|
||||||
|
unpause: { icon: "play-circle-outline", pulse: "oneshot" },
|
||||||
|
pause: { icon: "pause-circle-outline", pulse: "oneshot" },
|
||||||
|
seek: { icon: "refresh-outline", pulse: "infinite" },
|
||||||
|
buffering: { icon: "time-outline", pulse: "infinite" },
|
||||||
|
"wait-pause": {
|
||||||
|
icon: "time-outline",
|
||||||
|
secondary: { icon: "pause", position: "shifted" },
|
||||||
|
pulse: "infinite",
|
||||||
|
},
|
||||||
|
"wait-unpause": {
|
||||||
|
icon: "time-outline",
|
||||||
|
secondary: { icon: "play", position: "shifted" },
|
||||||
|
pulse: "infinite",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SyncPlayActionIconProps {
|
||||||
|
size: number;
|
||||||
|
color?: string;
|
||||||
|
/** Rendered when no SyncPlay action is active. */
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncPlayActionIcon({
|
||||||
|
size,
|
||||||
|
color = SYNC_PLAY_COLOR,
|
||||||
|
fallback = null,
|
||||||
|
}: SyncPlayActionIconProps) {
|
||||||
|
const { osdAction } = useSyncPlay();
|
||||||
|
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cancelAnimation(rotation);
|
||||||
|
cancelAnimation(scale);
|
||||||
|
rotation.value = 0;
|
||||||
|
scale.value = 1;
|
||||||
|
|
||||||
|
if (!osdAction) return;
|
||||||
|
|
||||||
|
const config = CONFIG[osdAction];
|
||||||
|
|
||||||
|
if (config.spin) {
|
||||||
|
rotation.value = withRepeat(
|
||||||
|
withTiming(360, { duration: 1200, easing: Easing.linear }),
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.pulse === "infinite") {
|
||||||
|
scale.value = withRepeat(
|
||||||
|
withSequence(
|
||||||
|
withTiming(1.1, {
|
||||||
|
duration: 700,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
}),
|
||||||
|
withTiming(0.95, {
|
||||||
|
duration: 700,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// one-shot: single scale flash; the provider clears the action
|
||||||
|
// ~1500ms later (transient OSD) so the icon then unmounts.
|
||||||
|
scale.value = withSequence(
|
||||||
|
withTiming(1.2, { duration: 220, easing: Easing.out(Easing.quad) }),
|
||||||
|
withTiming(1, { duration: 220, easing: Easing.inOut(Easing.quad) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [osdAction, rotation, scale]);
|
||||||
|
|
||||||
|
const pulseStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: scale.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const spinStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${rotation.value}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!osdAction) return <>{fallback}</>;
|
||||||
|
|
||||||
|
const config = CONFIG[osdAction];
|
||||||
|
const secondarySize = Math.round(size * 0.42);
|
||||||
|
|
||||||
|
// centered: geometric middle of the primary (e.g. play arrow inside
|
||||||
|
// the spinning `sync` ring for schedule-play).
|
||||||
|
// shifted: bottom-right corner (e.g. play/pause badge on the clock
|
||||||
|
// for wait-unpause / wait-pause).
|
||||||
|
const secondaryPosStyle =
|
||||||
|
config.secondary?.position === "centered"
|
||||||
|
? {
|
||||||
|
top: (size - secondarySize) / 2,
|
||||||
|
left: (size - secondarySize) / 2,
|
||||||
|
}
|
||||||
|
: { bottom: 0, right: 0 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={pulseStyle}>
|
||||||
|
<View style={{ width: size, height: size }}>
|
||||||
|
<Animated.View style={[StyleSheet.absoluteFill, spinStyle]}>
|
||||||
|
<Ionicons name={config.icon} size={size} color={color} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{config.secondary && (
|
||||||
|
<View
|
||||||
|
pointerEvents='none'
|
||||||
|
style={[styles.secondary, secondaryPosStyle]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={config.secondary.icon}
|
||||||
|
size={secondarySize}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
secondary: {
|
||||||
|
position: "absolute",
|
||||||
|
},
|
||||||
|
});
|
||||||
97
components/syncplay/SyncPlayButton.tsx
Normal file
97
components/syncplay/SyncPlayButton.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlayButton
|
||||||
|
*
|
||||||
|
* Header button for accessing SyncPlay functionality.
|
||||||
|
* Shows group status and opens the group selection sheet.
|
||||||
|
*
|
||||||
|
* Uses the @expo/ui drop-in BottomSheetModal (SwiftUI sheet on iOS, Jetpack
|
||||||
|
* Compose ModalBottomSheet on Android). Because it presents natively, it
|
||||||
|
* works correctly even when triggered from `headerRight` — no portal or
|
||||||
|
* provider context is required (unlike @gorhom/bottom-sheet, which fails
|
||||||
|
* silently from detached UINavigationItem subtrees).
|
||||||
|
*
|
||||||
|
* Safe to import statically: this whole module is lazy-required only on
|
||||||
|
* non-TV platforms by app/(auth)/(tabs)/(home)/_layout.tsx.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type BottomSheetMethods,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@expo/ui/community/bottom-sheet";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
|
import { useCastDevice } from "react-native-google-cast";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
import { GroupSelectionMenu } from "./GroupSelectionMenu";
|
||||||
|
|
||||||
|
interface SyncPlayButtonProps {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncPlayButton({ size = 22 }: SyncPlayButtonProps) {
|
||||||
|
const { isEnabled, canJoinGroups } = useSyncPlay();
|
||||||
|
const { isConnected } = useNetworkStatus();
|
||||||
|
const castDevice = useCastDevice();
|
||||||
|
const sheetRef = useRef<BottomSheetMethods>(null);
|
||||||
|
|
||||||
|
const isCasting = !!castDevice;
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
if (isCasting) {
|
||||||
|
toast("SyncPlay not available while casting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sheetRef.current?.present();
|
||||||
|
}, [isCasting]);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
sheetRef.current?.dismiss();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (Platform.isTV) return null;
|
||||||
|
if (!canJoinGroups) return null;
|
||||||
|
if (!isConnected) return null;
|
||||||
|
|
||||||
|
const iconColor = isCasting ? "#6b7280" : isEnabled ? "#00a4dc" : "white";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
className='mr-4'
|
||||||
|
onPress={handlePress}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<View className='relative'>
|
||||||
|
<Ionicons
|
||||||
|
name={isEnabled ? "people" : "people-outline"}
|
||||||
|
size={size}
|
||||||
|
color={iconColor}
|
||||||
|
/>
|
||||||
|
{isEnabled && !isCasting && (
|
||||||
|
<View
|
||||||
|
className='absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-[#00a4dc]'
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#171717",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={sheetRef}
|
||||||
|
snapPoints={Platform.OS === "android" ? ["100%"] : ["60%"]}
|
||||||
|
enablePanDownToClose
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<GroupSelectionMenu onClose={handleDismiss} />
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/syncplay/SyncPlaySpinner.tsx
Normal file
53
components/syncplay/SyncPlaySpinner.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlaySpinner
|
||||||
|
*
|
||||||
|
* Compact rotating SyncPlay icon shown in place of the play/pause button
|
||||||
|
* while a play/pause command is in flight to the server (the "schedule-play"
|
||||||
|
* indicator from jellyfin-web).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
// SyncPlay cyan color (matches jellyfin-web)
|
||||||
|
const SYNC_PLAY_COLOR = "#00a4dc";
|
||||||
|
|
||||||
|
interface SyncPlaySpinnerProps {
|
||||||
|
size: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncPlaySpinner({
|
||||||
|
size,
|
||||||
|
color = SYNC_PLAY_COLOR,
|
||||||
|
}: SyncPlaySpinnerProps) {
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rotation.value = withRepeat(
|
||||||
|
withTiming(360, {
|
||||||
|
duration: 1200,
|
||||||
|
easing: Easing.linear,
|
||||||
|
}),
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}, [rotation]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${rotation.value}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={animatedStyle}>
|
||||||
|
<Ionicons name='sync' size={size} color={color} />
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
components/syncplay/index.ts
Normal file
8
components/syncplay/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay UI Components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { GroupSelectionMenu } from "./GroupSelectionMenu";
|
||||||
|
export { SyncPlayActionIcon } from "./SyncPlayActionIcon";
|
||||||
|
export { SyncPlayButton } from "./SyncPlayButton";
|
||||||
|
export { SyncPlaySpinner } from "./SyncPlaySpinner";
|
||||||
@@ -3,6 +3,7 @@ import type { FC } from "react";
|
|||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { SyncPlayActionIcon } from "@/components/syncplay/SyncPlayActionIcon";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import AudioSlider from "./AudioSlider";
|
import AudioSlider from "./AudioSlider";
|
||||||
@@ -121,7 +122,10 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
|
|
||||||
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
||||||
<TouchableOpacity onPress={togglePlay}>
|
<TouchableOpacity onPress={togglePlay}>
|
||||||
{!isBuffering ? (
|
<SyncPlayActionIcon
|
||||||
|
size={ICON_SIZES.CENTER}
|
||||||
|
fallback={
|
||||||
|
!isBuffering ? (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={isPlaying ? "pause" : "play"}
|
name={isPlaying ? "pause" : "play"}
|
||||||
size={ICON_SIZES.CENTER}
|
size={ICON_SIZES.CENTER}
|
||||||
@@ -129,7 +133,9 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Loader size={"large"} />
|
<Loader size={"large"} />
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ import useRouter from "@/hooks/useAppRouter";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export interface ContinueWatchingOverlayProps {
|
export interface ContinueWatchingOverlayProps {
|
||||||
goToNextItem: (options: {
|
/** Invoked when the user confirms they want to keep watching. */
|
||||||
isAutoPlay: boolean;
|
onContinue: () => void;
|
||||||
resetWatchCount: boolean;
|
|
||||||
}) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
||||||
goToNextItem,
|
onContinue,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -29,13 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
|||||||
<Text className='text-2xl font-bold text-white py-4 '>
|
<Text className='text-2xl font-bold text-white py-4 '>
|
||||||
Are you still watching ?
|
Are you still watching ?
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button onPress={onContinue} color={"purple"} className='my-4 w-2/3'>
|
||||||
onPress={() => {
|
|
||||||
goToNextItem({ isAutoPlay: false, resetWatchCount: true });
|
|
||||||
}}
|
|
||||||
color={"purple"}
|
|
||||||
className='my-4 w-2/3'
|
|
||||||
>
|
|
||||||
{t("player.continue_watching")}
|
{t("player.continue_watching")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -15,17 +15,15 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
|
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { ticksToMs } from "@/utils/time";
|
import { ticksToMs } from "@/utils/time";
|
||||||
import { BottomControls } from "./BottomControls";
|
import { BottomControls } from "./BottomControls";
|
||||||
import { CenterControls } from "./CenterControls";
|
import { CenterControls } from "./CenterControls";
|
||||||
@@ -104,9 +102,7 @@ export const Controls: FC<Props> = ({
|
|||||||
transcodeReasons,
|
transcodeReasons,
|
||||||
}) => {
|
}) => {
|
||||||
const offline = useOfflineMode();
|
const offline = useOfflineMode();
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const router = useRouter();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const [episodeView, setEpisodeView] = useState(false);
|
const [episodeView, setEpisodeView] = useState(false);
|
||||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||||
@@ -338,130 +334,27 @@ export const Controls: FC<Props> = ({
|
|||||||
maxMs,
|
maxMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
const goToItemCommon = useCallback(
|
/*
|
||||||
(item: BaseItemDto) => {
|
* Single source of truth for next / previous / picker / autoplay
|
||||||
if (!item || !settings) {
|
* navigation. Handles SyncPlay dispatch, autoplay count gating,
|
||||||
return;
|
* platform-appropriate local navigation, and offline param injection.
|
||||||
}
|
*/
|
||||||
lightHapticFeedback();
|
const {
|
||||||
const previousIndexes = {
|
goToNextItem: handleNextEpisodeManual,
|
||||||
subtitleIndex: subtitleIndex
|
goToPreviousItem: handlePreviousItem,
|
||||||
|
goToItem: handleGoToItem,
|
||||||
|
handleAutoPlayNext: handleNextEpisodeAutoPlay,
|
||||||
|
handleContinueWatching,
|
||||||
|
} = usePlayerItemNavigation({
|
||||||
|
nextItem,
|
||||||
|
previousItem,
|
||||||
|
mediaSource,
|
||||||
|
currentAudioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
|
||||||
|
currentSubtitleIndex: subtitleIndex
|
||||||
? Number.parseInt(subtitleIndex, 10)
|
? Number.parseInt(subtitleIndex, 10)
|
||||||
: undefined,
|
: undefined,
|
||||||
audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
|
bitrateValue: bitrateValue ? Number.parseInt(bitrateValue, 10) : undefined,
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
mediaSource: newMediaSource,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
|
||||||
} = getDefaultPlaySettings(
|
|
||||||
item,
|
|
||||||
settings,
|
|
||||||
{
|
|
||||||
indexes: previousIndexes,
|
|
||||||
source: mediaSource ?? undefined,
|
|
||||||
},
|
|
||||||
{ applyLanguagePreferences: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use setParams instead of replace to avoid unmounting/remounting the player,
|
|
||||||
// which would create a new MPV native view and crash with "mp_initialize already initialized".
|
|
||||||
router.setParams({
|
|
||||||
...(offline && { offline: "true" }),
|
|
||||||
itemId: item.Id ?? "",
|
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: newMediaSource?.Id ?? "",
|
|
||||||
bitrateValue: bitrateValue?.toString(),
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
|
||||||
});
|
});
|
||||||
},
|
|
||||||
[
|
|
||||||
settings,
|
|
||||||
subtitleIndex,
|
|
||||||
audioIndex,
|
|
||||||
mediaSource,
|
|
||||||
bitrateValue,
|
|
||||||
router,
|
|
||||||
offline,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
|
||||||
if (!previousItem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goToItemCommon(previousItem);
|
|
||||||
}, [previousItem, goToItemCommon]);
|
|
||||||
|
|
||||||
const goToNextItem = useCallback(
|
|
||||||
({
|
|
||||||
isAutoPlay,
|
|
||||||
resetWatchCount,
|
|
||||||
}: {
|
|
||||||
isAutoPlay?: boolean;
|
|
||||||
resetWatchCount?: boolean;
|
|
||||||
}) => {
|
|
||||||
if (!nextItem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAutoPlay) {
|
|
||||||
// if we are not autoplaying, we won't update anything, we just go to the next item
|
|
||||||
goToItemCommon(nextItem);
|
|
||||||
if (resetWatchCount) {
|
|
||||||
updateSettings({
|
|
||||||
autoPlayEpisodeCount: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip autoplay logic if maxAutoPlayEpisodeCount is -1
|
|
||||||
if (settings.maxAutoPlayEpisodeCount.value === -1) {
|
|
||||||
goToItemCommon(nextItem);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
settings.autoPlayEpisodeCount + 1 <
|
|
||||||
settings.maxAutoPlayEpisodeCount.value
|
|
||||||
) {
|
|
||||||
goToItemCommon(nextItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the autoPlayEpisodeCount is less than maxAutoPlayEpisodeCount for the autoPlay
|
|
||||||
if (
|
|
||||||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value
|
|
||||||
) {
|
|
||||||
// update the autoPlayEpisodeCount in settings
|
|
||||||
updateSettings({
|
|
||||||
autoPlayEpisodeCount: settings.autoPlayEpisodeCount + 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[nextItem, goToItemCommon],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add a memoized handler for autoplay next episode
|
|
||||||
const handleNextEpisodeAutoPlay = useCallback(() => {
|
|
||||||
goToNextItem({ isAutoPlay: true });
|
|
||||||
}, [goToNextItem]);
|
|
||||||
|
|
||||||
// Add a memoized handler for manual next episode
|
|
||||||
const handleNextEpisodeManual = useCallback(() => {
|
|
||||||
goToNextItem({ isAutoPlay: false });
|
|
||||||
}, [goToNextItem]);
|
|
||||||
|
|
||||||
// Add a memoized handler for ContinueWatchingOverlay
|
|
||||||
const handleContinueWatching = useCallback(
|
|
||||||
(options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
|
|
||||||
goToNextItem(options);
|
|
||||||
},
|
|
||||||
[goToNextItem],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hideControls = useCallback(() => {
|
const hideControls = useCallback(() => {
|
||||||
setShowControls(false);
|
setShowControls(false);
|
||||||
@@ -490,7 +383,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<EpisodeList
|
<EpisodeList
|
||||||
item={item}
|
item={item}
|
||||||
close={() => setEpisodeView(false)}
|
close={() => setEpisodeView(false)}
|
||||||
goToItem={goToItemCommon}
|
goToItem={handleGoToItem}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -524,8 +417,8 @@ export const Controls: FC<Props> = ({
|
|||||||
mediaSource={mediaSource}
|
mediaSource={mediaSource}
|
||||||
startPictureInPicture={startPictureInPicture}
|
startPictureInPicture={startPictureInPicture}
|
||||||
switchOnEpisodeMode={switchOnEpisodeMode}
|
switchOnEpisodeMode={switchOnEpisodeMode}
|
||||||
goToPreviousItem={goToPreviousItem}
|
goToPreviousItem={handlePreviousItem}
|
||||||
goToNextItem={goToNextItem}
|
goToNextItem={handleNextEpisodeManual}
|
||||||
previousItem={previousItem}
|
previousItem={previousItem}
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
aspectRatio={aspectRatio}
|
aspectRatio={aspectRatio}
|
||||||
@@ -597,7 +490,7 @@ export const Controls: FC<Props> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||||
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
|
<ContinueWatchingOverlay onContinue={handleContinueWatching} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface HeaderControlsProps {
|
|||||||
startPictureInPicture?: () => Promise<void>;
|
startPictureInPicture?: () => Promise<void>;
|
||||||
switchOnEpisodeMode: () => void;
|
switchOnEpisodeMode: () => void;
|
||||||
goToPreviousItem: () => void;
|
goToPreviousItem: () => void;
|
||||||
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
|
goToNextItem: () => void;
|
||||||
previousItem?: BaseItemDto | null;
|
previousItem?: BaseItemDto | null;
|
||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
aspectRatio?: AspectRatio;
|
aspectRatio?: AspectRatio;
|
||||||
@@ -172,7 +172,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
)}
|
)}
|
||||||
{nextItem && (
|
{nextItem && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => goToNextItem({ isAutoPlay: false })}
|
onPress={() => goToNextItem()}
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
22
hooks/useKeepWebSocketAlive.ts
Normal file
22
hooks/useKeepWebSocketAlive.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While `active` is true, hold a keep-alive token on the global
|
||||||
|
* WebSocket so it is NOT closed when the app moves to
|
||||||
|
* background/inactive. Releases automatically when `active` flips
|
||||||
|
* false or the component unmounts.
|
||||||
|
*
|
||||||
|
* Used by the video player while in Picture-in-Picture so SyncPlay
|
||||||
|
* commands (and any other server pushes) keep flowing while the OS
|
||||||
|
* thinks the app is backgrounded.
|
||||||
|
*/
|
||||||
|
export function useKeepWebSocketAlive(active: boolean): void {
|
||||||
|
const { acquireKeepAlive } = useWebSocketContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const release = acquireKeepAlive();
|
||||||
|
return release;
|
||||||
|
}, [active, acquireKeepAlive]);
|
||||||
|
}
|
||||||
405
hooks/usePlayerItemNavigation.ts
Normal file
405
hooks/usePlayerItemNavigation.ts
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
/**
|
||||||
|
* Single source of truth for *all* item-level navigation inside the
|
||||||
|
* player (next / previous / picked-from-episode-list / autoplay-next).
|
||||||
|
*
|
||||||
|
* This hook encapsulates three orthogonal concerns so callers don't
|
||||||
|
* have to:
|
||||||
|
*
|
||||||
|
* 1. **SyncPlay** — when a group is active, every advance/rewind
|
||||||
|
* dispatches through `Controller`. `SyncPlayProvider` handles the
|
||||||
|
* resulting `localPlay` / `localSetCurrentPlaylistItem` events and
|
||||||
|
* navigates the local screen.
|
||||||
|
* 2. **Autoplay gating** — `maxAutoPlayEpisodeCount` limits how many
|
||||||
|
* episodes auto-play before stopping. Manual presses bypass this.
|
||||||
|
* SyncPlay bypasses it too (the server drives the queue).
|
||||||
|
* 3. **Platform navigation** — mobile uses `router.setParams` so the
|
||||||
|
* player view stays mounted (avoids a full re-mount + bitrate /
|
||||||
|
* stream re-pick cycle). TV uses `router.replace` because MPV's
|
||||||
|
* native view can't be re-initialized in place. Offline state is
|
||||||
|
* preserved automatically by `useAppRouter`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, Platform } from "react-native";
|
||||||
|
import useAppRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { getDownloadedItemById } from "@/providers/Downloads";
|
||||||
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
|
||||||
|
interface UsePlayerItemNavigationParams {
|
||||||
|
/**
|
||||||
|
* The adjacent item that "next" should target (from `usePlaybackManager`).
|
||||||
|
* Only needed by callers that use the in-session nav methods.
|
||||||
|
*/
|
||||||
|
nextItem?: BaseItemDto | null;
|
||||||
|
/** The adjacent item that "previous" should target. */
|
||||||
|
previousItem?: BaseItemDto | null;
|
||||||
|
/** The active media source for the *current* item; used to seed track defaults. */
|
||||||
|
mediaSource?: MediaSourceInfo | null;
|
||||||
|
/** Live audio track index (may differ from the URL param after the user changed tracks). */
|
||||||
|
currentAudioIndex?: number;
|
||||||
|
/** Live subtitle track index. */
|
||||||
|
currentSubtitleIndex?: number;
|
||||||
|
/** Currently-active bitrate cap. */
|
||||||
|
bitrateValue?: number;
|
||||||
|
/**
|
||||||
|
* Optional guard for "we're already stopping the player". TV passes
|
||||||
|
* `isPlaybackStopped` here to drop spurious next-item dispatches that
|
||||||
|
* fire during teardown.
|
||||||
|
*/
|
||||||
|
isDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for `playItem` — the entry-point method used by PlayButton et al. */
|
||||||
|
export interface PlayItemOptions {
|
||||||
|
audioIndex?: number;
|
||||||
|
subtitleIndex?: number;
|
||||||
|
mediaSourceId?: string;
|
||||||
|
bitrateValue?: number;
|
||||||
|
/** Defaults to `item.UserData?.PlaybackPositionTicks`. */
|
||||||
|
playbackPosition?: number;
|
||||||
|
/**
|
||||||
|
* Force local-file playback even outside the offline UI context, and
|
||||||
|
* skip SyncPlay broadcasting. Used when the user explicitly picks the
|
||||||
|
* downloaded copy from a "play downloaded?" prompt.
|
||||||
|
*/
|
||||||
|
forceOffline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerItemNavigation {
|
||||||
|
/** SyncPlay-aware previous. No-op when there's no previous item. */
|
||||||
|
goToPreviousItem: () => void;
|
||||||
|
/**
|
||||||
|
* Manual next (e.g. user tapped the skip-forward / next button).
|
||||||
|
* Autoplay gating is bypassed.
|
||||||
|
*/
|
||||||
|
goToNextItem: () => void;
|
||||||
|
/** Jump to an arbitrary item (episode picker). */
|
||||||
|
goToItem: (item: BaseItemDto) => void;
|
||||||
|
/**
|
||||||
|
* Autoplay next (e.g. "Up Next" overlay countdown completed). Respects
|
||||||
|
* `maxAutoPlayEpisodeCount`. Bypassed when SyncPlay is active.
|
||||||
|
*/
|
||||||
|
handleAutoPlayNext: () => void;
|
||||||
|
/**
|
||||||
|
* Helper for the "Keep Watching" overlay button — advances and resets
|
||||||
|
* the auto-play counter so the next stretch of episodes can autoplay.
|
||||||
|
*/
|
||||||
|
handleContinueWatching: () => void;
|
||||||
|
/**
|
||||||
|
* Entry-point: start playback of an item from outside the player
|
||||||
|
* (PlayButton, Continue Watching, episode picker on the item page).
|
||||||
|
* SyncPlay-aware. Resets the autoplay counter.
|
||||||
|
*/
|
||||||
|
playItem: (item: BaseItemDto, opts?: PlayItemOptions) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayerItemNavigation(
|
||||||
|
params: UsePlayerItemNavigationParams = {},
|
||||||
|
): PlayerItemNavigation {
|
||||||
|
const {
|
||||||
|
nextItem,
|
||||||
|
previousItem,
|
||||||
|
mediaSource,
|
||||||
|
currentAudioIndex,
|
||||||
|
currentSubtitleIndex,
|
||||||
|
bitrateValue,
|
||||||
|
isDisabled,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const router = useAppRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
|
||||||
|
useSyncPlay();
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
// Note: "offline mode" is a *UI context* flag (set by pages entered from
|
||||||
|
// the downloads tab), not a network-connectivity status. A user may be
|
||||||
|
// in offline mode with perfect internet, watching a downloaded copy.
|
||||||
|
const inOfflineContext = useOfflineMode();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Compute the destination URL params for a given target item, using
|
||||||
|
* the live selection state (which may differ from the URL params the
|
||||||
|
* episode started with — the user may have switched tracks mid-play).
|
||||||
|
*/
|
||||||
|
const buildNavigationParams = useCallback(
|
||||||
|
(target: BaseItemDto) => {
|
||||||
|
if (!settings) return null;
|
||||||
|
const {
|
||||||
|
mediaSource: newMediaSource,
|
||||||
|
audioIndex: defaultAudioIndex,
|
||||||
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
|
} = getDefaultPlaySettings(
|
||||||
|
target,
|
||||||
|
settings,
|
||||||
|
{
|
||||||
|
indexes: {
|
||||||
|
subtitleIndex: currentSubtitleIndex,
|
||||||
|
audioIndex: currentAudioIndex,
|
||||||
|
},
|
||||||
|
source: mediaSource ?? undefined,
|
||||||
|
},
|
||||||
|
{ applyLanguagePreferences: true },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
itemId: target.Id ?? "",
|
||||||
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: newMediaSource?.Id ?? "",
|
||||||
|
bitrateValue: bitrateValue?.toString() ?? "",
|
||||||
|
playbackPosition:
|
||||||
|
target.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[
|
||||||
|
settings,
|
||||||
|
currentSubtitleIndex,
|
||||||
|
currentAudioIndex,
|
||||||
|
mediaSource,
|
||||||
|
bitrateValue,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stamp the `offline` URL param onto a params object based on whether
|
||||||
|
* the target item is actually downloaded.
|
||||||
|
*
|
||||||
|
* - Online (no offline UI context) → pass through unchanged; the
|
||||||
|
* `offline` key is absent so `useAppRouter` doesn't touch it.
|
||||||
|
* - Offline context, target IS downloaded → `offline: "true"` (play
|
||||||
|
* the local copy).
|
||||||
|
* - Offline context, target is NOT downloaded → `offline: ""` (force
|
||||||
|
* online streaming; the key's presence blocks `useAppRouter` from
|
||||||
|
* auto-injecting `"true"`, and direct-player only treats the value
|
||||||
|
* `"true"` as offline).
|
||||||
|
*
|
||||||
|
* That last case is the important one: a user can be in the offline
|
||||||
|
* UI context with perfect internet (e.g. navigated in from downloads)
|
||||||
|
* and pick an episode they never downloaded. Without this the player
|
||||||
|
* would hang waiting for a local file that doesn't exist.
|
||||||
|
*/
|
||||||
|
const withOfflineParam = useCallback(
|
||||||
|
(params: Record<string, string>, target: BaseItemDto) => {
|
||||||
|
if (!inOfflineContext) return params;
|
||||||
|
const isDownloaded = !!(target.Id && getDownloadedItemById(target.Id));
|
||||||
|
return { ...params, offline: isDownloaded ? "true" : "" };
|
||||||
|
},
|
||||||
|
[inOfflineContext],
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Platform-appropriate local navigation. Mobile keeps the same player
|
||||||
|
* view mounted via setParams; TV swaps the whole route via replace.
|
||||||
|
*/
|
||||||
|
const localNavigate = useCallback(
|
||||||
|
(target: BaseItemDto) => {
|
||||||
|
if (isDisabled) return;
|
||||||
|
const navParams = buildNavigationParams(target);
|
||||||
|
if (!navParams) return;
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
const finalParams = withOfflineParam(navParams, target);
|
||||||
|
|
||||||
|
if (Platform.isTV) {
|
||||||
|
const queryString = new URLSearchParams(finalParams).toString();
|
||||||
|
router.replace(`/player/direct-player?${queryString}`);
|
||||||
|
} else {
|
||||||
|
router.setParams(finalParams);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isDisabled,
|
||||||
|
buildNavigationParams,
|
||||||
|
router,
|
||||||
|
lightHapticFeedback,
|
||||||
|
withOfflineParam,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToPreviousItem = useCallback(() => {
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
syncPlayController.previousItem();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!previousItem) return;
|
||||||
|
localNavigate(previousItem);
|
||||||
|
}, [isSyncPlayEnabled, syncPlayController, previousItem, localNavigate]);
|
||||||
|
|
||||||
|
const goToNextItem = useCallback(() => {
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
syncPlayController.nextItem();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!nextItem) return;
|
||||||
|
localNavigate(nextItem);
|
||||||
|
}, [isSyncPlayEnabled, syncPlayController, nextItem, localNavigate]);
|
||||||
|
|
||||||
|
const goToItem = useCallback(
|
||||||
|
(target: BaseItemDto) => {
|
||||||
|
if (isSyncPlayEnabled && syncPlayController && target.Id) {
|
||||||
|
syncPlayController.goToItem(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localNavigate(target);
|
||||||
|
},
|
||||||
|
[isSyncPlayEnabled, syncPlayController, localNavigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAutoPlayNext = useCallback(() => {
|
||||||
|
// SyncPlay always advances unconditionally — the server is the source
|
||||||
|
// of truth for queue progression and per-client gating would desync us.
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
syncPlayController.nextItem();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!nextItem) return;
|
||||||
|
|
||||||
|
const maxCount = settings?.maxAutoPlayEpisodeCount.value ?? 0;
|
||||||
|
const currentCount = settings?.autoPlayEpisodeCount ?? 0;
|
||||||
|
|
||||||
|
// -1 means "no limit"
|
||||||
|
if (maxCount === -1) {
|
||||||
|
localNavigate(nextItem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCount + 1 < maxCount) {
|
||||||
|
localNavigate(nextItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCount < maxCount) {
|
||||||
|
updateSettings({ autoPlayEpisodeCount: currentCount + 1 });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isSyncPlayEnabled,
|
||||||
|
syncPlayController,
|
||||||
|
nextItem,
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
localNavigate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleContinueWatching = useCallback(() => {
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
syncPlayController.nextItem();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!nextItem) return;
|
||||||
|
updateSettings({ autoPlayEpisodeCount: 0 });
|
||||||
|
localNavigate(nextItem);
|
||||||
|
}, [
|
||||||
|
isSyncPlayEnabled,
|
||||||
|
syncPlayController,
|
||||||
|
nextItem,
|
||||||
|
updateSettings,
|
||||||
|
localNavigate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Entry-point: start playback of an item from outside the player.
|
||||||
|
*
|
||||||
|
* Used by PlayButton, Continue Watching cards, episode pickers on the
|
||||||
|
* item page, etc. Unlike the in-session methods, this always uses
|
||||||
|
* `router.push` (we're entering the player, not navigating within it)
|
||||||
|
* and runs on both mobile and TV with the same shape.
|
||||||
|
*
|
||||||
|
* SyncPlay: when in a group and the user *didn't* explicitly request
|
||||||
|
* local playback, we route through `controller.play()` so every group
|
||||||
|
* member gets the same `PlayQueue: NewPlaylist` update and navigates
|
||||||
|
* together. Errors surface as an Alert and abort the local navigation
|
||||||
|
* (matches `PlayButton`'s previous behavior).
|
||||||
|
*/
|
||||||
|
const playItem = useCallback(
|
||||||
|
async (item: BaseItemDto, opts: PlayItemOptions = {}) => {
|
||||||
|
if (!item.Id) return;
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
const startPositionTicks =
|
||||||
|
opts.playbackPosition ?? item.UserData?.PlaybackPositionTicks ?? 0;
|
||||||
|
|
||||||
|
// Fresh playback start — reset the autoplay budget so the next
|
||||||
|
// stretch of episodes can autoplay.
|
||||||
|
if (settings && settings.maxAutoPlayEpisodeCount.value !== -1) {
|
||||||
|
updateSettings({ autoPlayEpisodeCount: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncPlay: broadcast to the group instead of navigating locally.
|
||||||
|
// Skipped when the user explicitly picked the downloaded copy — a
|
||||||
|
// local file can't be part of a synced session.
|
||||||
|
if (!opts.forceOffline && isSyncPlayEnabled && syncPlayController) {
|
||||||
|
try {
|
||||||
|
await syncPlayController.play({
|
||||||
|
items: [item],
|
||||||
|
ids: [item.Id],
|
||||||
|
startPositionTicks,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to start group playback", error);
|
||||||
|
Alert.alert(
|
||||||
|
t("player.client_error"),
|
||||||
|
t("syncplay.failed_to_start", {
|
||||||
|
defaultValue: "Failed to start SyncPlay group playback",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a string-record so we can run it through `withOfflineParam`
|
||||||
|
// and `URLSearchParams` uniformly.
|
||||||
|
const baseParams: Record<string, string> = {
|
||||||
|
itemId: item.Id,
|
||||||
|
playbackPosition: String(startPositionTicks),
|
||||||
|
};
|
||||||
|
if (opts.audioIndex !== undefined) {
|
||||||
|
baseParams.audioIndex = String(opts.audioIndex);
|
||||||
|
}
|
||||||
|
if (opts.subtitleIndex !== undefined) {
|
||||||
|
baseParams.subtitleIndex = String(opts.subtitleIndex);
|
||||||
|
}
|
||||||
|
if (opts.mediaSourceId) {
|
||||||
|
baseParams.mediaSourceId = opts.mediaSourceId;
|
||||||
|
}
|
||||||
|
if (opts.bitrateValue !== undefined) {
|
||||||
|
baseParams.bitrateValue = String(opts.bitrateValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalParams = opts.forceOffline
|
||||||
|
? { ...baseParams, offline: "true" }
|
||||||
|
: withOfflineParam(baseParams, item);
|
||||||
|
|
||||||
|
const queryString = new URLSearchParams(finalParams).toString();
|
||||||
|
router.push(`/player/direct-player?${queryString}`);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
lightHapticFeedback,
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
isSyncPlayEnabled,
|
||||||
|
syncPlayController,
|
||||||
|
withOfflineParam,
|
||||||
|
router,
|
||||||
|
t,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
goToPreviousItem,
|
||||||
|
goToNextItem,
|
||||||
|
goToItem,
|
||||||
|
handleAutoPlayNext,
|
||||||
|
handleContinueWatching,
|
||||||
|
playItem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePlayerItemNavigation;
|
||||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
||||||
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||||
|
|
||||||
interface UseWebSocketProps {
|
interface UseWebSocketProps {
|
||||||
@@ -80,9 +81,9 @@ export const useWebSocket = ({
|
|||||||
playTrailers,
|
playTrailers,
|
||||||
}: UseWebSocketProps) => {
|
}: UseWebSocketProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { lastMessage } = useWebSocketContext();
|
const { lastMessage, clearLastMessage } = useWebSocketContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { clearLastMessage } = useWebSocketContext();
|
const { isEnabled: isSyncPlayEnabled } = useSyncPlay();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastMessage) return;
|
if (!lastMessage) return;
|
||||||
@@ -96,6 +97,25 @@ export const useWebSocket = ({
|
|||||||
| Record<string, string>
|
| Record<string, string>
|
||||||
| undefined; // Arguments are Dictionary<string, string>
|
| undefined; // Arguments are Dictionary<string, string>
|
||||||
|
|
||||||
|
// Skip playback commands when SyncPlay is enabled - SyncPlay handles these
|
||||||
|
const isSyncPlayCommand =
|
||||||
|
lastMessage.MessageType === "SyncPlayCommand" ||
|
||||||
|
lastMessage.MessageType === "SyncPlayGroupUpdate";
|
||||||
|
const isPlaybackCommand = [
|
||||||
|
"PlayPause",
|
||||||
|
"Pause",
|
||||||
|
"Unpause",
|
||||||
|
"Stop",
|
||||||
|
"Seek",
|
||||||
|
"NextTrack",
|
||||||
|
"PreviousTrack",
|
||||||
|
].includes(command ?? "");
|
||||||
|
|
||||||
|
if (isSyncPlayEnabled && (isSyncPlayCommand || isPlaybackCommand)) {
|
||||||
|
console.log(`Command ~ ${command} - skipping, SyncPlay handles playback`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (command === "PlayPause") {
|
if (command === "PlayPause") {
|
||||||
console.log("Command ~ PlayPause");
|
console.log("Command ~ PlayPause");
|
||||||
togglePlay();
|
togglePlay();
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ class MpvPlayerModule : Module() {
|
|||||||
// No-op on Android - media session integration would require MediaSessionCompat
|
// No-op on Android - media session integration would require MediaSessionCompat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When true, PiP play/pause/skip controls emit JS events
|
||||||
|
// instead of driving MPV directly, so the host app can route
|
||||||
|
// through SyncPlay (server -> group broadcast -> all clients).
|
||||||
|
Prop("syncPlayDelegated") { view: MpvPlayerView, delegated: Boolean ->
|
||||||
|
view.syncPlayDelegated = delegated
|
||||||
|
}
|
||||||
|
|
||||||
// Async function to play video
|
// Async function to play video
|
||||||
AsyncFunction("play") { view: MpvPlayerView ->
|
AsyncFunction("play") { view: MpvPlayerView ->
|
||||||
view.play()
|
view.play()
|
||||||
@@ -198,7 +205,7 @@ 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", "onPictureInPictureChange", "onPipPlayRequest", "onPipPauseRequest", "onPipSkipRequest")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
val onError by EventDispatcher()
|
val onError by EventDispatcher()
|
||||||
val onTracksReady by EventDispatcher()
|
val onTracksReady by EventDispatcher()
|
||||||
val onPictureInPictureChange by EventDispatcher()
|
val onPictureInPictureChange by EventDispatcher()
|
||||||
|
// SyncPlay: when `syncPlayDelegated == true`, PiP playback controls
|
||||||
|
// (play / pause / skip) emit these events instead of driving MPV
|
||||||
|
// directly, so JS can route the action through the SyncPlay
|
||||||
|
// controller (server -> group broadcast -> all clients). Default
|
||||||
|
// behavior (non-SyncPlay) is unchanged.
|
||||||
|
val onPipPlayRequest by EventDispatcher()
|
||||||
|
val onPipPauseRequest by EventDispatcher()
|
||||||
|
val onPipSkipRequest by EventDispatcher()
|
||||||
|
|
||||||
|
var syncPlayDelegated: Boolean = false
|
||||||
|
|
||||||
private var textureView: TextureView
|
private var textureView: TextureView
|
||||||
private var renderer: MPVLayerRenderer? = null
|
private var renderer: MPVLayerRenderer? = null
|
||||||
@@ -85,14 +95,32 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
pipController?.setPlayerView(textureView)
|
pipController?.setPlayerView(textureView)
|
||||||
pipController?.delegate = object : PiPController.Delegate {
|
pipController?.delegate = object : PiPController.Delegate {
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
|
if (syncPlayDelegated) {
|
||||||
|
onPipPlayRequest(mapOf<String, Any>())
|
||||||
|
return
|
||||||
|
}
|
||||||
play()
|
play()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
if (syncPlayDelegated) {
|
||||||
|
onPipPauseRequest(mapOf<String, Any>())
|
||||||
|
return
|
||||||
|
}
|
||||||
pause()
|
pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSeekBy(seconds: Double) {
|
override fun onSeekBy(seconds: Double) {
|
||||||
|
if (syncPlayDelegated) {
|
||||||
|
val target = (cachedPosition + seconds).coerceAtLeast(0.0)
|
||||||
|
onPipSkipRequest(
|
||||||
|
mapOf(
|
||||||
|
"targetSeconds" to target,
|
||||||
|
"intervalSeconds" to seconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
seekBy(seconds)
|
seekBy(seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ public class MpvPlayerModule: Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When true, PiP play/pause/skip controls emit JS events instead
|
||||||
|
// of driving MPV directly, so the host app can route through
|
||||||
|
// SyncPlay (server -> group broadcast -> all clients).
|
||||||
|
Prop("syncPlayDelegated") { (view: MpvPlayerView, delegated: Bool) in
|
||||||
|
view.syncPlayDelegated = delegated
|
||||||
|
}
|
||||||
|
|
||||||
// Async function to play video
|
// Async function to play video
|
||||||
AsyncFunction("play") { (view: MpvPlayerView) in
|
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||||
view.play()
|
view.play()
|
||||||
@@ -213,7 +220,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")
|
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange", "onPipPlayRequest", "onPipPauseRequest", "onPipSkipRequest")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ class MpvPlayerView: ExpoView {
|
|||||||
let onProgress = EventDispatcher()
|
let onProgress = EventDispatcher()
|
||||||
let onError = EventDispatcher()
|
let onError = EventDispatcher()
|
||||||
let onTracksReady = EventDispatcher()
|
let onTracksReady = EventDispatcher()
|
||||||
|
let onPictureInPictureChange = EventDispatcher()
|
||||||
|
// SyncPlay: when `syncPlayDelegated == true`, PiP playback controls
|
||||||
|
// (play / pause / skip) emit these events instead of driving MPV
|
||||||
|
// directly, so JS can route the action through the SyncPlay
|
||||||
|
// controller (server → group broadcast → all clients). Default
|
||||||
|
// behavior (non-SyncPlay) is unchanged.
|
||||||
|
let onPipPlayRequest = EventDispatcher()
|
||||||
|
let onPipPauseRequest = EventDispatcher()
|
||||||
|
let onPipSkipRequest = EventDispatcher()
|
||||||
|
|
||||||
|
var syncPlayDelegated: Bool = false
|
||||||
|
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
private var cachedPosition: Double = 0
|
private var cachedPosition: Double = 0
|
||||||
@@ -637,6 +648,9 @@ 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) {
|
||||||
@@ -655,6 +669,9 @@ 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) {
|
||||||
@@ -664,6 +681,12 @@ extension MpvPlayerView: PiPControllerDelegate {
|
|||||||
|
|
||||||
func pipControllerPlay(_ controller: PiPController) {
|
func pipControllerPlay(_ controller: PiPController) {
|
||||||
print("PiP play requested")
|
print("PiP play requested")
|
||||||
|
if syncPlayDelegated {
|
||||||
|
// Let JS route through SyncPlay. We deliberately do NOT touch
|
||||||
|
// MPV here; the WS command coming back will drive playback.
|
||||||
|
onPipPlayRequest([:])
|
||||||
|
return
|
||||||
|
}
|
||||||
intendedPlayState = true
|
intendedPlayState = true
|
||||||
renderer?.play()
|
renderer?.play()
|
||||||
pipController?.setPlaybackRate(1.0)
|
pipController?.setPlaybackRate(1.0)
|
||||||
@@ -671,6 +694,10 @@ extension MpvPlayerView: PiPControllerDelegate {
|
|||||||
|
|
||||||
func pipControllerPause(_ controller: PiPController) {
|
func pipControllerPause(_ controller: PiPController) {
|
||||||
print("PiP pause requested")
|
print("PiP pause requested")
|
||||||
|
if syncPlayDelegated {
|
||||||
|
onPipPauseRequest([:])
|
||||||
|
return
|
||||||
|
}
|
||||||
intendedPlayState = false
|
intendedPlayState = false
|
||||||
renderer?.pausePlayback()
|
renderer?.pausePlayback()
|
||||||
pipController?.setPlaybackRate(0.0)
|
pipController?.setPlaybackRate(0.0)
|
||||||
@@ -680,6 +707,16 @@ extension MpvPlayerView: PiPControllerDelegate {
|
|||||||
let seconds = CMTimeGetSeconds(interval)
|
let seconds = CMTimeGetSeconds(interval)
|
||||||
print("PiP skip by interval: \(seconds)")
|
print("PiP skip by interval: \(seconds)")
|
||||||
let target = max(0, cachedPosition + seconds)
|
let target = max(0, cachedPosition + seconds)
|
||||||
|
if syncPlayDelegated {
|
||||||
|
// `targetSeconds` lets JS convert to ticks and call
|
||||||
|
// syncPlayController.seek(). `intervalSeconds` is also sent
|
||||||
|
// for telemetry / debug.
|
||||||
|
onPipSkipRequest([
|
||||||
|
"targetSeconds": target,
|
||||||
|
"intervalSeconds": seconds
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
seekTo(position: target)
|
seekTo(position: target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ export type OnPictureInPictureChangePayload = {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emitted when the user taps a PiP playback control while the view
|
||||||
|
* was rendered with `syncPlayDelegated`. The host app should route
|
||||||
|
* the action through the SyncPlay controller instead of acting
|
||||||
|
* locally.
|
||||||
|
*/
|
||||||
|
export type OnPipPlayRequestPayload = Record<string, never>;
|
||||||
|
export type OnPipPauseRequestPayload = Record<string, never>;
|
||||||
|
export type OnPipSkipRequestPayload = {
|
||||||
|
/** Absolute target position the user wants to seek to, in seconds. */
|
||||||
|
targetSeconds: number;
|
||||||
|
/** Skip interval requested by the OS (signed seconds). Debug only. */
|
||||||
|
intervalSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type NowPlayingMetadata = {
|
export type NowPlayingMetadata = {
|
||||||
title?: string;
|
title?: string;
|
||||||
artist?: string;
|
artist?: string;
|
||||||
@@ -84,6 +99,18 @@ export type MpvPlayerViewProps = {
|
|||||||
onPictureInPictureChange?: (event: {
|
onPictureInPictureChange?: (event: {
|
||||||
nativeEvent: OnPictureInPictureChangePayload;
|
nativeEvent: OnPictureInPictureChangePayload;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
/**
|
||||||
|
* When true, PiP play/pause/skip controls emit the corresponding
|
||||||
|
* `onPipPlayRequest` / `onPipPauseRequest` / `onPipSkipRequest`
|
||||||
|
* events instead of driving MPV directly. Used to route PiP control
|
||||||
|
* actions through SyncPlay.
|
||||||
|
*/
|
||||||
|
syncPlayDelegated?: boolean;
|
||||||
|
onPipPlayRequest?: (event: { nativeEvent: OnPipPlayRequestPayload }) => void;
|
||||||
|
onPipPauseRequest?: (event: {
|
||||||
|
nativeEvent: OnPipPauseRequestPayload;
|
||||||
|
}) => void;
|
||||||
|
onPipSkipRequest?: (event: { nativeEvent: OnPipSkipRequestPayload }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MpvPlayerViewRef {
|
export interface MpvPlayerViewRef {
|
||||||
|
|||||||
@@ -52,7 +52,6 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.15",
|
"expo-build-properties": "~56.0.15",
|
||||||
"expo-camera": "~56.0.7",
|
"expo-camera": "~56.0.7",
|
||||||
"expo-clipboard": "~56.0.3",
|
|
||||||
"expo-constants": "~56.0.16",
|
"expo-constants": "~56.0.16",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.16",
|
"expo-dev-client": "~56.0.16",
|
||||||
@@ -125,10 +124,8 @@
|
|||||||
"@biomejs/biome": "2.4.16",
|
"@biomejs/biome": "2.4.16",
|
||||||
"@react-native-community/cli": "20.1.3",
|
"@react-native-community/cli": "20.1.3",
|
||||||
"@react-native-tvos/config-tv": "0.1.6",
|
"@react-native-tvos/config-tv": "0.1.6",
|
||||||
"@types/bun": "^1.3.14",
|
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.24",
|
"@types/lodash": "4.17.24",
|
||||||
"@types/node": "^24",
|
|
||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import type * as NotificationsType from "expo-notifications";
|
import type * as NotificationsType from "expo-notifications";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
// Conditionally import expo-notifications only on non-TV platforms
|
// Conditionally import expo-notifications only on non-TV platforms
|
||||||
const Notifications = Platform.isTV
|
const Notifications = Platform.isTV
|
||||||
@@ -68,14 +67,6 @@ export async function sendDownloadNotification(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (Platform.isTV || !Notifications) return;
|
if (Platform.isTV || !Notifications) return;
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = storage.getString("settings");
|
|
||||||
const s = raw ? JSON.parse(raw) : {};
|
|
||||||
if (s.notificationsEnabled === false || s.notifyDownloads === false) return;
|
|
||||||
} catch {
|
|
||||||
// ignore parse errors; fall through to sending (defaults are enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Notifications.scheduleNotificationAsync({
|
await Notifications.scheduleNotificationAsync({
|
||||||
content: {
|
content: {
|
||||||
|
|||||||
@@ -619,6 +619,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(storedUser);
|
setUser(storedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dismiss splash screen with cached data immediately,
|
||||||
|
// fetch fresh user data in the background
|
||||||
|
setInitialLoaded(true);
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||||
setUser(response.data);
|
setUser(response.data);
|
||||||
|
|
||||||
@@ -653,10 +658,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Background fetch failed — app already rendered with cached data
|
||||||
|
console.warn("Background user fetch failed, using cached data:", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setInitialLoaded(true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
|
||||||
setInitialLoaded(true);
|
setInitialLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
191
providers/SyncPlay/Controller.ts
Normal file
191
providers/SyncPlay/Controller.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay Controller — public playback API exposed to consumers.
|
||||||
|
*
|
||||||
|
* Methods are fire-and-forget by design: SyncPlay HTTP responses don't
|
||||||
|
* carry useful info (the real state arrives via WebSocket broadcast).
|
||||||
|
* Wrap calls in try/catch so transient network errors don't reach the UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import type { SyncPlayManager } from "./Manager";
|
||||||
|
import {
|
||||||
|
getItemsForPlayback,
|
||||||
|
type TranslateOptions,
|
||||||
|
translateItemsForPlayback,
|
||||||
|
} from "./transport/queueTranslation";
|
||||||
|
|
||||||
|
export interface PlayOptions extends TranslateOptions {
|
||||||
|
items?: BaseItemDto[];
|
||||||
|
ids?: string[];
|
||||||
|
startIndex?: number;
|
||||||
|
startPositionTicks?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Controller {
|
||||||
|
private manager!: SyncPlayManager;
|
||||||
|
|
||||||
|
init(manager: SyncPlayManager): void {
|
||||||
|
this.manager = manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle play/pause for the whole group. */
|
||||||
|
playPause(): void {
|
||||||
|
if (this.manager.isPlaying()) {
|
||||||
|
this.pause();
|
||||||
|
} else {
|
||||||
|
this.unpause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resume the group's playback. */
|
||||||
|
unpause(): void {
|
||||||
|
this.manager.markPendingPlaybackCommand("Unpause");
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlayUnpause();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.unpause failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pause the group's playback. */
|
||||||
|
pause(): void {
|
||||||
|
this.manager.markPendingPlaybackCommand("Pause");
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlayPause();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.pause failed", error);
|
||||||
|
}
|
||||||
|
// Pause locally too so the user sees instant feedback.
|
||||||
|
this.manager.getPlayerWrapper().localPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seek the group's playback. `positionTicks` is in ticks (1ms = 10000 ticks). */
|
||||||
|
seek(positionTicks: number): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlaySeek({
|
||||||
|
seekRequestDto: { PositionTicks: positionTicks },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.seek failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start playback in the group. Expands containers (Series, Season,
|
||||||
|
* BoxSet, Playlist, single Episode w/ autoplay) into the real
|
||||||
|
* playable queue before broadcasting.
|
||||||
|
*
|
||||||
|
* Resolves once the SetNewQueue request completes; the server then
|
||||||
|
* broadcasts a PlayQueue update and Play command to every member.
|
||||||
|
*/
|
||||||
|
async play(options: PlayOptions): Promise<void> {
|
||||||
|
const api = this.manager.getApiClient();
|
||||||
|
|
||||||
|
const sendPlayRequest = async (items: BaseItemDto[]) => {
|
||||||
|
const queue = items
|
||||||
|
.map((item) => item.Id)
|
||||||
|
.filter((id): id is string => typeof id === "string");
|
||||||
|
await getSyncPlayApi(api).syncPlaySetNewQueue({
|
||||||
|
playRequestDto: {
|
||||||
|
PlayingQueue: queue,
|
||||||
|
PlayingItemPosition: options.startIndex ?? 0,
|
||||||
|
StartPositionTicks: options.startPositionTicks ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceItems = options.items
|
||||||
|
? options.items
|
||||||
|
: await getItemsForPlayback(api, options.ids ?? []);
|
||||||
|
const items = await translateItemsForPlayback(api, sourceItems, options);
|
||||||
|
await sendPlayRequest(items);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.play failed", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the group's playback. */
|
||||||
|
stop(): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlayStop();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.stop failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump to the next item in the group's queue. */
|
||||||
|
nextItem(): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlayNextItem({
|
||||||
|
nextItemRequestDto: {
|
||||||
|
PlaylistItemId: this.manager
|
||||||
|
.getQueueCore()
|
||||||
|
.getCurrentPlaylistItemId(),
|
||||||
|
} as unknown as Parameters<
|
||||||
|
ReturnType<typeof getSyncPlayApi>["syncPlayNextItem"]
|
||||||
|
>[0]["nextItemRequestDto"],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.nextItem failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump to the previous item in the group's queue. */
|
||||||
|
previousItem(): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlayPreviousItem({
|
||||||
|
previousItemRequestDto: {
|
||||||
|
PlaylistItemId: this.manager
|
||||||
|
.getQueueCore()
|
||||||
|
.getCurrentPlaylistItemId(),
|
||||||
|
} as unknown as Parameters<
|
||||||
|
ReturnType<typeof getSyncPlayApi>["syncPlayPreviousItem"]
|
||||||
|
>[0]["previousItemRequestDto"],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.previousItem failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump to a specific item in the queue by playlist item id. */
|
||||||
|
setCurrentPlaylistItem(playlistItemId: string): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlaySetPlaylistItem({
|
||||||
|
setPlaylistItemRequestDto: { PlaylistItemId: playlistItemId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.setCurrentPlaylistItem failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jump the group to `item`. If the item is already in the current queue
|
||||||
|
* (by `Id`), dispatches a cheap `SetPlaylistItem` so the queue stays
|
||||||
|
* intact. Otherwise starts a new playback request, which replaces the
|
||||||
|
* group's queue (matches jellyfin-web's playbackManager.play behavior
|
||||||
|
* when picking an episode from a different series/season).
|
||||||
|
*/
|
||||||
|
goToItem(item: BaseItemDto): void {
|
||||||
|
if (!item.Id) {
|
||||||
|
console.warn("SyncPlay Controller.goToItem called without item.Id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queueEntry = this.manager
|
||||||
|
.getQueueCore()
|
||||||
|
.getPlaylist()
|
||||||
|
.find((q) => q.Id === item.Id);
|
||||||
|
if (queueEntry?.PlaylistItemId) {
|
||||||
|
this.setCurrentPlaylistItem(queueEntry.PlaylistItemId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.play({
|
||||||
|
ids: [item.Id],
|
||||||
|
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Controller;
|
||||||
93
providers/SyncPlay/EventEmitter.ts
Normal file
93
providers/SyncPlay/EventEmitter.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Per-instance event emitter — replaces jellyfin-web's global `Events.trigger`
|
||||||
|
* bus. Listeners that throw are caught and logged so one bad listener can't
|
||||||
|
* break the rest.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WaitForEventDefaultTimeout } from "./constants";
|
||||||
|
|
||||||
|
export class EventEmitter {
|
||||||
|
private listeners: Map<string, Set<(...args: unknown[]) => void>> = new Map();
|
||||||
|
|
||||||
|
on(event: string, callback: (...args: unknown[]) => void): void {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, new Set());
|
||||||
|
}
|
||||||
|
this.listeners.get(event)!.add(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, callback: (...args: unknown[]) => void): void {
|
||||||
|
this.listeners.get(event)?.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, ...args: unknown[]): void {
|
||||||
|
this.listeners.get(event)?.forEach((callback) => {
|
||||||
|
try {
|
||||||
|
callback(...args);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`SyncPlay EventEmitter: handler for "${event}" threw`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllListeners(event?: string): void {
|
||||||
|
if (event) {
|
||||||
|
this.listeners.delete(event);
|
||||||
|
} else {
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve on the next emission of `event`, or reject after `timeoutMs`
|
||||||
|
* (or any event in `rejectEventTypes`). Cleans up every listener.
|
||||||
|
*/
|
||||||
|
export function waitForEventOnce(
|
||||||
|
emitter: EventEmitter,
|
||||||
|
event: string,
|
||||||
|
timeoutMs: number = WaitForEventDefaultTimeout,
|
||||||
|
rejectEventTypes?: string[],
|
||||||
|
): Promise<unknown[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
emitter.off(event, handler);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (Array.isArray(rejectEventTypes)) {
|
||||||
|
for (const eventName of rejectEventTypes) {
|
||||||
|
emitter.off(eventName, rejectCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = (...args: unknown[]) => {
|
||||||
|
clearAll();
|
||||||
|
resolve(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectCallback = (...args: unknown[]) => {
|
||||||
|
clearAll();
|
||||||
|
reject(args[0] ?? new Error("rejected"));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (timeoutMs) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
clearAll();
|
||||||
|
reject(new Error("Timed out."));
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on(event, handler);
|
||||||
|
|
||||||
|
if (Array.isArray(rejectEventTypes)) {
|
||||||
|
for (const eventName of rejectEventTypes) {
|
||||||
|
emitter.on(eventName, rejectCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
383
providers/SyncPlay/Manager.ts
Normal file
383
providers/SyncPlay/Manager.ts
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlayManager — central orchestrator for a SyncPlay session.
|
||||||
|
*
|
||||||
|
* Owns the three "cores" (TimeSync, PlaybackCore, QueueCore) and the
|
||||||
|
* PlayerWrapper, and routes WebSocket events between them.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* constructor → init() → (joinGroup → group-state-change "Idle"+) →
|
||||||
|
* group-state-change "Playing" → group-state-change "Paused" → ...
|
||||||
|
* → (leaveGroup) → destroy()
|
||||||
|
*
|
||||||
|
* Events emitted (provider listens):
|
||||||
|
* - `group-info-update` `(GroupInfoDto | null)`
|
||||||
|
* - `group-state-change` `(state: string, oldState: string)`
|
||||||
|
* - `enabled` `(isEnabled: boolean)`
|
||||||
|
* - `play-state-change` `(isFollowing: boolean)`
|
||||||
|
* - `playbackstart` / `playbackerror` — from PlayerWrapper hooks
|
||||||
|
* - `osd` `(action: SyncPlayOsdAction)`
|
||||||
|
* - `toast` `(messageKey: string)`
|
||||||
|
*
|
||||||
|
* The manager exposes a per-instance `EventEmitter` rather than upstream
|
||||||
|
* `Events.on(manager, ...)` — replaces the global Events bus pattern.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import { Controller } from "./Controller";
|
||||||
|
import { PlaybackCore } from "./cores/PlaybackCore";
|
||||||
|
import { QueueCore } from "./cores/QueueCore";
|
||||||
|
import { TimeSync } from "./cores/TimeSync";
|
||||||
|
import { EventEmitter } from "./EventEmitter";
|
||||||
|
import { PendingPlaybackTracker } from "./player/PendingPlaybackTracker";
|
||||||
|
import { PlayerWrapper } from "./player/PlayerWrapper";
|
||||||
|
import { reconcileToGroupOnAttach } from "./player/reconcileToGroupOnAttach";
|
||||||
|
import type {
|
||||||
|
GroupInfoDto,
|
||||||
|
GroupUpdate,
|
||||||
|
PlayerControls,
|
||||||
|
PlayQueueUpdate,
|
||||||
|
SendCommand,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/** Raw WebSocket message data shapes (already unwrapped by the hook). */
|
||||||
|
|
||||||
|
export class SyncPlayManager extends EventEmitter {
|
||||||
|
private apiClient: Api;
|
||||||
|
private playerWrapper: PlayerWrapper;
|
||||||
|
private timeSync: TimeSync;
|
||||||
|
private playbackCore: PlaybackCore;
|
||||||
|
private queueCore: QueueCore;
|
||||||
|
private pendingPlaybackTracker: PendingPlaybackTracker;
|
||||||
|
private controller: Controller;
|
||||||
|
|
||||||
|
/** Current group info. `null` when not in a group. */
|
||||||
|
private groupInfo: GroupInfoDto | null = null;
|
||||||
|
/** Is SyncPlay actively enabled (i.e., we're in a group)? */
|
||||||
|
private syncPlayEnabledAtPlayer = false;
|
||||||
|
/** Are we mirroring the group's commands locally? */
|
||||||
|
private followingGroupPlayback = true;
|
||||||
|
|
||||||
|
constructor(api: Api) {
|
||||||
|
super();
|
||||||
|
this.apiClient = api;
|
||||||
|
this.playerWrapper = new PlayerWrapper();
|
||||||
|
this.timeSync = new TimeSync(api);
|
||||||
|
this.playbackCore = new PlaybackCore();
|
||||||
|
this.queueCore = new QueueCore();
|
||||||
|
this.pendingPlaybackTracker = new PendingPlaybackTracker();
|
||||||
|
this.controller = new Controller();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wire up cores. Called once after construction. */
|
||||||
|
init(): void {
|
||||||
|
this.playbackCore.init(this);
|
||||||
|
this.queueCore.init(this);
|
||||||
|
this.controller.init(this);
|
||||||
|
|
||||||
|
// Forward PlaybackCore OSD events to provider listeners.
|
||||||
|
this.playbackCore.on("osd", (...args) => {
|
||||||
|
this.emit("osd", ...args);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bridge optimistic pending Pause/Unpause → React state.
|
||||||
|
this.pendingPlaybackTracker.setChangeHandler((cmd) => {
|
||||||
|
this.emit("pending-playback-change", cmd);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.timeSync.startPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public controller for callers. */
|
||||||
|
getController(): Controller {
|
||||||
|
return this.controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by SyncPlayProvider when the user switches Jellyfin servers. */
|
||||||
|
updateApiClient(api: Api): void {
|
||||||
|
this.apiClient = api;
|
||||||
|
this.timeSync.updateApiClient(api);
|
||||||
|
}
|
||||||
|
|
||||||
|
getApiClient(): Api {
|
||||||
|
return this.apiClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayerWrapper(): PlayerWrapper {
|
||||||
|
return this.playerWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimeSync(): TimeSync {
|
||||||
|
return this.timeSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaybackCore(): PlaybackCore {
|
||||||
|
return this.playbackCore;
|
||||||
|
}
|
||||||
|
|
||||||
|
getQueueCore(): QueueCore {
|
||||||
|
return this.queueCore;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingPlaybackTracker(): PendingPlaybackTracker {
|
||||||
|
return this.pendingPlaybackTracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// WebSocket message handlers (called by useSyncPlayWebSocket)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a `SyncPlayGroupUpdate` WebSocket message.
|
||||||
|
*
|
||||||
|
* Cast: the SDK's `GroupUpdate.Type` union is narrower than what the
|
||||||
|
* server actually emits (it omits `SyncPlayIsDisabled`, `GroupUpdate`,
|
||||||
|
* `CreateGroupDenied`, `JoinGroupDenied`). Wire format is the source
|
||||||
|
* of truth here.
|
||||||
|
*/
|
||||||
|
processGroupUpdate(rawUpdate: GroupUpdate): void {
|
||||||
|
if (!rawUpdate) {
|
||||||
|
console.warn("SyncPlay processGroupUpdate: empty update");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const update = rawUpdate as unknown as {
|
||||||
|
Type: string;
|
||||||
|
Data: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (update.Type) {
|
||||||
|
case "PlayQueue":
|
||||||
|
this.queueCore.updatePlayQueue(
|
||||||
|
this.apiClient,
|
||||||
|
update.Data as unknown as PlayQueueUpdate,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "UserJoined":
|
||||||
|
case "UserLeft":
|
||||||
|
// Group membership notifications — current group will follow
|
||||||
|
// via GroupUpdate, but emit a toast for friendliness.
|
||||||
|
this.emit("toast", `MessageSyncPlay${update.Type}`, update.Data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "GroupJoined": {
|
||||||
|
this.groupInfo = update.Data as GroupInfoDto;
|
||||||
|
this.enableSyncPlay(this.groupInfo);
|
||||||
|
this.emit("group-update", this.groupInfo);
|
||||||
|
this.emit("toast", "MessageSyncPlayGroupJoined");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GroupLeft":
|
||||||
|
case "NotInGroup":
|
||||||
|
case "SyncPlayIsDisabled": {
|
||||||
|
const previousState = this.groupInfo?.State;
|
||||||
|
this.groupInfo = null;
|
||||||
|
this.disableSyncPlay();
|
||||||
|
this.emit("group-update", null);
|
||||||
|
if (update.Type === "GroupLeft") {
|
||||||
|
this.emit("toast", "MessageSyncPlayGroupLeft");
|
||||||
|
}
|
||||||
|
if (previousState) {
|
||||||
|
this.emit("group-state-change", "Idle", previousState);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GroupUpdate": {
|
||||||
|
const previousState = this.groupInfo?.State;
|
||||||
|
this.groupInfo = update.Data as GroupInfoDto;
|
||||||
|
this.emit("group-update", this.groupInfo);
|
||||||
|
const newState = this.groupInfo.State;
|
||||||
|
if (newState && newState !== previousState) {
|
||||||
|
this.emit("group-state-change", newState, previousState ?? "Idle");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "StateUpdate": {
|
||||||
|
const stateData = update.Data as {
|
||||||
|
State?: string;
|
||||||
|
PreviousState?: string;
|
||||||
|
Reason?: string;
|
||||||
|
};
|
||||||
|
const newState = stateData.State ?? "Idle";
|
||||||
|
const previousState = stateData.PreviousState ?? "Idle";
|
||||||
|
const reason = stateData.Reason;
|
||||||
|
if (this.groupInfo) {
|
||||||
|
this.groupInfo.State = newState as GroupInfoDto["State"];
|
||||||
|
this.emit("group-update", this.groupInfo);
|
||||||
|
}
|
||||||
|
this.emit("group-state-change", newState, previousState, reason);
|
||||||
|
// Server signals "Playing" or "Paused" → clear any in-flight
|
||||||
|
// optimistic tap state.
|
||||||
|
if (newState === "Playing" || newState === "Paused") {
|
||||||
|
this.pendingPlaybackTracker.clear();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "CreateGroupDenied":
|
||||||
|
this.emit("toast", "MessageSyncPlayCreateGroupDenied");
|
||||||
|
break;
|
||||||
|
case "JoinGroupDenied":
|
||||||
|
this.emit("toast", "MessageSyncPlayJoinGroupDenied");
|
||||||
|
break;
|
||||||
|
case "LibraryAccessDenied":
|
||||||
|
this.emit("toast", "MessageSyncPlayLibraryAccessDenied");
|
||||||
|
break;
|
||||||
|
case "GroupDoesNotExist":
|
||||||
|
this.emit("toast", "MessageSyncPlayGroupDoesNotExist");
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn("SyncPlay processGroupUpdate: unknown type", update.Type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle a `SyncPlayCommand` WebSocket message. */
|
||||||
|
processCommand(command: SendCommand): void {
|
||||||
|
if (!command) {
|
||||||
|
console.warn("SyncPlay processCommand: empty command");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.playbackCore.applyCommand(command);
|
||||||
|
// Server told us the new playing state — clear optimistic UI.
|
||||||
|
if (command.Command === "Unpause" || command.Command === "Pause") {
|
||||||
|
this.pendingPlaybackTracker.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Enable / disable SyncPlay
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
private enableSyncPlay(_group: GroupInfoDto): void {
|
||||||
|
if (this.syncPlayEnabledAtPlayer) return;
|
||||||
|
this.syncPlayEnabledAtPlayer = true;
|
||||||
|
this.followingGroupPlayback = true;
|
||||||
|
this.timeSync.forceUpdate();
|
||||||
|
this.emit("enabled", true);
|
||||||
|
this.emit("play-state-change", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private disableSyncPlay(): void {
|
||||||
|
if (!this.syncPlayEnabledAtPlayer) return;
|
||||||
|
this.syncPlayEnabledAtPlayer = false;
|
||||||
|
this.followingGroupPlayback = false;
|
||||||
|
this.playbackCore.clearScheduledCommand();
|
||||||
|
this.queueCore.clear();
|
||||||
|
this.pendingPlaybackTracker.clear();
|
||||||
|
this.emit("enabled", false);
|
||||||
|
this.emit("play-state-change", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume following group playback after the user temporarily took
|
||||||
|
* local control (e.g. scrubbed the seek bar).
|
||||||
|
*/
|
||||||
|
async followGroupPlayback(_api: Api): Promise<void> {
|
||||||
|
this.followingGroupPlayback = true;
|
||||||
|
this.emit("play-state-change", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop following group playback (e.g., user takes local control). */
|
||||||
|
haltGroupPlayback(_api: Api): void {
|
||||||
|
this.followingGroupPlayback = false;
|
||||||
|
this.emit("play-state-change", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
isFollowingGroupPlayback(): boolean {
|
||||||
|
return this.followingGroupPlayback;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSyncPlayEnabled(): boolean {
|
||||||
|
return this.syncPlayEnabledAtPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Player attach + provider bridges
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind the RN player controls.
|
||||||
|
* Called from the player screen's `useEffect`. Triggers a reconcile
|
||||||
|
* if a group is active and the player is late-arriving.
|
||||||
|
*/
|
||||||
|
setPlayerControls(controls: PlayerControls | null): void {
|
||||||
|
this.playerWrapper.bindToControls(controls);
|
||||||
|
if (controls && this.syncPlayEnabledAtPlayer) {
|
||||||
|
const lastCommand = this.playbackCore.getLastCommand();
|
||||||
|
reconcileToGroupOnAttach(controls, lastCommand, (local) =>
|
||||||
|
this.timeSync.localDateToRemote(local),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Player-side notify hook: media is ready to play. */
|
||||||
|
notifyReady(): void {
|
||||||
|
this.emit("playbackstart");
|
||||||
|
if (this.syncPlayEnabledAtPlayer) {
|
||||||
|
this.playbackCore.onReady(this.apiClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Player-side notify hook: buffering state changed. */
|
||||||
|
notifyBuffering(isBuffering: boolean): void {
|
||||||
|
if (!this.syncPlayEnabledAtPlayer) return;
|
||||||
|
if (isBuffering) {
|
||||||
|
this.playbackCore.onBuffering(this.apiClient);
|
||||||
|
} else {
|
||||||
|
this.playbackCore.onReady(this.apiClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Player-side notify hook: local playback started. */
|
||||||
|
notifyPlaybackStart(): void {
|
||||||
|
this.emit("playbackstart");
|
||||||
|
if (this.syncPlayEnabledAtPlayer) {
|
||||||
|
this.playbackCore.onPlaybackStart(this.apiClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Pending playback (optimistic UI for play/pause taps)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/** Called by Controller before sending an Unpause/Pause request. */
|
||||||
|
markPendingPlaybackCommand(command: "Unpause" | "Pause"): void {
|
||||||
|
this.pendingPlaybackTracker.mark(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is the group currently playing? Used by Controller.playPause. */
|
||||||
|
isPlaying(): boolean {
|
||||||
|
const pending = this.pendingPlaybackTracker.get();
|
||||||
|
if (pending === "Unpause") return true;
|
||||||
|
if (pending === "Pause") return false;
|
||||||
|
return this.groupInfo?.State === "Playing";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group info for consumers. */
|
||||||
|
getGroupInfo(): GroupInfoDto | null {
|
||||||
|
return this.groupInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last playback command (for QueueCore.startPlayback resumption). */
|
||||||
|
getLastPlaybackCommand(): SendCommand | null {
|
||||||
|
return this.playbackCore.getLastCommand();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Teardown
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.timeSync.destroy();
|
||||||
|
this.playbackCore.destroy();
|
||||||
|
this.queueCore.destroy();
|
||||||
|
this.playerWrapper.bindToControls(null);
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SyncPlayManager;
|
||||||
600
providers/SyncPlay/SyncPlayProvider.tsx
Normal file
600
providers/SyncPlay/SyncPlayProvider.tsx
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlayProvider — React glue around `SyncPlayManager`.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Manager lifecycle (construct on api change, destroy on unmount)
|
||||||
|
* - React mirrors of manager state (`isEnabled`, `groupInfo`,
|
||||||
|
* `pendingPlaybackCommand`) so components re-render
|
||||||
|
* - Navigation handlers wired into `PlayerWrapper.localPlay` /
|
||||||
|
* `localSetCurrentPlaylistItem` — these are what jellyfin-web does
|
||||||
|
* synchronously via `playbackManager.play`; on RN they navigate
|
||||||
|
* to the player screen instead
|
||||||
|
* - AppState foreground re-join (we may miss broadcasts while
|
||||||
|
* suspended)
|
||||||
|
*
|
||||||
|
* External API surface (`useSyncPlay`) is stable; components don't
|
||||||
|
* change when the internals do.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { usePathname } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { useAppRouter } from "@/hooks/useAppRouter";
|
||||||
|
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
import { getDownloadedItemById } from "@/providers/Downloads";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||||
|
import type { Controller as SyncPlayController } from "./Controller";
|
||||||
|
import { SyncPlayManager } from "./Manager";
|
||||||
|
import { useSyncPlayWebSocket } from "./transport/useSyncPlayWebSocket";
|
||||||
|
import type { GroupInfoDto, PlayerControls, SyncPlayOsdAction } from "./types";
|
||||||
|
|
||||||
|
interface SyncPlayContextValue {
|
||||||
|
isEnabled: boolean;
|
||||||
|
groupInfo: GroupInfoDto | null;
|
||||||
|
canJoinGroups: boolean;
|
||||||
|
canCreateGroups: boolean;
|
||||||
|
|
||||||
|
joinGroup: (groupId: string) => Promise<void>;
|
||||||
|
createGroup: (groupName?: string) => Promise<void>;
|
||||||
|
leaveGroup: () => Promise<void>;
|
||||||
|
getGroups: () => Promise<GroupInfoDto[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-attach to the group's command stream and jump back to the
|
||||||
|
* group's currently-playing item. Mirrors jellyfin-web's "Resume
|
||||||
|
* playback" menu entry: in jellyfin-web it just calls
|
||||||
|
* `playbackManager.play` on the group's current queue position.
|
||||||
|
* Here we navigate to direct-player with the same params our
|
||||||
|
* `localSetCurrentItem` bridge would use, so the player picks up
|
||||||
|
* mid-group with `syncPlay=true` and the right offset.
|
||||||
|
*/
|
||||||
|
resumeGroupPlayback: () => Promise<void>;
|
||||||
|
|
||||||
|
controller: SyncPlayController | null;
|
||||||
|
|
||||||
|
setPlayerControls: (controls: PlayerControls | null) => void;
|
||||||
|
notifyReady: () => void;
|
||||||
|
notifyBuffering: (isBuffering: boolean) => void;
|
||||||
|
notifyPlaybackStart: () => void;
|
||||||
|
|
||||||
|
pendingPlaybackCommand: "Unpause" | "Pause" | null;
|
||||||
|
/**
|
||||||
|
* Current SyncPlay OSD overlay state. Drives the animated icon over the
|
||||||
|
* video that mirrors jellyfin-web's `#syncPlayIcon`. `null` means hidden.
|
||||||
|
*/
|
||||||
|
osdAction: SyncPlayOsdAction | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SyncPlayContext = createContext<SyncPlayContextValue | null>(null);
|
||||||
|
|
||||||
|
interface SyncPlayProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const router = useAppRouter();
|
||||||
|
const { isConnected: isWsConnected } = useWebSocketContext();
|
||||||
|
|
||||||
|
const [manager, setManager] = useState<SyncPlayManager | null>(null);
|
||||||
|
const isNavigatingToPlayerRef = useRef(false);
|
||||||
|
|
||||||
|
// Keep a live ref of the current route pathname so the
|
||||||
|
// navigateToPlayer helper (wired up once inside the manager-lifecycle
|
||||||
|
// effect) can read the *current* page without stale-closure issues.
|
||||||
|
const pathname = usePathname();
|
||||||
|
const pathnameRef = useRef(pathname);
|
||||||
|
useEffect(() => {
|
||||||
|
pathnameRef.current = pathname;
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
|
const [groupInfo, setGroupInfo] = useState<GroupInfoDto | null>(null);
|
||||||
|
const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState<
|
||||||
|
"Unpause" | "Pause" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
// While in a SyncPlay group, hold a keep-alive token on the global
|
||||||
|
// WebSocket so backgrounding the app does NOT cleanly close the
|
||||||
|
// socket. A clean close is interpreted by the Jellyfin server as
|
||||||
|
// leaving the group and is broadcast to every other member as
|
||||||
|
// "<user> has left the group". Keeping the socket open across a
|
||||||
|
// short suspend lets us stay in the group while quickly switching
|
||||||
|
// apps; if the OS eventually tears the TCP connection down anyway,
|
||||||
|
// the app-foreground rejoin effect below will pull us back in.
|
||||||
|
useKeepWebSocketAlive(isEnabled);
|
||||||
|
|
||||||
|
const [osdAction, setOsdAction] = useState<SyncPlayOsdAction | null>(null);
|
||||||
|
const osdTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the OSD overlay action.
|
||||||
|
*
|
||||||
|
* `transient` mirrors jellyfin-web's `iconVisibilityTime = 1500` for the
|
||||||
|
* Unpause / Pause / Seek command-confirmation flashes. Persistent actions
|
||||||
|
* (schedule-play, buffering, wait-*) stay until cleared by a state
|
||||||
|
* transition or a subsequent call with `null`.
|
||||||
|
*/
|
||||||
|
const showOsd = useCallback(
|
||||||
|
(action: SyncPlayOsdAction | null, transient = false) => {
|
||||||
|
if (osdTimeoutRef.current) {
|
||||||
|
clearTimeout(osdTimeoutRef.current);
|
||||||
|
osdTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
setOsdAction(action);
|
||||||
|
if (transient && action !== null) {
|
||||||
|
osdTimeoutRef.current = setTimeout(() => {
|
||||||
|
osdTimeoutRef.current = null;
|
||||||
|
setOsdAction((cur) => (cur === action ? null : cur));
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pending play/pause tap → optimistic schedule-play overlay (unless another
|
||||||
|
// overlay reason has already taken precedence).
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingPlaybackCommand) {
|
||||||
|
setOsdAction((cur) => cur ?? "schedule-play");
|
||||||
|
} else {
|
||||||
|
setOsdAction((cur) => (cur === "schedule-play" ? null : cur));
|
||||||
|
}
|
||||||
|
}, [pendingPlaybackCommand]);
|
||||||
|
|
||||||
|
// Clear the OSD auto-expire timeout on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (osdTimeoutRef.current) {
|
||||||
|
clearTimeout(osdTimeoutRef.current);
|
||||||
|
osdTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canJoinGroups = useMemo(() => {
|
||||||
|
const access = user?.Policy?.SyncPlayAccess;
|
||||||
|
return access !== "None" && access !== undefined;
|
||||||
|
}, [user?.Policy?.SyncPlayAccess]);
|
||||||
|
|
||||||
|
const canCreateGroups = useMemo(
|
||||||
|
() => user?.Policy?.SyncPlayAccess === "CreateAndJoinGroups",
|
||||||
|
[user?.Policy?.SyncPlayAccess],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Latch: `true` once we've fired the per-attach `playbackstart` event.
|
||||||
|
const playbackStartFiredRef = useRef(false);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Navigation to the player screen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Single navigate-to-direct-player helper, used by every code path
|
||||||
|
* that needs to (re-)open the player while in a SyncPlay group:
|
||||||
|
* - localPlay (group's leader started a new queue / we just joined)
|
||||||
|
* - localSetCurrentPlaylistItem (group advanced to next episode)
|
||||||
|
* - resumeGroupPlayback (user tapped "Resume playback" in the menu)
|
||||||
|
*
|
||||||
|
* Both jellyfin-web's playbackManager.play and its setCurrentPlaylistItem
|
||||||
|
* collapse to "point the player at this item / position" — RN is the
|
||||||
|
* same shape, just a router navigation instead of an in-page DOM swap.
|
||||||
|
*
|
||||||
|
* Note: no "joining playback" toast here — the `GroupJoined`
|
||||||
|
* WebSocket event already triggers a "Joined group" toast via
|
||||||
|
* `Manager.ts`, and showing both on a fresh join was redundant.
|
||||||
|
*/
|
||||||
|
const navigateToPlayer = useCallback(
|
||||||
|
(itemId: string, startPositionTicks: number) => {
|
||||||
|
if (isNavigatingToPlayerRef.current) {
|
||||||
|
console.debug("SyncPlay: already navigating to player");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isNavigatingToPlayerRef.current = true;
|
||||||
|
|
||||||
|
// Opportunistic local playback: if we have a downloaded copy of
|
||||||
|
// the target item, use it instead of streaming. Matters most when
|
||||||
|
// the group advances to an episode you've downloaded — the local
|
||||||
|
// file starts instantly and survives spotty wifi. SyncPlay's
|
||||||
|
// position/pause/seek commands keep flowing normally; only the
|
||||||
|
// source changes.
|
||||||
|
const isDownloaded = !!getDownloadedItemById(itemId);
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId,
|
||||||
|
playbackPosition: String(startPositionTicks),
|
||||||
|
syncPlay: "true",
|
||||||
|
...(isDownloaded && { offline: "true" }),
|
||||||
|
}).toString();
|
||||||
|
// Use `replace` when we're already on the player screen so queue
|
||||||
|
// advances don't stack a second player on the nav stack; `push`
|
||||||
|
// otherwise so the user can back out to where they came from.
|
||||||
|
const onPlayerScreen =
|
||||||
|
pathnameRef.current?.startsWith("/player/direct-player") ?? false;
|
||||||
|
if (onPlayerScreen) {
|
||||||
|
router.replace(`/player/direct-player?${queryParams}`);
|
||||||
|
} else {
|
||||||
|
router.push(`/player/direct-player?${queryParams}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isNavigatingToPlayerRef.current = false;
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Manager lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
const mgr = new SyncPlayManager(api);
|
||||||
|
mgr.init();
|
||||||
|
setManager(mgr);
|
||||||
|
|
||||||
|
const playerWrapper = mgr.getPlayerWrapper();
|
||||||
|
|
||||||
|
// localPlay → navigate to direct-player with syncPlay=true
|
||||||
|
playerWrapper.setLocalPlayHandler((options) => {
|
||||||
|
const itemId = options.ids[0];
|
||||||
|
if (!itemId) {
|
||||||
|
console.warn("SyncPlay: localPlay called with no ids");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToPlayer(itemId, options.startPositionTicks ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// localSetCurrentPlaylistItem → navigate to the new playlist item
|
||||||
|
playerWrapper.setLocalSetCurrentItemHandler((playlistItemId) => {
|
||||||
|
if (!playlistItemId) return;
|
||||||
|
const queueCore = mgr.getQueueCore();
|
||||||
|
const target = queueCore
|
||||||
|
.getPlaylist()
|
||||||
|
.find((i) => i.PlaylistItemId === playlistItemId);
|
||||||
|
const itemId = target?.Id;
|
||||||
|
if (!itemId) {
|
||||||
|
console.warn(
|
||||||
|
"SyncPlay: localSetCurrentPlaylistItem — item not in playlist",
|
||||||
|
playlistItemId,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToPlayer(itemId, queueCore.getStartPositionTicks());
|
||||||
|
});
|
||||||
|
|
||||||
|
mgr.on("enabled", (...args: unknown[]) => {
|
||||||
|
const enabled = args[0] as boolean;
|
||||||
|
setIsEnabled(enabled);
|
||||||
|
if (!enabled) setGroupInfo(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
mgr.on("group-update", (...args: unknown[]) => {
|
||||||
|
setGroupInfo((args[0] as GroupInfoDto | null | undefined) ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
mgr.on("pending-playback-change", (...args: unknown[]) => {
|
||||||
|
setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// group-state-change → on "Waiting", pause locally so we don't drift
|
||||||
|
// ahead of the group while the server is reconciling buffering/seek
|
||||||
|
// state. Position resync is *only* done from the explicit Pause /
|
||||||
|
// Unpause / Seek SendCommands that follow (`PlaybackCore.applyCommand`
|
||||||
|
// → `scheduleUnpause` etc.) — those commands carry the canonical
|
||||||
|
// `PositionTicks` for the action's `When`. The old code here also
|
||||||
|
// called `wrapper.localSeek(lastCommand.PositionTicks)`, but
|
||||||
|
// `lastCommand` is the *previous* Pause/Unpause and can be many
|
||||||
|
// seconds stale, so it rewound the user every time someone else
|
||||||
|
// buffered. Don't put a seek back here.
|
||||||
|
mgr.on("group-state-change", (...args: unknown[]) => {
|
||||||
|
const state = args[0] as string | undefined;
|
||||||
|
const reason = args[2] as string | undefined;
|
||||||
|
const wrapper = mgr.getPlayerWrapper();
|
||||||
|
if (!wrapper.isPlaybackActive()) return;
|
||||||
|
if (state === "Waiting") {
|
||||||
|
wrapper.localPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive the persistent OSD overlay from (state, reason).
|
||||||
|
// Mirrors jellyfin-web's `group-state-update` → `showIcon` mapping.
|
||||||
|
if (state === "Waiting") {
|
||||||
|
if (reason === "Buffer") showOsd("buffering");
|
||||||
|
else if (reason === "Unpause") showOsd("wait-unpause");
|
||||||
|
else if (reason === "Pause") showOsd("wait-pause");
|
||||||
|
else if (reason === "Seek") showOsd("seek");
|
||||||
|
} else if (state === "Playing" || state === "Paused") {
|
||||||
|
// Stable state — clear any persistent overlay; transient flashes
|
||||||
|
// come from the `osd` event below and self-expire.
|
||||||
|
setOsdAction((cur) => {
|
||||||
|
if (
|
||||||
|
cur === "schedule-play" ||
|
||||||
|
cur === "buffering" ||
|
||||||
|
cur === "wait-pause" ||
|
||||||
|
cur === "wait-unpause"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PlaybackCore-emitted OSD events. Transient ones auto-expire in 1.5s.
|
||||||
|
mgr.on("osd", (...args: unknown[]) => {
|
||||||
|
const action = args[0] as SyncPlayOsdAction;
|
||||||
|
const transient =
|
||||||
|
action === "unpause" || action === "pause" || action === "seek";
|
||||||
|
showOsd(action, transient);
|
||||||
|
});
|
||||||
|
|
||||||
|
mgr.on("toast", (...args: unknown[]) => {
|
||||||
|
const key = args[0] as string;
|
||||||
|
const arg = args[1] as string | undefined;
|
||||||
|
const message = arg
|
||||||
|
? i18n.t(`syncplay.toasts.${key}`, { user: arg })
|
||||||
|
: i18n.t(`syncplay.toasts.${key}`);
|
||||||
|
toast(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mgr.destroy();
|
||||||
|
setManager(null);
|
||||||
|
};
|
||||||
|
}, [api, navigateToPlayer]);
|
||||||
|
|
||||||
|
// Initial join race: once `enabled` flips true, snapshot the current group.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEnabled && manager) {
|
||||||
|
setGroupInfo(manager.getGroupInfo());
|
||||||
|
}
|
||||||
|
}, [isEnabled, manager]);
|
||||||
|
|
||||||
|
// Wire WebSocket messages → manager
|
||||||
|
useSyncPlayWebSocket(manager);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Group management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const getGroups = useCallback(async (): Promise<GroupInfoDto[]> => {
|
||||||
|
if (!api) return [];
|
||||||
|
try {
|
||||||
|
const response = await getSyncPlayApi(api).syncPlayGetGroups();
|
||||||
|
return (response.data as unknown as GroupInfoDto[]) ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to get groups", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const joinGroup = useCallback(
|
||||||
|
async (groupId: string): Promise<void> => {
|
||||||
|
if (!api) return;
|
||||||
|
try {
|
||||||
|
await getSyncPlayApi(api).syncPlayJoinGroup({
|
||||||
|
joinGroupRequestDto: { GroupId: groupId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to join group", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createGroup = useCallback(
|
||||||
|
async (groupName?: string): Promise<void> => {
|
||||||
|
if (!api || !user) return;
|
||||||
|
const name = groupName || `${user.Name}'s Group`;
|
||||||
|
try {
|
||||||
|
await getSyncPlayApi(api).syncPlayCreateGroup({
|
||||||
|
newGroupRequestDto: { GroupName: name },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to create group", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, user],
|
||||||
|
);
|
||||||
|
|
||||||
|
const leaveGroup = useCallback(async (): Promise<void> => {
|
||||||
|
if (!api) return;
|
||||||
|
try {
|
||||||
|
await getSyncPlayApi(api).syncPlayLeaveGroup();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to leave group", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resume playback: re-follow the group's command stream and jump
|
||||||
|
* the local player to the group's current item + position. This is
|
||||||
|
* the only entry point a user needs from the menu — there is no
|
||||||
|
* separate "halt" UI; the player exit/back already detaches us.
|
||||||
|
*/
|
||||||
|
const resumeGroupPlayback = useCallback(async (): Promise<void> => {
|
||||||
|
if (!api || !manager) return;
|
||||||
|
await manager.followGroupPlayback(api);
|
||||||
|
const queueCore = manager.getQueueCore();
|
||||||
|
const index = queueCore.getCurrentPlaylistIndex();
|
||||||
|
const itemId =
|
||||||
|
index >= 0 ? (queueCore.getPlaylist()[index]?.Id ?? null) : null;
|
||||||
|
if (!itemId) {
|
||||||
|
console.warn("SyncPlay: resumeGroupPlayback — no current group item");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToPlayer(itemId, queueCore.getStartPositionTicks());
|
||||||
|
}, [api, manager, navigateToPlayer]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// App foreground re-join (idempotent; gets us a fresh GroupJoined snapshot)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const lastGroupIdRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
lastGroupIdRef.current = groupInfo?.GroupId ?? null;
|
||||||
|
}, [groupInfo?.GroupId]);
|
||||||
|
|
||||||
|
// Track whether the WebSocket got torn down while the app was
|
||||||
|
// backgrounded. If it survived (keep-alive worked), the server
|
||||||
|
// still has us in the group and we must NOT call JoinGroup again —
|
||||||
|
// doing so would trigger a redundant "X joined the group" broadcast
|
||||||
|
// to every other member every time we briefly leave the app.
|
||||||
|
const wsClosedWhileBackgroundedRef = useRef(false);
|
||||||
|
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWsConnected && appStateRef.current !== "active") {
|
||||||
|
wsClosedWhileBackgroundedRef.current = true;
|
||||||
|
}
|
||||||
|
}, [isWsConnected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||||
|
const previousAppState = appStateRef.current;
|
||||||
|
appStateRef.current = nextAppState;
|
||||||
|
|
||||||
|
const becameActive =
|
||||||
|
(previousAppState === "background" ||
|
||||||
|
previousAppState === "inactive") &&
|
||||||
|
nextAppState === "active";
|
||||||
|
if (!becameActive) return;
|
||||||
|
|
||||||
|
const groupId = lastGroupIdRef.current;
|
||||||
|
if (!groupId) return;
|
||||||
|
|
||||||
|
// Happy path: keep-alive held the socket open across the
|
||||||
|
// suspend. Server still considers us a member — nothing to do.
|
||||||
|
if (!wsClosedWhileBackgroundedRef.current) {
|
||||||
|
console.log(
|
||||||
|
"SyncPlay: app foregrounded with WS still alive, skipping rejoin",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wsClosedWhileBackgroundedRef.current = false;
|
||||||
|
|
||||||
|
// Small delay so the WebSocket has a moment to reconnect.
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: app foregrounded after WS drop, rejoining group ${groupId}`,
|
||||||
|
);
|
||||||
|
getSyncPlayApi(api)
|
||||||
|
.syncPlayJoinGroup({ joinGroupRequestDto: { GroupId: groupId } })
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("SyncPlay: failed to rejoin group", error);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Player attach bridges
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const setPlayerControls = useCallback(
|
||||||
|
(controls: PlayerControls | null) => {
|
||||||
|
// Reset the playbackstart latch on each new attach.
|
||||||
|
playbackStartFiredRef.current = false;
|
||||||
|
manager?.setPlayerControls(controls);
|
||||||
|
},
|
||||||
|
[manager],
|
||||||
|
);
|
||||||
|
|
||||||
|
const notifyReady = useCallback(() => {
|
||||||
|
manager?.notifyReady();
|
||||||
|
}, [manager]);
|
||||||
|
|
||||||
|
const notifyBuffering = useCallback(
|
||||||
|
(isBuffering: boolean) => {
|
||||||
|
manager?.notifyBuffering(isBuffering);
|
||||||
|
if (!isBuffering && !playbackStartFiredRef.current) {
|
||||||
|
playbackStartFiredRef.current = true;
|
||||||
|
manager?.notifyPlaybackStart();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[manager],
|
||||||
|
);
|
||||||
|
|
||||||
|
const notifyPlaybackStart = useCallback(() => {
|
||||||
|
manager?.notifyPlaybackStart();
|
||||||
|
}, [manager]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context value
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const contextValue: SyncPlayContextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
isEnabled,
|
||||||
|
groupInfo,
|
||||||
|
canJoinGroups,
|
||||||
|
canCreateGroups,
|
||||||
|
joinGroup,
|
||||||
|
createGroup,
|
||||||
|
leaveGroup,
|
||||||
|
getGroups,
|
||||||
|
resumeGroupPlayback,
|
||||||
|
controller: manager?.getController() ?? null,
|
||||||
|
setPlayerControls,
|
||||||
|
notifyReady,
|
||||||
|
notifyBuffering,
|
||||||
|
notifyPlaybackStart,
|
||||||
|
pendingPlaybackCommand,
|
||||||
|
osdAction,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
isEnabled,
|
||||||
|
groupInfo,
|
||||||
|
canJoinGroups,
|
||||||
|
canCreateGroups,
|
||||||
|
joinGroup,
|
||||||
|
createGroup,
|
||||||
|
leaveGroup,
|
||||||
|
getGroups,
|
||||||
|
resumeGroupPlayback,
|
||||||
|
manager,
|
||||||
|
setPlayerControls,
|
||||||
|
notifyReady,
|
||||||
|
notifyBuffering,
|
||||||
|
notifyPlaybackStart,
|
||||||
|
pendingPlaybackCommand,
|
||||||
|
osdAction,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SyncPlayContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</SyncPlayContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSyncPlay(): SyncPlayContextValue {
|
||||||
|
const context = useContext(SyncPlayContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSyncPlay must be used within a SyncPlayProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
23
providers/SyncPlay/constants.ts
Normal file
23
providers/SyncPlay/constants.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Constants — shared timing/threshold values used across SyncPlay files.
|
||||||
|
* Kept separate from `types.ts` because these are implementation tuning
|
||||||
|
* values, not the public protocol/types surface.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TicksPerMillisecond } from "./types";
|
||||||
|
|
||||||
|
export { TicksPerMillisecond };
|
||||||
|
|
||||||
|
/** Default timeout for `waitForEventOnce` (matches jellyfin-web). */
|
||||||
|
export const WaitForEventDefaultTimeout = 30000;
|
||||||
|
|
||||||
|
/** Short-lived timeout for player events (matches jellyfin-web). */
|
||||||
|
export const WaitForPlayerEventTimeout = 500;
|
||||||
|
|
||||||
|
export function ticksToMs(ticks: number): number {
|
||||||
|
return ticks / TicksPerMillisecond;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function msToTicks(ms: number): number {
|
||||||
|
return Math.round(ms * TicksPerMillisecond);
|
||||||
|
}
|
||||||
381
providers/SyncPlay/cores/PlaybackCore.ts
Normal file
381
providers/SyncPlay/cores/PlaybackCore.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay PlaybackCore — schedules unpauses/pauses/seeks/stops to fire
|
||||||
|
* at the precise group-wide moment and keeps the player drift-corrected.
|
||||||
|
*
|
||||||
|
* Design choices that diverge from jellyfin-web:
|
||||||
|
* - **No SpeedToSync**. Our RN players' `setPlaybackSpeed` is unreliable
|
||||||
|
* across platforms (mpv/VLC/expo-video each behave differently for
|
||||||
|
* fractional speeds). We always seek to catch up.
|
||||||
|
* - **No `MessageSyncPlayDuplicateMedia` detection**. Web's detection
|
||||||
|
* used HTML element identity; on RN we don't have a stable handle
|
||||||
|
* and the false-positive rate would be much higher than the value.
|
||||||
|
* - **No syncMethod / showSyncIcon**. We don't surface the sync
|
||||||
|
* technique to the UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import {
|
||||||
|
TicksPerMillisecond,
|
||||||
|
ticksToMs,
|
||||||
|
WaitForPlayerEventTimeout,
|
||||||
|
} from "../constants";
|
||||||
|
import { EventEmitter, waitForEventOnce } from "../EventEmitter";
|
||||||
|
import type { SyncPlayManager } from "../Manager";
|
||||||
|
import { type SendCommand, SYNC_PLAY_TUNING } from "../types";
|
||||||
|
|
||||||
|
export class PlaybackCore extends EventEmitter {
|
||||||
|
private manager!: SyncPlayManager;
|
||||||
|
private lastCommand: SendCommand | null = null;
|
||||||
|
private scheduledCommand: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
init(manager: SyncPlayManager): void {
|
||||||
|
this.manager = manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local "playback started" hook — fires the initial Ready request. */
|
||||||
|
onPlaybackStart(apiClient: Api): void {
|
||||||
|
try {
|
||||||
|
const playerWrapper = this.manager.getPlayerWrapper();
|
||||||
|
const positionMs = playerWrapper.currentTime();
|
||||||
|
const positionTicks = Math.round(positionMs * TicksPerMillisecond);
|
||||||
|
const isPlaying = playerWrapper.isPlaying();
|
||||||
|
const playlistItemId =
|
||||||
|
this.manager.getQueueCore().getCurrentPlaylistItemId() ?? undefined;
|
||||||
|
const now = this.manager.getTimeSync().localDateToRemote(new Date());
|
||||||
|
|
||||||
|
getSyncPlayApi(apiClient).syncPlayReady({
|
||||||
|
readyRequestDto: {
|
||||||
|
When: now.toISOString(),
|
||||||
|
PositionTicks: positionTicks,
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
PlaylistItemId: playlistItemId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay onPlaybackStart:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local pause → tell the server. */
|
||||||
|
onPause(apiClient: Api): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(apiClient).syncPlayPause();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay onPause:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local unpause → tell the server. */
|
||||||
|
onUnpause(apiClient: Api): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(apiClient).syncPlayUnpause();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay onUnpause:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local "ready" hook — server uses this to know we've finished buffering. */
|
||||||
|
onReady(apiClient: Api): void {
|
||||||
|
this.sendBufferingRequest(apiClient, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local "buffering" hook — server uses this to (optionally) pause the group. */
|
||||||
|
onBuffering(apiClient: Api): void {
|
||||||
|
this.sendBufferingRequest(apiClient, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a Ready or Buffering request. */
|
||||||
|
sendBufferingRequest(apiClient: Api, isBuffering: boolean): void {
|
||||||
|
const playerWrapper = this.manager.getPlayerWrapper();
|
||||||
|
const positionMs = playerWrapper.currentTime();
|
||||||
|
const positionTicks = Math.round(positionMs * TicksPerMillisecond);
|
||||||
|
const isPlaying = playerWrapper.isPlaying();
|
||||||
|
const playlistItemId =
|
||||||
|
this.manager.getQueueCore().getCurrentPlaylistItemId() ?? undefined;
|
||||||
|
const now = this.manager.getTimeSync().localDateToRemote(new Date());
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isBuffering) {
|
||||||
|
getSyncPlayApi(apiClient).syncPlayBuffering({
|
||||||
|
bufferRequestDto: {
|
||||||
|
When: now.toISOString(),
|
||||||
|
PositionTicks: positionTicks,
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
PlaylistItemId: playlistItemId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
getSyncPlayApi(apiClient).syncPlayReady({
|
||||||
|
readyRequestDto: {
|
||||||
|
When: now.toISOString(),
|
||||||
|
PositionTicks: positionTicks,
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
PlaylistItemId: playlistItemId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay sendBufferingRequest:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a group command (Unpause, Pause, Stop, Seek). Times the
|
||||||
|
* execution to fire at the group-wide instant the server selected.
|
||||||
|
*/
|
||||||
|
applyCommand(command: SendCommand): void {
|
||||||
|
(command as unknown as { EmittedAt: Date }).EmittedAt = new Date(
|
||||||
|
command.EmittedAt as unknown as string,
|
||||||
|
);
|
||||||
|
(command as unknown as { When: Date }).When = new Date(
|
||||||
|
command.When as unknown as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Duplicate-detection — mirrors jellyfin-web's PlaybackCore.applyCommand.
|
||||||
|
// The server can redeliver the same command (WebSocket reconnect, multiple
|
||||||
|
// group-state transitions referencing the same instant, etc). If every
|
||||||
|
// identifying field matches the previously applied command, we don't
|
||||||
|
// re-schedule — we just verify player state still matches and bail.
|
||||||
|
//
|
||||||
|
// IMPORTANT: this is NOT a monotonic-clock check. `When` is the scheduled
|
||||||
|
// execution time and can legitimately move backward between commands
|
||||||
|
// (e.g. a Pause emitted now with `When = now` arriving after an earlier
|
||||||
|
// Unpause whose `When` was scheduled 10s in the future). An earlier
|
||||||
|
// version of this code rejected anything whose `When` or `EmittedAt`
|
||||||
|
// wasn't strictly greater than `lastCommand`'s — that silently locked
|
||||||
|
// out every subsequent pause/unpause whenever group playback first
|
||||||
|
// started with a future-scheduled Unpause.
|
||||||
|
if (
|
||||||
|
this.lastCommand &&
|
||||||
|
(this.lastCommand as unknown as { When: Date }).When.getTime() ===
|
||||||
|
(command as unknown as { When: Date }).When.getTime() &&
|
||||||
|
this.lastCommand.PositionTicks === command.PositionTicks &&
|
||||||
|
this.lastCommand.Command === command.Command &&
|
||||||
|
this.lastCommand.PlaylistItemId === command.PlaylistItemId
|
||||||
|
) {
|
||||||
|
console.debug("SyncPlay applyCommand: duplicate command", command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastCommand = command;
|
||||||
|
if (!this.manager.isFollowingGroupPlayback()) {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay applyCommand: dropping command (not following playback)",
|
||||||
|
command,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerWrapper = this.manager.getPlayerWrapper();
|
||||||
|
if (!playerWrapper.isPlaybackActive()) {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay applyCommand: dropping command (playback not active)",
|
||||||
|
command,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enqueuedAt = new Date();
|
||||||
|
const remoteEnqueuedAt = this.manager
|
||||||
|
.getTimeSync()
|
||||||
|
.localDateToRemote(enqueuedAt);
|
||||||
|
const localCommandWhen = this.manager
|
||||||
|
.getTimeSync()
|
||||||
|
.remoteDateToLocal(command.When as unknown as Date);
|
||||||
|
|
||||||
|
switch (command.Command) {
|
||||||
|
case "Unpause":
|
||||||
|
this.scheduleUnpause(localCommandWhen, command.PositionTicks ?? 0);
|
||||||
|
this.emit("osd", "unpause");
|
||||||
|
break;
|
||||||
|
case "Pause":
|
||||||
|
this.schedulePause(localCommandWhen, command.PositionTicks ?? 0);
|
||||||
|
this.emit("osd", "pause");
|
||||||
|
break;
|
||||||
|
case "Stop":
|
||||||
|
this.scheduleStop(localCommandWhen);
|
||||||
|
break;
|
||||||
|
case "Seek":
|
||||||
|
this.scheduleSeek(localCommandWhen, command.PositionTicks ?? 0);
|
||||||
|
this.emit("osd", "seek");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn("SyncPlay applyCommand: unknown command", command);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(command as unknown as { When: Date }).When.getTime() <
|
||||||
|
remoteEnqueuedAt.getTime()
|
||||||
|
) {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay applyCommand: command was scheduled for the past",
|
||||||
|
command,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-Unpause hook: emit a wait OSD while we wait for the moment. */
|
||||||
|
scheduleUnpause(when: Date, positionTicks: number): void {
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
const now = Date.now();
|
||||||
|
const playAtTime = when.getTime();
|
||||||
|
const currentPositionMs = this.manager.getPlayerWrapper().currentTime();
|
||||||
|
const currentPositionTicks = Math.round(
|
||||||
|
currentPositionMs * TicksPerMillisecond,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (playAtTime > now) {
|
||||||
|
// Future: seek now, then play at the right moment.
|
||||||
|
this.localSeek(positionTicks);
|
||||||
|
this.scheduledCommand = setTimeout(() => {
|
||||||
|
this.localUnpause();
|
||||||
|
// After playback resumes, the player position will need a
|
||||||
|
// small bump to land on the group target. waitForPlayerEvent
|
||||||
|
// is best-effort.
|
||||||
|
waitForEventOnce(
|
||||||
|
this.manager,
|
||||||
|
"unpause",
|
||||||
|
WaitForPlayerEventTimeout,
|
||||||
|
).catch(() => undefined);
|
||||||
|
}, playAtTime - now);
|
||||||
|
this.emit("osd", "wait-unpause");
|
||||||
|
} else {
|
||||||
|
// Past: catch up now.
|
||||||
|
const targetMs = ticksToMs(positionTicks);
|
||||||
|
const delayMs = now - playAtTime;
|
||||||
|
this.localSeek(Math.round((targetMs + delayMs) * TicksPerMillisecond));
|
||||||
|
this.localUnpause();
|
||||||
|
void currentPositionTicks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schedulePause(when: Date, positionTicks: number): void {
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
const now = Date.now();
|
||||||
|
const pauseAtTime = when.getTime();
|
||||||
|
|
||||||
|
const callback = () => {
|
||||||
|
this.localUnpause();
|
||||||
|
this.localSeek(positionTicks);
|
||||||
|
this.localPause();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pauseAtTime > now) {
|
||||||
|
this.scheduledCommand = setTimeout(callback, pauseAtTime - now);
|
||||||
|
this.emit("osd", "wait-pause");
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleStop(when: Date): void {
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
const now = Date.now();
|
||||||
|
const stopAtTime = when.getTime();
|
||||||
|
if (stopAtTime > now) {
|
||||||
|
this.scheduledCommand = setTimeout(() => {
|
||||||
|
this.localStop();
|
||||||
|
}, stopAtTime - now);
|
||||||
|
} else {
|
||||||
|
this.localStop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleSeek(when: Date, positionTicks: number): void {
|
||||||
|
this.applyCommand({
|
||||||
|
...this.lastCommand!,
|
||||||
|
Command: "Pause",
|
||||||
|
PositionTicks: positionTicks,
|
||||||
|
When: when as unknown as string,
|
||||||
|
EmittedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScheduledCommand(): void {
|
||||||
|
if (this.scheduledCommand) {
|
||||||
|
clearTimeout(this.scheduledCommand);
|
||||||
|
this.scheduledCommand = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- local player ops ------------------------------------------------------
|
||||||
|
|
||||||
|
localUnpause(): void {
|
||||||
|
this.manager.getPlayerWrapper().localUnpause();
|
||||||
|
}
|
||||||
|
|
||||||
|
localPause(): void {
|
||||||
|
this.manager.getPlayerWrapper().localPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
localSeek(positionTicks: number): void {
|
||||||
|
this.manager.getPlayerWrapper().localSeek(positionTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStop(): void {
|
||||||
|
this.manager.getPlayerWrapper().localStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- queries ---------------------------------------------------------------
|
||||||
|
|
||||||
|
getLastCommand(): SendCommand | null {
|
||||||
|
return this.lastCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate where the group should be in ticks, given a known
|
||||||
|
* starting position and the time the position was valid at.
|
||||||
|
*/
|
||||||
|
estimateCurrentTicks(positionTicks: number, when: Date): number {
|
||||||
|
const lastCommand = this.lastCommand;
|
||||||
|
if (!lastCommand) return positionTicks;
|
||||||
|
const remoteNow = this.manager.getTimeSync().localDateToRemote(new Date());
|
||||||
|
const elapsedMs = remoteNow.getTime() - when.getTime();
|
||||||
|
if (lastCommand.Command === "Unpause") {
|
||||||
|
return positionTicks + elapsedMs * TicksPerMillisecond;
|
||||||
|
}
|
||||||
|
return positionTicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drift correction tick — called on every player time update. Skips
|
||||||
|
* to the group's expected position if drift exceeds the threshold.
|
||||||
|
* SpeedToSync is intentionally not implemented (see file header).
|
||||||
|
*/
|
||||||
|
syncPlaybackTime(): void {
|
||||||
|
const lastCommand = this.lastCommand;
|
||||||
|
if (lastCommand?.Command !== "Unpause") return;
|
||||||
|
|
||||||
|
const playerWrapper = this.manager.getPlayerWrapper();
|
||||||
|
if (!playerWrapper.isPlaying()) return;
|
||||||
|
|
||||||
|
const currentMs = playerWrapper.currentTime();
|
||||||
|
const expectedTicks = this.estimateCurrentTicks(
|
||||||
|
lastCommand.PositionTicks ?? 0,
|
||||||
|
lastCommand.When as unknown as Date,
|
||||||
|
);
|
||||||
|
const expectedMs = ticksToMs(expectedTicks);
|
||||||
|
const driftMs = Math.abs(currentMs - expectedMs);
|
||||||
|
|
||||||
|
if (driftMs > SYNC_PLAY_TUNING.minDelaySkipToSync) {
|
||||||
|
console.log(
|
||||||
|
`SyncPlay syncPlaybackTime: drift ${driftMs.toFixed(
|
||||||
|
0,
|
||||||
|
)}ms exceeds threshold, seeking to ${expectedMs.toFixed(0)}ms`,
|
||||||
|
);
|
||||||
|
this.localSeek(expectedTicks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- teardown --------------------------------------------------------------
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
this.lastCommand = null;
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlaybackCore;
|
||||||
332
providers/SyncPlay/cores/QueueCore.ts
Normal file
332
providers/SyncPlay/cores/QueueCore.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay QueueCore — tracks the group's playlist.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Handle `PlayQueue` group updates (NewPlaylist, SetCurrentItem,
|
||||||
|
* NextItem, PreviousItem, RemoveItems, etc.)
|
||||||
|
* - Resolve the server's flat list of ItemIds into full `BaseItemDto`s
|
||||||
|
* (with PlaylistItemId glued on for SyncPlay requests)
|
||||||
|
* - Expose `currentPlaylistItemId` — required by every SyncPlay
|
||||||
|
* request (Ready, Buffering, Seek) so the server can ignore stale
|
||||||
|
* ones from before the playlist moved
|
||||||
|
* - On NewPlaylist, ask the server we're ready by sending a Buffering
|
||||||
|
* request after the local player emits `playbackstart`
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { TicksPerMillisecond, WaitForEventDefaultTimeout } from "../constants";
|
||||||
|
import { EventEmitter, waitForEventOnce } from "../EventEmitter";
|
||||||
|
import type { SyncPlayManager } from "../Manager";
|
||||||
|
import {
|
||||||
|
getItemsForPlayback,
|
||||||
|
translateItemsForPlayback,
|
||||||
|
} from "../transport/queueTranslation";
|
||||||
|
import type {
|
||||||
|
PlayQueueUpdate,
|
||||||
|
PlayQueueUpdateReason,
|
||||||
|
SyncPlayQueueItem,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export class QueueCore extends EventEmitter {
|
||||||
|
private manager!: SyncPlayManager;
|
||||||
|
private lastPlayQueueUpdate: PlayQueueUpdate | null = null;
|
||||||
|
/** Playable items with `PlaylistItemId` glued on. */
|
||||||
|
private playlist: BaseItemDto[] = [];
|
||||||
|
|
||||||
|
init(manager: SyncPlayManager): void {
|
||||||
|
this.manager = manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle a PlayQueue group update from the server. */
|
||||||
|
updatePlayQueue(apiClient: Api, newPlayQueue: PlayQueueUpdate): void {
|
||||||
|
(newPlayQueue as unknown as { LastUpdate: Date }).LastUpdate = new Date(
|
||||||
|
newPlayQueue.LastUpdate as unknown as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(newPlayQueue.LastUpdate as unknown as Date).getTime() <=
|
||||||
|
this.getLastUpdateTime()
|
||||||
|
) {
|
||||||
|
console.debug("SyncPlay updatePlayQueue: ignoring old update");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onPlayQueueUpdate(apiClient, newPlayQueue)
|
||||||
|
.then(() => {
|
||||||
|
if (
|
||||||
|
(newPlayQueue.LastUpdate as unknown as Date).getTime() <
|
||||||
|
this.getLastUpdateTime()
|
||||||
|
) {
|
||||||
|
console.warn("SyncPlay updatePlayQueue: trying to apply old update");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = newPlayQueue.Reason as PlayQueueUpdateReason;
|
||||||
|
switch (reason) {
|
||||||
|
case "NewPlaylist": {
|
||||||
|
if (!this.manager.isFollowingGroupPlayback()) {
|
||||||
|
this.manager.followGroupPlayback(apiClient).then(() => {
|
||||||
|
this.startPlayback(apiClient);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.startPlayback(apiClient);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "SetCurrentItem":
|
||||||
|
case "NextItem":
|
||||||
|
case "PreviousItem": {
|
||||||
|
const playlistItemId = this.getCurrentPlaylistItemId();
|
||||||
|
this.setCurrentPlaylistItem(apiClient, playlistItemId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "RemoveItems":
|
||||||
|
case "MoveItem":
|
||||||
|
case "Queue":
|
||||||
|
case "QueueNext":
|
||||||
|
case "RepeatMode":
|
||||||
|
case "ShuffleMode":
|
||||||
|
// Video-focused: we don't expose repeat/shuffle/queue mutation
|
||||||
|
// controls in the RN UI yet, so these reasons just update our
|
||||||
|
// local snapshot (already done by onPlayQueueUpdate) without
|
||||||
|
// triggering any local action.
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn(
|
||||||
|
"SyncPlay updatePlayQueue: unknown reason",
|
||||||
|
newPlayQueue.Reason,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn("SyncPlay updatePlayQueue:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a play-queue update to local state. */
|
||||||
|
async onPlayQueueUpdate(
|
||||||
|
apiClient: Api,
|
||||||
|
playQueueUpdate: PlayQueueUpdate,
|
||||||
|
): Promise<void> {
|
||||||
|
const itemIds = (playQueueUpdate.Playlist ?? [])
|
||||||
|
.map((queueItem: SyncPlayQueueItem) => queueItem.ItemId)
|
||||||
|
.filter((id): id is string => typeof id === "string");
|
||||||
|
|
||||||
|
if (!itemIds.length) {
|
||||||
|
this.lastPlayQueueUpdate = playQueueUpdate;
|
||||||
|
this.playlist = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetched = await getItemsForPlayback(apiClient, itemIds);
|
||||||
|
const items = await translateItemsForPlayback(apiClient, fetched, {
|
||||||
|
ids: itemIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.lastPlayQueueUpdate &&
|
||||||
|
(playQueueUpdate.LastUpdate as unknown as Date).getTime() <=
|
||||||
|
this.getLastUpdateTime()
|
||||||
|
) {
|
||||||
|
throw new Error("Trying to apply old update");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glue PlaylistItemId from the server's playlist entries onto each
|
||||||
|
// resolved item. The server-assigned IDs are what every SyncPlay
|
||||||
|
// request needs to identify the queue slot.
|
||||||
|
const playlistItems = playQueueUpdate.Playlist ?? [];
|
||||||
|
for (let i = 0; i < items.length && i < playlistItems.length; i++) {
|
||||||
|
items[i].PlaylistItemId = playlistItems[i].PlaylistItemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastPlayQueueUpdate = playQueueUpdate;
|
||||||
|
this.playlist = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a Ready request once the local player begins playback. The
|
||||||
|
* server uses this to wait until every member is buffered before
|
||||||
|
* issuing the next Unpause.
|
||||||
|
*
|
||||||
|
* On timeout (player never starts), halt group playback so the rest
|
||||||
|
* of the group can proceed without us.
|
||||||
|
*/
|
||||||
|
scheduleReadyRequestOnPlaybackStart(apiClient: Api, origin: string): void {
|
||||||
|
waitForEventOnce(
|
||||||
|
this.manager,
|
||||||
|
"playbackstart",
|
||||||
|
WaitForEventDefaultTimeout,
|
||||||
|
["playbackerror"],
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay scheduleReadyRequestOnPlaybackStart: notifying server",
|
||||||
|
);
|
||||||
|
const playerWrapper = this.manager.getPlayerWrapper();
|
||||||
|
playerWrapper.localPause();
|
||||||
|
|
||||||
|
const currentPosition = playerWrapper.currentTime();
|
||||||
|
const currentPositionTicks = Math.round(
|
||||||
|
currentPosition * TicksPerMillisecond,
|
||||||
|
);
|
||||||
|
const isPlaying = playerWrapper.isPlaying();
|
||||||
|
const now = this.manager.getTimeSync().localDateToRemote(new Date());
|
||||||
|
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(apiClient).syncPlayReady({
|
||||||
|
readyRequestDto: {
|
||||||
|
When: now.toISOString(),
|
||||||
|
PositionTicks: currentPositionTicks,
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
PlaylistItemId: this.getCurrentPlaylistItemId() ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay syncPlayReady failed", error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(
|
||||||
|
"Timed out waiting for 'playbackstart' event!",
|
||||||
|
origin,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
if (!this.manager.isSyncPlayEnabled()) {
|
||||||
|
this.manager.emit("toast", "MessageSyncPlayErrorMedia");
|
||||||
|
}
|
||||||
|
this.manager.haltGroupPlayback(apiClient);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start local playback by navigating to the player screen for the current item. */
|
||||||
|
startPlayback(apiClient: Api): void {
|
||||||
|
if (!this.manager.isFollowingGroupPlayback()) {
|
||||||
|
console.debug("SyncPlay startPlayback: ignoring, not following playback");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPlaylistEmpty()) {
|
||||||
|
console.debug("SyncPlay startPlayback: empty playlist");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate where to start playback from. Prefer the last playback
|
||||||
|
// command if newer than the queue update (playback ticks change
|
||||||
|
// more often than queue position).
|
||||||
|
const playbackCommand = this.manager.getLastPlaybackCommand();
|
||||||
|
let startPositionTicks = 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
playbackCommand &&
|
||||||
|
(
|
||||||
|
playbackCommand as unknown as { EmittedAt: Date }
|
||||||
|
).EmittedAt?.getTime() >= this.getLastUpdateTime()
|
||||||
|
) {
|
||||||
|
startPositionTicks = this.manager
|
||||||
|
.getPlaybackCore()
|
||||||
|
.estimateCurrentTicks(
|
||||||
|
playbackCommand.PositionTicks ?? 0,
|
||||||
|
(playbackCommand as unknown as { When: Date }).When,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
startPositionTicks = this.manager
|
||||||
|
.getPlaybackCore()
|
||||||
|
.estimateCurrentTicks(
|
||||||
|
this.getStartPositionTicks(),
|
||||||
|
(this.getLastUpdate() ?? new Date()) as Date,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverId = apiClient.deviceInfo?.id ?? "";
|
||||||
|
|
||||||
|
this.scheduleReadyRequestOnPlaybackStart(apiClient, "startPlayback");
|
||||||
|
|
||||||
|
this.manager
|
||||||
|
.getPlayerWrapper()
|
||||||
|
.localPlay({
|
||||||
|
ids: this.getPlaylistAsItemIds(),
|
||||||
|
startPositionTicks,
|
||||||
|
startIndex: this.getCurrentPlaylistIndex(),
|
||||||
|
serverId,
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error("SyncPlay startPlayback: localPlay failed", error);
|
||||||
|
this.manager.emit("toast", "MessageSyncPlayErrorMedia");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a specific item in the queue. */
|
||||||
|
setCurrentPlaylistItem(apiClient: Api, playlistItemId: string | null): void {
|
||||||
|
if (!this.manager.isFollowingGroupPlayback()) {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay setCurrentPlaylistItem: ignoring, not following playback",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduleReadyRequestOnPlaybackStart(
|
||||||
|
apiClient,
|
||||||
|
"setCurrentPlaylistItem",
|
||||||
|
);
|
||||||
|
|
||||||
|
this.manager.getPlayerWrapper().localSetCurrentPlaylistItem(playlistItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- getters ---------------------------------------------------------------
|
||||||
|
|
||||||
|
getCurrentPlaylistIndex(): number {
|
||||||
|
return this.lastPlayQueueUpdate?.PlayingItemIndex ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentPlaylistItemId(): string | null {
|
||||||
|
if (!this.lastPlayQueueUpdate) return null;
|
||||||
|
const index = this.lastPlayQueueUpdate.PlayingItemIndex ?? -1;
|
||||||
|
if (index === -1) return null;
|
||||||
|
return this.playlist[index]?.PlaylistItemId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaylist(): BaseItemDto[] {
|
||||||
|
return this.playlist.slice(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaylistEmpty(): boolean {
|
||||||
|
return this.playlist.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastUpdate(): Date | null {
|
||||||
|
if (!this.lastPlayQueueUpdate) return null;
|
||||||
|
return this.lastPlayQueueUpdate.LastUpdate as unknown as Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastUpdateTime(): number {
|
||||||
|
if (!this.lastPlayQueueUpdate) return 0;
|
||||||
|
return (this.lastPlayQueueUpdate.LastUpdate as unknown as Date).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
getStartPositionTicks(): number {
|
||||||
|
return this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaylistAsItemIds(): (string | undefined)[] {
|
||||||
|
if (!this.lastPlayQueueUpdate) return [];
|
||||||
|
return (this.lastPlayQueueUpdate.Playlist ?? []).map((q) => q.ItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- teardown --------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Clear cached playlist. Called on group disable so a re-join starts clean. */
|
||||||
|
clear(): void {
|
||||||
|
this.lastPlayQueueUpdate = null;
|
||||||
|
this.playlist = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.clear();
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QueueCore;
|
||||||
220
providers/SyncPlay/cores/TimeSync.ts
Normal file
220
providers/SyncPlay/cores/TimeSync.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* TimeSync — NTP-style time synchronisation with the Jellyfin server.
|
||||||
|
*
|
||||||
|
* Merged port of jellyfin-web's `core/timeSync/{TimeSync,TimeSyncServer,
|
||||||
|
* TimeSyncCore}.js` — three classes that exist on web because the
|
||||||
|
* abstract layer supports syncing against other group members, not just
|
||||||
|
* the server. RN only syncs against the server, so it's one class.
|
||||||
|
*
|
||||||
|
* Algorithm: repeatedly time a round-trip request to `getUtcTime`,
|
||||||
|
* compute `offset = ((requestReceived - requestSent) + (responseSent -
|
||||||
|
* responseReceived)) / 2`, keep the minimum-delay measurement out of
|
||||||
|
* the last 8. This is the standard NTP outlier-rejection trick — the
|
||||||
|
* measurement with the shortest delay is the most accurate because
|
||||||
|
* less network jitter could have skewed the timestamps.
|
||||||
|
*
|
||||||
|
* Polling: greedy mode at 1s intervals for the first 3 pings to warm
|
||||||
|
* up the offset, then low-profile at 60s intervals for steady-state.
|
||||||
|
* `forceUpdate()` resets to greedy mode (called on group join).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import { getTimeSyncApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { EventEmitter } from "../EventEmitter";
|
||||||
|
|
||||||
|
const NumberOfTrackedMeasurements = 8;
|
||||||
|
const PollingIntervalGreedy = 1000; // ms
|
||||||
|
const PollingIntervalLowProfile = 60000; // ms
|
||||||
|
const GreedyPingCount = 3;
|
||||||
|
|
||||||
|
class Measurement {
|
||||||
|
requestSent: number;
|
||||||
|
requestReceived: number;
|
||||||
|
responseSent: number;
|
||||||
|
responseReceived: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
requestSent: Date,
|
||||||
|
requestReceived: Date,
|
||||||
|
responseSent: Date,
|
||||||
|
responseReceived: Date,
|
||||||
|
) {
|
||||||
|
this.requestSent = requestSent.getTime();
|
||||||
|
this.requestReceived = requestReceived.getTime();
|
||||||
|
this.responseSent = responseSent.getTime();
|
||||||
|
this.responseReceived = responseReceived.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time offset (ms): positive means server clock is ahead of ours. */
|
||||||
|
getOffset(): number {
|
||||||
|
return (
|
||||||
|
(this.requestReceived -
|
||||||
|
this.requestSent +
|
||||||
|
(this.responseSent - this.responseReceived)) /
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round-trip delay (ms), excluding server processing. */
|
||||||
|
getDelay(): number {
|
||||||
|
return (
|
||||||
|
this.responseReceived -
|
||||||
|
this.requestSent -
|
||||||
|
(this.responseSent - this.requestReceived)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-way ping (ms). */
|
||||||
|
getPing(): number {
|
||||||
|
return this.getDelay() / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the offset between this client's clock and the Jellyfin server's
|
||||||
|
* clock, and exposes conversions between local and remote Dates.
|
||||||
|
*
|
||||||
|
* Listeners:
|
||||||
|
* - `"update"` (timeOffset: number, ping: number) — fires on every
|
||||||
|
* successful ping. Errors are logged but not emitted; consumers
|
||||||
|
* should treat absence of updates as transient.
|
||||||
|
*/
|
||||||
|
export class TimeSync extends EventEmitter {
|
||||||
|
private api: Api;
|
||||||
|
private pingStop = true;
|
||||||
|
private pollingInterval = PollingIntervalGreedy;
|
||||||
|
private poller: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private pings = 0;
|
||||||
|
private measurement: Measurement | null = null;
|
||||||
|
private measurements: Measurement[] = [];
|
||||||
|
|
||||||
|
constructor(api: Api) {
|
||||||
|
super();
|
||||||
|
this.api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when the user switches Jellyfin servers. */
|
||||||
|
updateApiClient(api: Api): void {
|
||||||
|
this.api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether we've completed at least one successful measurement. */
|
||||||
|
isReady(): boolean {
|
||||||
|
return !!this.measurement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current best-estimate time offset (ms). */
|
||||||
|
getTimeOffset(): number {
|
||||||
|
return this.measurement ? this.measurement.getOffset() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current best-estimate one-way ping (ms). */
|
||||||
|
getPing(): number {
|
||||||
|
return this.measurement ? this.measurement.getPing() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a server-time Date to local time. */
|
||||||
|
remoteDateToLocal(remote: Date): Date {
|
||||||
|
return new Date(remote.getTime() - this.getTimeOffset());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a local Date to server time. */
|
||||||
|
localDateToRemote(local: Date): Date {
|
||||||
|
return new Date(local.getTime() + this.getTimeOffset());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start polling. Idempotent. */
|
||||||
|
startPing(): void {
|
||||||
|
this.pingStop = false;
|
||||||
|
this.scheduleNextPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop polling. Idempotent. */
|
||||||
|
stopPing(): void {
|
||||||
|
this.pingStop = true;
|
||||||
|
if (this.poller) {
|
||||||
|
clearTimeout(this.poller);
|
||||||
|
this.poller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset to greedy polling and force a fresh measurement immediately. */
|
||||||
|
forceUpdate(): void {
|
||||||
|
this.stopPing();
|
||||||
|
this.pollingInterval = PollingIntervalGreedy;
|
||||||
|
this.pings = 0;
|
||||||
|
this.startPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop all measurements. Used on group leave. */
|
||||||
|
resetMeasurements(): void {
|
||||||
|
this.measurement = null;
|
||||||
|
this.measurements = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full teardown on provider unmount. */
|
||||||
|
destroy(): void {
|
||||||
|
this.stopPing();
|
||||||
|
this.resetMeasurements();
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNextPing(): void {
|
||||||
|
if (this.poller || this.pingStop) return;
|
||||||
|
this.poller = setTimeout(() => {
|
||||||
|
this.poller = null;
|
||||||
|
this.requestPing()
|
||||||
|
.then((result) => this.onPingResponse(result))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("SyncPlay TimeSync: ping failed", error);
|
||||||
|
})
|
||||||
|
.finally(() => this.scheduleNextPing());
|
||||||
|
}, this.pollingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestPing() {
|
||||||
|
const requestSent = new Date();
|
||||||
|
const response = await getTimeSyncApi(this.api).getUtcTime();
|
||||||
|
const responseReceived = new Date();
|
||||||
|
const data = response.data;
|
||||||
|
const requestReceived = new Date(data.RequestReceptionTime as string);
|
||||||
|
const responseSent = new Date(data.ResponseTransmissionTime as string);
|
||||||
|
return { requestSent, requestReceived, responseSent, responseReceived };
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPingResponse(result: {
|
||||||
|
requestSent: Date;
|
||||||
|
requestReceived: Date;
|
||||||
|
responseSent: Date;
|
||||||
|
responseReceived: Date;
|
||||||
|
}): void {
|
||||||
|
const measurement = new Measurement(
|
||||||
|
result.requestSent,
|
||||||
|
result.requestReceived,
|
||||||
|
result.responseSent,
|
||||||
|
result.responseReceived,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.measurements.push(measurement);
|
||||||
|
if (this.measurements.length > NumberOfTrackedMeasurements) {
|
||||||
|
this.measurements.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outlier rejection: pick the measurement with the shortest delay.
|
||||||
|
const sorted = [...this.measurements].sort(
|
||||||
|
(a, b) => a.getDelay() - b.getDelay(),
|
||||||
|
);
|
||||||
|
this.measurement = sorted[0];
|
||||||
|
|
||||||
|
// Throttle once we've warmed up.
|
||||||
|
if (this.pings >= GreedyPingCount) {
|
||||||
|
this.pollingInterval = PollingIntervalLowProfile;
|
||||||
|
} else {
|
||||||
|
this.pings++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit("update", this.getTimeOffset(), this.getPing());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimeSync;
|
||||||
13
providers/SyncPlay/index.ts
Normal file
13
providers/SyncPlay/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay — public exports.
|
||||||
|
*
|
||||||
|
* Only what external consumers (components, hooks, screens) need.
|
||||||
|
* Internal modules (PlaybackCore, QueueCore, TimeSync, PlayerWrapper,
|
||||||
|
* queueTranslation, EventEmitter, etc.) stay package-private.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { Controller as SyncPlayController } from "./Controller";
|
||||||
|
export { msToTicks, ticksToMs } from "./constants";
|
||||||
|
export { SyncPlayManager } from "./Manager";
|
||||||
|
export { SyncPlayProvider, useSyncPlay } from "./SyncPlayProvider";
|
||||||
|
export * from "./types";
|
||||||
58
providers/SyncPlay/player/PendingPlaybackTracker.ts
Normal file
58
providers/SyncPlay/player/PendingPlaybackTracker.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* PendingPlaybackTracker — tracks an in-flight `Unpause` / `Pause` request
|
||||||
|
* that we've sent to the server but haven't seen echoed back via
|
||||||
|
* `SyncPlayCommand`.
|
||||||
|
*
|
||||||
|
* Drives three things:
|
||||||
|
* 1. Drop duplicate rapid taps
|
||||||
|
* 2. Provide an optimistic-UI hint for the in-flight state
|
||||||
|
* 3. Override "current play state" when deciding pause-vs-unpause
|
||||||
|
* for the next tap
|
||||||
|
*
|
||||||
|
* Auto-clears after `pendingPlaybackTimeoutMs` so a lost broadcast
|
||||||
|
* doesn't freeze the UI forever.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SYNC_PLAY_TUNING } from "../types";
|
||||||
|
|
||||||
|
export class PendingPlaybackTracker {
|
||||||
|
private command: "Unpause" | "Pause" | null = null;
|
||||||
|
private timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private onChange: ((cmd: "Unpause" | "Pause" | null) => void) | null = null;
|
||||||
|
|
||||||
|
setChangeHandler(
|
||||||
|
handler: ((cmd: "Unpause" | "Pause" | null) => void) | null,
|
||||||
|
): void {
|
||||||
|
this.onChange = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): "Unpause" | "Pause" | null {
|
||||||
|
return this.command;
|
||||||
|
}
|
||||||
|
|
||||||
|
mark(command: "Unpause" | "Pause"): void {
|
||||||
|
this.command = command;
|
||||||
|
if (this.timeout) clearTimeout(this.timeout);
|
||||||
|
this.timeout = setTimeout(() => {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay PendingPlaybackTracker: timed out waiting for broadcast",
|
||||||
|
command,
|
||||||
|
);
|
||||||
|
this.command = null;
|
||||||
|
this.timeout = null;
|
||||||
|
this.onChange?.(null);
|
||||||
|
}, SYNC_PLAY_TUNING.pendingPlaybackTimeoutMs);
|
||||||
|
this.onChange?.(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
if (this.timeout) {
|
||||||
|
clearTimeout(this.timeout);
|
||||||
|
this.timeout = null;
|
||||||
|
}
|
||||||
|
if (this.command !== null) {
|
||||||
|
this.command = null;
|
||||||
|
this.onChange?.(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
providers/SyncPlay/player/PlayerWrapper.ts
Normal file
87
providers/SyncPlay/player/PlayerWrapper.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* PlayerWrapper — adapter between jellyfin's tick-based playerWrapper API
|
||||||
|
* and our millisecond-based `PlayerControls`. Methods that have no RN
|
||||||
|
* analog (queue mutation hooks) delegate to provider-supplied handlers
|
||||||
|
* which navigate to the player screen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TicksPerMillisecond } from "../constants";
|
||||||
|
import type { PlayerControls } from "../types";
|
||||||
|
|
||||||
|
/** Options passed to `playerWrapper.localPlay` — provider navigates to the player screen. */
|
||||||
|
export interface LocalPlayOptions {
|
||||||
|
ids: (string | undefined)[];
|
||||||
|
startPositionTicks: number;
|
||||||
|
startIndex: number;
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlayerWrapper {
|
||||||
|
private controls: PlayerControls | null = null;
|
||||||
|
private localPlayHandler: ((options: LocalPlayOptions) => void) | null = null;
|
||||||
|
private setCurrentItemHandler:
|
||||||
|
| ((playlistItemId: string | null) => void)
|
||||||
|
| null = null;
|
||||||
|
|
||||||
|
/** Attach / detach the underlying player. */
|
||||||
|
bindToControls(controls: PlayerControls | null): void {
|
||||||
|
this.controls = controls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider wires this to navigate to the player screen. */
|
||||||
|
setLocalPlayHandler(handler: ((options: LocalPlayOptions) => void) | null) {
|
||||||
|
this.localPlayHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider wires this to navigate to a different queue item. */
|
||||||
|
setLocalSetCurrentItemHandler(
|
||||||
|
handler: ((playlistItemId: string | null) => void) | null,
|
||||||
|
) {
|
||||||
|
this.setCurrentItemHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
localUnpause(): void {
|
||||||
|
this.controls?.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
localPause(): void {
|
||||||
|
this.controls?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upstream takes ticks; RN's `seekTo` takes ms. */
|
||||||
|
localSeek(positionTicks: number): void {
|
||||||
|
this.controls?.seekTo(positionTicks / TicksPerMillisecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RN: pause instead of teardown — leaving the player screen is the navigator's job. */
|
||||||
|
localStop(): void {
|
||||||
|
this.controls?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Position in ms. */
|
||||||
|
currentTime(): number {
|
||||||
|
return this.controls?.getCurrentPosition() ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaying(): boolean {
|
||||||
|
return this.controls?.isPlaying() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaybackActive(): boolean {
|
||||||
|
return this.controls !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RN never runs as a remote-managed player. */
|
||||||
|
isRemote(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
localPlay(options: LocalPlayOptions): Promise<void> {
|
||||||
|
this.localPlayHandler?.(options);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
localSetCurrentPlaylistItem(playlistItemId: string | null): void {
|
||||||
|
this.setCurrentItemHandler?.(playlistItemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
providers/SyncPlay/player/bufferingDebouncer.ts
Normal file
64
providers/SyncPlay/player/bufferingDebouncer.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* bufferingDebouncer — wrap an `isBuffering: boolean → void` notify callback
|
||||||
|
* with three RN-only guards. Web gets these for free from HTML `waiting`/
|
||||||
|
* `canplay`; our `PlayerControls` exposes state (not events) and the React
|
||||||
|
* effect that polls it can fire many times per second.
|
||||||
|
*
|
||||||
|
* - **dedup**: drop redundant calls when state hasn't changed
|
||||||
|
* - **debounce buffering→true**: only escalate after the threshold;
|
||||||
|
* going back to ready cancels the pending escalation
|
||||||
|
* - **coalesce inflight**: serialize concurrent sends
|
||||||
|
*
|
||||||
|
* Returns `{ notify, dispose }`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SYNC_PLAY_TUNING } from "../types";
|
||||||
|
|
||||||
|
export function createBufferingDebouncer(
|
||||||
|
send: (isBuffering: boolean) => Promise<void>,
|
||||||
|
) {
|
||||||
|
let lastSent: boolean | null = null;
|
||||||
|
let inflight: Promise<void> | null = null;
|
||||||
|
let pendingTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const flush = async (isBuffering: boolean) => {
|
||||||
|
if (lastSent === isBuffering) return;
|
||||||
|
if (inflight) {
|
||||||
|
try {
|
||||||
|
await inflight;
|
||||||
|
} catch {
|
||||||
|
// ignore — used only for ordering
|
||||||
|
}
|
||||||
|
if (lastSent === isBuffering) return;
|
||||||
|
}
|
||||||
|
lastSent = isBuffering;
|
||||||
|
inflight = send(isBuffering).finally(() => {
|
||||||
|
inflight = null;
|
||||||
|
});
|
||||||
|
return inflight;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
notify(isBuffering: boolean): void {
|
||||||
|
if (pendingTimeout) {
|
||||||
|
clearTimeout(pendingTimeout);
|
||||||
|
pendingTimeout = null;
|
||||||
|
}
|
||||||
|
if (!isBuffering) {
|
||||||
|
// Ready always fires immediately.
|
||||||
|
void flush(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingTimeout = setTimeout(() => {
|
||||||
|
pendingTimeout = null;
|
||||||
|
void flush(true);
|
||||||
|
}, SYNC_PLAY_TUNING.minBufferingThresholdMs);
|
||||||
|
},
|
||||||
|
dispose(): void {
|
||||||
|
if (pendingTimeout) {
|
||||||
|
clearTimeout(pendingTimeout);
|
||||||
|
pendingTimeout = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
58
providers/SyncPlay/player/reconcileToGroupOnAttach.ts
Normal file
58
providers/SyncPlay/player/reconcileToGroupOnAttach.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* reconcileToGroupOnAttach — estimate the group's current position from
|
||||||
|
* the last play/pause broadcast and seek the freshly-attached player
|
||||||
|
* there if drift exceeds the threshold.
|
||||||
|
*
|
||||||
|
* Web's player binds at group-join, so this race doesn't exist there.
|
||||||
|
* On RN the player mounts in a separate route after the join, so
|
||||||
|
* commands arrive before controls attach. Without this, the player
|
||||||
|
* resumes from its local position and is silently behind the group.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TicksPerMillisecond } from "../constants";
|
||||||
|
import {
|
||||||
|
type PlayerControls,
|
||||||
|
type SendCommand,
|
||||||
|
SYNC_PLAY_TUNING,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export function reconcileToGroupOnAttach(
|
||||||
|
controls: PlayerControls,
|
||||||
|
lastCommand: SendCommand | null,
|
||||||
|
localToRemote: (local: Date) => Date,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
!lastCommand ||
|
||||||
|
(lastCommand.Command !== "Unpause" && lastCommand.Command !== "Pause") ||
|
||||||
|
!lastCommand.When ||
|
||||||
|
lastCommand.PositionTicks == null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const commandWhen = new Date(lastCommand.When);
|
||||||
|
let targetTicks = lastCommand.PositionTicks;
|
||||||
|
if (lastCommand.Command === "Unpause") {
|
||||||
|
const remoteNow = localToRemote(new Date());
|
||||||
|
targetTicks +=
|
||||||
|
(remoteNow.getTime() - commandWhen.getTime()) * TicksPerMillisecond;
|
||||||
|
}
|
||||||
|
const targetMs = Math.max(0, targetTicks / TicksPerMillisecond);
|
||||||
|
const currentMs = controls.getCurrentPosition();
|
||||||
|
if (
|
||||||
|
Math.abs(currentMs - targetMs) >
|
||||||
|
SYNC_PLAY_TUNING.positionReconcileThresholdMs
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: player attached — seeking to estimated group position ${targetMs}ms (was ${currentMs}ms)`,
|
||||||
|
);
|
||||||
|
controls.seekTo(targetMs);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"SyncPlay: failed to estimate group position on attach",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
183
providers/SyncPlay/transport/queueTranslation.ts
Normal file
183
providers/SyncPlay/transport/queueTranslation.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* queueTranslation — expand container items into a real playable queue.
|
||||||
|
*
|
||||||
|
* The server takes the queue we send via `syncPlaySetNewQueue` and
|
||||||
|
* rebroadcasts it verbatim to every group member. Sending a container
|
||||||
|
* ID (Series, Season, BoxSet, Playlist) means every receiver fails to
|
||||||
|
* open the player because they can't directly play a container. We must
|
||||||
|
* expand to real playable item IDs before sending the queue.
|
||||||
|
*
|
||||||
|
* Video-focused: music (MusicArtist/MusicGenre) and photo branches are
|
||||||
|
* intentionally omitted. Live TV (Program), Episode auto-advance, and
|
||||||
|
* folder expansion are preserved because they're the common video flows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {
|
||||||
|
getItemsApi,
|
||||||
|
getTvShowsApi,
|
||||||
|
getUserApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
|
||||||
|
export interface TranslateOptions {
|
||||||
|
ids?: string[];
|
||||||
|
shuffle?: boolean;
|
||||||
|
queryOptions?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAYBACK_FIELDS = ["Chapters", "Trickplay"] as const;
|
||||||
|
|
||||||
|
async function getCurrentUser(api: Api) {
|
||||||
|
const user = (await getUserApi(api).getCurrentUser()).data;
|
||||||
|
if (!user?.Id) {
|
||||||
|
throw new Error("SyncPlay queueTranslation: no authenticated user");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryItems(
|
||||||
|
api: Api,
|
||||||
|
userId: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Promise<BaseItemDto[]> {
|
||||||
|
const res = await getItemsApi(api).getItems({
|
||||||
|
limit: 300,
|
||||||
|
fields: PLAYBACK_FIELDS as unknown as never,
|
||||||
|
excludeLocationTypes: ["Virtual"] as unknown as never,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
...params,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return res.data.Items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchFolderChildren(
|
||||||
|
api: Api,
|
||||||
|
userId: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Promise<BaseItemDto[]> {
|
||||||
|
return queryItems(api, userId, {
|
||||||
|
filters: ["IsNotFolder"],
|
||||||
|
recursive: true,
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve item IDs into full `BaseItemDto`s.
|
||||||
|
*
|
||||||
|
* - single ID → `getItem` (cheap, no Items wrapper)
|
||||||
|
* - multi ID → `getItems` with playback defaults
|
||||||
|
*/
|
||||||
|
export async function getItemsForPlayback(
|
||||||
|
api: Api,
|
||||||
|
ids: string[],
|
||||||
|
): Promise<BaseItemDto[]> {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const userId = (await getCurrentUser(api)).Id as string;
|
||||||
|
if (ids.length === 1) {
|
||||||
|
const res = await getUserLibraryApi(api).getItem({
|
||||||
|
userId,
|
||||||
|
itemId: ids[0],
|
||||||
|
});
|
||||||
|
return res.data ? [res.data] : [];
|
||||||
|
}
|
||||||
|
return queryItems(api, userId, { ids });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a "first item" into a real playable queue.
|
||||||
|
*
|
||||||
|
* - Program → channel items
|
||||||
|
* - Playlist → playlist children
|
||||||
|
* - IsFolder (Series, Season, BoxSet, MusicAlbum, ...) → recursive descendants
|
||||||
|
* - single Episode (when `EnableNextEpisodeAutoPlay`) → remaining series episodes
|
||||||
|
* - everything else → passthrough (Movies, Audio, single Episode w/ autoplay off)
|
||||||
|
*
|
||||||
|
* Preserves the caller's `ids` order so the receiver sees the same
|
||||||
|
* queue order the sender intended.
|
||||||
|
*/
|
||||||
|
export async function translateItemsForPlayback(
|
||||||
|
api: Api,
|
||||||
|
items: BaseItemDto[],
|
||||||
|
options: TranslateOptions = {},
|
||||||
|
): Promise<BaseItemDto[]> {
|
||||||
|
if (!items.length) return [];
|
||||||
|
|
||||||
|
const workingItems =
|
||||||
|
items.length > 1 && options.ids
|
||||||
|
? [...items].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(options.ids ?? []).indexOf(a.Id ?? "") -
|
||||||
|
(options.ids ?? []).indexOf(b.Id ?? ""),
|
||||||
|
)
|
||||||
|
: items;
|
||||||
|
|
||||||
|
const firstItem = workingItems[0];
|
||||||
|
|
||||||
|
if (firstItem.Type === "Program" && firstItem.ChannelId) {
|
||||||
|
return getItemsForPlayback(api, [firstItem.ChannelId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getCurrentUser(api);
|
||||||
|
const userId = user.Id as string;
|
||||||
|
|
||||||
|
if (firstItem.Type === "Playlist") {
|
||||||
|
return queryItems(api, userId, {
|
||||||
|
parentId: firstItem.Id,
|
||||||
|
sortBy: options.shuffle ? ["Random"] : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstItem.IsFolder) {
|
||||||
|
// Series, Season, BoxSet, MusicAlbum, etc.
|
||||||
|
const sortBy = options.shuffle
|
||||||
|
? ["Random"]
|
||||||
|
: firstItem.Type === "BoxSet"
|
||||||
|
? ["SortName"]
|
||||||
|
: undefined;
|
||||||
|
return fetchFolderChildren(api, userId, {
|
||||||
|
parentId: firstItem.Id,
|
||||||
|
mediaTypes: ["Audio", "Video"],
|
||||||
|
sortBy,
|
||||||
|
...(options.queryOptions ?? {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstItem.Type === "Episode" && workingItems.length === 1) {
|
||||||
|
// Single-episode auto-next: load all remaining episodes in the
|
||||||
|
// series, starting at this one. Gated on the user preference so we
|
||||||
|
// don't surprise users who disabled autoplay.
|
||||||
|
if (!user.Configuration?.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) {
|
||||||
|
return workingItems;
|
||||||
|
}
|
||||||
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: firstItem.SeriesId,
|
||||||
|
userId,
|
||||||
|
isMissing: false,
|
||||||
|
fields: PLAYBACK_FIELDS as unknown as never,
|
||||||
|
// SDK omits `isVirtualUnaired` from typed request; server honours
|
||||||
|
// it. Cast keeps wire payload identical to jellyfin-web.
|
||||||
|
...({ isVirtualUnaired: false } as Record<string, unknown>),
|
||||||
|
} as Parameters<ReturnType<typeof getTvShowsApi>["getEpisodes"]>[0]);
|
||||||
|
const all = res.data.Items ?? [];
|
||||||
|
// Drop everything before firstItem; keep firstItem and everything
|
||||||
|
// after. Empty list if firstItem isn't in the series (shouldn't
|
||||||
|
// happen, but matches upstream's behaviour).
|
||||||
|
let foundItem = false;
|
||||||
|
return all.filter((e) => {
|
||||||
|
if (foundItem) return true;
|
||||||
|
if (e.Id === firstItem.Id) {
|
||||||
|
foundItem = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movies, Audio, single Episode w/ autoplay off, etc.
|
||||||
|
return workingItems;
|
||||||
|
}
|
||||||
94
providers/SyncPlay/transport/useSyncPlayWebSocket.ts
Normal file
94
providers/SyncPlay/transport/useSyncPlayWebSocket.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* useSyncPlayWebSocket
|
||||||
|
*
|
||||||
|
* Hook that connects the SyncPlay manager to WebSocket messages.
|
||||||
|
* Listens for SyncPlayCommand and SyncPlayGroupUpdate messages.
|
||||||
|
*
|
||||||
|
* IMPORTANT: We subscribe directly to the WebSocket via `addEventListener`
|
||||||
|
* rather than reading WebSocketProvider's `lastMessage` state. That state
|
||||||
|
* only holds the most recent message, so when the server emits bursts
|
||||||
|
* after a join (GroupJoined + StateUpdate + UserJoined + PlayQueue, all
|
||||||
|
* within a few ms), React's batching causes earlier messages to be
|
||||||
|
* overwritten before our effect can read them — most notably the
|
||||||
|
* GroupJoined message, which left the joining client thinking it hadn't
|
||||||
|
* joined while other members already saw it as a participant.
|
||||||
|
*
|
||||||
|
* Listening on the raw socket guarantees we see every frame in order.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||||
|
import type { SyncPlayManager } from "../Manager";
|
||||||
|
import type { GroupUpdate, SendCommand } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to connect SyncPlay manager to WebSocket
|
||||||
|
*/
|
||||||
|
export function useSyncPlayWebSocket(manager: SyncPlayManager | null): void {
|
||||||
|
const { ws } = useWebSocketContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ws || !manager) return;
|
||||||
|
|
||||||
|
const handleMessage = (event: WebSocketMessageEvent) => {
|
||||||
|
let parsed: { MessageType?: string; Data?: unknown };
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(event.data as string);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to parse WebSocket message", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { MessageType, Data } = parsed;
|
||||||
|
|
||||||
|
// Only handle SyncPlay messages here; everything else is handled
|
||||||
|
// elsewhere via WebSocketProvider's lastMessage.
|
||||||
|
if (!MessageType?.startsWith("SyncPlay")) return;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`SyncPlay WebSocket [${MessageType}]:`,
|
||||||
|
JSON.stringify(Data).substring(0, 300),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (MessageType) {
|
||||||
|
case "SyncPlayCommand": {
|
||||||
|
const command = Data as SendCommand;
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: COMMAND received - ${command.Command} at ${command.When}`,
|
||||||
|
command.Command === "Seek"
|
||||||
|
? `position=${command.PositionTicks}`
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: it's normal for controls to be missing here during the
|
||||||
|
// join → navigate → load window. Manager stashes the command and
|
||||||
|
// replays it on attach.
|
||||||
|
manager.processCommand(command);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SyncPlayGroupUpdate": {
|
||||||
|
// SDK's `GroupUpdate` type is a discriminated union with a
|
||||||
|
// narrower `Type` enum than the wire format. Cast through
|
||||||
|
// unknown so upstream `Manager.processGroupUpdate` can switch
|
||||||
|
// on the real string.
|
||||||
|
const update = Data as unknown as GroupUpdate;
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay: group update -",
|
||||||
|
(update as { Type?: string }).Type,
|
||||||
|
);
|
||||||
|
manager.processGroupUpdate(update);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.addEventListener("message", handleMessage);
|
||||||
|
return () => {
|
||||||
|
ws.removeEventListener("message", handleMessage);
|
||||||
|
};
|
||||||
|
}, [ws, manager]);
|
||||||
|
}
|
||||||
88
providers/SyncPlay/types.ts
Normal file
88
providers/SyncPlay/types.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay — public types and tuning constants.
|
||||||
|
*
|
||||||
|
* Re-exports the SDK types we use, defines the small RN-specific
|
||||||
|
* extensions (PlayerControls, OSD actions), and centralises the magic
|
||||||
|
* numbers that govern sync behaviour.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
// SDK type re-exports — kept narrow on purpose, only what callers
|
||||||
|
// actually reach for.
|
||||||
|
export type {
|
||||||
|
GroupInfoDto,
|
||||||
|
GroupQueueMode,
|
||||||
|
GroupRepeatMode,
|
||||||
|
GroupShuffleMode,
|
||||||
|
GroupStateType,
|
||||||
|
GroupUpdate,
|
||||||
|
PlayQueueUpdate,
|
||||||
|
PlayQueueUpdateReason,
|
||||||
|
SendCommand,
|
||||||
|
SendCommandType,
|
||||||
|
SyncPlayQueueItem,
|
||||||
|
SyncPlayUserAccessType,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
|
||||||
|
/** Jellyfin's tick unit. 1ms = 10000 ticks. */
|
||||||
|
export const TicksPerMillisecond = 10000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player controls SyncPlay drives. The provider wires this up against
|
||||||
|
* the active RN player (mpv / VLC / expo-video).
|
||||||
|
*/
|
||||||
|
export interface PlayerControls {
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
/** Seek to absolute position in milliseconds. */
|
||||||
|
seekTo: (positionMs: number) => void;
|
||||||
|
setSpeed: (speed: number) => void;
|
||||||
|
getSpeed: () => number;
|
||||||
|
/** Current position in milliseconds. */
|
||||||
|
getCurrentPosition: () => number;
|
||||||
|
isPlaying: () => boolean;
|
||||||
|
isBuffering: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OSD action types — drive optional player-overlay feedback. */
|
||||||
|
export type SyncPlayOsdAction =
|
||||||
|
/** transient — 1.5s pulse, the unpause command fired locally */
|
||||||
|
| "unpause"
|
||||||
|
/** transient — 1.5s pulse, the pause command fired locally */
|
||||||
|
| "pause"
|
||||||
|
/** transient — 1.5s pulse, a seek command applied locally */
|
||||||
|
| "seek"
|
||||||
|
/** persistent — group is about to play (Waiting+Unpause / pending Unpause) */
|
||||||
|
| "schedule-play"
|
||||||
|
/** persistent — another client is buffering (Waiting+Buffer) */
|
||||||
|
| "buffering"
|
||||||
|
/** persistent — group transitioning to pause (Waiting+Pause) */
|
||||||
|
| "wait-pause"
|
||||||
|
/** persistent — group transitioning to unpause; sibling of schedule-play */
|
||||||
|
| "wait-unpause";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tuning constants. These mirror jellyfin-web's defaults; tweak with
|
||||||
|
* care — they affect perceived sync quality across all clients.
|
||||||
|
*/
|
||||||
|
export const SYNC_PLAY_TUNING = {
|
||||||
|
/** Drift threshold (ms) above which we hard-seek to catch up. */
|
||||||
|
minDelaySkipToSync: 400,
|
||||||
|
/** Drift beyond this (ms) is always corrected by seeking. */
|
||||||
|
maxDelaySync: 3000,
|
||||||
|
/** Don't escalate buffering to the group for blips shorter than this (ms). */
|
||||||
|
minBufferingThresholdMs: 3000,
|
||||||
|
/** Player-attach drift (ms) above which we reconcile to group position. */
|
||||||
|
positionReconcileThresholdMs: 500,
|
||||||
|
/** Safety timeout (ms) for in-flight Pause/Unpause optimistic UI. */
|
||||||
|
pendingPlaybackTimeoutMs: 1500,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Options accepted by `Controller.play`. */
|
||||||
|
export interface PlayOptions {
|
||||||
|
ids?: string[];
|
||||||
|
items?: BaseItemDto[];
|
||||||
|
startIndex?: number;
|
||||||
|
startPositionTicks?: number;
|
||||||
|
}
|
||||||
@@ -44,6 +44,15 @@ interface WebSocketContextType {
|
|||||||
lastMessage: WebSocketMessage | null;
|
lastMessage: WebSocketMessage | null;
|
||||||
sendMessage: (message: any) => void;
|
sendMessage: (message: any) => void;
|
||||||
clearLastMessage: () => void;
|
clearLastMessage: () => void;
|
||||||
|
/**
|
||||||
|
* Acquire a keep-alive token. While at least one token is held the
|
||||||
|
* WebSocket will NOT be closed on AppState background/inactive. Used
|
||||||
|
* by the video player while in Picture-in-Picture so SyncPlay (and
|
||||||
|
* any other server-pushed events) keep flowing. Returns a release
|
||||||
|
* function — call it (or rely on the React effect cleanup) when the
|
||||||
|
* keep-alive is no longer needed.
|
||||||
|
*/
|
||||||
|
acquireKeepAlive: () => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||||
@@ -63,6 +72,21 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
// Ref-counted keep-alive: while > 0 we skip the AppState→background
|
||||||
|
// close so the socket survives PiP / brief OS suspensions. iOS keeps
|
||||||
|
// the audio session (and therefore networking) alive while PiP is
|
||||||
|
// active, so the WS can continue to receive SyncPlay commands.
|
||||||
|
const keepAliveCountRef = useRef(0);
|
||||||
|
|
||||||
|
const acquireKeepAlive = useCallback((): (() => void) => {
|
||||||
|
keepAliveCountRef.current += 1;
|
||||||
|
let released = false;
|
||||||
|
return () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
keepAliveCountRef.current = Math.max(0, keepAliveCountRef.current - 1);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const connectWebSocket = useCallback(() => {
|
const connectWebSocket = useCallback(() => {
|
||||||
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
||||||
@@ -235,9 +259,20 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleAppStateChange = (state: AppStateStatus) => {
|
const handleAppStateChange = (state: AppStateStatus) => {
|
||||||
if (state === "background" || state === "inactive") {
|
if (state === "background" || state === "inactive") {
|
||||||
|
if (keepAliveCountRef.current > 0) {
|
||||||
|
console.log(
|
||||||
|
`App backgrounded but WS keep-alive held (${keepAliveCountRef.current}); leaving socket open`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log("App moving to background, closing WebSocket...");
|
console.log("App moving to background, closing WebSocket...");
|
||||||
ws?.close();
|
ws?.close();
|
||||||
} else if (state === "active") {
|
} else if (state === "active") {
|
||||||
|
// Only reconnect if we actually lost the socket (we may have
|
||||||
|
// skipped the close above because of a keep-alive token).
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log("App coming to foreground, reconnecting WebSocket...");
|
console.log("App coming to foreground, reconnecting WebSocket...");
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
}
|
}
|
||||||
@@ -267,7 +302,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<WebSocketContext.Provider
|
<WebSocketContext.Provider
|
||||||
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
|
value={{
|
||||||
|
ws,
|
||||||
|
isConnected,
|
||||||
|
lastMessage,
|
||||||
|
sendMessage,
|
||||||
|
clearLastMessage,
|
||||||
|
acquireKeepAlive,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</WebSocketContext.Provider>
|
</WebSocketContext.Provider>
|
||||||
|
|||||||
@@ -119,9 +119,6 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Settings",
|
"settings_title": "Settings",
|
||||||
"log_out_button": "Log Out",
|
"log_out_button": "Log Out",
|
||||||
"search_placeholder": "Search settings",
|
|
||||||
"search_results": "Results",
|
|
||||||
"search_no_results": "No matching settings",
|
|
||||||
"switch_user": {
|
"switch_user": {
|
||||||
"title": "Switch User",
|
"title": "Switch User",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
@@ -129,25 +126,7 @@
|
|||||||
"current": "current"
|
"current": "current"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories",
|
"title": "Categories"
|
||||||
"playback": "Playback",
|
|
||||||
"personalization": "Personalization",
|
|
||||||
"advanced": "Advanced"
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"title": "Notifications",
|
|
||||||
"disabled_title": "Notifications are off",
|
|
||||||
"disabled_description": "Allow notifications to get alerts about your downloads and more.",
|
|
||||||
"enable_button": "Enable notifications",
|
|
||||||
"events_title": "Notify me about",
|
|
||||||
"master": "Enable notifications",
|
|
||||||
"downloads": "Download completed / failed"
|
|
||||||
},
|
|
||||||
"account": {
|
|
||||||
"title": "Account",
|
|
||||||
"copy_token": "Copy token",
|
|
||||||
"copied": "Copied to clipboard",
|
|
||||||
"copy_failed": "Couldn't copy to clipboard"
|
|
||||||
},
|
},
|
||||||
"playback_controls": {
|
"playback_controls": {
|
||||||
"title": "Playback & Controls"
|
"title": "Playback & Controls"
|
||||||
@@ -220,10 +199,6 @@
|
|||||||
"rewind_length": "Rewind Length",
|
"rewind_length": "Rewind Length",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
"chromecast": {
|
|
||||||
"title": "Chromecast",
|
|
||||||
"enable_h265": "Enable H265 for Chromecast"
|
|
||||||
},
|
|
||||||
"buffer": {
|
"buffer": {
|
||||||
"title": "Buffer Settings",
|
"title": "Buffer Settings",
|
||||||
"cache_mode": "Cache Mode",
|
"cache_mode": "Cache Mode",
|
||||||
@@ -1028,6 +1003,28 @@
|
|||||||
"all": "All media (default)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"syncplay": {
|
||||||
|
"title": "SyncPlay",
|
||||||
|
"my_group": "My Group",
|
||||||
|
"join_group": "Join Group",
|
||||||
|
"leave_group": "Leave Group",
|
||||||
|
"create_new_group": "Create New Group",
|
||||||
|
"available_groups": "Available Groups",
|
||||||
|
"members": "members",
|
||||||
|
"failed_to_start": "Failed to start SyncPlay group playback",
|
||||||
|
"resume_playback": "Resume playback",
|
||||||
|
"toasts": {
|
||||||
|
"MessageSyncPlayGroupJoined": "Joined group",
|
||||||
|
"MessageSyncPlayGroupLeft": "Left group",
|
||||||
|
"MessageSyncPlayUserJoined": "{{user}} joined the group",
|
||||||
|
"MessageSyncPlayUserLeft": "{{user}} left the group",
|
||||||
|
"MessageSyncPlayCreateGroupDenied": "Permission denied to create a group",
|
||||||
|
"MessageSyncPlayJoinGroupDenied": "Permission denied to join group",
|
||||||
|
"MessageSyncPlayLibraryAccessDenied": "Access to the content has been denied",
|
||||||
|
"MessageSyncPlayGroupDoesNotExist": "Failed to join group because it does not exist",
|
||||||
|
"MessageSyncPlayErrorMedia": "SyncPlay error during media playback"
|
||||||
|
}
|
||||||
|
},
|
||||||
"companion_login": {
|
"companion_login": {
|
||||||
"title": "Pair with TV",
|
"title": "Pair with TV",
|
||||||
"align_qr": "Align the QR code within the frame",
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
|||||||
@@ -294,12 +294,6 @@ export type Settings = {
|
|||||||
openSubtitlesApiKey?: string;
|
openSubtitlesApiKey?: string;
|
||||||
// TV-only: Inactivity timeout for auto-logout
|
// TV-only: Inactivity timeout for auto-logout
|
||||||
inactivityTimeout: InactivityTimeout;
|
inactivityTimeout: InactivityTimeout;
|
||||||
// Settings-redo additions (SP1)
|
|
||||||
notificationsEnabled: boolean;
|
|
||||||
notifyDownloads: boolean;
|
|
||||||
defaultLandingTab: "(home)" | "(search)" | "(favorites)" | "(libraries)";
|
|
||||||
downloadOnWifiOnly: boolean;
|
|
||||||
cellularBitrate?: Bitrate;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Lockable<T> {
|
export interface Lockable<T> {
|
||||||
@@ -401,11 +395,6 @@ export const defaultValues: Settings = {
|
|||||||
audioTranscodeMode: AudioTranscodeMode.Auto,
|
audioTranscodeMode: AudioTranscodeMode.Auto,
|
||||||
// TV-only: Inactivity timeout (disabled by default)
|
// TV-only: Inactivity timeout (disabled by default)
|
||||||
inactivityTimeout: InactivityTimeout.Disabled,
|
inactivityTimeout: InactivityTimeout.Disabled,
|
||||||
notificationsEnabled: true,
|
|
||||||
notifyDownloads: true,
|
|
||||||
defaultLandingTab: "(home)",
|
|
||||||
downloadOnWifiOnly: false,
|
|
||||||
cellularBitrate: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSettings = (): Partial<Settings> => {
|
const loadSettings = (): Partial<Settings> => {
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TV-safe re-exports of `@expo/ui/community/bottom-sheet`.
|
|
||||||
*
|
|
||||||
* `@expo/ui` resolves its native bridge at module load via
|
|
||||||
* `requireNativeModule('ExpoUI')`, which does not exist on tvOS — a static
|
|
||||||
* top-level import would crash the whole expo-router route tree. We `require()`
|
|
||||||
* it lazily and only off-TV; on TV the exports are undefined, which is fine
|
|
||||||
* because every call site early-returns on `Platform.isTV`.
|
|
||||||
*/
|
|
||||||
type BottomSheetMod = typeof import("@expo/ui/community/bottom-sheet");
|
|
||||||
|
|
||||||
const mod: BottomSheetMod = Platform.isTV
|
|
||||||
? ({} as BottomSheetMod)
|
|
||||||
: (require("@expo/ui/community/bottom-sheet") as BottomSheetMod);
|
|
||||||
|
|
||||||
export const BottomSheetModal = mod.BottomSheetModal;
|
|
||||||
export const BottomSheetView = mod.BottomSheetView;
|
|
||||||
export const BottomSheetScrollView = mod.BottomSheetScrollView;
|
|
||||||
export const BottomSheetTextInput = mod.BottomSheetTextInput;
|
|
||||||
|
|
||||||
export type { BottomSheetMethods } from "@expo/ui/community/bottom-sheet";
|
|
||||||
Reference in New Issue
Block a user