Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
4c35860407 chore(deps): Update dependency react-native-reanimated to v4.4.0 2026-05-30 20:45:58 +00:00
45 changed files with 461 additions and 5530 deletions

View File

@@ -45,4 +45,4 @@ This file previously contained facts about the codebase learned from past sessio
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_ - **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_ - **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.54.1", "version": "0.54.0",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -36,6 +36,7 @@
"appleTeamId": "MWD5K362T8" "appleTeamId": "MWD5K362T8"
}, },
"android": { "android": {
"versionCode": 93,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png", "monochromeImage": "./assets/images/icon-android-themed.png",
@@ -143,8 +144,8 @@
[ [
"./plugins/withGitPod.js", "./plugins/withGitPod.js",
{ {
"podName": "MPVKit", "podName": "MPVKit-GPL",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec" "podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
} }
] ]
], ],

View File

@@ -7,9 +7,6 @@ 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";
@@ -36,7 +33,6 @@ 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 />
</> </>

View File

@@ -49,8 +49,7 @@ 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 { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
@@ -129,7 +128,6 @@ 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;
@@ -139,24 +137,9 @@ 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,
notifyReady,
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
@@ -420,102 +403,8 @@ 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;
if (isLocallyReady) {
notifyReady();
} else {
notifyBuffering();
}
}, [
isSyncPlayEnabled,
isVideoLoaded,
isBuffering,
notifyReady,
notifyBuffering,
]);
// SyncPlay: Pause playback when group is waiting
useEffect(() => {
if (!isSyncPlayEnabled) {
return;
}
const groupState = syncPlay.groupInfo?.State;
const isLocalReady = isVideoLoaded && !isBuffering;
const isWaitingForGroup = groupState === "Waiting";
// Pause playback when waiting for group
if (isLocalReady && isWaitingForGroup && isPlaying) {
videoRef.current?.pause();
}
}, [
isSyncPlayEnabled,
syncPlay.groupInfo?.State,
isVideoLoaded,
isBuffering,
isPlaying,
]);
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();
@@ -749,12 +638,10 @@ 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: shouldAutoplay, autoplay: true,
initialSubtitleId, initialSubtitleId,
initialAudioId, initialAudioId,
// Pass cache/buffer settings from user preferences // Pass cache/buffer settings from user preferences
@@ -938,10 +825,12 @@ export default function DirectPlayerPage() {
], ],
); );
/** PiP handler for MPV */
const _onPictureInPictureChange = useCallback( const _onPictureInPictureChange = useCallback(
(e: { nativeEvent: { isActive: boolean } }) => { (e: { nativeEvent: { isActive: boolean } }) => {
const { isActive } = e.nativeEvent; const { isActive } = e.nativeEvent;
setIsPipMode(isActive); setIsPipMode(isActive);
// Hide controls when entering PiP
if (isActive) { if (isActive) {
_setShowControls(false); _setShowControls(false);
} }
@@ -959,9 +848,6 @@ export default function DirectPlayerPage() {
// Memoize video ref functions to prevent unnecessary re-renders // Memoize video ref functions to prevent unnecessary re-renders
const startPictureInPicture = useCallback(async () => { const startPictureInPicture = useCallback(async () => {
// Hide controls BEFORE entering PiP so the window captures a clean view
_setShowControls(false);
setIsPipMode(true);
return videoRef.current?.startPictureInPicture?.(); return videoRef.current?.startPictureInPicture?.();
}, []); }, []);
@@ -973,20 +859,10 @@ export default function DirectPlayerPage() {
videoRef.current?.pause?.(); videoRef.current?.pause?.();
}, []); }, []);
const seek = useCallback( const seek = useCallback((position: number) => {
(position: number) => { // MPV expects seconds, convert from ms
// Route through SyncPlay when active videoRef.current?.seekTo?.(position / 1000);
if (isSyncPlayEnabled && syncPlayController) { }, []);
console.log("SyncPlay: seek requested via SyncPlay", position);
syncPlayController.seekMs(position);
return;
}
// MPV expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000);
},
[isSyncPlayEnabled, syncPlayController],
);
// TV audio track change handler // TV audio track change handler
const handleAudioIndexChange = useCallback( const handleAudioIndexChange = useCallback(
@@ -1377,7 +1253,6 @@ export default function DirectPlayerPage() {
nowPlayingMetadata={nowPlayingMetadata} nowPlayingMetadata={nowPlayingMetadata}
onProgress={onProgress} onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged} onPlaybackStateChange={onPlaybackStateChanged}
onPictureInPictureChange={_onPictureInPictureChange}
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);

View File

@@ -25,7 +25,6 @@ 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 {
@@ -410,136 +409,133 @@ function Layout() {
<PlaySettingsProvider> <PlaySettingsProvider>
<LogProvider> <LogProvider>
<WebSocketProvider> <WebSocketProvider>
<SyncPlayProvider> <DownloadProvider>
<DownloadProvider> <MusicPlayerProvider>
<MusicPlayerProvider> <GlobalModalProvider>
<GlobalModalProvider> <BottomSheetModalProvider>
<BottomSheetModalProvider> <IntroSheetProvider>
<IntroSheetProvider> <ThemeProvider value={DarkTheme}>
<ThemeProvider value={DarkTheme}> <SystemBars style='light' hidden={false} />
<SystemBars style='light' hidden={false} /> <Stack initialRouteName='(auth)/(tabs)'>
<Stack initialRouteName='(auth)/(tabs)'> <Stack.Screen
<Stack.Screen name='(auth)/(tabs)'
name='(auth)/(tabs)' options={{
options={{ headerShown: false,
headerShown: false, title: "",
title: "", header: () => null,
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent:
Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
{!Platform.isTV && <GlobalModal />} <Stack.Screen
</ThemeProvider> name='(auth)/player'
</IntroSheetProvider> options={{
</BottomSheetModalProvider> headerShown: false,
</GlobalModalProvider> title: "",
</MusicPlayerProvider> header: () => null,
</DownloadProvider> }}
</SyncPlayProvider> />
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider> </WebSocketProvider>
</LogProvider> </LogProvider>
</PlaySettingsProvider> </PlaySettingsProvider>

View File

@@ -1,7 +1,10 @@
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644 index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift --- a/ios/BottomAccessoryProvider.swift
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift +++ b/ios/BottomAccessoryProvider.swift
@@ -8,7 +8,7 @@ import SwiftUI @@ -8,7 +8,7 @@ import SwiftUI
self.delegate = delegate self.delegate = delegate
} }
@@ -11,10 +14,10 @@ index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c1
@available(iOS 26.0, *) @available(iOS 26.0, *)
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) { public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
var placementValue = "none" var placementValue = "none"
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644 index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift --- a/ios/TabView/NewTabView.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift +++ b/ios/TabView/NewTabView.swift
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier { @@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
} }
@@ -53,10 +56,10 @@ index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cf
} }
#endif #endif
+ +
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644 index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift --- a/ios/TabViewImpl.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/ios/TabViewImpl.swift
@@ -281,7 +281,7 @@ extension View { @@ -281,7 +281,7 @@ extension View {
@ViewBuilder @ViewBuilder
@@ -66,10 +69,10 @@ index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d
if #available(iOS 26.0, macOS 26.0, *) { if #available(iOS 26.0, macOS 26.0, *) {
if let behavior { if let behavior {
self.tabBarMinimizeBehavior(behavior.convert()) self.tabBarMinimizeBehavior(behavior.convert())
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644 index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift --- a/ios/TabViewProps.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift +++ b/ios/TabViewProps.swift
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String { @@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
case onScrollUp case onScrollUp
case onScrollDown case onScrollDown

View File

@@ -1,7 +1,7 @@
diff --git a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644 index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
--- a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift --- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
+++ b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift +++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
@@ -25,15 +25,14 @@ public extension RCTView { @@ -25,15 +25,14 @@ public extension RCTView {
return rootView.recursivelyFindSubview(whereType: targetType); return rootView.recursivelyFindSubview(whereType: targetType);
}; };

View File

@@ -1,7 +1,10 @@
diff --git a/node_modules/react-native-udp/react-native-udp.podspec b/node_modules/react-native-udp/react-native-udp.podspec diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/react-native-udp.podspec b/react-native-udp.podspec
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644 index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
--- a/node_modules/react-native-udp/react-native-udp.podspec --- a/react-native-udp.podspec
+++ b/node_modules/react-native-udp/react-native-udp.podspec +++ b/react-native-udp.podspec
@@ -9,7 +9,8 @@ Pod::Spec.new do |s| @@ -9,7 +9,8 @@ Pod::Spec.new do |s|
s.homepage = package_json["homepage"] s.homepage = package_json["homepage"]
s.license = package_json["license"] s.license = package_json["license"]

View File

@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "streamyfin", "name": "streamyfin",
@@ -13,7 +14,6 @@
"@gorhom/bottom-sheet": "5.2.8", "@gorhom/bottom-sheet": "5.2.8",
"@jellyfin/sdk": "^0.13.0", "@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0", "@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.28",
"@react-navigation/native": "^7.2.5", "@react-navigation/native": "^7.2.5",
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.100.14", "@tanstack/query-sync-storage-persister": "^5.100.14",
@@ -78,12 +78,11 @@
"react-native-nitro-modules": "0.33.1", "react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "8.0.1", "react-native-pager-view": "8.0.1",
"react-native-qrcode-svg": "^6.3.21", "react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "4.3.1", "react-native-reanimated": "4.4.0",
"react-native-reanimated-carousel": "4.0.3", "react-native-reanimated-carousel": "4.0.3",
"react-native-safe-area-context": "~5.7.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2", "react-native-screens": "4.25.2",
"react-native-svg": "15.15.4", "react-native-svg": "15.15.4",
"react-native-tab-view": "4.3.0",
"react-native-text-ticker": "^1.15.0", "react-native-text-ticker": "^1.15.0",
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM", "react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
"react-native-udp": "^4.1.7", "react-native-udp": "^4.1.7",
@@ -115,6 +114,11 @@
}, },
}, },
}, },
"patchedDependencies": {
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch",
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
},
"packages": { "packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
@@ -538,10 +542,6 @@
"@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="], "@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="],
"@react-navigation/elements": ["@react-navigation/elements@2.9.19", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A=="],
"@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.28", "", { "dependencies": { "@react-navigation/elements": "^2.9.19", "color": "^4.2.3", "react-native-tab-view": "^4.3.0" }, "peerDependencies": { "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-WZHJSGV2PQOD2Vr9LF8apGvcsbDKukzF3Fhh8xVNIesqaSi9TPProv4dRw6YkenUkjvFVZYkOjvwAJOToePVpA=="],
"@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="], "@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="],
"@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="], "@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="],
@@ -1584,7 +1584,7 @@
"react-native-qrcode-svg": ["react-native-qrcode-svg@6.3.21", "", { "dependencies": { "prop-types": "^15.8.0", "qrcode": "^1.5.4", "text-encoding": "^0.7.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.63.4", "react-native-svg": ">=14.0.0" } }, "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA=="], "react-native-qrcode-svg": ["react-native-qrcode-svg@6.3.21", "", { "dependencies": { "prop-types": "^15.8.0", "qrcode": "^1.5.4", "text-encoding": "^0.7.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.63.4", "react-native-svg": ">=14.0.0" } }, "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA=="],
"react-native-reanimated": ["react-native-reanimated@4.3.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", "react-native-worklets": "0.8.x" } }, "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q=="], "react-native-reanimated": ["react-native-reanimated@4.4.0", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.83 - 0.86", "react-native-worklets": "0.9.x" } }, "sha512-0XbC1SpF3JZOz5QfmTEx3vt8VkmkTlS05CBIOKEg5q5ZSNlGtlacntlhj5CrfZlN1ciHAeoliJouTC2cLGKbDA=="],
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="], "react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],
@@ -1594,8 +1594,6 @@
"react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="], "react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
"react-native-tab-view": ["react-native-tab-view@4.3.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg=="],
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="], "react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"], "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
@@ -2008,10 +2006,6 @@
"@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], "@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
"@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
@@ -2230,14 +2224,6 @@
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="], "@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
"@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"@react-navigation/elements/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"@react-navigation/material-top-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
@@ -2350,14 +2336,6 @@
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], "@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
"@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/material-top-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],

View File

@@ -30,7 +30,6 @@ 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";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
@@ -68,14 +67,6 @@ export const PlayButton: React.FC<Props> = ({
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
// SyncPlay: when enabled, we DO NOT navigate locally on play — we tell the
// server, which broadcasts a PlayQueue: NewPlaylist update to every group
// member (including us). Our `setStartPlaybackHandler` in SyncPlayProvider
// then performs the navigation uniformly for everyone, matching
// jellyfin-web's playbackManager intercept (Controller.play).
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
useSyncPlay();
// 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;
@@ -103,37 +94,6 @@ export const PlayButton: React.FC<Props> = ({
const handleNormalPlayFlow = useCallback(async () => { const handleNormalPlayFlow = useCallback(async () => {
if (!item) return; if (!item) return;
// SyncPlay intercept: in a group, route playback through sthe server so
// every member gets the same PlayQueue: NewPlaylist update and navigates
// together. Skips local navigation and the Chromecast prompt entirely —
// SyncPlay + Chromecast isn't a supported combination yet, same as
// jellyfin-web.
if (isSyncPlayEnabled && syncPlayController && item.Id) {
try {
// Pass the full `item` (not just the ID) so the SyncPlay controller
// can run `translateItemsForPlayback` with full context — this is
// what jellyfin-web does, and it lets us expand Series / Season /
// BoxSet into real episode/track IDs before broadcasting the queue.
// Without expansion, receivers (jellyfin-web in particular) get
// container IDs they can't play and silently fail to open the
// player.
await syncPlayController.play({
items: [item],
ids: [item.Id],
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
});
} 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;
}
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id!, itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "", audioIndex: selectedOptions.audioIndex?.toString() ?? "",
@@ -330,8 +290,6 @@ export const PlayButton: React.FC<Props> = ({
goToPlayer, goToPlayer,
isOffline, isOffline,
t, t,
isSyncPlayEnabled,
syncPlayController,
]); ]);
const onPress = useCallback(async () => { const onPress = useCallback(async () => {

View File

@@ -1,235 +0,0 @@
/**
* 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,
} = 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]);
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>
<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>
);
}

View File

@@ -1,97 +0,0 @@
/**
* 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>
</>
);
}

View File

@@ -1,165 +0,0 @@
/**
* SyncPlayIndicator
*
* Visual indicator shown during SyncPlay operations.
* Only appears when user's stream is ready but waiting for other group members.
*
* Key principle: SyncPlay indicator = "You're ready, waiting on others"
*/
import { Ionicons } from "@expo/vector-icons";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { Text } from "@/components/common/Text";
// SyncPlay cyan color (matches Jellyfin-web)
const SYNC_PLAY_COLOR = "#00a4dc";
interface SyncPlayIndicatorProps {
/**
* Whether the indicator should be visible.
* Should only be true when:
* 1. User's stream has loaded
* 2. Waiting for other group members
*/
visible: boolean;
/**
* Optional message to display
*/
message?: string;
}
export function SyncPlayIndicator({
visible,
message,
}: SyncPlayIndicatorProps) {
const { t } = useTranslation();
const displayMessage = message ?? t("syncplay.waiting_for_group");
const opacity = useSharedValue(0);
const scale = useSharedValue(1);
useEffect(() => {
if (visible) {
opacity.value = withTiming(1, { duration: 200 });
scale.value = withRepeat(
withTiming(1.15, {
duration: 800,
easing: Easing.inOut(Easing.ease),
}),
-1,
true,
);
} else {
opacity.value = withTiming(0, { duration: 200 });
scale.value = 1;
}
}, [visible, opacity, scale]);
const containerStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
const pulseStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
if (!visible) {
return null;
}
return (
<Animated.View style={[styles.container, containerStyle]}>
<View style={styles.content}>
{/* Pulsing icon container */}
<Animated.View style={[styles.iconContainer, pulseStyle]}>
<View style={styles.iconCircle}>
<Ionicons name='people' size={28} color='white' />
</View>
</Animated.View>
{/* Message */}
<Text style={styles.message}>{displayMessage}</Text>
{/* SyncPlay badge */}
<View style={styles.badge}>
<Ionicons name='sync' size={12} color='white' />
<Text style={styles.badgeText}>SyncPlay</Text>
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFill,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.7)",
zIndex: 100,
},
content: {
alignItems: "center",
},
iconContainer: {
marginBottom: 16,
},
iconCircle: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: SYNC_PLAY_COLOR,
justifyContent: "center",
alignItems: "center",
// Glow effect
shadowColor: SYNC_PLAY_COLOR,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 16,
elevation: 8,
},
message: {
color: "white",
fontSize: 16,
fontWeight: "500",
marginBottom: 8,
},
badge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0, 164, 220, 0.2)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: SYNC_PLAY_COLOR,
},
badgeText: {
color: SYNC_PLAY_COLOR,
fontSize: 12,
fontWeight: "600",
marginLeft: 4,
},
});
/**
* Hook-compatible version that reads SyncPlay state directly
*/
export function useSyncPlayIndicatorState(
isLocalReady: boolean,
isGroupWaiting: boolean,
): boolean {
// Show indicator only when:
// 1. User's local stream has loaded (isLocalReady)
// 2. Group is still waiting for others (isGroupWaiting)
return isLocalReady && isGroupWaiting;
}

View File

@@ -1,53 +0,0 @@
/**
* 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>
);
}

View File

@@ -1,10 +0,0 @@
/**
* SyncPlay UI Components
*/
export { GroupSelectionMenu } from "./GroupSelectionMenu";
export { SyncPlayButton } from "./SyncPlayButton";
export {
SyncPlayIndicator,
useSyncPlayIndicatorState,
} from "./SyncPlayIndicator";

View File

@@ -105,14 +105,14 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
maximumValue={max} maximumValue={max}
thumbWidth={0} thumbWidth={0}
onValueChange={handleValueChange} onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{ containerStyle={{
borderRadius: 50, borderRadius: 50,
}} }}
theme={{ theme={{
minimumTrackTintColor: "#FDFDFD", minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A", maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}} }}
/> />
<Ionicons <Ionicons

View File

@@ -88,14 +88,14 @@ const BrightnessSlider = () => {
maximumValue={max} maximumValue={max}
thumbWidth={0} thumbWidth={0}
onValueChange={handleValueChange} onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{ containerStyle={{
borderRadius: 50, borderRadius: 50,
}} }}
theme={{ theme={{
minimumTrackTintColor: "#FDFDFD", minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A", maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}} }}
/> />
<Ionicons <Ionicons

View File

@@ -4,16 +4,11 @@ import { Platform, 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 { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { SyncPlaySpinner } from "@/components/syncplay/SyncPlaySpinner";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider"; import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider"; import BrightnessSlider from "./BrightnessSlider";
import { ICON_SIZES } from "./constants"; import { ICON_SIZES } from "./constants";
// SyncPlay cyan color (matches Jellyfin-web)
const SYNC_PLAY_COLOR = "#00a4dc";
interface CenterControlsProps { interface CenterControlsProps {
showControls: boolean; showControls: boolean;
isPlaying: boolean; isPlaying: boolean;
@@ -49,18 +44,6 @@ export const CenterControls: FC<CenterControlsProps> = ({
const { settings } = useSettings(); const { settings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
// SyncPlay state from global provider
const {
isEnabled: isSyncPlayEnabled,
groupInfo,
pendingPlaybackCommand,
} = useSyncPlay();
const isSyncPlayWaiting = isSyncPlayEnabled && groupInfo?.State === "Waiting";
// Show the rotating SyncPlay icon ("schedule-play" in jellyfin-web) while a
// play/pause request is in flight to the server.
const isSyncPlayScheduling =
isSyncPlayEnabled && pendingPlaybackCommand !== null;
return ( return (
<View <View
style={{ style={{
@@ -138,17 +121,7 @@ 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}>
{isSyncPlayScheduling ? ( {!isBuffering ? (
// SyncPlay command in flight - rotating spinner ("schedule-play")
<SyncPlaySpinner size={ICON_SIZES.CENTER} />
) : isSyncPlayWaiting ? (
// SyncPlay waiting indicator - clock icon, still pressable to toggle
<Ionicons
name='time'
size={ICON_SIZES.CENTER}
color={SYNC_PLAY_COLOR}
/>
) : !isBuffering ? (
<Ionicons <Ionicons
name={isPlaying ? "pause" : "play"} name={isPlaying ? "pause" : "play"}
size={ICON_SIZES.CENTER} size={ICON_SIZES.CENTER}

View File

@@ -1,5 +1,4 @@
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { formatTimeString } from "@/utils/time"; import { formatTimeString } from "@/utils/time";
@@ -17,8 +16,6 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
currentTime, currentTime,
remainingTime, remainingTime,
}) => { }) => {
const { t } = useTranslation();
const getFinishTime = () => { const getFinishTime = () => {
const now = new Date(); const now = new Date();
// remainingTime is in ms // remainingTime is in ms
@@ -40,7 +37,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
-{formatTimeString(remainingTime, "ms")} -{formatTimeString(remainingTime, "ms")}
</Text> </Text>
<Text className='text-[10px] text-neutral-500 opacity-70'> <Text className='text-[10px] text-neutral-500 opacity-70'>
{t("player.ends_at", { time: getFinishTime() })} ends at {getFinishTime()}
</Text> </Text>
</View> </View>
</View> </View>

View File

@@ -1,7 +1,6 @@
{ {
"cli": { "cli": {
"version": ">= 9.1.0", "version": ">= 9.1.0"
"appVersionSource": "remote"
}, },
"build": { "build": {
"development": { "development": {
@@ -53,14 +52,14 @@
}, },
"production": { "production": {
"environment": "production", "environment": "production",
"autoIncrement": true, "channel": "0.54.0",
"android": { "android": {
"image": "latest" "image": "latest"
} }
}, },
"production-apk": { "production-apk": {
"environment": "production", "environment": "production",
"autoIncrement": true, "channel": "0.54.0",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"
@@ -68,7 +67,7 @@
}, },
"production-apk-tv": { "production-apk-tv": {
"environment": "production", "environment": "production",
"autoIncrement": true, "channel": "0.54.0",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"
@@ -79,7 +78,7 @@
}, },
"production_tv": { "production_tv": {
"environment": "production", "environment": "production",
"autoIncrement": true, "channel": "0.54.0",
"env": { "env": {
"EXPO_TV": "1" "EXPO_TV": "1"
}, },
@@ -89,17 +88,7 @@
} }
}, },
"submit": { "submit": {
"production": { "production": {},
"ios": { "production_tv": {}
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
}
},
"production_tv": {
"ios": {
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
}
}
} }
} }

View File

@@ -2,7 +2,6 @@ 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 {
@@ -81,9 +80,9 @@ export const useWebSocket = ({
playTrailers, playTrailers,
}: UseWebSocketProps) => { }: UseWebSocketProps) => {
const router = useRouter(); const router = useRouter();
const { lastMessage, clearLastMessage } = useWebSocketContext(); const { lastMessage } = useWebSocketContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { isEnabled: isSyncPlayEnabled } = useSyncPlay(); const { clearLastMessage } = useWebSocketContext();
useEffect(() => { useEffect(() => {
if (!lastMessage) return; if (!lastMessage) return;
@@ -97,25 +96,6 @@ 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();

View File

@@ -236,43 +236,37 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
/** /**
* Attach surface and ensure video output is active. * Attach surface and re-enable video output.
* * Based on Findroid's implementation.
* During PiP transitions, the surface is destroyed and recreated by Android.
* We keep the VO pipeline alive (not killed with vo=null) so that rendering
* resumes immediately when the new surface is attached — avoiding the black
* screen that occurs when the VO is fully re-initialized via setOptionString.
*/ */
fun attachSurface(surface: Surface) { fun attachSurface(surface: Surface) {
this.surface = surface this.surface = surface
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
if (isRunning) { if (isRunning) {
MPVLib.attachSurface(surface) MPVLib.attachSurface(surface)
// Re-enable video output after attaching surface (Findroid approach)
MPVLib.setOptionString("force-window", "yes") MPVLib.setOptionString("force-window", "yes")
// Read back vo to confirm it's still active MPVLib.setOptionString("vo", voDriver)
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null } Log.i(TAG, "Surface attached, video output re-enabled (vo=$voDriver)")
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
} }
} }
/** /**
* Detach surface without killing the VO pipeline. * Detach surface and disable video output.
* * Based on Findroid's implementation.
* The previous approach (vo=null / force-window=no) destroyed the entire video
* output pipeline on every surface transition. During PiP mode, the rapid
* destroy/recreate cycle caused a black screen because setOptionString("vo", ...)
* did not properly re-initialize rendering into the new PiP surface.
*
* By keeping the VO alive, frames are simply dropped while no surface is
* attached, and rendering resumes immediately when the new surface arrives.
*/ */
fun detachSurface() { fun detachSurface() {
this.surface = null this.surface = null
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
if (isRunning) { if (isRunning) {
try {
// Disable video output before detaching surface (Findroid approach)
MPVLib.setOptionString("vo", "null")
MPVLib.setOptionString("force-window", "no")
Log.i(TAG, "Video output disabled before surface detach")
} catch (e: Exception) {
Log.e(TAG, "Failed to disable video output: ${e.message}")
}
MPVLib.detachSurface() MPVLib.detachSurface()
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
} }
} }
@@ -283,24 +277,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun updateSurfaceSize(width: Int, height: Int) { fun updateSurfaceSize(width: Int, height: Int) {
if (isRunning) { if (isRunning) {
MPVLib.setPropertyString("android-surface-size", "${width}x$height") MPVLib.setPropertyString("android-surface-size", "${width}x$height")
Log.i(TAG, "[PiP] updateSurfaceSize ${width}x${height}") Log.i(TAG, "Surface size updated: ${width}x$height")
} else {
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
}
}
/**
* Force mpv to render a frame to the current surface.
* Steps forward one frame then seeks back to the original position.
* Used after PiP entry to work around mpv stopping pixel output.
*/
fun forceRedraw() {
if (!isRunning) return
val pos = cachedPosition
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
MPVLib.command(arrayOf("frame-step"))
if (pos > 0) {
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
} }
} }
@@ -715,7 +692,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// dropped), so we (re)apply here for embedded and external alike. // dropped), so we (re)apply here for embedded and external alike.
// This is what makes a carried-over subtitle show up on the next // This is what makes a carried-over subtitle show up on the next
// episode without a manual re-selection. // episode without a manual re-selection.
initialAudioId?.let { if (it > 0) setAudioTrack(it) } if (initialAudioId != null && initialAudioId > 0) {
setAudioTrack(initialAudioId)
}
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles() initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
if (!isReadyToSeek) { if (!isReadyToSeek) {

View File

@@ -198,7 +198,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")
} }
} }
} }

View File

@@ -2,15 +2,12 @@ package expo.modules.mpvplayer
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect import android.os.Build
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import android.view.TextureView import android.view.SurfaceHolder
import android.view.View import android.view.SurfaceView
import android.view.ViewGroup import android.widget.FrameLayout
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView import expo.modules.kotlin.views.ExpoView
@@ -31,27 +28,26 @@ data class VideoLoadConfig(
/** /**
* MpvPlayerView - ExpoView that hosts the MPV player. * MpvPlayerView - ExpoView that hosts the MPV player.
* Uses TextureView for reliable Picture-in-Picture support. * This mirrors the iOS MpvPlayerView implementation.
*/ */
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener { MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
companion object { companion object {
private const val TAG = "MpvPlayerView" private const val TAG = "MpvPlayerView"
} }
// Event dispatchers // Event dispatchers
val onLoad by EventDispatcher() val onLoad by EventDispatcher()
val onPlaybackStateChange by EventDispatcher() val onPlaybackStateChange by EventDispatcher()
val onProgress by EventDispatcher() val onProgress by EventDispatcher()
val onError by EventDispatcher() val onError by EventDispatcher()
val onTracksReady by EventDispatcher() val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher()
private var surfaceView: SurfaceView
private var textureView: TextureView
private var renderer: MPVLayerRenderer? = null private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null private var pipController: PiPController? = null
private var currentUrl: String? = null private var currentUrl: String? = null
private var cachedPosition: Double = 0.0 private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0 private var cachedDuration: Double = 0.0
@@ -60,29 +56,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var pendingConfig: VideoLoadConfig? = null private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null private var pendingSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = null
// PiP state tracking
private var isWaitingForPiPTransition: Boolean = false
private var isPiPSurfaceForced: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper())
init { init {
setBackgroundColor(Color.BLACK) setBackgroundColor(Color.BLACK)
// Create TextureView for video rendering (composites into app window for PiP support) // Create SurfaceView for video rendering
textureView = TextureView(context).apply { surfaceView = SurfaceView(context).apply {
layoutParams = ViewGroup.LayoutParams( layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT FrameLayout.LayoutParams.MATCH_PARENT
) )
surfaceTextureListener = this@MpvPlayerView holder.addCallback(this@MpvPlayerView)
} }
addView(textureView) addView(surfaceView)
// Initialize PiP controller with Expo's AppContext for proper activity access // Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext) pipController = PiPController(context, appContext)
pipController?.setPlayerView(textureView) pipController?.setPlayerView(surfaceView)
pipController?.delegate = object : PiPController.Delegate { pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() { override fun onPlay() {
play() play()
@@ -95,23 +85,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onSeekBy(seconds: Double) { override fun onSeekBy(seconds: Double) {
seekBy(seconds) seekBy(seconds)
} }
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
if (isInPiP) {
if (!isWaitingForPiPTransition) {
isWaitingForPiPTransition = true
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
} else {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
restoreFromPiP()
}
onPictureInPictureChange(mapOf("isActive" to isInPiP))
}
} }
// Renderer is created lazily in loadVideo once we have the voDriver setting // Renderer is created lazily in loadVideo once we have the voDriver setting
@@ -129,29 +102,32 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
try { try {
renderer?.start(voDriver ?: "gpu-next") renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true rendererStarted = true
Log.i(TAG, "Renderer started with vo=$voDriver")
// If surface was created before renderer started, attach it now
pendingSurface?.let { surface -> pendingSurface?.let { surface ->
renderer?.attachSurface(surface) renderer?.attachSurface(surface)
pendingSurface = null pendingSurface = null
Log.i(TAG, "Attached pending surface after renderer start")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}") Log.e(TAG, "Failed to start renderer: ${e.message}")
onError(mapOf("error" to "Failed to start renderer: ${e.message}")) onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
} }
} }
// MARK: - TextureView.SurfaceTextureListener // MARK: - SurfaceHolder.Callback
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) { override fun surfaceCreated(holder: SurfaceHolder) {
this.surfaceTexture = surfaceTexture Log.i(TAG, "Surface created")
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
surfaceReady = true surfaceReady = true
if (rendererStarted) { if (rendererStarted) {
renderer?.attachSurface(surface) renderer?.attachSurface(holder.surface)
} else { } else {
pendingSurface = surface // Renderer not started yet - store surface to attach after start
pendingSurface = holder.surface
Log.i(TAG, "Surface created before renderer started, storing as pending")
} }
// If we have a pending load, execute it now // If we have a pending load, execute it now
@@ -161,23 +137,19 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
pendingConfig = null pendingConfig = null
} }
} }
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) { override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
surfaceTexture.setDefaultBufferSize(width, height) Log.i(TAG, "Surface changed: ${width}x${height}")
// Update MPV with the new surface size (Findroid approach)
renderer?.updateSurfaceSize(width, height) renderer?.updateSurfaceSize(width, height)
} }
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { override fun surfaceDestroyed(holder: SurfaceHolder) {
this.surfaceTexture = null Log.i(TAG, "Surface destroyed")
surfaceReady = false surfaceReady = false
renderer?.detachSurface() renderer?.detachSurface()
return false // mpv manages the SurfaceTexture
} }
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
// Called every frame — no action needed, mpv drives rendering directly
}
// MARK: - Video Loading // MARK: - Video Loading
fun loadVideo(config: VideoLoadConfig) { fun loadVideo(config: VideoLoadConfig) {
@@ -197,10 +169,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
loadVideoInternal(config) loadVideoInternal(config)
} }
private fun loadVideoInternal(config: VideoLoadConfig) { private fun loadVideoInternal(config: VideoLoadConfig) {
currentUrl = config.url currentUrl = config.url
renderer?.load( renderer?.load(
url = config.url, url = config.url,
headers = config.headers, headers = config.headers,
@@ -209,173 +181,124 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
initialSubtitleId = config.initialSubtitleId, initialSubtitleId = config.initialSubtitleId,
initialAudioId = config.initialAudioId initialAudioId = config.initialAudioId
) )
if (config.autoplay) { if (config.autoplay) {
play() play()
} }
onLoad(mapOf("url" to config.url)) onLoad(mapOf("url" to config.url))
} }
// Convenience method for simple loads // Convenience method for simple loads
fun loadVideo(url: String, headers: Map<String, String>? = null) { fun loadVideo(url: String, headers: Map<String, String>? = null) {
loadVideo(VideoLoadConfig(url = url, headers = headers)) loadVideo(VideoLoadConfig(url = url, headers = headers))
} }
// MARK: - Playback Controls // MARK: - Playback Controls
fun play() { fun play() {
intendedPlayState = true intendedPlayState = true
renderer?.play() renderer?.play()
pipController?.setPlaybackRate(1.0) pipController?.setPlaybackRate(1.0)
} }
fun pause() { fun pause() {
intendedPlayState = false intendedPlayState = false
renderer?.pause() renderer?.pause()
pipController?.setPlaybackRate(0.0) pipController?.setPlaybackRate(0.0)
} }
fun seekTo(position: Double) { fun seekTo(position: Double) {
renderer?.seekTo(position) renderer?.seekTo(position)
} }
fun seekBy(offset: Double) { fun seekBy(offset: Double) {
renderer?.seekBy(offset) renderer?.seekBy(offset)
} }
fun setSpeed(speed: Double) { fun setSpeed(speed: Double) {
renderer?.setSpeed(speed) renderer?.setSpeed(speed)
} }
fun getSpeed(): Double { fun getSpeed(): Double {
return renderer?.getSpeed() ?: 1.0 return renderer?.getSpeed() ?: 1.0
} }
fun isPaused(): Boolean { fun isPaused(): Boolean {
return renderer?.isPausedState ?: true return renderer?.isPausedState ?: true
} }
fun getCurrentPosition(): Double { fun getCurrentPosition(): Double {
return cachedPosition return cachedPosition
} }
fun getDuration(): Double { fun getDuration(): Double {
return cachedDuration return cachedDuration
} }
// MARK: - Picture in Picture // MARK: - Picture in Picture
fun startPictureInPicture() { fun startPictureInPicture() {
isWaitingForPiPTransition = true Log.i(TAG, "startPictureInPicture called")
pipController?.startPictureInPicture() pipController?.startPictureInPicture()
// Resize buffer to match PiP window after animation settles
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
} }
/**
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
* visible rect so mpv renders at the PiP window's actual dimensions.
*/
private fun forcePiPBufferSize() {
if (!isWaitingForPiPTransition || !surfaceReady) return
val rect = Rect()
textureView.getGlobalVisibleRect(rect)
val visW = rect.width()
val visH = rect.height()
val vw = textureView.width
val vh = textureView.height
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
surfaceTexture?.setDefaultBufferSize(visW, visH)
renderer?.updateSurfaceSize(visW, visH)
// Force TextureView layout to match PiP visible area.
// layoutParams alone doesn't work during PiP because the parent
// never re-lays out its children.
textureView.measure(
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
)
textureView.layout(0, 0, visW, visH)
isPiPSurfaceForced = true
}
private fun restoreFromPiP() {
if (!isPiPSurfaceForced) return
isPiPSurfaceForced = false
val lp = textureView.layoutParams
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
textureView.layoutParams = lp
textureView.requestLayout()
}
fun stopPictureInPicture() { fun stopPictureInPicture() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
} }
fun isPictureInPictureSupported(): Boolean { fun isPictureInPictureSupported(): Boolean {
return pipController?.isPictureInPictureSupported() ?: false return pipController?.isPictureInPictureSupported() ?: false
} }
fun isPictureInPictureActive(): Boolean { fun isPictureInPictureActive(): Boolean {
return pipController?.isPictureInPictureActive() ?: false return pipController?.isPictureInPictureActive() ?: false
} }
// MARK: - Subtitle Controls // MARK: - Subtitle Controls
fun getSubtitleTracks(): List<Map<String, Any>> { fun getSubtitleTracks(): List<Map<String, Any>> {
return renderer?.getSubtitleTracks() ?: emptyList() return renderer?.getSubtitleTracks() ?: emptyList()
} }
fun setSubtitleTrack(trackId: Int) { fun setSubtitleTrack(trackId: Int) {
renderer?.setSubtitleTrack(trackId) renderer?.setSubtitleTrack(trackId)
} }
fun disableSubtitles() { fun disableSubtitles() {
renderer?.disableSubtitles() renderer?.disableSubtitles()
} }
fun getCurrentSubtitleTrack(): Int { fun getCurrentSubtitleTrack(): Int {
return renderer?.getCurrentSubtitleTrack() ?: 0 return renderer?.getCurrentSubtitleTrack() ?: 0
} }
fun addSubtitleFile(url: String, select: Boolean = true) { fun addSubtitleFile(url: String, select: Boolean = true) {
renderer?.addSubtitleFile(url, select) renderer?.addSubtitleFile(url, select)
} }
// MARK: - Subtitle Positioning // MARK: - Subtitle Positioning
fun setSubtitlePosition(position: Int) { fun setSubtitlePosition(position: Int) {
renderer?.setSubtitlePosition(position) renderer?.setSubtitlePosition(position)
} }
fun setSubtitleScale(scale: Double) { fun setSubtitleScale(scale: Double) {
renderer?.setSubtitleScale(scale) renderer?.setSubtitleScale(scale)
} }
fun setSubtitleMarginY(margin: Int) { fun setSubtitleMarginY(margin: Int) {
renderer?.setSubtitleMarginY(margin) renderer?.setSubtitleMarginY(margin)
} }
fun setSubtitleAlignX(alignment: String) { fun setSubtitleAlignX(alignment: String) {
renderer?.setSubtitleAlignX(alignment) renderer?.setSubtitleAlignX(alignment)
} }
fun setSubtitleAlignY(alignment: String) { fun setSubtitleAlignY(alignment: String) {
renderer?.setSubtitleAlignY(alignment) renderer?.setSubtitleAlignY(alignment)
} }
fun setSubtitleFontSize(size: Int) { fun setSubtitleFontSize(size: Int) {
renderer?.setSubtitleFontSize(size) renderer?.setSubtitleFontSize(size)
} }
@@ -393,15 +316,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
// MARK: - Audio Track Controls // MARK: - Audio Track Controls
fun getAudioTracks(): List<Map<String, Any>> { fun getAudioTracks(): List<Map<String, Any>> {
return renderer?.getAudioTracks() ?: emptyList() return renderer?.getAudioTracks() ?: emptyList()
} }
fun setAudioTrack(trackId: Int) { fun setAudioTrack(trackId: Int) {
renderer?.setAudioTrack(trackId) renderer?.setAudioTrack(trackId)
} }
fun getCurrentAudioTrack(): Int { fun getCurrentAudioTrack(): Int {
return renderer?.getCurrentAudioTrack() ?: 0 return renderer?.getCurrentAudioTrack() ?: 0
} }
@@ -426,16 +349,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
// MARK: - MPVLayerRenderer.Delegate // MARK: - MPVLayerRenderer.Delegate
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) { override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position cachedPosition = position
cachedDuration = duration cachedDuration = duration
// Update PiP progress // Update PiP progress
if (pipController?.isPictureInPictureActive() == true) { if (pipController?.isPictureInPictureActive() == true) {
pipController?.setCurrentTime(position, duration) pipController?.setCurrentTime(position, duration)
} }
onProgress(mapOf( onProgress(mapOf(
"position" to position, "position" to position,
"duration" to duration, "duration" to duration,
@@ -443,51 +366,50 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
"cacheSeconds" to cacheSeconds "cacheSeconds" to cacheSeconds
)) ))
} }
override fun onPauseChanged(isPaused: Boolean) { override fun onPauseChanged(isPaused: Boolean) {
// Sync PiP playback rate
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0) pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
onPlaybackStateChange(mapOf( onPlaybackStateChange(mapOf(
"isPaused" to isPaused, "isPaused" to isPaused,
"isPlaying" to !isPaused "isPlaying" to !isPaused
)) ))
} }
override fun onLoadingChanged(isLoading: Boolean) { override fun onLoadingChanged(isLoading: Boolean) {
onPlaybackStateChange(mapOf( onPlaybackStateChange(mapOf(
"isLoading" to isLoading "isLoading" to isLoading
)) ))
} }
override fun onReadyToSeek() { override fun onReadyToSeek() {
onPlaybackStateChange(mapOf( onPlaybackStateChange(mapOf(
"isReadyToSeek" to true "isReadyToSeek" to true
)) ))
} }
override fun onTracksReady() { override fun onTracksReady() {
onTracksReady(emptyMap<String, Any>()) onTracksReady(emptyMap<String, Any>())
} }
override fun onVideoDimensionsChanged(width: Int, height: Int) { override fun onVideoDimensionsChanged(width: Int, height: Int) {
// Update PiP controller with video dimensions for proper aspect ratio
pipController?.setVideoDimensions(width, height) pipController?.setVideoDimensions(width, height)
} }
override fun onError(message: String) { override fun onError(message: String) {
onError(mapOf("error" to message)) onError(mapOf("error" to message))
} }
// MARK: - Cleanup // MARK: - Cleanup
fun cleanup() { fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
renderer?.stop() renderer?.stop()
surfaceTexture = null surfaceView.holder.removeCallback(this)
surfaceReady = false
} }
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
super.onDetachedFromWindow() super.onDetachedFromWindow()
cleanup() cleanup()

View File

@@ -1,62 +1,51 @@
package expo.modules.mpvplayer package expo.modules.mpvplayer
import android.app.Activity import android.app.Activity
import android.app.Application
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.graphics.Rect import android.graphics.Rect
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.util.Rational import android.util.Rational
import android.view.View import android.view.View
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
/**
* Picture-in-Picture controller for Android.
* This mirrors the iOS PiPController implementation.
*/
class PiPController(private val context: Context, private val appContext: AppContext? = null) { class PiPController(private val context: Context, private val appContext: AppContext? = null) {
companion object { companion object {
private const val TAG = "PiPController" private const val TAG = "PiPController"
private const val DEFAULT_ASPECT_WIDTH = 16 private const val DEFAULT_ASPECT_WIDTH = 16
private const val DEFAULT_ASPECT_HEIGHT = 9 private const val DEFAULT_ASPECT_HEIGHT = 9
private const val ACTION_PIP_PLAY_PAUSE = "expo.modules.mpvplayer.PIP_PLAY_PAUSE"
private const val ACTION_PIP_SKIP_FORWARD = "expo.modules.mpvplayer.PIP_SKIP_FORWARD"
private const val ACTION_PIP_SKIP_BACKWARD = "expo.modules.mpvplayer.PIP_SKIP_BACKWARD"
} }
interface Delegate { interface Delegate {
fun onPlay() fun onPlay()
fun onPause() fun onPause()
fun onSeekBy(seconds: Double) fun onSeekBy(seconds: Double)
fun onPictureInPictureModeChanged(isInPiP: Boolean)
} }
var delegate: Delegate? = null var delegate: Delegate? = null
private var currentPosition: Double = 0.0 private var currentPosition: Double = 0.0
private var currentDuration: Double = 0.0 private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0 private var playbackRate: Double = 1.0
// Video dimensions for proper aspect ratio
private var videoWidth: Int = 0 private var videoWidth: Int = 0
private var videoHeight: Int = 0 private var videoHeight: Int = 0
// Reference to the player view for source rect
private var playerView: View? = null private var playerView: View? = null
// PiP state tracking /**
private var isInPiPMode: Boolean = false * Check if Picture-in-Picture is supported on this device
private var pipEntryNotified: Boolean = false */
private val pipHandler = Handler(Looper.getMainLooper())
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
private var lifecycleRegistered = false
private var pipBroadcastReceiver: BroadcastReceiver? = null
fun isPictureInPictureSupported(): Boolean { fun isPictureInPictureSupported(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
@@ -64,7 +53,10 @@ class PiPController(private val context: Context, private val appContext: AppCon
false false
} }
} }
/**
* Check if Picture-in-Picture is currently active
*/
fun isPictureInPictureActive(): Boolean { fun isPictureInPictureActive(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity()
@@ -72,69 +64,69 @@ class PiPController(private val context: Context, private val appContext: AppCon
} }
return false return false
} }
/**
* Start Picture-in-Picture mode
*/
fun startPictureInPicture() { fun startPictureInPicture() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
val activity = getActivity() ?: run { if (activity == null) {
Log.e(TAG, "Cannot start PiP: no activity") Log.e(TAG, "Cannot start PiP: no activity found")
return
}
if (!isPictureInPictureSupported()) {
Log.e(TAG, "PiP not supported on this device")
return
}
try {
val params = buildPiPParams(forEntering = true)
val result = activity.enterPictureInPictureMode(params)
if (!result) {
Log.e(TAG, "enterPictureInPictureMode rejected by system")
isInPiPMode = false
return return
} }
isInPiPMode = true if (!isPictureInPictureSupported()) {
pipEntryNotified = true Log.e(TAG, "PiP not supported on this device")
delegate?.onPictureInPictureModeChanged(true) return
registerLifecycleCallbacks() }
} catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP: ${e.message}") try {
val params = buildPiPParams(forEntering = true)
activity.enterPictureInPictureMode(params)
Log.i(TAG, "Entered PiP mode")
} catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP: ${e.message}")
}
} else {
Log.w(TAG, "PiP requires Android O or higher")
} }
} }
/**
* Stop Picture-in-Picture mode
*/
fun stopPictureInPicture() { fun stopPictureInPicture() {
isInPiPMode = false // On Android, exiting PiP is typically done by the user
pipEntryNotified = false // or by finishing the activity. We can request to move task to back.
unregisterLifecycleCallbacks()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) { if (activity?.isInPictureInPictureMode == true) {
// Move task to back which will exit PiP
activity.moveTaskToBack(false) activity.moveTaskToBack(false)
} }
} }
} }
fun isCurrentlyInPiP(): Boolean = isInPiPMode /**
* Update the current playback position and duration
* Note: We don't update PiP params here as we're not using progress in PiP controls
*/
fun setCurrentTime(position: Double, duration: Double) { fun setCurrentTime(position: Double, duration: Double) {
currentPosition = position currentPosition = position
currentDuration = duration currentDuration = duration
} }
/**
* Set the playback rate (0.0 for paused, 1.0 for playing)
*/
fun setPlaybackRate(rate: Double) { fun setPlaybackRate(rate: Double) {
playbackRate = rate playbackRate = rate
if (rate > 0) { // Update PiP params to reflect play/pause state
registerLifecycleCallbacks()
}
// Update PiP params so autoEnterEnabled and action icons track play/pause state
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity()
if (activity != null) { if (activity?.isInPictureInPictureMode == true) {
try { try {
activity.setPictureInPictureParams(buildPiPParams()) activity.setPictureInPictureParams(buildPiPParams())
} catch (e: Exception) { } catch (e: Exception) {
@@ -143,19 +135,28 @@ class PiPController(private val context: Context, private val appContext: AppCon
} }
} }
} }
/**
* Set the video dimensions for proper aspect ratio calculation
*/
fun setVideoDimensions(width: Int, height: Int) { fun setVideoDimensions(width: Int, height: Int) {
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
videoWidth = width videoWidth = width
videoHeight = height videoHeight = height
Log.i(TAG, "Video dimensions set: ${width}x${height}")
// Update PiP params if active
updatePiPParamsIfNeeded() updatePiPParamsIfNeeded()
} }
} }
/**
* Set the player view reference for source rect hint
*/
fun setPlayerView(view: View?) { fun setPlayerView(view: View?) {
playerView = view playerView = view
} }
private fun updatePiPParamsIfNeeded() { private fun updatePiPParamsIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity()
@@ -168,16 +169,23 @@ class PiPController(private val context: Context, private val appContext: AppCon
} }
} }
} }
/**
* Build Picture-in-Picture params for the current player state.
* Calculates proper aspect ratio and source rect based on video and view dimensions.
*/
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams { private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
val view = playerView val view = playerView
val viewWidth = view?.width ?: 0 val viewWidth = view?.width ?: 0
val viewHeight = view?.height ?: 0 val viewHeight = view?.height ?: 0
// Display aspect ratio from view (exactly like Findroid)
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1)) val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
// Video aspect ratio with 2.39:1 clamping // Video aspect ratio with 2.39:1 clamping (exactly like Findroid)
// Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()),
// it.height.coerceAtMost((it.width * 2.39f).toInt()))
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) { val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
Rational( Rational(
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()), videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
@@ -186,235 +194,70 @@ class PiPController(private val context: Context, private val appContext: AppCon
} else { } else {
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT) Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
} }
// Source rect hint calculation (exactly like Findroid)
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) { val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
if (displayAspectRatio < aspectRatio) { if (displayAspectRatio < aspectRatio) {
// Letterboxing - black bars top/bottom
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt() val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space) Rect(
0,
space,
viewWidth,
(viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space
)
} else { } else {
// Pillarboxing - black bars left/right
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt() val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight) Rect(
space,
0,
(viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space,
viewHeight
)
} }
} else { } else {
null null
} }
val builder = PictureInPictureParams.Builder() val builder = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio) .setAspectRatio(aspectRatio)
sourceRectHint?.let { builder.setSourceRectHint(it) } sourceRectHint?.let { builder.setSourceRectHint(it) }
ensurePiPReceiverRegistered() // On Android 12+, enable auto-enter (like Findroid)
builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(forEntering || playbackRate > 0) builder.setAutoEnterEnabled(true)
} }
return builder.build() return builder.build()
} }
private fun getActivity(): Activity? { private fun getActivity(): Activity? {
// First try Expo's AppContext (preferred in React Native)
appContext?.currentActivity?.let { return it } appContext?.currentActivity?.let { return it }
// Fallback: Try to get from context wrapper chain
var ctx = context var ctx = context
while (ctx is android.content.ContextWrapper) { while (ctx is android.content.ContextWrapper) {
if (ctx is Activity) return ctx if (ctx is Activity) {
return ctx
}
ctx = ctx.baseContext ctx = ctx.baseContext
} }
return null return null
} }
// MARK: - Lifecycle-based PiP Detection /**
* Handle PiP action (called from activity when user taps PiP controls)
private fun registerLifecycleCallbacks() { */
if (lifecycleRegistered) return fun handlePiPAction(action: String) {
when (action) {
val app = context.applicationContext as? Application ?: run { "play" -> delegate?.onPlay()
Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling") "pause" -> delegate?.onPause()
startFallbackPolling() "skip_forward" -> delegate?.onSeekBy(10.0)
return "skip_backward" -> delegate?.onSeekBy(-10.0)
} }
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {
if (!isInPiPMode) return
if (!activity.isInPictureInPictureMode) {
isInPiPMode = false
pipEntryNotified = false
delegate?.onPictureInPictureModeChanged(false)
}
}
override fun onActivityPaused(activity: Activity) {
// Proactively hide controls when user leaves while playing,
// before the PiP window captures the UI. onActivityStopped
// will restore if PiP didn't actually enter.
if (playbackRate > 0 && !isInPiPMode) {
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
}
}
override fun onActivityStopped(activity: Activity) {
pipHandler.postDelayed({
val inPip = activity.isInPictureInPictureMode
if (inPip && !isInPiPMode) {
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
return@postDelayed
}
if (!isInPiPMode) return@postDelayed
if (inPip) return@postDelayed
// Not in PiP after 1s — check again to avoid false positive during transition
pipHandler.postDelayed({
if (!isInPiPMode) return@postDelayed
if (!activity.isInPictureInPictureMode) {
isInPiPMode = false
pipEntryNotified = false
delegate?.onPictureInPictureModeChanged(false)
}
}, 1500)
}, 1000)
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {
isInPiPMode = false
}
}
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
lifecycleRegistered = true
}
private fun unregisterLifecycleCallbacks() {
if (!lifecycleRegistered) return
lifecycleCallbacks?.let {
(context.applicationContext as? Application)
?.unregisterActivityLifecycleCallbacks(it)
}
lifecycleCallbacks = null
lifecycleRegistered = false
pipHandler.removeCallbacksAndMessages(null)
unregisterPiPBroadcastReceiver()
}
private fun startFallbackPolling() {
var falseReadCount = 0
pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed(object : Runnable {
override fun run() {
if (!isInPiPMode) return
var ctx = context
var activity: Activity? = null
while (ctx is android.content.ContextWrapper) {
if (ctx is Activity) { activity = ctx; break }
ctx = ctx.baseContext
}
val stillInPip = activity?.isInPictureInPictureMode == true
if (!stillInPip) {
falseReadCount++
if (falseReadCount >= 3) {
isInPiPMode = false
delegate?.onPictureInPictureModeChanged(false)
return
}
pipHandler.postDelayed(this, 500)
return
}
falseReadCount = 0
pipHandler.postDelayed(this, 1000)
}
}, 3000)
}
// MARK: - PiP Remote Actions
private fun ensurePiPReceiverRegistered() {
if (pipBroadcastReceiver != null) return
pipBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_PIP_PLAY_PAUSE -> {
if (playbackRate > 0) delegate?.onPause() else delegate?.onPlay()
}
ACTION_PIP_SKIP_FORWARD -> delegate?.onSeekBy(10.0)
ACTION_PIP_SKIP_BACKWARD -> delegate?.onSeekBy(-10.0)
}
}
}
val filter = IntentFilter().apply {
addAction(ACTION_PIP_PLAY_PAUSE)
addAction(ACTION_PIP_SKIP_FORWARD)
addAction(ACTION_PIP_SKIP_BACKWARD)
}
val registerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_EXPORTED
} else {
0
}
context.applicationContext.registerReceiver(pipBroadcastReceiver, filter, registerFlags)
}
private fun unregisterPiPBroadcastReceiver() {
pipBroadcastReceiver?.let {
try {
context.applicationContext.unregisterReceiver(it)
} catch (_: Exception) {}
}
pipBroadcastReceiver = null
}
private fun buildPiPActions(): List<RemoteAction> {
val isPlaying = playbackRate > 0
return listOf(
RemoteAction(
Icon.createWithResource(context, android.R.drawable.ic_media_rew),
"Rewind", "Skip backward 10 seconds",
createPiPPendingIntent(ACTION_PIP_SKIP_BACKWARD)
),
RemoteAction(
Icon.createWithResource(
context,
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
),
if (isPlaying) "Pause" else "Play",
if (isPlaying) "Pause playback" else "Resume playback",
createPiPPendingIntent(ACTION_PIP_PLAY_PAUSE)
),
RemoteAction(
Icon.createWithResource(context, android.R.drawable.ic_media_ff),
"Fast Forward", "Skip forward 10 seconds",
createPiPPendingIntent(ACTION_PIP_SKIP_FORWARD)
)
)
}
private fun createPiPPendingIntent(action: String): android.app.PendingIntent {
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
android.app.PendingIntent.FLAG_IMMUTABLE
} else {
0
}
return android.app.PendingIntent.getBroadcast(
context.applicationContext, 0, Intent(action), flags
)
} }
} }

View File

@@ -1,19 +1,32 @@
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = 'MpvPlayer' s.name = 'MpvPlayer'
s.version = '1.0.0' s.version = '1.0.0'
s.summary = 'MPV-based video player for Streamyfin (Expo module)' s.summary = 'MPVKit for Expo'
s.author = 'Streamyfin' s.description = 'MPVKit for Expo'
s.homepage = 'https://github.com/streamyfin/streamyfin' s.author = 'mpvkit'
s.platforms = { :ios => '15.1', :tvos => '15.1' } s.homepage = 'https://github.com/mpvkit/MPVKit'
s.source = { git: '' } s.platforms = {
:ios => '15.1',
:tvos => '15.1'
}
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
s.static_framework = true s.static_framework = true
s.dependency 'ExpoModulesCore' s.dependency 'ExpoModulesCore'
s.dependency 'MPVKit' s.dependency 'MPVKit-GPL'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = { s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES', 'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule' 'VALID_ARCHS' => 'arm64',
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
'STRIP_INSTALLED_PRODUCT' => 'YES',
'DEPLOYMENT_POSTPROCESSING' => 'YES',
}
s.user_target_xcconfig = {
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
} }
s.source_files = "*.{h,m,mm,swift,hpp,cpp}" s.source_files = "*.{h,m,mm,swift,hpp,cpp}"

View File

@@ -76,7 +76,6 @@ class MpvPlayerView: ExpoView {
super.init(appContext: appContext) super.init(appContext: appContext)
setupNotifications() setupNotifications()
setupView() setupView()
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
} }
private func setupView() { private func setupView() {

View File

@@ -25,10 +25,6 @@ export type OnErrorEventPayload = {
export type OnTracksReadyEventPayload = Record<string, never>; export type OnTracksReadyEventPayload = Record<string, never>;
export type OnPictureInPictureChangePayload = {
isActive: boolean;
};
export type NowPlayingMetadata = { export type NowPlayingMetadata = {
title?: string; title?: string;
artist?: string; artist?: string;
@@ -81,9 +77,6 @@ export type MpvPlayerViewProps = {
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void; onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void; onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void; onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
onPictureInPictureChange?: (event: {
nativeEvent: OnPictureInPictureChangePayload;
}) => void;
}; };
export interface MpvPlayerViewRef { export interface MpvPlayerViewRef {

View File

@@ -7,8 +7,6 @@ import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> = const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
requireNativeView("MpvPlayer"); requireNativeView("MpvPlayer");
const PIP_LOG = "[PiP] MpvPlayerView.tsx:";
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>( export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
function MpvPlayerView(props, ref) { function MpvPlayerView(props, ref) {
const nativeRef = useRef<any>(null); const nativeRef = useRef<any>(null);
@@ -42,24 +40,16 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
return await nativeRef.current?.getDuration(); return await nativeRef.current?.getDuration();
}, },
startPictureInPicture: async () => { startPictureInPicture: async () => {
console.log(PIP_LOG, "startPictureInPicture → native");
await nativeRef.current?.startPictureInPicture(); await nativeRef.current?.startPictureInPicture();
console.log(PIP_LOG, "startPictureInPicture ← native returned");
}, },
stopPictureInPicture: async () => { stopPictureInPicture: async () => {
console.log(PIP_LOG, "stopPictureInPicture → native");
await nativeRef.current?.stopPictureInPicture(); await nativeRef.current?.stopPictureInPicture();
console.log(PIP_LOG, "stopPictureInPicture ← native returned");
}, },
isPictureInPictureSupported: async () => { isPictureInPictureSupported: async () => {
const result = await nativeRef.current?.isPictureInPictureSupported(); return await nativeRef.current?.isPictureInPictureSupported();
console.log(PIP_LOG, "isPictureInPictureSupported =", result);
return result;
}, },
isPictureInPictureActive: async () => { isPictureInPictureActive: async () => {
const result = await nativeRef.current?.isPictureInPictureActive(); return await nativeRef.current?.isPictureInPictureActive();
console.log(PIP_LOG, "isPictureInPictureActive =", result);
return result;
}, },
getSubtitleTracks: async () => { getSubtitleTracks: async () => {
return await nativeRef.current?.getSubtitleTracks(); return await nativeRef.current?.getSubtitleTracks();

View File

@@ -35,7 +35,6 @@
"@gorhom/bottom-sheet": "5.2.8", "@gorhom/bottom-sheet": "5.2.8",
"@jellyfin/sdk": "^0.13.0", "@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0", "@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.28",
"@react-navigation/native": "^7.2.5", "@react-navigation/native": "^7.2.5",
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.100.14", "@tanstack/query-sync-storage-persister": "^5.100.14",
@@ -100,12 +99,11 @@
"react-native-nitro-modules": "0.33.1", "react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "8.0.1", "react-native-pager-view": "8.0.1",
"react-native-qrcode-svg": "^6.3.21", "react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "4.3.1", "react-native-reanimated": "4.4.0",
"react-native-reanimated-carousel": "4.0.3", "react-native-reanimated-carousel": "4.0.3",
"react-native-safe-area-context": "~5.7.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2", "react-native-screens": "4.25.2",
"react-native-svg": "15.15.4", "react-native-svg": "15.15.4",
"react-native-tab-view": "4.3.0",
"react-native-text-ticker": "^1.15.0", "react-native-text-ticker": "^1.15.0",
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM", "react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
"react-native-udp": "^4.1.7", "react-native-udp": "^4.1.7",
@@ -164,5 +162,10 @@
}, },
"trustedDependencies": [ "trustedDependencies": [
"unrs-resolver" "unrs-resolver"
] ],
"patchedDependencies": {
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch"
}
} }

View File

@@ -39,28 +39,6 @@ function buildPatch() {
" end", " end",
" end", " end",
"", "",
" # iOS 26 / Xcode 26: the APP target itself compiles ExpoModulesProvider.swift,",
" # which imports SwiftUI-based modules (ExpoUI, ExpoGlassEffect, GlassPoster, ExpoBlur, …).",
" # That emits a `-framework SwiftUICore` autolink into the app executable's OWN object",
" # files, so the pods-only flag above is not enough — the app's link still fails with",
" # `cannot link directly with 'SwiftUICore'`. Drop the autolink on the user app target",
" # too. Phone-only — tvOS has no SwiftUICore split and must stay untouched.",
" if ENV['EXPO_TV'] != '1'",
" installer.aggregate_targets.each do |agg|",
" next unless agg.user_project",
" agg.user_project.native_targets.each do |target|",
" target.build_configurations.each do |cfg|",
" existing = cfg.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)'",
" existing = existing.join(' ') if existing.is_a?(Array)",
" unless existing.include?('-disable-autolink-framework -Xfrontend SwiftUICore')",
" cfg.build_settings['OTHER_SWIFT_FLAGS'] = existing + ' -Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore'",
" end",
" end",
" end",
" agg.user_project.save",
" end",
" end",
"",
" # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components", " # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components",
' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"', ' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"',
" if File.exist?(filepath)", " if File.exist?(filepath)",

View File

@@ -53,7 +53,7 @@ const initialApi = (() => {
const id = getOrSetDeviceId(); const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync(); const deviceName = getDeviceNameSync();
const jellyfinInstance = new Jellyfin({ const jellyfinInstance = new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.1" }, clientInfo: { name: "Streamyfin", version: "0.54.0" },
deviceInfo: { deviceInfo: {
name: deviceName, name: deviceName,
id, id,
@@ -128,7 +128,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const id = getOrSetDeviceId(); const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync(); const deviceName = getDeviceNameSync();
return new Jellyfin({ return new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.1" }, clientInfo: { name: "Streamyfin", version: "0.54.0" },
deviceInfo: { deviceInfo: {
name: deviceName, name: deviceName,
id, id,
@@ -162,7 +162,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return { return {
authorization: `MediaBrowser Client="Streamyfin", Device=${ authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS" Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.54.1"`, }, DeviceId="${deviceId}", Version="0.54.0"`,
}; };
}, [deviceId]); }, [deviceId]);

View File

@@ -1,402 +0,0 @@
/**
* SyncPlay Controller
*
* Exposes SyncPlay API calls to external modules.
* Provides methods for controlling synchronized playback.
*
* Based on jellyfin-web's Controller.js
*/
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import {
getItemsForPlayback,
msToTicks,
translateItemsForPlayback,
} from "./Helper";
import type { SyncPlayManager } from "./Manager";
import type { QueueCore } from "./QueueCore";
import type { GroupRepeatMode, GroupShuffleMode, PlayOptions } from "./types";
/**
* SyncPlay Controller - External API for controlling SyncPlay
*/
export class SyncPlayController {
private api: Api;
private manager: SyncPlayManager;
private queueCore: QueueCore;
constructor(api: Api, manager: SyncPlayManager, queueCore: QueueCore) {
this.api = api;
this.manager = manager;
this.queueCore = queueCore;
}
// ============================================================================
// Playback Control
// ============================================================================
/**
* Toggle play/pause
*/
playPause(): void {
// Use server group state (with pending in-flight command preferred) as
// the source of truth. The local player can lag the group by hundreds of
// ms while a scheduled command is pending, so reading `playerControls`
// here would cause rapid taps to send duplicate / wrong commands and
// desync other clients.
const state = this.manager.getEffectivePlayState();
console.log(`SyncPlay Controller: playPause - effectiveState=${state}`);
if (state === "Playing") {
console.log("SyncPlay Controller: requesting PAUSE");
this.pause();
} else {
console.log("SyncPlay Controller: requesting UNPAUSE");
this.unpause();
}
}
/**
* Request unpause (play)
*/
async unpause(): Promise<void> {
// Drop duplicate rapid taps while a previous request is still in flight
// (cleared when the server broadcasts back via SyncPlayCommand, or after
// a safety timeout).
if (this.manager.getPendingPlaybackCommand() === "Unpause") {
console.debug("SyncPlay Controller: unpause ignored — already pending");
return;
}
this.manager.markPendingPlaybackCommand("Unpause");
try {
console.log("SyncPlay Controller: sending syncPlayUnpause to server");
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayUnpause();
console.log("SyncPlay Controller: syncPlayUnpause sent successfully");
} catch (error) {
console.error("SyncPlay Controller: failed to unpause", error);
}
}
/**
* Request pause
*/
async pause(): Promise<void> {
if (this.manager.getPendingPlaybackCommand() === "Pause") {
console.debug("SyncPlay Controller: pause ignored — already pending");
return;
}
this.manager.markPendingPlaybackCommand("Pause");
try {
console.log("SyncPlay Controller: sending syncPlayPause to server");
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayPause();
console.log("SyncPlay Controller: syncPlayPause sent successfully");
// Also pause locally for immediate feedback
this.manager.getPlayerControls()?.pause();
} catch (error) {
console.error("SyncPlay Controller: failed to pause", error);
}
}
/**
* Request seek to position
*/
async seek(positionTicks: number): Promise<void> {
try {
console.log(
`SyncPlay Controller: sending syncPlaySeek to ${positionTicks} ticks`,
);
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySeek({
seekRequestDto: {
PositionTicks: positionTicks,
},
});
console.log("SyncPlay Controller: syncPlaySeek sent successfully");
// Also seek locally for immediate feedback
const positionMs = positionTicks / 10000;
this.manager.getPlayerControls()?.seekTo(positionMs);
} catch (error) {
console.error("SyncPlay Controller: failed to seek", error);
}
}
/**
* Request seek to position in milliseconds
*/
async seekMs(positionMs: number): Promise<void> {
console.log(`SyncPlay Controller: seekMs to ${positionMs}ms`);
await this.seek(msToTicks(positionMs));
}
/**
* Request stop
*/
async stop(): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayStop();
} catch (error) {
console.error("SyncPlay Controller: failed to stop", error);
}
}
// ============================================================================
// Queue Control
// ============================================================================
/**
* Start playback with a new SyncPlay group queue.
*
* Mirrors jellyfin-web's `Controller.play`:
*
* - If the caller passed full `items` objects, translate them directly
* (Series → episodes, BoxSet → children, etc.).
* - Otherwise fetch the items by ID first (`getItemsForPlayback`), then
* translate.
* - Send the translated, real playable IDs to
* `syncPlaySetNewQueue` so every group member receives a queue of
* playable items — not container IDs (Series / Season / BoxSet) that
* receivers like jellyfin-web silently drop.
*
* `startIndex` / `startPositionTicks` default to 0, same as jellyfin-web.
*/
async play(options: PlayOptions): Promise<void> {
const { items, ids, startIndex = 0, startPositionTicks = 0 } = options;
if ((!ids || ids.length === 0) && (!items || items.length === 0)) {
console.error("SyncPlay Controller: no items or ids to play");
return;
}
try {
// Step 1 — resolve to a list of BaseItemDto. Prefer the caller-supplied
// items (no extra round trip), fall back to a fetch by IDs.
const sourceItems: BaseItemDto[] =
items && items.length > 0
? items
: await getItemsForPlayback(this.api, ids ?? []);
if (!sourceItems.length) {
console.error(
"SyncPlay Controller: getItemsForPlayback returned no items",
);
return;
}
// Step 2 — expand Series / Season / BoxSet / Playlist / single-Episode
// into the real playable queue.
const translated = await translateItemsForPlayback(
this.api,
sourceItems,
{ ids, queryOptions: {} },
);
const queueIds = translated
.map((item) => item.Id)
.filter((id): id is string => !!id);
if (!queueIds.length) {
console.error(
"SyncPlay Controller: translateItemsForPlayback produced empty queue",
);
return;
}
console.log(
`SyncPlay Controller: sending syncPlaySetNewQueue (${queueIds.length} item${queueIds.length === 1 ? "" : "s"}, startIndex=${startIndex})`,
);
// Step 3 — broadcast the resolved queue to the group.
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetNewQueue({
playRequestDto: {
PlayingQueue: queueIds,
PlayingItemPosition: startIndex,
StartPositionTicks: startPositionTicks,
},
});
} catch (error) {
// Surface the server response body when available — a SetNewQueue
// that 4xx's silently is the most common "why didn't the other
// client start?" cause. Without the body we'd just see a generic
// axios error and have no way to tell whether it was a permission
// problem, an unknown item ID, or the server rejecting the queue.
const err = error as {
response?: { status?: number; data?: unknown };
message?: string;
};
console.error("SyncPlay Controller: failed to set new queue", {
status: err?.response?.status,
data: err?.response?.data,
message: err?.message,
});
}
}
/**
* Set current item in playlist
*/
async setCurrentPlaylistItem(playlistItemId: string): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetPlaylistItem({
setPlaylistItemRequestDto: {
PlaylistItemId: playlistItemId,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to set playlist item", error);
}
}
/**
* Play next item
*/
async nextItem(): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayNextItem({
nextItemRequestDto: {
PlaylistItemId:
this.queueCore.getCurrentPlaylistItemId() ?? undefined,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to play next", error);
}
}
/**
* Play previous item
*/
async previousItem(): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayPreviousItem({
previousItemRequestDto: {
PlaylistItemId:
this.queueCore.getCurrentPlaylistItemId() ?? undefined,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to play previous", error);
}
}
/**
* Add items to queue
*/
async queue(
itemIds: string[],
mode: "Queue" | "QueueNext" = "Queue",
): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayQueue({
queueRequestDto: {
ItemIds: itemIds,
Mode: mode,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to queue items", error);
}
}
/**
* Add items to play next
*/
async queueNext(itemIds: string[]): Promise<void> {
await this.queue(itemIds, "QueueNext");
}
/**
* Remove items from playlist
*/
async removeFromPlaylist(playlistItemIds: string[]): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayRemoveFromPlaylist({
removeFromPlaylistRequestDto: {
PlaylistItemIds: playlistItemIds,
},
});
} catch (error) {
console.error(
"SyncPlay Controller: failed to remove from playlist",
error,
);
}
}
/**
* Move item in playlist
*/
async movePlaylistItem(
playlistItemId: string,
newIndex: number,
): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayMovePlaylistItem({
movePlaylistItemRequestDto: {
PlaylistItemId: playlistItemId,
NewIndex: newIndex,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to move playlist item", error);
}
}
// ============================================================================
// Playback Settings
// ============================================================================
/**
* Set repeat mode
*/
async setRepeatMode(mode: GroupRepeatMode): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetRepeatMode({
setRepeatModeRequestDto: {
Mode: mode,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to set repeat mode", error);
}
}
/**
* Set shuffle mode
*/
async setShuffleMode(mode: GroupShuffleMode): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetShuffleMode({
setShuffleModeRequestDto: {
Mode: mode,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to set shuffle mode", error);
}
}
/**
* Toggle shuffle mode
*/
async toggleShuffleMode(): Promise<void> {
const currentMode = this.queueCore.getShuffleMode();
const newMode: GroupShuffleMode =
currentMode === "Sorted" ? "Shuffle" : "Sorted";
await this.setShuffleMode(newMode);
}
}

View File

@@ -1,448 +0,0 @@
/**
* SyncPlay Helper
*
* Utility functions for SyncPlay functionality.
* Based on jellyfin-web's Helper.js
*/
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";
import { TicksPerMillisecond } from "./types";
/**
* Wait for an event to be triggered, with optional timeout.
*/
export function waitForEvent<T>(
eventEmitter: {
addEventListener: (event: string, handler: (data: T) => void) => void;
removeEventListener: (event: string, handler: (data: T) => void) => void;
},
eventType: string,
timeout?: number,
rejectEvents?: string[],
): Promise<T> {
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
eventEmitter.removeEventListener(eventType, handler);
if (timeoutId) {
clearTimeout(timeoutId);
}
if (rejectEvents) {
for (const event of rejectEvents) {
eventEmitter.removeEventListener(event, rejectHandler);
}
}
};
const handler = (data: T) => {
cleanup();
resolve(data);
};
const rejectHandler = () => {
cleanup();
reject(new Error("Rejected by event"));
};
eventEmitter.addEventListener(eventType, handler);
if (rejectEvents) {
for (const event of rejectEvents) {
eventEmitter.addEventListener(event, rejectHandler);
}
}
if (timeout) {
timeoutId = setTimeout(() => {
cleanup();
reject(new Error("Timed out waiting for event"));
}, timeout);
}
});
}
/**
* Wait for a promise-based callback, with timeout.
*/
export function waitWithTimeout<T>(
promise: Promise<T>,
timeout: number,
): Promise<T> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error("Operation timed out"));
}, timeout);
promise
.then((result) => {
clearTimeout(timeoutId);
resolve(result);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
/**
* Convert ticks to milliseconds.
*/
export function ticksToMs(ticks: number): number {
return ticks / TicksPerMillisecond;
}
/**
* Convert milliseconds to ticks.
*/
export function msToTicks(ms: number): number {
return Math.round(ms * TicksPerMillisecond);
}
/**
* Convert a GUID string to standard format.
*/
export function stringToGuid(input: string): string {
return input.replace(
/([0-z]{8})([0-z]{4})([0-z]{4})([0-z]{4})([0-z]{12})/,
"$1-$2-$3-$4-$5",
);
}
/**
* Parse a date string to Date object.
*/
export function parseDate(dateString: string): Date {
return new Date(dateString);
}
/**
* Get current time as ISO string for API requests.
*/
export function nowAsIsoString(): string {
return new Date().toISOString();
}
/**
* Clamp a value between min and max.
*/
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* Simple event emitter for internal use.
*/
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(`Error in event handler for ${event}:`, error);
}
});
}
removeAllListeners(event?: string): void {
if (event) {
this.listeners.delete(event);
} else {
this.listeners.clear();
}
}
}
/**
* Wait for the next emission of an event on our internal {@link EventEmitter},
* or reject after `timeoutMs`. Auto-cleans the listener.
*/
export function waitForOwnEvent(
emitter: EventEmitter,
event: string,
timeoutMs = 5000,
): Promise<unknown[]> {
return new Promise((resolve, reject) => {
const handler = (...args: unknown[]) => {
clearTimeout(timer);
emitter.off(event, handler);
resolve(args);
};
const timer = setTimeout(() => {
emitter.off(event, handler);
reject(new Error(`Timed out waiting for "${event}"`));
}, timeoutMs);
emitter.on(event, handler);
});
}
// ============================================================================
// Item fetching / queue translation
//
// Faithful port of jellyfin-web's `getItemsForPlayback` and
// `translateItemsForPlayback` from `src/plugins/syncPlay/core/Helper.js`.
//
// Why this matters for SyncPlay:
// - The server takes the queue we send via `syncPlaySetNewQueue` and
// broadcasts it verbatim to every group member. If we send a Series /
// Season / BoxSet ID, every receiver tries to load that container as a
// playable item, which silently fails on jellyfin-web (it never opens
// the player). Sending an Episode ID without sibling expansion breaks
// next-episode auto-advance for everyone in the group.
// - jellyfin-web's `playbackManager.play` runs the same translation
// locally; SyncPlay's `Controller.play` runs it before the SetNewQueue
// request so the broadcast carries real playable item IDs.
// - We replicate the same translation here so a mobile sender produces
// the same broadcast a jellyfin-web sender would.
// ============================================================================
/** Options bag accepted by `translateItemsForPlayback`. */
export interface TranslateOptions {
ids?: string[];
shuffle?: boolean;
queryOptions?: Record<string, unknown>;
}
/** Fields jellyfin-web requests for any playback queue. */
const PLAYBACK_FIELDS = ["Chapters", "Trickplay"] as const;
/** Resolve the current user. Cached only for the duration of one call. */
async function getCurrentUser(api: Api) {
const user = (await getUserApi(api).getCurrentUser()).data;
if (!user?.Id) {
throw new Error("SyncPlay Helper: no authenticated user");
}
return user;
}
/**
* Generic `getItems` wrapper with the playback defaults jellyfin-web uses
* (`Chapters,Trickplay` fields, `ExcludeLocationTypes: Virtual`,
* `CollapseBoxSetItems: false`, `EnableTotalRecordCount: false`, `Limit: 300`).
*
* Callers pass camelCase params straight to the SDK — no PascalCase shim.
*/
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 ?? [];
}
/**
* Recursive "fetch children/tracks under X" — the shape MusicArtist /
* MusicGenre / Photo / PhotoAlbum / IsFolder all share.
*/
function fetchSiblings(
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.
* Mirrors jellyfin-web's `Helper.getItemsForPlayback`:
* - single ID → `getUserLibraryApi.getItem` (cheap)
* - multi ID → `getItemsApi.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.
*
* Mirrors jellyfin-web's `Helper.translateItemsForPlayback`:
* - Program → channel items
* - Playlist → playlist children
* - MusicArtist → artist tracks
* - MusicGenre → genre tracks
* - Photo / PhotoAlbum → sibling photos
* - IsFolder (Series, Season, BoxSet, MusicAlbum, ...) → recursive descendants
* - single Episode w/ `EnableNextEpisodeAutoPlay` → remaining series episodes
* - anything else → passthrough (Movies, Audio, single Episodes when autoplay off)
*
* Preserves the original `options.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];
const defaultSortBy = options.shuffle ? "Random" : "SortName";
// Program → channel's playable items. Doesn't need a user lookup.
if (firstItem.Type === "Program" && firstItem.ChannelId) {
return getItemsForPlayback(api, [firstItem.ChannelId]);
}
// Resolve the user once for every branch that follows. Saves 1-2 round
// trips vs. each helper resolving independently.
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.Type === "MusicArtist") {
return fetchSiblings(api, userId, {
artistIds: firstItem.Id ? [firstItem.Id] : undefined,
mediaTypes: ["Audio"],
sortBy: options.shuffle
? ["Random"]
: ["Album", "ParentIndexNumber", "IndexNumber", "SortName"],
});
}
if (firstItem.MediaType === "Photo") {
const siblings = await fetchSiblings(api, userId, {
parentId: firstItem.ParentId,
recursive: false,
mediaTypes: ["Photo", "Video"],
sortBy: [defaultSortBy],
});
// Re-anchor startIndex to the chosen photo, same as jellyfin-web.
// SyncPlay doesn't currently consume startIndex from queryOptions,
// but we keep parity for any future caller.
if (siblings.length && options.queryOptions) {
const idx = siblings.findIndex((i) => i.Id === firstItem.Id);
if (idx >= 0) options.queryOptions.startIndex = idx;
}
return siblings;
}
if (firstItem.Type === "PhotoAlbum") {
return fetchSiblings(api, userId, {
parentId: firstItem.Id,
recursive: false,
mediaTypes: ["Photo", "Video"],
sortBy: [defaultSortBy],
limit: 1000,
});
}
if (firstItem.Type === "MusicGenre") {
return fetchSiblings(api, userId, {
genreIds: firstItem.Id ? [firstItem.Id] : undefined,
mediaTypes: ["Audio"],
sortBy: [defaultSortBy],
});
}
if (firstItem.IsFolder) {
// Series, Season, BoxSet, MusicAlbum, etc. jellyfin-web only sets
// SortBy for shuffle or BoxSet — everything else inherits server-side
// sort order (typically index/premiere date).
const sortBy = options.shuffle
? ["Random"]
: firstItem.Type === "BoxSet"
? ["SortName"]
: undefined;
return fetchSiblings(api, userId, {
parentId: firstItem.Id,
mediaTypes: ["Audio", "Video"],
sortBy,
});
}
if (firstItem.Type === "Episode" && workingItems.length === 1) {
// Single-episode auto-next: drop everything before this episode so
// playback starts here and auto-advances through the rest of the
// series. Gated on the user's `EnableNextEpisodeAutoPlay` like
// jellyfin-web does.
if (!user.Configuration?.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) {
return workingItems;
}
try {
const res = await getTvShowsApi(api).getEpisodes({
seriesId: firstItem.SeriesId,
userId,
isMissing: false,
fields: PLAYBACK_FIELDS as unknown as never,
});
const all = res.data.Items ?? [];
const foundIdx = Math.max(
0,
all.findIndex((e) => e.Id === firstItem.Id),
);
return all.slice(foundIdx);
} catch (error) {
// Don't block playback on a translation failure — fall back to the
// single-item queue the caller already supplied.
console.warn(
"SyncPlay Helper: Episode translation failed, falling back to single item",
error,
);
return workingItems;
}
}
// Everything else (Movie, Audio, ...) plays as-is.
return workingItems;
}

View File

@@ -1,797 +0,0 @@
/**
* SyncPlay Manager
*
* Main orchestrator for SyncPlay functionality.
* Manages group state, coordinates time sync, playback, and queue.
*
* Based on jellyfin-web's Manager.js
*/
import type { Api } from "@jellyfin/sdk";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { toast } from "sonner-native";
import i18n from "@/i18n";
import { EventEmitter, msToTicks } from "./Helper";
import { TimeSyncCore } from "./TimeSyncCore";
import type {
GroupInfoDto,
PlayerControls,
PlayQueueUpdate,
SendCommand,
SyncPlayStats,
} from "./types";
/**
* SyncPlay Manager - Main orchestrator
*/
export class SyncPlayManager extends EventEmitter {
private api: Api;
private timeSyncCore: TimeSyncCore;
// Group state
private groupInfo: GroupInfoDto | null = null;
private syncPlayEnabledAt: Date | null = null;
private syncPlayReady = false;
private queuedCommand: SendCommand | null = null;
private followingGroupPlayback = true;
private lastPlaybackCommand: SendCommand | null = null;
// Pending play/pause request awaiting server broadcast.
// Used to (1) ignore duplicate rapid taps and (2) treat the server's
// upcoming state as the source of truth while a request is in flight.
private pendingPlaybackCommand: "Unpause" | "Pause" | null = null;
private pendingPlaybackTimeout: ReturnType<typeof setTimeout> | null = null;
// Safety: drop the pending guard after this long if no broadcast arrives.
private static readonly PENDING_PLAYBACK_TIMEOUT_MS = 1500;
// Player state
private playerControls: PlayerControls | null = null;
private syncMethod = "None";
// Callbacks
private onPlaybackCoreCommand: ((command: SendCommand) => void) | null = null;
private onQueueUpdate: ((update: PlayQueueUpdate) => void) | null = null;
private onGetPlaylistItemId: (() => string | null) | null = null;
// Fired when SyncPlay is disabled — PlaybackCore wires up here to reset its
// own scheduled timers / cached command so we don't carry stale state into
// the next group.
private onDisable: (() => void) | null = null;
// Fired when SyncPlay is disabled — QueueCore wires up here to clear its
// last PlayQueue snapshot. Without this, re-joining the same group later
// causes the first PlayQueue echo (which can have a `LastUpdate` equal to
// or older than the snapshot we cached before the disable) to be dropped
// by the stale-update guard in `QueueCore.updatePlayQueue`.
private onQueueClear: (() => void) | null = null;
constructor(api: Api) {
super();
this.api = api;
this.timeSyncCore = new TimeSyncCore(api);
// Listen for time sync updates
this.timeSyncCore.onUpdate((error, timeOffset, ping) => {
if (error) {
console.debug("SyncPlay Manager: time sync error", error);
return;
}
this.emit("time-sync-update", timeOffset, ping);
// Report ping to server when enabled
if (this.isSyncPlayEnabled() && ping !== null) {
this.sendPing(ping);
}
});
}
/**
* Initialize the manager
*/
init(): void {
this.timeSyncCore.startPing();
}
/**
* Update the API client
*/
updateApiClient(api: Api): void {
this.api = api;
}
/**
* Get the API client
*/
getApiClient(): Api {
return this.api;
}
/**
* Get the time sync core
*/
getTimeSyncCore(): TimeSyncCore {
return this.timeSyncCore;
}
/**
* Set player controls for playback management
*/
setPlayerControls(controls: PlayerControls | null): void {
this.playerControls = controls;
// When player controls are connected and SyncPlay is active, sync to group state
if (controls && this.isSyncPlayEnabled() && this.syncPlayReady) {
const state = this.groupInfo?.State;
console.log(
`SyncPlay: player controls connected, group state is ${state}`,
);
// CRITICAL: Tell server we're following group playback
// This ensures the server sends us SyncPlayCommand messages
this.followGroupPlayback();
// Reconcile position: if we know the last command and group is playing,
// estimate where the group is *now* and seek there before resuming. This
// fixes the case where the player attaches mid-stream and would
// otherwise resume from 0 or the last-known local position.
const last = this.lastPlaybackCommand;
if (
last &&
(last.Command === "Unpause" || last.Command === "Pause") &&
last.When &&
last.PositionTicks != null
) {
try {
const commandWhen = new Date(last.When);
let targetTicks = last.PositionTicks;
if (last.Command === "Unpause") {
const remoteNow = this.timeSyncCore.localDateToRemote(new Date());
targetTicks +=
(remoteNow.getTime() - commandWhen.getTime()) * 10000;
}
const targetMs = Math.max(0, targetTicks / 10000);
const currentMs = controls.getCurrentPosition();
if (Math.abs(currentMs - targetMs) > 500) {
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,
);
}
}
if (state === "Playing" && !controls.isPlaying()) {
console.log("SyncPlay: starting playback to match group");
controls.play();
} else if (state === "Paused" && controls.isPlaying()) {
console.log("SyncPlay: pausing to match group");
controls.pause();
}
}
}
/**
* Get current player controls
*/
getPlayerControls(): PlayerControls | null {
return this.playerControls;
}
/**
* Set callback for playback commands
*/
setPlaybackCommandHandler(
handler: ((command: SendCommand) => void) | null,
): void {
this.onPlaybackCoreCommand = handler;
}
/**
* Set callback for queue updates
*/
setQueueUpdateHandler(
handler: ((update: PlayQueueUpdate) => void) | null,
): void {
this.onQueueUpdate = handler;
}
/**
* Set callback for getting current playlist item ID
*/
setPlaylistItemIdGetter(getter: (() => string | null) | null): void {
this.onGetPlaylistItemId = getter;
}
/**
* Set a callback invoked when SyncPlay is disabled. PlaybackCore registers
* here so it can flush scheduled commands and stale state.
*/
setDisableHandler(handler: (() => void) | null): void {
this.onDisable = handler;
}
/**
* Set a callback invoked when SyncPlay is disabled. QueueCore registers
* here so it can drop the cached PlayQueue snapshot and treat the next
* server update as fresh.
*/
setQueueClearHandler(handler: (() => void) | null): void {
this.onQueueClear = handler;
}
// ============================================================================
// Group Management
// ============================================================================
/**
* Check if SyncPlay is enabled (user is in a group)
*/
isSyncPlayEnabled(): boolean {
return this.syncPlayEnabledAt !== null;
}
/**
* Check if SyncPlay is ready (time sync complete)
*/
isSyncPlayReady(): boolean {
return this.syncPlayReady;
}
/**
* Get current group info
*/
getGroupInfo(): GroupInfoDto | null {
return this.groupInfo;
}
/**
* Get the last playback command
*/
getLastPlaybackCommand(): SendCommand | null {
return this.lastPlaybackCommand;
}
/**
* Check if currently playing
*/
isPlaying(): boolean {
// First check actual player state
if (this.playerControls) {
return this.playerControls.isPlaying();
}
// Fall back to group state
if (this.groupInfo?.State) {
return this.groupInfo.State === "Playing";
}
// Last resort: check last command
return this.lastPlaybackCommand?.Command === "Unpause";
}
/**
* Effective play state for SyncPlay routing decisions.
*
* Prefers (1) a pending in-flight command we just sent, (2) the server's
* group state, and only falls back to the local player. This avoids the
* race where a rapid second tap reads the local player (which hasn't
* applied the scheduled command yet) and sends a duplicate request that
* either re-broadcasts with a new `When` or flips the group the wrong way.
*/
getEffectivePlayState(): "Playing" | "Paused" {
if (this.pendingPlaybackCommand === "Unpause") return "Playing";
if (this.pendingPlaybackCommand === "Pause") return "Paused";
if (this.groupInfo?.State === "Playing") return "Playing";
if (this.groupInfo?.State === "Paused") return "Paused";
return this.playerControls?.isPlaying() ? "Playing" : "Paused";
}
/**
* Returns the in-flight play/pause request, if any.
*/
getPendingPlaybackCommand(): "Unpause" | "Pause" | null {
return this.pendingPlaybackCommand;
}
/**
* Mark a play/pause request as in flight. Auto-clears on a safety timeout
* in case the server broadcast is missed.
*/
markPendingPlaybackCommand(command: "Unpause" | "Pause"): void {
this.pendingPlaybackCommand = command;
if (this.pendingPlaybackTimeout) {
clearTimeout(this.pendingPlaybackTimeout);
}
this.pendingPlaybackTimeout = setTimeout(() => {
console.debug(
"SyncPlay Manager: pending playback command timed out",
command,
);
this.pendingPlaybackCommand = null;
this.pendingPlaybackTimeout = null;
this.emit("pending-playback-change", null);
}, SyncPlayManager.PENDING_PLAYBACK_TIMEOUT_MS);
this.emit("pending-playback-change", command);
}
private clearPendingPlaybackCommand(): void {
if (this.pendingPlaybackTimeout) {
clearTimeout(this.pendingPlaybackTimeout);
this.pendingPlaybackTimeout = null;
}
if (this.pendingPlaybackCommand !== null) {
this.pendingPlaybackCommand = null;
this.emit("pending-playback-change", null);
}
}
/**
* Check if following group playback
*/
isFollowingGroupPlayback(): boolean {
return this.followingGroupPlayback;
}
/**
* Enable SyncPlay (join a group)
*/
enableSyncPlay(groupInfo: GroupInfoDto, showMessage = false): void {
if (this.isSyncPlayEnabled()) {
if (groupInfo.GroupId === this.groupInfo?.GroupId) {
console.debug(
`SyncPlay: group ${this.groupInfo?.GroupId} already joined.`,
);
return;
}
console.warn(
`SyncPlay: switching from group ${this.groupInfo?.GroupId} to ${groupInfo.GroupId}`,
);
this.disableSyncPlay(false);
}
this.groupInfo = groupInfo;
this.syncPlayEnabledAt = groupInfo.LastUpdatedAt
? new Date(groupInfo.LastUpdatedAt)
: new Date();
this.followingGroupPlayback = true;
this.syncPlayReady = false;
console.log(`SyncPlay: enableSyncPlay - group state is ${groupInfo.State}`);
this.emit("enabled", true);
// Wait for time sync to be ready
const checkReady = () => {
if (this.timeSyncCore.isReady()) {
this.syncPlayReady = true;
// CRITICAL: Tell server we're following group playback
// This ensures the server sends us SyncPlayCommand messages
this.followGroupPlayback();
if (this.queuedCommand) {
this.processCommand(this.queuedCommand);
this.queuedCommand = null;
}
// Act on initial group state if player is connected
if (this.playerControls && groupInfo.State) {
console.log(`SyncPlay: applying initial state ${groupInfo.State}`);
if (groupInfo.State === "Playing") {
this.playerControls.play();
} else if (groupInfo.State === "Paused") {
this.playerControls.pause();
}
}
} else {
setTimeout(checkReady, 100);
}
};
this.timeSyncCore.forceUpdate();
checkReady();
if (showMessage) {
toast(i18n.t("syncplay.enabled"));
}
}
/**
* Disable SyncPlay (leave group)
*/
disableSyncPlay(showMessage = false): void {
this.syncPlayEnabledAt = null;
this.syncPlayReady = false;
this.followingGroupPlayback = true;
this.lastPlaybackCommand = null;
this.queuedCommand = null;
this.groupInfo = null;
this.clearPendingPlaybackCommand();
// Tell PlaybackCore (or whoever subscribed) to flush any scheduled
// commands / cached state so a future re-enable starts clean.
try {
this.onDisable?.();
} catch (error) {
console.warn("SyncPlay: onDisable handler threw", error);
}
// Drop the cached PlayQueue snapshot so a future re-join doesn't get
// its first PlayQueue update silently dropped as "older than what we
// already have".
try {
this.onQueueClear?.();
} catch (error) {
console.warn("SyncPlay: onQueueClear handler threw", error);
}
this.emit("enabled", false);
if (showMessage) {
toast(i18n.t("syncplay.disabled"));
}
}
// ============================================================================
// Server Communication
// ============================================================================
/**
* Send ping to server
*/
private async sendPing(ping: number): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayPing({
pingRequestDto: { Ping: Math.round(ping) },
});
} catch (error) {
console.debug("SyncPlay: failed to send ping", error);
}
}
/**
* Report that we're ready (not buffering)
*/
async reportReady(): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
const now = new Date();
const currentPosition = this.playerControls?.getCurrentPosition() ?? 0;
const currentPositionTicks = msToTicks(currentPosition);
console.log(
"SyncPlay Manager: reporting READY at position",
currentPositionTicks,
);
await syncPlayApi.syncPlayReady({
readyRequestDto: {
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: this.playerControls?.isPlaying() ?? false,
PlaylistItemId:
this.onGetPlaylistItemId?.() ??
"00000000-0000-0000-0000-000000000000",
},
});
console.log("SyncPlay Manager: READY sent successfully");
} catch (error) {
console.error("SyncPlay Manager: failed to report ready", error);
}
}
/**
* Follow group playback
*/
async followGroupPlayback(): Promise<void> {
this.followingGroupPlayback = true;
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetIgnoreWait({
ignoreWaitRequestDto: { IgnoreWait: false },
});
} catch (error) {
console.error("SyncPlay: failed to follow group playback", error);
}
}
/**
* Halt group playback (stop following)
*/
async haltGroupPlayback(): Promise<void> {
this.followingGroupPlayback = false;
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetIgnoreWait({
ignoreWaitRequestDto: { IgnoreWait: true },
});
// Stop local playback
this.playerControls?.pause();
} catch (error) {
console.error("SyncPlay: failed to halt group playback", error);
}
}
// ============================================================================
// Message Processing
// ============================================================================
/**
* Process a group update from the server
* Uses generic type to handle all possible update types from server
*/
processGroupUpdate(update: { Type?: string; Data?: unknown }): void {
const { Type, Data } = update;
switch (Type) {
case "PlayQueue": {
const playQueueData = Data as PlayQueueUpdate;
console.log(
"SyncPlay: received PlayQueue update - position:",
playQueueData.StartPositionTicks,
"reason:",
playQueueData.Reason,
);
this.onQueueUpdate?.(playQueueData);
break;
}
case "UserJoined":
toast(i18n.t("syncplay.user_joined", { username: Data }));
if (this.groupInfo) {
if (!this.groupInfo.Participants) {
this.groupInfo.Participants = [Data as string];
} else {
this.groupInfo.Participants.push(Data as string);
}
}
break;
case "UserLeft":
toast(i18n.t("syncplay.user_left", { username: Data }));
if (this.groupInfo?.Participants) {
this.groupInfo.Participants = this.groupInfo.Participants.filter(
(user: string) => user !== Data,
);
}
break;
case "GroupJoined": {
const groupData = Data as GroupInfoDto;
this.enableSyncPlay(groupData, true);
break;
}
case "SyncPlayIsDisabled":
toast(i18n.t("syncplay.permission_required"));
break;
case "NotInGroup":
case "GroupLeft":
this.disableSyncPlay(true);
break;
case "GroupUpdate": {
const updatedData = Data as GroupInfoDto;
this.groupInfo = updatedData;
this.emit("group-info-change", updatedData);
break;
}
case "StateUpdate": {
// Log full state data to see if position is included
console.log("SyncPlay: StateUpdate full data:", JSON.stringify(Data));
const stateData = Data as {
State: string;
Reason: string;
PositionTicks?: number;
};
// CRITICAL: Update the stored group state so subsequent checks use the correct value
if (this.groupInfo) {
this.groupInfo.State = stateData.State as any;
// Emit a fresh object so React state subscribers re-render —
// mutating in place would not trigger re-renders.
this.emit("group-info-change", { ...this.groupInfo });
}
this.emit("group-state-update", stateData.State, stateData.Reason);
console.log(
`SyncPlay: state changed to ${stateData.State} because ${stateData.Reason}`,
);
// Handle seek from StateUpdate if position is included
if (stateData.Reason === "Seek" && stateData.PositionTicks != null) {
console.log(
"SyncPlay: StateUpdate contains seek position:",
stateData.PositionTicks,
);
this.emit("seek-from-state-update", stateData.PositionTicks);
}
// Use StateUpdate as a fallback to control playback when SyncPlayCommand isn't received
// This ensures we stay in sync even if the server doesn't send commands
if (this.playerControls) {
const currentlyPlaying = this.playerControls.isPlaying();
console.log(
`SyncPlay: StateUpdate handler - state=${stateData.State}, currentlyPlaying=${currentlyPlaying}`,
);
if (stateData.State === "Paused" && currentlyPlaying) {
console.log("SyncPlay: StateUpdate -> PAUSING player");
this.playerControls.pause();
} else if (stateData.State === "Playing" && !currentlyPlaying) {
console.log("SyncPlay: StateUpdate -> PLAYING");
this.playerControls.play();
} else if (stateData.State === "Waiting") {
console.log("SyncPlay: StateUpdate -> Waiting for other members");
// Pause player when waiting
if (currentlyPlaying) {
this.playerControls.pause();
}
// Emit event so PlaybackCore can report ready
this.emit("waiting-for-ready");
}
} else {
console.warn("SyncPlay: StateUpdate but no playerControls!");
}
break;
}
case "GroupDoesNotExist":
toast(i18n.t("syncplay.group_does_not_exist"));
break;
case "CreateGroupDenied":
toast(i18n.t("syncplay.create_denied"));
break;
case "JoinGroupDenied":
toast(i18n.t("syncplay.join_denied"));
break;
case "LibraryAccessDenied":
toast(i18n.t("syncplay.library_access_denied"));
break;
default:
console.warn(`SyncPlay: unrecognized group update type: ${Type}`);
}
}
/**
* Process a playback command from the server
*/
processCommand(command: SendCommand): void {
console.log(`SyncPlay Manager: processCommand called - ${command.Command}`);
if (!this.isSyncPlayEnabled()) {
console.warn(
"SyncPlay Manager: not enabled, ignoring command",
command.Command,
);
return;
}
const emittedAt = command.EmittedAt ? new Date(command.EmittedAt) : null;
if (this.syncPlayEnabledAt && emittedAt) {
if (emittedAt.getTime() < this.syncPlayEnabledAt.getTime()) {
console.debug("SyncPlay Manager: ignoring old command", command);
return;
}
}
// Reject commands targeted at a different playlist item than the one we
// currently have loaded. Stop is always honored (it may be a teardown
// before a queue swap). This prevents (e.g.) seeking the wrong episode
// when a queue change is racing a command.
if (command.Command !== "Stop" && command.PlaylistItemId) {
const currentItemId = this.onGetPlaylistItemId?.();
if (currentItemId && currentItemId !== command.PlaylistItemId) {
console.debug(
`SyncPlay Manager: ignoring command for playlist item ${command.PlaylistItemId} (current is ${currentItemId})`,
);
return;
}
}
if (!this.syncPlayReady) {
console.log(
"SyncPlay Manager: not ready, queuing command",
command.Command,
);
this.queuedCommand = command;
return;
}
// Remember the command even if we can't act on it yet. When the player
// attaches (setPlayerControls), the reconcile-on-attach path uses
// `lastPlaybackCommand` to seek to the estimated group position and
// resume/pause to match the group. Without this assignment, a command
// that arrives during the join→navigate→load window is lost.
this.lastPlaybackCommand = command;
// Clear pending guard once the matching broadcast arrives. We treat any
// Unpause/Pause arrival as satisfying the pending request (the server
// may coalesce or override our intent — either way we trust its decision).
if (command.Command === "Unpause" || command.Command === "Pause") {
this.clearPendingPlaybackCommand();
}
if (!this.playerControls) {
// Expected when a command arrives between joining the group and the
// player finishing its initial load. The reconciliation in
// setPlayerControls will replay this command from `lastPlaybackCommand`
// once controls attach.
console.debug(
`SyncPlay Manager: ${command.Command} stored for replay (player not attached yet)`,
);
return;
}
console.log(
`SyncPlay Manager: delegating ${command.Command} to playback core`,
);
// Delegate to playback handler
if (this.onPlaybackCoreCommand) {
this.onPlaybackCoreCommand(command);
} else {
console.error("SyncPlay Manager: no playback command handler set!");
}
}
// ============================================================================
// Stats
// ============================================================================
/**
* Get SyncPlay stats for display
*/
getStats(): SyncPlayStats {
return {
timeSyncDevice: this.timeSyncCore.getActiveDeviceName(),
timeSyncOffset: this.timeSyncCore.getTimeOffset().toFixed(2),
playbackDiff: "0.00",
syncMethod: this.syncMethod,
};
}
/**
* Show sync icon
*/
showSyncIcon(method: string): void {
this.syncMethod = method;
this.emit("syncing", true, method);
}
/**
* Clear sync icon
*/
clearSyncIcon(): void {
this.syncMethod = "None";
this.emit("syncing", false, "None");
}
// ============================================================================
// Cleanup
// ============================================================================
/**
* Destroy the manager
*/
destroy(): void {
this.timeSyncCore.destroy();
this.disableSyncPlay(false);
this.removeAllListeners();
this.playerControls = null;
this.onPlaybackCoreCommand = null;
this.onQueueUpdate = null;
}
}

View File

@@ -1,881 +0,0 @@
/**
* PlaybackCore
*
* Manages synchronized playback for SyncPlay.
* Handles scheduling commands at precise times and sync correction.
*
* Based on jellyfin-web's PlaybackCore.js
*/
import type { Api } from "@jellyfin/sdk";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { EventEmitter, msToTicks, ticksToMs, waitForOwnEvent } from "./Helper";
import type { TimeSyncCore } from "./TimeSyncCore";
import type {
PlayerControls,
SendCommand,
SyncPlayOsdAction,
SyncPlaySettings,
} from "./types";
import { TicksPerMillisecond } from "./types";
// Random offset added when re-issuing a duplicate Seek to force the player
// off-position so the next sync correction has something to chew on. Matches
// jellyfin-web's behavior (server tolerates a range, so we deliberately land
// just outside it).
const SEEK_FORCE_RANGE_MS = 100;
// Timeout for waiting on the local player's "ready" event after seek.
// Matches jellyfin-web's Helper.WaitForEventDefaultTimeout.
const WAIT_FOR_READY_TIMEOUT_MS = 30000;
// How close player position must be to command position to consider it
// already in the correct place (fuzz to account for player imprecision).
const POSITION_MATCH_TOLERANCE_MS = 500;
/**
* PlaybackCore - Handles synchronized playback
*/
export class PlaybackCore extends EventEmitter {
private api: Api;
private timeSyncCore: TimeSyncCore;
private playerControls: PlayerControls | null = null;
// Sync state
private syncEnabled = false;
private playbackDiffMillis = 0;
private syncAttempts = 0;
private lastSyncTime = new Date();
private playerIsBuffering = false;
// Command tracking
private lastCommand: SendCommand | null = null;
private scheduledCommandTimeout: ReturnType<typeof setTimeout> | null = null;
private syncTimeout: ReturnType<typeof setTimeout> | null = null;
// Last buffering state we reported to the server. Used to dedupe
// sendBufferingRequest so we only send on actual transitions —
// jellyfin-web gets this for free from the HTML5 `waiting`/`canplay`
// events, but our player exposes state, not events, and the React
// effect that drives notifyReady/notifyBuffering can re-run many times
// per second during normal playback. Without this guard we flood the
// server with redundant Ready/Buffering requests.
private lastBufferingSent: boolean | null = null;
private inflightBufferingRequest: Promise<void> | null = null;
// Debounce buffering notifications, matching jellyfin-web's
// `minBufferingThresholdMillis = 3000` in HtmlVideoPlayer.js. A short
// re-buffer blip (<3s) shouldn't notify the server at all — there's no
// reason to pause the whole group for a hiccup that resolves itself.
// Going Ready cancels any pending buffering notification.
private notifyBufferingTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly minBufferingThresholdMillis = 3000;
// Set to true by `scheduleReadyRequestOnPlaybackStart` whenever a new
// SyncPlay queue starts loading (NewPlaylist / SetCurrentItem / NextItem
// / PreviousItem). On the next `onReady` we pause the player BEFORE
// sending SyncPlayReady so the server sees us as `IsPlaying:false`,
// parked at the start position, awaiting an Unpause command. Mirrors
// jellyfin-web's `QueueCore.scheduleReadyRequestOnPlaybackStart` which
// registers a one-shot `playbackstart` listener that does the same.
// Without this the receiver's player auto-plays the moment it loads and
// the group's Unpause command arrives to an already-playing player —
// which leaves the receiver subtly out of sync with the sender (or, on
// slower devices, stuck on a blank loading screen because the early
// play attempt races the media load and never recovers).
private pendingInitialPause = false;
// Settings
private minDelaySpeedToSync = 60.0;
private maxDelaySpeedToSync = 3000.0;
private speedToSyncDuration = 1000.0;
private minDelaySkipToSync = 400.0;
private useSpeedToSync = true;
private useSkipToSync = true;
private enableSyncCorrection = false;
// Callbacks
private onNotifyOsd: ((action: SyncPlayOsdAction) => void) | null = null;
private getCurrentPlaylistItemId: (() => string | null) | null = null;
constructor(api: Api, timeSyncCore: TimeSyncCore) {
super();
this.api = api;
this.timeSyncCore = timeSyncCore;
}
/**
* Set player controls
*/
setPlayerControls(controls: PlayerControls | null): void {
this.playerControls = controls;
// A new (or detached) player means the server's view of our ready
// state is stale — drop the dedupe latch so the next notifyReady /
// notifyBuffering actually reaches the server.
this.lastBufferingSent = null;
}
/**
* Set OSD notification handler
*/
setOsdHandler(handler: ((action: SyncPlayOsdAction) => void) | null): void {
this.onNotifyOsd = handler;
}
/**
* Set playlist item ID getter
*/
setPlaylistItemIdGetter(getter: (() => string | null) | null): void {
this.getCurrentPlaylistItemId = getter;
}
/**
* Load settings
*/
loadSettings(settings: Partial<SyncPlaySettings>): void {
if (settings.minDelaySpeedToSync !== undefined) {
this.minDelaySpeedToSync = settings.minDelaySpeedToSync;
}
if (settings.maxDelaySpeedToSync !== undefined) {
this.maxDelaySpeedToSync = settings.maxDelaySpeedToSync;
}
if (settings.speedToSyncDuration !== undefined) {
this.speedToSyncDuration = settings.speedToSyncDuration;
}
if (settings.minDelaySkipToSync !== undefined) {
this.minDelaySkipToSync = settings.minDelaySkipToSync;
}
if (settings.useSpeedToSync !== undefined) {
this.useSpeedToSync = settings.useSpeedToSync;
}
if (settings.useSkipToSync !== undefined) {
this.useSkipToSync = settings.useSkipToSync;
}
if (settings.enableSyncCorrection !== undefined) {
this.enableSyncCorrection = settings.enableSyncCorrection;
}
}
// ============================================================================
// Player Events
// ============================================================================
/**
* Called when playback starts
*/
onPlaybackStart(): void {
this.emit("playbackstart");
}
/**
* Called when playback stops
*/
onPlaybackStop(): void {
this.lastCommand = null;
this.emit("playbackstop");
}
/**
* Called when player is ready to play
*/
onReady(): void {
this.playerIsBuffering = false;
// Cancel any pending buffering notification — we're ready before the
// 3s threshold fired, so the server never needs to know we hiccupped.
if (this.notifyBufferingTimeout) {
clearTimeout(this.notifyBufferingTimeout);
this.notifyBufferingTimeout = null;
}
// If we're handling the first ready event after a queue change,
// pause the player BEFORE reporting ready. The subsequent
// `sendBufferingRequest(false)` will then read `isPlaying() === false`
// and the server will hold the group until we receive an Unpause.
if (this.pendingInitialPause) {
this.pendingInitialPause = false;
if (this.playerControls?.isPlaying()) {
console.log(
"SyncPlay PlaybackCore: pausing on initial ready (awaiting group Unpause)",
);
this.playerControls.pause();
}
}
this.sendBufferingRequest(false);
this.emit("ready");
}
/**
* Mark the next `onReady` call as the initial ready for a new queue
* item. The player will be paused before SyncPlayReady is sent so the
* server keeps the group in `Waiting` until our Unpause arrives.
*
* Mirrors jellyfin-web's `QueueCore.scheduleReadyRequestOnPlaybackStart`.
* Called by the provider when a PlayQueue update is `NewPlaylist`,
* `SetCurrentItem`, `NextItem`, or `PreviousItem`.
*/
scheduleReadyRequestOnPlaybackStart(): void {
this.pendingInitialPause = true;
}
/**
* Called when player is buffering
*/
onBuffering(): void {
// Debounce: only flip into "buffering" state (and notify the server)
// if the stall lasts longer than minBufferingThresholdMillis. Mirrors
// jellyfin-web's HtmlVideoPlayer.js `_onWaiting` handler, which only
// calls `onBuffering()` after the 3s timeout elapses. Keeping
// playerIsBuffering=false during brief blips lets sync correction
// continue to run normally.
if (this.notifyBufferingTimeout) {
clearTimeout(this.notifyBufferingTimeout);
}
this.notifyBufferingTimeout = setTimeout(() => {
this.notifyBufferingTimeout = null;
this.playerIsBuffering = true;
this.sendBufferingRequest(true);
this.emit("buffering");
}, this.minBufferingThresholdMillis);
}
/**
* Check if player is buffering
*/
isBuffering(): boolean {
return this.playerIsBuffering;
}
/**
* Get playback difference in milliseconds
*/
getPlaybackDiff(): number {
return this.playbackDiffMillis;
}
// ============================================================================
// Server Communication
// ============================================================================
/**
* Send buffering/ready request to server.
*
* NOTE: This must work even before player controls are bound, so that we
* can signal "I'm not ready yet, hold the group" while the video is still
* loading. jellyfin-web's HTML5 player gets this for free via the
* `waiting` event firing during initial buffering; we don't bind controls
* until the video is loaded, so we synthesize a position=0 buffering
* signal in the pre-bind window.
*/
async sendBufferingRequest(isBuffering: boolean): Promise<void> {
if (!this.api) {
console.warn("SyncPlay PlaybackCore: no api for buffering request");
return;
}
// Skip if the desired state matches what we last sent. Without this,
// the React effect that drives notifyReady/notifyBuffering will flood
// the server every time the video player's isBuffering momentarily
// toggles during normal playback.
if (this.lastBufferingSent === isBuffering) {
return;
}
// Coalesce: if a request is already in flight, wait for it. This
// prevents racing two requests when state flips rapidly.
if (this.inflightBufferingRequest) {
await this.inflightBufferingRequest;
// Re-check after the in-flight request settled — the new state may
// already match.
if (this.lastBufferingSent === isBuffering) {
return;
}
}
const request = this.doSendBufferingRequest(isBuffering);
this.inflightBufferingRequest = request;
try {
await request;
this.lastBufferingSent = isBuffering;
} finally {
if (this.inflightBufferingRequest === request) {
this.inflightBufferingRequest = null;
}
}
}
private async doSendBufferingRequest(isBuffering: boolean): Promise<void> {
if (!this.api) return;
try {
const currentPosition = this.playerControls?.getCurrentPosition() ?? 0;
const currentPositionTicks = msToTicks(currentPosition);
const isPlaying = this.playerControls?.isPlaying() ?? false;
const now = this.timeSyncCore.localDateToRemote(new Date());
const playlistItemId = this.getCurrentPlaylistItemId?.() ?? null;
const syncPlayApi = getSyncPlayApi(this.api);
console.log(
`SyncPlay PlaybackCore: sending ${isBuffering ? "BUFFERING" : "READY"} to server`,
{
position: currentPositionTicks,
playlistItemId,
hasPlayerControls: !!this.playerControls,
},
);
if (isBuffering) {
await syncPlayApi.syncPlayBuffering({
bufferRequestDto: {
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: isPlaying,
PlaylistItemId: playlistItemId ?? undefined,
},
});
} else {
await syncPlayApi.syncPlayReady({
readyRequestDto: {
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: isPlaying,
PlaylistItemId: playlistItemId ?? undefined,
},
});
}
console.log(
`SyncPlay PlaybackCore: ${isBuffering ? "BUFFERING" : "READY"} sent successfully`,
);
} catch (error) {
console.error("SyncPlay: failed to send buffering request", error);
// On failure, clear the dedupe latch so the next attempt actually
// re-sends rather than getting stuck thinking the server knows.
throw error;
}
}
// ============================================================================
// Command Handling
// ============================================================================
/**
* Apply a playback command
*/
async applyCommand(command: SendCommand): Promise<void> {
console.log(`SyncPlay PlaybackCore: applyCommand - ${command.Command}`);
// Parse the When time from string
const commandWhen = command.When ? new Date(command.When) : new Date();
const positionTicks = command.PositionTicks ?? 0;
// Duplicate command handling — don't blindly skip. Match jellyfin-web:
// if the duplicate's scheduled time has already passed and local player
// state doesn't match, re-apply (with a force-offset for seek). This
// self-heals after a missed broadcast, reconnect, or local drift.
if (this.lastCommand?.When) {
const lastWhen = new Date(this.lastCommand.When);
if (
lastWhen.getTime() === commandWhen.getTime() &&
this.lastCommand.PositionTicks === command.PositionTicks &&
this.lastCommand.Command === command.Command &&
this.lastCommand.PlaylistItemId === command.PlaylistItemId
) {
const whenLocal = this.timeSyncCore.remoteDateToLocal(commandWhen);
if (whenLocal > new Date()) {
// Still in the future — already scheduled, nothing to do.
console.debug(
"SyncPlay PlaybackCore: duplicate (still scheduled), skipping",
);
return;
}
if (!this.playerControls) {
console.debug(
"SyncPlay PlaybackCore: duplicate past command but no player",
);
return;
}
const currentPositionMs = this.playerControls.getCurrentPosition();
const isPlaying = this.playerControls.isPlaying();
const targetMs = ticksToMs(positionTicks);
const positionMatches =
Math.abs(currentPositionMs - targetMs) <= POSITION_MATCH_TOLERANCE_MS;
switch (command.Command) {
case "Unpause":
if (!isPlaying) {
console.debug("SyncPlay PlaybackCore: dup Unpause — reconciling");
await this.scheduleUnpause(commandWhen, positionTicks);
}
return;
case "Pause":
if (isPlaying || !positionMatches) {
console.debug("SyncPlay PlaybackCore: dup Pause — reconciling");
this.schedulePause(commandWhen, positionTicks);
}
return;
case "Stop":
if (isPlaying) {
console.debug("SyncPlay PlaybackCore: dup Stop — reconciling");
this.scheduleStop(commandWhen);
}
return;
case "Seek": {
if (!isPlaying && positionMatches) {
// Already paused at target — just confirm ready.
this.sendBufferingRequest(false);
return;
}
// Force a re-seek with a small random offset so the player
// actually moves (server tolerates a range).
const randomOffsetTicks =
Math.round((Math.random() - 0.5) * SEEK_FORCE_RANGE_MS) *
TicksPerMillisecond;
console.debug(
`SyncPlay PlaybackCore: dup Seek — reconciling with offset ${randomOffsetTicks} ticks`,
);
this.scheduleSeek(commandWhen, positionTicks + randomOffsetTicks);
return;
}
default:
console.error(
"SyncPlay PlaybackCore: unrecognized duplicate command",
command,
);
return;
}
}
}
this.lastCommand = command;
if (!this.playerControls) {
console.error("SyncPlay PlaybackCore: NO PLAYER CONTROLS!");
return;
}
console.log(
`SyncPlay PlaybackCore: executing ${command.Command} scheduled for ${commandWhen.toISOString()}`,
);
switch (command.Command) {
case "Unpause":
await this.scheduleUnpause(commandWhen, positionTicks);
break;
case "Pause":
this.schedulePause(commandWhen, positionTicks);
break;
case "Stop":
this.scheduleStop(commandWhen);
break;
case "Seek":
this.scheduleSeek(commandWhen, positionTicks);
break;
default:
console.error("SyncPlay PlaybackCore: unrecognized command", command);
}
}
/**
* Schedule an unpause at a specific time
*/
private async scheduleUnpause(
playAtTime: Date,
positionTicks: number,
): Promise<void> {
this.clearScheduledCommand();
const currentTime = new Date();
const playAtTimeLocal = this.timeSyncCore.remoteDateToLocal(playAtTime);
const positionMs = ticksToMs(positionTicks);
if (playAtTimeLocal > currentTime) {
// Future command - schedule it
const playTimeout = playAtTimeLocal.getTime() - currentTime.getTime();
// Pre-seek only when we're AHEAD of the target by more than the skip
// threshold. If we're behind, the unpause itself plays forward and
// SkipToSync/SpeedToSync will catch us up — forward-seeking now would
// just cause needless buffering. (Matches jellyfin-web.)
const currentPositionMs = this.playerControls?.getCurrentPosition() ?? 0;
const aheadByMs = currentPositionMs - positionMs;
console.log(
`SyncPlay: pre-seek check - current=${currentPositionMs}ms, target=${positionMs}ms, aheadBy=${aheadByMs}ms, threshold=${this.minDelaySkipToSync}ms`,
);
if (aheadByMs > this.minDelaySkipToSync) {
console.log(`SyncPlay: PRE-SEEKING back to ${positionMs}ms`);
this.localSeek(positionMs);
}
this.scheduledCommandTimeout = setTimeout(() => {
this.localUnpause();
this.onNotifyOsd?.("unpause");
// Enable sync after a delay
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
}, this.maxDelaySpeedToSync / 2);
}, playTimeout);
console.debug(`SyncPlay: scheduled unpause in ${playTimeout}ms`);
} else {
// Past command - play immediately and seek to estimated position
const elapsed = currentTime.getTime() - playAtTimeLocal.getTime();
const serverPositionTicks = positionTicks + elapsed * TicksPerMillisecond;
const serverPositionMs = ticksToMs(serverPositionTicks);
this.localUnpause();
this.localSeek(serverPositionMs);
setTimeout(() => {
this.onNotifyOsd?.("unpause");
}, 100);
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
}, this.maxDelaySpeedToSync / 2);
console.debug(`SyncPlay: immediate unpause at ${serverPositionMs}ms`);
}
}
/**
* Schedule a pause at a specific time
*/
private schedulePause(pauseAtTime: Date, positionTicks: number): void {
console.log("SyncPlay PlaybackCore: schedulePause called");
this.clearScheduledCommand();
const currentTime = new Date();
const pauseAtTimeLocal = this.timeSyncCore.remoteDateToLocal(pauseAtTime);
const positionMs = ticksToMs(positionTicks);
const callback = () => {
console.log("SyncPlay PlaybackCore: EXECUTING PAUSE NOW");
// If we're already paused at the target position, do nothing.
// jellyfin-web gets this for free because HTML5 video's seekTo is a
// no-op when the target equals currentTime, and pause() is a no-op
// when already paused. Our PlayerControls.seekTo always actually
// seeks, which triggers waiting→canplay and a notifyBuffering →
// notifyReady cycle. The server reacts by re-sending Pause, which
// re-enters this callback → infinite feedback loop. Guarding here
// breaks the loop while preserving normal pause behaviour.
if (this.playerControls) {
const isPlaying = this.playerControls.isPlaying();
const currentPositionMs = this.playerControls.getCurrentPosition();
const positionMatches =
positionMs <= 100 ||
Math.abs(currentPositionMs - positionMs) <=
POSITION_MATCH_TOLERANCE_MS;
if (!isPlaying && positionMatches) {
console.debug(
"SyncPlay PlaybackCore: already paused at target position, skipping",
);
this.onNotifyOsd?.("pause");
return;
}
}
this.localPause();
// Only seek if we have a valid position (not 0 or very small)
if (positionMs > 100) {
this.localSeek(positionMs);
} else {
console.log("SyncPlay PlaybackCore: skipping seek (no valid position)");
}
this.onNotifyOsd?.("pause");
};
if (pauseAtTimeLocal > currentTime) {
const pauseTimeout = pauseAtTimeLocal.getTime() - currentTime.getTime();
this.scheduledCommandTimeout = setTimeout(callback, pauseTimeout);
console.log(
`SyncPlay PlaybackCore: scheduled pause in ${pauseTimeout}ms`,
);
} else {
console.log("SyncPlay PlaybackCore: immediate pause (past time)");
callback();
}
}
/**
* Schedule a stop at a specific time
*/
private scheduleStop(stopAtTime: Date): void {
this.clearScheduledCommand();
const currentTime = new Date();
const stopAtTimeLocal = this.timeSyncCore.remoteDateToLocal(stopAtTime);
const callback = () => {
this.localStop();
};
if (stopAtTimeLocal > currentTime) {
const stopTimeout = stopAtTimeLocal.getTime() - currentTime.getTime();
this.scheduledCommandTimeout = setTimeout(callback, stopTimeout);
console.debug(`SyncPlay: scheduled stop in ${stopTimeout}ms`);
} else {
callback();
console.debug("SyncPlay: immediate stop");
}
}
/**
* Schedule a seek at a specific time.
*
* Flow (matches jellyfin-web): unpause -> seek -> wait for local "ready"
* (player finished buffering at the new position) -> pause and report ready
* to the server so the group can resume. This handles the common case
* where the player must rebuffer after the seek.
*/
private scheduleSeek(seekAtTime: Date, positionTicks: number): void {
this.clearScheduledCommand();
const currentTime = new Date();
const seekAtTimeLocal = this.timeSyncCore.remoteDateToLocal(seekAtTime);
const positionMs = ticksToMs(positionTicks);
const callback = () => {
this.localUnpause();
this.localSeek(positionMs);
this.onNotifyOsd?.("seek");
// Wait for the local player to report ready ("onReady" fires this),
// then pause and tell the server we're ready at the new position.
waitForOwnEvent(this, "ready", WAIT_FOR_READY_TIMEOUT_MS)
.then(() => {
this.localPause();
this.sendBufferingRequest(false);
})
.catch((error) => {
console.warn(
`SyncPlay: seek ready-wait timed out, seeking to ${positionMs}ms anyway`,
error,
);
this.localSeek(positionMs);
});
};
if (seekAtTimeLocal > currentTime) {
const seekTimeout = seekAtTimeLocal.getTime() - currentTime.getTime();
this.scheduledCommandTimeout = setTimeout(callback, seekTimeout);
console.debug(`SyncPlay: scheduled seek in ${seekTimeout}ms`);
} else {
callback();
console.debug("SyncPlay: immediate seek");
}
}
/**
* Clear scheduled command
*/
private clearScheduledCommand(): void {
if (this.scheduledCommandTimeout) {
clearTimeout(this.scheduledCommandTimeout);
this.scheduledCommandTimeout = null;
}
if (this.syncTimeout) {
clearTimeout(this.syncTimeout);
this.syncTimeout = null;
}
this.syncEnabled = false;
// Reset playback rate
if (this.playerControls && this.playerControls.getSpeed() !== 1.0) {
this.playerControls.setSpeed(1.0);
}
this.emit("syncing", false, "None");
}
// ============================================================================
// Local Playback Control
// ============================================================================
private localUnpause(): void {
this.playerControls?.play();
}
private localPause(): void {
this.playerControls?.pause();
}
private localSeek(positionMs: number): void {
console.log(`SyncPlay PlaybackCore: localSeek to ${positionMs}ms`);
if (this.playerControls) {
this.playerControls.seekTo(positionMs);
console.log("SyncPlay PlaybackCore: seekTo called on playerControls");
} else {
console.error("SyncPlay PlaybackCore: NO PLAYER CONTROLS for seek!");
}
}
private localStop(): void {
this.playerControls?.pause();
}
// ============================================================================
// Time Sync
// ============================================================================
/**
* Estimate current position ticks given a past state
*/
estimateCurrentTicks(
ticks: number,
when: Date,
currentTime: Date = new Date(),
): number {
const remoteTime = this.timeSyncCore.localDateToRemote(currentTime);
return (
ticks + (remoteTime.getTime() - when.getTime()) * TicksPerMillisecond
);
}
/**
* Sync playback time during playback
*/
syncPlaybackTime(currentPositionMs: number): void {
if (!this.playerControls || !this.lastCommand) return;
// Only sync during unpause
if (this.lastCommand.Command !== "Unpause" || this.isBuffering()) return;
// Don't apply sync corrections if the active player isn't on the same
// playlist item that the group is playing (e.g. user switched item
// locally, or queue update in flight). Prevents seeking the wrong item.
const currentItemId = this.getCurrentPlaylistItemId?.();
if (
currentItemId &&
this.lastCommand.PlaylistItemId &&
this.lastCommand.PlaylistItemId !== currentItemId
) {
return;
}
const currentTime = new Date();
const currentPositionTicks = msToTicks(currentPositionMs);
const lastCommandWhen = this.lastCommand.When
? new Date(this.lastCommand.When)
: new Date();
// Estimate server position
const serverPositionTicks = this.estimateCurrentTicks(
this.lastCommand.PositionTicks ?? 0,
lastCommandWhen,
currentTime,
);
// Calculate difference
const diffMillis =
(serverPositionTicks - currentPositionTicks) / TicksPerMillisecond;
this.playbackDiffMillis = diffMillis;
this.emit("playback-diff", diffMillis);
// Rate-limit sync attempts
const elapsed = currentTime.getTime() - this.lastSyncTime.getTime();
if (elapsed < this.maxDelaySpeedToSync / 2) return;
this.lastSyncTime = currentTime;
if (!this.syncEnabled || !this.enableSyncCorrection) return;
const absDiffMillis = Math.abs(diffMillis);
// SpeedToSync
if (
this.useSpeedToSync &&
absDiffMillis >= this.minDelaySpeedToSync &&
absDiffMillis < this.maxDelaySpeedToSync
) {
let speedToSyncTime = this.speedToSyncDuration;
// Prevent negative speed
const MinSpeed = 0.2;
if (diffMillis <= -speedToSyncTime * MinSpeed) {
speedToSyncTime = Math.abs(diffMillis) / (1.0 - MinSpeed);
}
const speed = 1 + diffMillis / speedToSyncTime;
if (speed > 0) {
this.playerControls.setSpeed(speed);
this.syncEnabled = false;
this.syncAttempts++;
this.emit("syncing", true, `SpeedToSync (x${speed.toFixed(2)})`);
this.syncTimeout = setTimeout(() => {
this.playerControls?.setSpeed(1.0);
this.syncEnabled = true;
this.emit("syncing", false, "None");
}, speedToSyncTime);
console.debug(`SyncPlay: SpeedToSync x${speed.toFixed(2)}`);
}
}
// SkipToSync
else if (this.useSkipToSync && absDiffMillis >= this.minDelaySkipToSync) {
const serverPositionMs = ticksToMs(serverPositionTicks);
this.localSeek(serverPositionMs);
this.syncEnabled = false;
this.syncAttempts++;
this.emit("syncing", true, `SkipToSync (${this.syncAttempts})`);
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
this.emit("syncing", false, "None");
}, this.maxDelaySpeedToSync / 2);
console.debug(`SyncPlay: SkipToSync to ${serverPositionMs}ms`);
} else {
// Synced
if (this.syncAttempts > 0) {
console.debug(`SyncPlay: synced after ${this.syncAttempts} attempts`);
}
this.syncAttempts = 0;
}
}
// ============================================================================
// Cleanup
// ============================================================================
/**
* Reset PlaybackCore state — used when SyncPlay is disabled so we don't
* carry stale commands, scheduled timers, or sync state into the next
* session.
*/
reset(): void {
this.clearScheduledCommand();
this.lastCommand = null;
this.lastSyncTime = new Date();
this.syncAttempts = 0;
this.playbackDiffMillis = 0;
this.playerIsBuffering = false;
// Forget what we last told the server so the next session starts fresh.
this.lastBufferingSent = null;
this.inflightBufferingRequest = null;
if (this.notifyBufferingTimeout) {
clearTimeout(this.notifyBufferingTimeout);
this.notifyBufferingTimeout = null;
}
// Drop a pending pause-before-ready flag so it can't leak into the
// next group.
this.pendingInitialPause = false;
}
/**
* Destroy the playback core
*/
destroy(): void {
this.clearScheduledCommand();
this.removeAllListeners();
this.playerControls = null;
this.onNotifyOsd = null;
this.getCurrentPlaylistItemId = null;
}
}

View File

@@ -1,292 +0,0 @@
/**
* QueueCore
*
* Manages the shared playlist/queue for SyncPlay.
* Handles queue updates from the server.
*
* Based on jellyfin-web's QueueCore.js
*/
import { EventEmitter } from "./Helper";
import type {
GroupRepeatMode,
GroupShuffleMode,
PlayQueueUpdate,
SyncPlayQueueItem,
} from "./types";
/**
* QueueCore - Manages the shared playlist
*/
export class QueueCore extends EventEmitter {
// Queue state
private lastPlayQueueUpdate: PlayQueueUpdate | null = null;
private playlist: SyncPlayQueueItem[] = [];
// Callbacks
private onStartPlayback: (() => void) | null = null;
private estimateCurrentTicks: ((ticks: number, when: Date) => number) | null =
null;
/**
* Set the start playback callback
*/
setStartPlaybackHandler(handler: (() => void) | null): void {
this.onStartPlayback = handler;
}
/**
* Set the ticks estimator function
*/
setTicksEstimator(
estimator: ((ticks: number, when: Date) => number) | null,
): void {
this.estimateCurrentTicks = estimator;
}
// ============================================================================
// Queue State
// ============================================================================
/**
* Get the current playlist
*/
getPlaylist(): SyncPlayQueueItem[] {
return [...this.playlist];
}
/**
* Check if playlist is empty
*/
isPlaylistEmpty(): boolean {
return this.playlist.length === 0;
}
/**
* Get current playing index
*/
getCurrentPlaylistIndex(): number {
return this.lastPlayQueueUpdate?.PlayingItemIndex ?? -1;
}
/**
* Get current playlist item ID
*/
getCurrentPlaylistItemId(): string | null {
if (!this.lastPlayQueueUpdate) return null;
const index = this.lastPlayQueueUpdate.PlayingItemIndex;
if (index === undefined || index === -1 || index >= this.playlist.length) {
return null;
}
return this.playlist[index]?.PlaylistItemId ?? null;
}
/**
* Get current item's Jellyfin ID (the actual media item ID)
*/
getCurrentItemId(): string | null {
if (!this.lastPlayQueueUpdate) return null;
const index = this.lastPlayQueueUpdate.PlayingItemIndex;
if (index === undefined || index === -1 || index >= this.playlist.length) {
return null;
}
return this.playlist[index]?.ItemId ?? null;
}
/**
* Get the current item from the playlist
*/
getCurrentItem(): SyncPlayQueueItem | null {
if (!this.lastPlayQueueUpdate) return null;
const index = this.lastPlayQueueUpdate.PlayingItemIndex;
if (index === undefined || index === -1 || index >= this.playlist.length) {
return null;
}
return this.playlist[index] ?? null;
}
/**
* Get the last update time
*/
getLastUpdate(): Date | null {
const lastUpdate = this.lastPlayQueueUpdate?.LastUpdate;
return lastUpdate ? new Date(lastUpdate) : null;
}
/**
* Get the last update time as timestamp
*/
getLastUpdateTime(): number {
const lastUpdate = this.lastPlayQueueUpdate?.LastUpdate;
return lastUpdate ? new Date(lastUpdate).getTime() : 0;
}
/**
* Get start position ticks
*/
getStartPositionTicks(): number {
return this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
}
/**
* Get repeat mode
*/
getRepeatMode(): GroupRepeatMode {
return this.lastPlayQueueUpdate?.RepeatMode ?? "RepeatNone";
}
/**
* Get shuffle mode
*/
getShuffleMode(): GroupShuffleMode {
return this.lastPlayQueueUpdate?.ShuffleMode ?? "Sorted";
}
/**
* Get playlist as item IDs
*/
getPlaylistAsItemIds(): (string | undefined)[] {
return this.playlist.map((item) => item.ItemId);
}
// ============================================================================
// Queue Updates
// ============================================================================
/**
* Update the play queue from server
*/
async updatePlayQueue(update: PlayQueueUpdate): Promise<void> {
// Parse the last update time
const updateTime = update.LastUpdate
? new Date(update.LastUpdate).getTime()
: 0;
// Ignore old updates
if (updateTime <= this.getLastUpdateTime()) {
console.debug("SyncPlay QueueCore: ignoring old update", update);
return;
}
console.log("SyncPlay QueueCore: processing update", {
reason: update.Reason,
position: update.StartPositionTicks,
index: update.PlayingItemIndex,
});
// Check for position change (seek)
const oldPosition = this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
const newPosition = update.StartPositionTicks ?? 0;
const positionChanged = Math.abs(newPosition - oldPosition) > 10000000; // > 1 second difference
// Store the update
this.lastPlayQueueUpdate = update;
this.playlist = update.Playlist ?? [];
// Emit update event
this.emit("queue-update", update);
// Handle different update reasons
switch (update.Reason) {
case "NewPlaylist":
// Start playback with new playlist
this.onStartPlayback?.();
break;
case "SetCurrentItem":
case "NextItem":
case "PreviousItem":
// Item changed
this.emit("item-change", this.getCurrentPlaylistItemId());
break;
case "RemoveItems":
case "MoveItem":
case "Queue":
case "QueueNext":
// Playlist modified
this.emit("playlist-change", this.playlist);
break;
case "RepeatMode":
this.emit("repeat-mode-change", update.RepeatMode);
break;
case "ShuffleMode":
this.emit("shuffle-mode-change", update.ShuffleMode);
break;
default:
console.debug(
"SyncPlay QueueCore: unhandled update reason",
update.Reason,
);
break;
}
// Emit seek if position changed significantly (likely a seek from another device)
if (positionChanged && update.Reason !== "NewPlaylist") {
console.log(
`SyncPlay QueueCore: position changed ${oldPosition} -> ${newPosition}, emitting seek`,
);
this.emit("seek", newPosition);
}
}
/**
* Get estimated start position based on last command
*/
getEstimatedStartPosition(
lastCommandPositionTicks: number | null,
lastCommandWhen: Date | null,
): number {
if (lastCommandPositionTicks !== null && lastCommandWhen !== null) {
// Use playback command if recent enough
if (lastCommandWhen.getTime() >= this.getLastUpdateTime()) {
return (
this.estimateCurrentTicks?.(
lastCommandPositionTicks,
lastCommandWhen,
) ?? lastCommandPositionTicks
);
}
}
// Fall back to queue update position
const startTicks = this.getStartPositionTicks();
const lastUpdate = this.getLastUpdate();
if (lastUpdate) {
return this.estimateCurrentTicks?.(startTicks, lastUpdate) ?? startTicks;
}
return startTicks;
}
// ============================================================================
// Cleanup
// ============================================================================
/**
* Clear the queue
*/
clear(): void {
this.lastPlayQueueUpdate = null;
this.playlist = [];
}
/**
* Destroy the queue core
*/
destroy(): void {
this.clear();
this.removeAllListeners();
this.onStartPlayback = null;
this.estimateCurrentTicks = null;
}
}

View File

@@ -1,594 +0,0 @@
/**
* SyncPlayProvider
*
* React context provider for SyncPlay functionality.
* Manages the SyncPlay manager and exposes hooks for components.
*/
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
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 i18n from "@/i18n";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { SyncPlayController } from "./Controller";
import { ticksToMs } from "./Helper";
import { SyncPlayManager } from "./Manager";
import { PlaybackCore } from "./PlaybackCore";
import { QueueCore } from "./QueueCore";
import type {
GroupInfoDto,
PlayerControls,
PlayQueueUpdate,
SendCommand,
SyncPlayOsdAction,
SyncPlayStats,
} from "./types";
import { useSyncPlayWebSocket } from "./useSyncPlayWebSocket";
// ============================================================================
// Context Types
// ============================================================================
interface SyncPlayContextValue {
// State
isEnabled: boolean;
isReady: boolean;
groupInfo: GroupInfoDto | null;
canJoinGroups: boolean;
canCreateGroups: boolean;
// Group management
joinGroup: (groupId: string) => Promise<void>;
createGroup: (groupName?: string) => Promise<void>;
leaveGroup: () => Promise<void>;
getGroups: () => Promise<GroupInfoDto[]>;
// Playback control delegation
controller: SyncPlayController | null;
// Player integration
setPlayerControls: (controls: PlayerControls | null) => void;
notifyReady: () => void;
notifyBuffering: () => void;
// Stats
getStats: () => SyncPlayStats;
// OSD state
osdAction: SyncPlayOsdAction | null;
isSyncing: boolean;
syncMethod: string;
/** In-flight Unpause/Pause request, before the server has echoed back. */
pendingPlaybackCommand: "Unpause" | "Pause" | null;
}
const SyncPlayContext = createContext<SyncPlayContextValue | null>(null);
// ============================================================================
// Provider Component
// ============================================================================
interface SyncPlayProviderProps {
children: ReactNode;
}
export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const router = useAppRouter();
// Core modules - use state for manager so WebSocket hook re-runs when ready
const [manager, setManager] = useState<SyncPlayManager | null>(null);
const playbackCoreRef = useRef<PlaybackCore | null>(null);
const queueCoreRef = useRef<QueueCore | null>(null);
const controllerRef = useRef<SyncPlayController | null>(null);
// Track if we're already on the player page to avoid duplicate navigations
const isNavigatingToPlayerRef = useRef(false);
// State
const [isEnabled, setIsEnabled] = useState(false);
const [isReady, setIsReady] = useState(false);
const [groupInfo, setGroupInfoDto] = useState<GroupInfoDto | null>(null);
const [osdAction, setOsdAction] = useState<SyncPlayOsdAction | null>(null);
const [isSyncing, setIsSyncing] = useState(false);
const [syncMethod, setSyncMethod] = useState("None");
const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState<
"Unpause" | "Pause" | null
>(null);
// Permission checks
const canJoinGroups = useMemo(() => {
const access = user?.Policy?.SyncPlayAccess;
return access !== "None" && access !== undefined;
}, [user?.Policy?.SyncPlayAccess]);
const canCreateGroups = useMemo(() => {
return user?.Policy?.SyncPlayAccess === "CreateAndJoinGroups";
}, [user?.Policy?.SyncPlayAccess]);
// Initialize manager
useEffect(() => {
if (!api) return;
// Create manager and cores
const manager = new SyncPlayManager(api);
const playbackCore = new PlaybackCore(api, manager.getTimeSyncCore());
const queueCore = new QueueCore();
const controller = new SyncPlayController(api, manager, queueCore);
setManager(manager);
playbackCoreRef.current = playbackCore;
queueCoreRef.current = queueCore;
controllerRef.current = controller;
// Wire up manager callbacks
manager.setPlaybackCommandHandler((command: SendCommand) => {
playbackCore.applyCommand(command);
});
manager.setQueueUpdateHandler((update: PlayQueueUpdate) => {
queueCore.updatePlayQueue(update);
});
manager.setPlaylistItemIdGetter(() => {
return queueCore.getCurrentPlaylistItemId();
});
// When SyncPlay is disabled, flush PlaybackCore's scheduled commands and
// cached state so we don't carry ghost commands into the next group.
manager.setDisableHandler(() => {
playbackCore.reset();
});
// Also clear the cached PlayQueue snapshot on disable. If we don't, then
// when the user later re-joins the same group, the server's first
// PlayQueue echo (which can carry the same LastUpdate as the snapshot we
// saw last session) gets dropped by QueueCore's stale-update guard, and
// the receiver never auto-navigates to the group's content.
manager.setQueueClearHandler(() => {
queueCore.clear();
});
// Wire up playback core callbacks
playbackCore.setPlaylistItemIdGetter(() => {
return queueCore.getCurrentPlaylistItemId();
});
playbackCore.setOsdHandler((action) => {
setOsdAction(action);
// Clear after display
setTimeout(() => setOsdAction(null), 1500);
});
// Wire up queue core
queueCore.setTicksEstimator((ticks, when) => {
return playbackCore.estimateCurrentTicks(ticks, when);
});
// Navigate to player when group starts playing new content
queueCore.setStartPlaybackHandler(async () => {
const itemId = queueCore.getCurrentItemId();
const startPositionTicks = queueCore.getStartPositionTicks();
if (!itemId) {
console.warn("SyncPlay: new playlist but no current item ID");
return;
}
// Avoid duplicate navigations
if (isNavigatingToPlayerRef.current) {
console.debug("SyncPlay: already navigating to player");
return;
}
console.log("SyncPlay: navigating to player for item", itemId);
isNavigatingToPlayerRef.current = true;
// Mirror jellyfin-web's `QueueCore.startPlayback` ordering:
// 1. followGroupPlayback (IgnoreWait:false) — tell server we follow
// 2. scheduleReadyRequestOnPlaybackStart — arm initial pause
// 3. playerWrapper.localPlay (== our router navigation) — start loading
// The arm-then-navigate order matters: scheduling must happen BEFORE
// navigation so the flag is set when the player attaches and fires
// its first `notifyReady`. Otherwise we race the player and the
// initial SyncPlayReady reports `IsPlaying:true`, defeating the
// server's "hold the group until everyone is parked" semantics.
await manager.followGroupPlayback();
playbackCore.scheduleReadyRequestOnPlaybackStart();
// Show toast notification
toast(i18n.t("syncplay.joining_playback"));
// Navigate to the player with the item. Use `replace` so repeated
// queue updates don't stack player screens on the history.
const queryParams = new URLSearchParams({
itemId: itemId,
playbackPosition: startPositionTicks.toString(),
syncPlay: "true", // Mark this as a SyncPlay-initiated playback
}).toString();
router.push(`/player/direct-player?${queryParams}` as any);
// Reset navigation flag after a short delay
setTimeout(() => {
isNavigatingToPlayerRef.current = false;
}, 2000);
});
// Also handle item changes (next/previous in playlist)
queueCore.on("item-change", () => {
const newItemId = queueCore.getCurrentItemId();
const startPositionTicks = queueCore.getStartPositionTicks();
if (!newItemId) {
console.warn("SyncPlay: item change but no current item ID");
return;
}
// Avoid duplicate navigations
if (isNavigatingToPlayerRef.current) {
return;
}
console.log("SyncPlay: item changed, navigating to", newItemId);
isNavigatingToPlayerRef.current = true;
// Same pause-before-ready dance as NewPlaylist — the new item's
// player needs to park at the start position and report
// IsPlaying:false so the server holds the group until everyone is
// ready for the next Unpause. Mirrors jellyfin-web's
// `QueueCore.setCurrentPlaylistItem`.
playbackCore.scheduleReadyRequestOnPlaybackStart();
const queryParams = new URLSearchParams({
itemId: newItemId,
playbackPosition: startPositionTicks.toString(),
syncPlay: "true",
}).toString();
router.push(`/player/direct-player?${queryParams}`);
setTimeout(() => {
isNavigatingToPlayerRef.current = false;
}, 2000);
});
// Handle seek events from other devices - pause first, then seek (like Jellyfin-web)
queueCore.on("seek", (...args: unknown[]) => {
const positionTicks = args[0] as number;
const positionMs = ticksToMs(positionTicks);
console.log(
"SyncPlay: seek event received, pausing then seeking to",
positionMs,
"ms",
);
const playerControls = manager.getPlayerControls();
if (playerControls) {
playerControls.pause();
playerControls.seekTo(positionMs);
}
});
// Subscribe to manager events
manager.on("enabled", (...args: unknown[]) => {
const enabled = args[0] as boolean;
setIsEnabled(enabled);
if (!enabled) {
setIsReady(false);
setGroupInfoDto(null);
}
});
manager.on("syncing", (...args: unknown[]) => {
const syncing = args[0] as boolean;
const method = args[1] as string;
setIsSyncing(syncing);
setSyncMethod(method);
});
// Keep React-side groupInfo in sync with Manager mutations. Without this,
// CenterControls' `groupInfo.State === 'Waiting'` check is stale because
// Manager mutates the existing object reference rather than emitting a
// fresh one.
manager.on("group-info-change", (...args: unknown[]) => {
setGroupInfoDto(args[0] as GroupInfoDto);
});
// Expose pending Unpause/Pause to consumers (e.g. CenterControls renders
// a spinner instead of the play/pause button while a request is in
// flight — mirrors jellyfin-web's "schedule-play" indicator).
manager.on("pending-playback-change", (...args: unknown[]) => {
setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null);
});
// When entering Waiting state, report ready through PlaybackCore
manager.on("waiting-for-ready", () => {
console.log(
"SyncPlay: waiting-for-ready event, calling PlaybackCore.onReady()",
);
playbackCore.onReady();
});
// Handle seek from StateUpdate (when SyncPlayCommand doesn't include seek)
manager.on("seek-from-state-update", (...args: unknown[]) => {
const positionTicks = args[0] as number;
const positionMs = ticksToMs(positionTicks);
console.log(
"SyncPlay: seek from StateUpdate, seeking to",
positionMs,
"ms",
);
const playerControls = manager.getPlayerControls();
if (playerControls) {
playerControls.pause();
playerControls.seekTo(positionMs);
}
});
// Initialize
manager.init();
return () => {
manager.destroy();
playbackCore.destroy();
queueCore.destroy();
setManager(null);
playbackCoreRef.current = null;
queueCoreRef.current = null;
controllerRef.current = null;
};
}, [api]);
// Update group info when enabled
useEffect(() => {
if (isEnabled && manager) {
setGroupInfoDto(manager.getGroupInfo());
setIsReady(manager.isSyncPlayReady());
}
}, [isEnabled, manager]);
// Connect to WebSocket messages - manager is now state so hook re-runs when ready
useSyncPlayWebSocket(manager);
// ============================================================================
// Group Management
// ============================================================================
const getGroups = useCallback(async (): Promise<GroupInfoDto[]> => {
if (!api) return [];
try {
const syncPlayApi = getSyncPlayApi(api);
const response = await syncPlayApi.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 {
const syncPlayApi = getSyncPlayApi(api);
await syncPlayApi.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 {
const syncPlayApi = getSyncPlayApi(api);
await syncPlayApi.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 {
const syncPlayApi = getSyncPlayApi(api);
await syncPlayApi.syncPlayLeaveGroup();
} catch (error) {
console.error("SyncPlay: failed to leave group", error);
throw error;
}
}, [api]);
// Re-join the SyncPlay group when the app returns from background.
//
// Backgrounding tears down our WebSocket (see WebSocketProvider) and the
// server may drop us from the group after its inactivity timeout. Even
// when it doesn't, we likely missed any commands/state-updates broadcast
// while we were suspended. Re-issuing the join is idempotent on the
// server and gets us a fresh GroupJoined snapshot.
const lastGroupIdRef = useRef<string | null>(null);
useEffect(() => {
lastGroupIdRef.current = groupInfo?.GroupId ?? null;
}, [groupInfo?.GroupId]);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
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;
// Give the WebSocket a moment to reconnect (handled by
// WebSocketProvider on the same 'active' transition) so the
// server's GroupJoined broadcast actually reaches us.
setTimeout(() => {
console.log(`SyncPlay: app foregrounded, 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 Integration
// ============================================================================
const setPlayerControls = useCallback(
(controls: PlayerControls | null) => {
manager?.setPlayerControls(controls);
playbackCoreRef.current?.setPlayerControls(controls);
},
[manager],
);
const notifyReady = useCallback(() => {
console.log("SyncPlay: notifyReady called");
playbackCoreRef.current?.onReady();
}, []);
const notifyBuffering = useCallback(() => {
console.log("SyncPlay: notifyBuffering called");
playbackCoreRef.current?.onBuffering();
}, []);
// ============================================================================
// Stats
// ============================================================================
const getStats = useCallback((): SyncPlayStats => {
return (
manager?.getStats() ?? {
timeSyncDevice: "None",
timeSyncOffset: "0.00",
playbackDiff: "0.00",
syncMethod: "None",
}
);
}, [manager]);
// ============================================================================
// Context Value
// ============================================================================
const contextValue: SyncPlayContextValue = useMemo(
() => ({
isEnabled,
isReady,
groupInfo,
canJoinGroups,
canCreateGroups,
joinGroup,
createGroup,
leaveGroup,
getGroups,
controller: controllerRef.current,
setPlayerControls,
notifyReady,
notifyBuffering,
getStats,
osdAction,
isSyncing,
syncMethod,
pendingPlaybackCommand,
}),
[
isEnabled,
isReady,
groupInfo,
canJoinGroups,
canCreateGroups,
joinGroup,
createGroup,
leaveGroup,
getGroups,
setPlayerControls,
notifyReady,
notifyBuffering,
getStats,
osdAction,
isSyncing,
syncMethod,
pendingPlaybackCommand,
],
);
return (
<SyncPlayContext.Provider value={contextValue}>
{children}
</SyncPlayContext.Provider>
);
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to access SyncPlay state and actions
*/
export function useSyncPlay(): SyncPlayContextValue {
const context = useContext(SyncPlayContext);
if (!context) {
throw new Error("useSyncPlay must be used within a SyncPlayProvider");
}
return context;
}
/**
* Hook to access the SyncPlay controller
*/
export function useSyncPlayController(): SyncPlayController | null {
const { controller } = useSyncPlay();
return controller;
}

View File

@@ -1,284 +0,0 @@
/**
* TimeSyncCore
*
* Manages time synchronization with the Jellyfin server.
* Uses NTP-like algorithm to calculate clock offset between client and server.
*
* Based on jellyfin-web's TimeSyncCore.js and TimeSync.js
*/
import type { Api } from "@jellyfin/sdk";
import { getTimeSyncApi } from "@jellyfin/sdk/lib/utils/api";
import type { TimeSyncMeasurement } from "./types";
// Time estimation constants
const NumberOfTrackedMeasurements = 8;
const PollingIntervalGreedy = 1000; // ms - fast polling initially
const PollingIntervalLowProfile = 60000; // ms - slow polling once synced
const GreedyPingCount = 3;
/**
* Stores a single time sync measurement
*/
class Measurement {
requestSent: number;
requestReceived: number;
responseSent: number;
responseReceived: number;
constructor(data: TimeSyncMeasurement) {
this.requestSent = data.requestSent;
this.requestReceived = data.requestReceived;
this.responseSent = data.responseSent;
this.responseReceived = data.responseReceived;
}
/**
* Calculate time offset from server, in milliseconds.
* Offset = (t1 - t0 + t2 - t3) / 2
* where t0 = request sent, t1 = request received, t2 = response sent, t3 = response received
*/
getOffset(): number {
return (
(this.requestReceived -
this.requestSent +
(this.responseSent - this.responseReceived)) /
2
);
}
/**
* Get round-trip delay, in milliseconds.
*/
getDelay(): number {
return (
this.responseReceived -
this.requestSent -
(this.responseSent - this.requestReceived)
);
}
/**
* Get ping time (half of round-trip), in milliseconds.
*/
getPing(): number {
return this.getDelay() / 2;
}
}
export type TimeSyncEventCallback = (
error: Error | null,
timeOffset: number | null,
ping: number | null,
) => void;
/**
* TimeSyncCore - Manages time synchronization with the server
*/
export class TimeSyncCore {
private api: Api;
private poller: ReturnType<typeof setTimeout> | null = null;
private pingStop = true;
private pollingInterval = PollingIntervalGreedy;
private pings = 0;
private measurement: Measurement | null = null;
private measurements: Measurement[] = [];
private extraTimeOffset = 0;
private onUpdateCallback: TimeSyncEventCallback | null = null;
constructor(api: Api) {
this.api = api;
}
/**
* Set callback for time sync updates
*/
onUpdate(callback: TimeSyncEventCallback): void {
this.onUpdateCallback = callback;
}
/**
* Check if time sync is ready (has at least one measurement)
*/
isReady(): boolean {
return this.measurement !== null;
}
/**
* Get the current time offset with server, in milliseconds.
*/
getTimeOffset(): number {
return (this.measurement?.getOffset() ?? 0) + this.extraTimeOffset;
}
/**
* Get current ping time to server, in milliseconds.
*/
getPing(): number {
return this.measurement?.getPing() ?? 0;
}
/**
* Set extra time offset for manual adjustment
*/
setExtraTimeOffset(offset: number): void {
this.extraTimeOffset = offset;
}
/**
* Convert server time to local time.
*/
remoteDateToLocal(remote: Date): Date {
// remote - local = offset, so local = remote - offset
return new Date(remote.getTime() - this.getTimeOffset());
}
/**
* Convert local time to server time.
*/
localDateToRemote(local: Date): Date {
// remote - local = offset, so remote = local + offset
return new Date(local.getTime() + this.getTimeOffset());
}
/**
* Get the display name of the sync device
*/
getActiveDeviceName(): string {
return "Server";
}
/**
* Make a ping request to the server to measure time offset
*/
private async requestPing(): Promise<TimeSyncMeasurement> {
const requestSent = Date.now();
const timeSyncApi = getTimeSyncApi(this.api);
const response = await timeSyncApi.getUtcTime();
const responseReceived = Date.now();
const data = response.data;
const requestReceived = new Date(data.RequestReceptionTime!).getTime();
const responseSent = new Date(data.ResponseTransmissionTime!).getTime();
return {
requestSent,
requestReceived,
responseSent,
responseReceived,
};
}
/**
* Update time offset with a new measurement
*/
private updateTimeOffset(measurement: Measurement): void {
this.measurements.push(measurement);
if (this.measurements.length > NumberOfTrackedMeasurements) {
this.measurements.shift();
}
// Pick measurement with minimum delay (most accurate)
const sortedMeasurements = [...this.measurements].sort(
(a, b) => a.getDelay() - b.getDelay(),
);
this.measurement = sortedMeasurements[0];
}
/**
* Internal poller for ping requests
*/
private internalRequestPing(): void {
if (this.poller !== null || this.pingStop) {
return;
}
this.poller = setTimeout(async () => {
this.poller = null;
try {
const result = await this.requestPing();
this.onPingSuccess(result);
} catch (error) {
this.onPingError(error as Error);
}
// Schedule next ping
this.internalRequestPing();
}, this.pollingInterval);
}
/**
* Handle successful ping response
*/
private onPingSuccess(result: TimeSyncMeasurement): void {
const measurement = new Measurement(result);
this.updateTimeOffset(measurement);
// Slow down polling after initial greedy phase
if (this.pings >= GreedyPingCount) {
this.pollingInterval = PollingIntervalLowProfile;
} else {
this.pings++;
}
this.onUpdateCallback?.(null, this.getTimeOffset(), this.getPing());
}
/**
* Handle ping error
*/
private onPingError(error: Error): void {
console.error("SyncPlay TimeSyncCore: ping error", error);
this.onUpdateCallback?.(error, null, null);
}
/**
* Start the time sync poller
*/
startPing(): void {
this.pingStop = false;
this.internalRequestPing();
}
/**
* Stop the time sync poller
*/
stopPing(): void {
this.pingStop = true;
if (this.poller !== null) {
clearTimeout(this.poller);
this.poller = null;
}
}
/**
* Force an immediate update (reset to greedy mode)
*/
forceUpdate(): void {
this.stopPing();
this.pollingInterval = PollingIntervalGreedy;
this.pings = 0;
this.startPing();
}
/**
* Drop all accumulated measurements
*/
resetMeasurements(): void {
this.measurement = null;
this.measurements = [];
}
/**
* Clean up resources
*/
destroy(): void {
this.stopPing();
this.resetMeasurements();
this.onUpdateCallback = null;
}
}

View File

@@ -1,25 +0,0 @@
/**
* SyncPlay Module
*
* Synchronized playback for Jellyfin.
* Allows multiple users to watch content together in sync.
*/
export { SyncPlayController } from "./Controller";
// Helpers
export * from "./Helper";
// Core modules
export { SyncPlayManager } from "./Manager";
export { PlaybackCore } from "./PlaybackCore";
export { QueueCore } from "./QueueCore";
// Provider and hooks
export {
SyncPlayProvider,
useSyncPlay,
useSyncPlayController,
} from "./SyncPlayProvider";
export { TimeSyncCore } from "./TimeSyncCore";
// Types
export * from "./types";

View File

@@ -1,139 +0,0 @@
/**
* SyncPlay Types
*
* Re-exports Jellyfin SDK types and defines app-specific types.
* Following the pattern used in offline downloads.
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
// ============================================================================
// Re-export SDK Types
// ============================================================================
// Group types
// Playback command types
// Queue types
// User access type
// Group update message types
export type {
GroupInfoDto,
GroupRepeatMode,
GroupShuffleMode,
GroupStateType,
GroupUpdate,
GroupUpdateType,
PlayQueueUpdate,
PlayQueueUpdateReason,
SendCommand,
SendCommandType,
SyncPlayGroupJoinedUpdate,
SyncPlayGroupLeftUpdate,
SyncPlayPlayQueueUpdate,
SyncPlayQueueItem,
SyncPlayStateUpdate,
SyncPlayUserAccessType,
SyncPlayUserJoinedUpdate,
SyncPlayUserLeftUpdate,
} from "@jellyfin/sdk/lib/generated-client/models";
// ============================================================================
// Constants
// ============================================================================
export const TicksPerMillisecond = 10000;
export const WaitForEventDefaultTimeout = 30000; // milliseconds
export const WaitForPlayerEventTimeout = 500; // milliseconds
// ============================================================================
// App-Specific Types (not in SDK)
// ============================================================================
/**
* Time sync measurement for NTP-like synchronization
*/
export interface TimeSyncMeasurement {
requestSent: number;
requestReceived: number;
responseSent: number;
responseReceived: number;
}
/**
* Player controls interface for integrating with MPV player
*/
export interface PlayerControls {
play: () => void;
pause: () => void;
seekTo: (positionMs: number) => void;
setSpeed: (speed: number) => void;
getSpeed: () => number;
getCurrentPosition: () => number;
isPlaying: () => boolean;
isBuffering: () => boolean;
}
/**
* OSD action types for visual feedback
*/
export type SyncPlayOsdAction =
| "schedule-play"
| "unpause"
| "pause"
| "seek"
| "buffering"
| "wait-pause"
| "wait-unpause";
/**
* SyncPlay settings for sync correction algorithms
*/
export interface SyncPlaySettings {
// SpeedToSync settings
minDelaySpeedToSync: number;
maxDelaySpeedToSync: number;
speedToSyncDuration: number;
// SkipToSync settings
minDelaySkipToSync: number;
// Feature toggles
useSpeedToSync: boolean;
useSkipToSync: boolean;
enableSyncCorrection: boolean;
// Time sync
extraTimeOffset: number;
}
export const DEFAULT_SYNC_PLAY_SETTINGS: SyncPlaySettings = {
minDelaySpeedToSync: 60.0,
maxDelaySpeedToSync: 3000.0,
speedToSyncDuration: 1000.0,
minDelaySkipToSync: 400.0,
useSpeedToSync: true,
useSkipToSync: true,
enableSyncCorrection: false,
extraTimeOffset: 0.0,
};
/**
* Stats for debugging/display
*/
export interface SyncPlayStats {
timeSyncDevice: string;
timeSyncOffset: string;
playbackDiff: string;
syncMethod: string;
}
/**
* Play options for starting playback
*/
export interface PlayOptions {
ids?: string[];
items?: BaseItemDto[];
startIndex?: number;
startPositionTicks?: number;
serverId?: string;
}

View File

@@ -1,87 +0,0 @@
/**
* 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 { 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": {
const update = Data as { Type?: string; Data?: unknown };
console.debug("SyncPlay: group update -", update.Type);
manager.processGroupUpdate(update);
break;
}
default:
break;
}
};
ws.addEventListener("message", handleMessage);
return () => {
ws.removeEventListener("message", handleMessage);
};
}, [ws, manager]);
}

View File

@@ -29,10 +29,6 @@
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>

View File

@@ -608,8 +608,7 @@
"downloaded_file_message": "Heruntergeladene Datei abspielen?", "downloaded_file_message": "Heruntergeladene Datei abspielen?",
"downloaded_file_yes": "Ja", "downloaded_file_yes": "Ja",
"downloaded_file_no": "Nein", "downloaded_file_no": "Nein",
"downloaded_file_cancel": "Abbrechen", "downloaded_file_cancel": "Abbrechen"
"ends_at": "Endet um {{time}}"
}, },
"item_card": { "item_card": {
"next_up": "Als Nächstes", "next_up": "Als Nächstes",

View File

@@ -698,7 +698,7 @@
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel", "downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings", "swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}", "ends_at": "ends at",
"search_subtitles": "Search Subtitles", "search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks", "subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download", "subtitle_search": "Search & Download",
@@ -998,30 +998,6 @@
"all": "All media (default)" "all": "All media (default)"
} }
}, },
"syncplay": {
"title": "SyncPlay",
"my_group": "My Group",
"join_group": "Join Group",
"leave_group": "Leave Group",
"create_group": "Create Group",
"create_new_group": "Create New Group",
"available_groups": "Available Groups",
"group_id": "Group ID",
"leader": "Leader",
"members": "members",
"enabled": "SyncPlay enabled",
"disabled": "SyncPlay disabled",
"user_joined": "{{username}} joined the group",
"user_left": "{{username}} left the group",
"permission_required": "Permission required to use SyncPlay",
"group_does_not_exist": "Group does not exist",
"create_denied": "Permission denied to create group",
"join_denied": "Permission denied to join group",
"library_access_denied": "Library access denied",
"waiting_for_group": "Waiting for group...",
"joining_playback": "Joining group playback...",
"failed_to_start": "Failed to start SyncPlay group 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",