mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 16:18:09 +00:00
Compare commits
9 Commits
develop
...
mpv-player
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1769cbd59 | ||
|
|
2b0e238799 | ||
|
|
b573a25203 | ||
|
|
074222050a | ||
|
|
7135be198a | ||
|
|
2648877eb8 | ||
|
|
bc78346760 | ||
|
|
c76d7eb877 | ||
|
|
36655bba43 |
@@ -1,4 +1,3 @@
|
|||||||
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -21,14 +20,8 @@ const Page: React.FC = () => {
|
|||||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||||
const isOffline = offline === "true";
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
const { data: item, isError } = useItemQuery(id, false, undefined, [
|
// Fetch item with all fields including MediaSources
|
||||||
ItemFields.MediaSources,
|
const { data: item, isError } = useItemQuery(id, isOffline, undefined, []);
|
||||||
ItemFields.MediaSourceCount,
|
|
||||||
ItemFields.MediaStreams,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// preload media sources
|
|
||||||
const { data: itemWithSources } = useItemQuery(id, false, undefined, []);
|
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
@@ -98,13 +91,7 @@ const Page: React.FC = () => {
|
|||||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{item && (
|
{item && <ItemContent item={item} isOffline={isOffline} />}
|
||||||
<ItemContent
|
|
||||||
item={item}
|
|
||||||
isOffline={isOffline}
|
|
||||||
itemWithSources={itemWithSources}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,33 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { AppState } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.defaultVideoOrientation) {
|
||||||
|
lockOrientation(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply orientation lock when app returns to foreground (iOS resets it)
|
||||||
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||||
|
if (nextAppState === "active" && settings?.defaultVideoOrientation) {
|
||||||
|
lockOrientation(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
unlockOrientation();
|
||||||
|
};
|
||||||
|
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
|
|||||||
@@ -22,35 +22,34 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import {
|
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||||
OUTLINE_THICKNESS,
|
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||||
OutlineThickness,
|
|
||||||
VLC_COLORS,
|
|
||||||
VLCColor,
|
|
||||||
} from "@/constants/SubtitleConstants";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { VlcPlayerView } from "@/modules";
|
import {
|
||||||
import type {
|
MpvPlayerView,
|
||||||
PlaybackStatePayload,
|
type MpvPlayerViewRef,
|
||||||
ProgressUpdatePayload,
|
type OnPlaybackStateChangePayload,
|
||||||
VlcPlayerViewRef,
|
type OnProgressEventPayload,
|
||||||
} from "@/modules/VlcPlayer.types";
|
type VideoSource,
|
||||||
|
} from "@/modules";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import {
|
||||||
|
getMpvAudioId,
|
||||||
|
getMpvSubtitleId,
|
||||||
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -58,7 +57,7 @@ export default function page() {
|
|||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [isPipMode, setIsPipMode] = useState(false);
|
const [isPipMode, _setIsPipMode] = useState(false);
|
||||||
const [aspectRatio, setAspectRatio] = useState<
|
const [aspectRatio, setAspectRatio] = useState<
|
||||||
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
||||||
>("default");
|
>("default");
|
||||||
@@ -69,6 +68,7 @@ export default function page() {
|
|||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
|
const [tracksReady, setTracksReady] = useState(false);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
@@ -111,7 +111,6 @@ export default function page() {
|
|||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
}>();
|
}>();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
|
||||||
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
const playbackManager = usePlaybackManager();
|
const playbackManager = usePlaybackManager();
|
||||||
@@ -174,16 +173,6 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}, [itemId, offline, api, user?.Id]);
|
}, [itemId, offline, api, user?.Id]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings?.defaultVideoOrientation) {
|
|
||||||
lockOrientation(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlockOrientation();
|
|
||||||
};
|
|
||||||
}, [settings?.defaultVideoOrientation]);
|
|
||||||
|
|
||||||
interface Stream {
|
interface Stream {
|
||||||
mediaSource: MediaSourceInfo;
|
mediaSource: MediaSourceInfo;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -230,8 +219,6 @@ export default function page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const native = generateDeviceProfile();
|
|
||||||
const transcoding = generateDeviceProfile({ transcode: true });
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
@@ -241,7 +228,7 @@ export default function page() {
|
|||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: bitrateValue ? transcoding : native,
|
deviceProfile: generateDeviceProfile(),
|
||||||
});
|
});
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
const { mediaSource, sessionId, url } = res;
|
const { mediaSource, sessionId, url } = res;
|
||||||
@@ -325,7 +312,8 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.stop();
|
// MPV doesn't have a stop method, use pause instead
|
||||||
|
videoRef.current?.pause();
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
}, [videoRef, reportPlaybackStopped, progress]);
|
}, [videoRef, reportPlaybackStopped, progress]);
|
||||||
|
|
||||||
@@ -338,6 +326,7 @@ export default function page() {
|
|||||||
|
|
||||||
const currentPlayStateInfo = useCallback(() => {
|
const currentPlayStateInfo = useCallback(() => {
|
||||||
if (!stream || !item?.Id) return;
|
if (!stream || !item?.Id) return;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
itemId: item.Id,
|
itemId: item.Id,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
@@ -380,10 +369,13 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
async (data: ProgressUpdatePayload) => {
|
async (data: { nativeEvent: OnProgressEventPayload }) => {
|
||||||
if (isSeeking.get() || isPlaybackStopped) return;
|
if (isSeeking.get() || isPlaybackStopped) return;
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
const { position } = data.nativeEvent;
|
||||||
|
// MPV reports position in seconds, convert to ms
|
||||||
|
const currentTime = position * 1000;
|
||||||
|
|
||||||
if (isBuffering) {
|
if (isBuffering) {
|
||||||
setIsBuffering(false);
|
setIsBuffering(false);
|
||||||
}
|
}
|
||||||
@@ -429,6 +421,46 @@ export default function page() {
|
|||||||
return ticksToSeconds(getInitialPlaybackTicks());
|
return ticksToSeconds(getInitialPlaybackTicks());
|
||||||
}, [getInitialPlaybackTicks]);
|
}, [getInitialPlaybackTicks]);
|
||||||
|
|
||||||
|
/** Build video source config for the native player */
|
||||||
|
const videoSource = useMemo<VideoSource | undefined>(() => {
|
||||||
|
if (!stream?.url) return undefined;
|
||||||
|
|
||||||
|
const mediaSource = stream.mediaSource;
|
||||||
|
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
|
// Get external subtitle URLs
|
||||||
|
const externalSubs = mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) =>
|
||||||
|
s.Type === "Subtitle" &&
|
||||||
|
s.DeliveryMethod === "External" &&
|
||||||
|
s.DeliveryUrl,
|
||||||
|
).map((s) => `${api?.basePath}${s.DeliveryUrl}`);
|
||||||
|
|
||||||
|
// Calculate MPV track IDs for initial selection
|
||||||
|
const initialSubtitleId = getMpvSubtitleId(
|
||||||
|
mediaSource,
|
||||||
|
subtitleIndex,
|
||||||
|
isTranscoding,
|
||||||
|
);
|
||||||
|
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: stream.url,
|
||||||
|
startPosition,
|
||||||
|
autoplay: true,
|
||||||
|
externalSubtitles: externalSubs,
|
||||||
|
initialSubtitleId,
|
||||||
|
initialAudioId,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
stream?.url,
|
||||||
|
stream?.mediaSource,
|
||||||
|
startPosition,
|
||||||
|
api?.basePath,
|
||||||
|
subtitleIndex,
|
||||||
|
audioIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
const volumeUpCb = useCallback(async () => {
|
const volumeUpCb = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -509,10 +541,12 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback(
|
const onPlaybackStateChanged = useCallback(
|
||||||
async (e: PlaybackStatePayload) => {
|
async (e: { nativeEvent: OnPlaybackStateChangePayload }) => {
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
|
||||||
if (state === "Playing") {
|
|
||||||
|
if (playing) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
setIsBuffering(false);
|
||||||
if (item?.Id) {
|
if (item?.Id) {
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||||
@@ -522,7 +556,7 @@ export default function page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === "Paused") {
|
if (isPaused) {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
if (item?.Id) {
|
if (item?.Id) {
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
@@ -533,87 +567,13 @@ export default function page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlaying) {
|
if (isLoading) {
|
||||||
setIsPlaying(true);
|
|
||||||
setIsBuffering(false);
|
|
||||||
} else if (isBuffering) {
|
|
||||||
setIsBuffering(true);
|
setIsBuffering(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[playbackManager, item?.Id, progress],
|
[playbackManager, item?.Id, progress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allAudio =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(audio) => audio.Type === "Audio",
|
|
||||||
) || [];
|
|
||||||
|
|
||||||
// Move all the external subtitles last, because vlc places them last.
|
|
||||||
const allSubs =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(sub) => sub.Type === "Subtitle",
|
|
||||||
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
|
|
||||||
|
|
||||||
const externalSubtitles = allSubs
|
|
||||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
|
||||||
.map((sub: any) => ({
|
|
||||||
name: sub.DisplayTitle,
|
|
||||||
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
|
|
||||||
}));
|
|
||||||
/** The text based subtitle tracks */
|
|
||||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
|
||||||
/** The user chosen subtitle track from the server */
|
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
|
||||||
(sub) => sub.Index === subtitleIndex,
|
|
||||||
);
|
|
||||||
/** The user chosen audio track from the server */
|
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
|
||||||
/** Whether the stream we're playing is not transcoding*/
|
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
|
||||||
/** The initial options to pass to the VLC Player */
|
|
||||||
const initOptions = [``];
|
|
||||||
if (
|
|
||||||
chosenSubtitleTrack &&
|
|
||||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
|
||||||
) {
|
|
||||||
// If not transcoding, we can the index as normal.
|
|
||||||
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
|
|
||||||
const finalIndex = notTranscoding
|
|
||||||
? allSubs.indexOf(chosenSubtitleTrack)
|
|
||||||
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
|
||||||
initOptions.push(`--sub-track=${finalIndex}`);
|
|
||||||
|
|
||||||
// Add VLC subtitle styling options from settings
|
|
||||||
const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
|
|
||||||
const backgroundColor = (settings.vlcBackgroundColor ??
|
|
||||||
"Black") as VLCColor;
|
|
||||||
const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
|
|
||||||
const outlineThickness = (settings.vlcOutlineThickness ??
|
|
||||||
"Normal") as OutlineThickness;
|
|
||||||
const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
|
|
||||||
const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
|
|
||||||
const isBold = settings.vlcIsBold ?? false;
|
|
||||||
// Add subtitle styling options
|
|
||||||
initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
|
|
||||||
initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
|
|
||||||
initOptions.push(
|
|
||||||
`--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
|
|
||||||
);
|
|
||||||
initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
|
|
||||||
initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
|
|
||||||
initOptions.push(
|
|
||||||
`--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
|
|
||||||
);
|
|
||||||
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
|
|
||||||
initOptions.push("--sub-margin=40");
|
|
||||||
if (isBold) {
|
|
||||||
initOptions.push("--freetype-bold");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (notTranscoding && chosenAudioTrack) {
|
|
||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
// Add useEffect to handle mounting
|
// Add useEffect to handle mounting
|
||||||
@@ -626,6 +586,7 @@ export default function page() {
|
|||||||
const startPictureInPicture = useCallback(async () => {
|
const startPictureInPicture = useCallback(async () => {
|
||||||
return videoRef.current?.startPictureInPicture?.();
|
return videoRef.current?.startPictureInPicture?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const play = useCallback(() => {
|
const play = useCallback(() => {
|
||||||
videoRef.current?.play?.();
|
videoRef.current?.play?.();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -635,69 +596,40 @@ export default function page() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const seek = useCallback((position: number) => {
|
const seek = useCallback((position: number) => {
|
||||||
videoRef.current?.seekTo?.(position);
|
// MPV expects seconds, convert from ms
|
||||||
}, []);
|
videoRef.current?.seekTo?.(position / 1000);
|
||||||
const getAudioTracks = useCallback(async () => {
|
|
||||||
return videoRef.current?.getAudioTracks?.() || null;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getSubtitleTracks = useCallback(async () => {
|
// Apply MPV subtitle settings when video loads
|
||||||
return videoRef.current?.getSubtitleTracks?.() || null;
|
useEffect(() => {
|
||||||
}, []);
|
if (!isVideoLoaded || !videoRef.current) return;
|
||||||
|
|
||||||
const setSubtitleTrack = useCallback((index: number) => {
|
const applySubtitleSettings = async () => {
|
||||||
videoRef.current?.setSubtitleTrack?.(index);
|
if (settings.mpvSubtitleScale !== undefined) {
|
||||||
}, []);
|
await videoRef.current?.setSubtitleScale(settings.mpvSubtitleScale);
|
||||||
|
}
|
||||||
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
|
if (settings.mpvSubtitleMarginY !== undefined) {
|
||||||
// Note: VlcPlayer type only expects url parameter
|
await videoRef.current?.setSubtitleMarginY(settings.mpvSubtitleMarginY);
|
||||||
videoRef.current?.setSubtitleURL?.(url);
|
}
|
||||||
}, []);
|
if (settings.mpvSubtitleAlignX !== undefined) {
|
||||||
|
await videoRef.current?.setSubtitleAlignX(settings.mpvSubtitleAlignX);
|
||||||
const setAudioTrack = useCallback((index: number) => {
|
}
|
||||||
videoRef.current?.setAudioTrack?.(index);
|
if (settings.mpvSubtitleAlignY !== undefined) {
|
||||||
}, []);
|
await videoRef.current?.setSubtitleAlignY(settings.mpvSubtitleAlignY);
|
||||||
|
}
|
||||||
const setVideoAspectRatio = useCallback(
|
if (settings.mpvSubtitleFontSize !== undefined) {
|
||||||
async (aspectRatio: string | null) => {
|
await videoRef.current?.setSubtitleFontSize(
|
||||||
return (
|
settings.mpvSubtitleFontSize,
|
||||||
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
|
);
|
||||||
Promise.resolve()
|
}
|
||||||
);
|
// Apply subtitle size from general settings
|
||||||
},
|
if (settings.subtitleSize) {
|
||||||
[],
|
await videoRef.current?.setSubtitleFontSize(settings.subtitleSize);
|
||||||
);
|
}
|
||||||
|
|
||||||
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
|
|
||||||
return (
|
|
||||||
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Prepare metadata for iOS native media controls
|
|
||||||
const nowPlayingMetadata = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
|
|
||||||
const artworkUri = getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 500,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: item.Name || "",
|
|
||||||
artist:
|
|
||||||
item.Type === "Episode"
|
|
||||||
? item.SeriesName || ""
|
|
||||||
: item.AlbumArtist || "",
|
|
||||||
albumTitle:
|
|
||||||
item.Type === "Episode" && item.SeasonName
|
|
||||||
? item.SeasonName
|
|
||||||
: undefined,
|
|
||||||
artworkUri: artworkUri || undefined,
|
|
||||||
};
|
};
|
||||||
}, [item, api]);
|
|
||||||
|
applySubtitleSettings();
|
||||||
|
}, [isVideoLoaded, settings]);
|
||||||
|
|
||||||
// Show error UI first, before checking loading/missing‐data
|
// Show error UI first, before checking loading/missing‐data
|
||||||
if (itemStatus.isError || streamStatus.isError) {
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
@@ -708,7 +640,7 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then show loader while either side is still fetching or data isn’t present
|
// Then show loader while either side is still fetching or data isn't present
|
||||||
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
||||||
// …loader UI…
|
// …loader UI…
|
||||||
return (
|
return (
|
||||||
@@ -726,91 +658,80 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlayerProvider
|
||||||
style={{
|
playerRef={videoRef}
|
||||||
flex: 1,
|
item={item}
|
||||||
backgroundColor: "black",
|
mediaSource={stream?.mediaSource}
|
||||||
height: "100%",
|
isVideoLoaded={isVideoLoaded}
|
||||||
width: "100%",
|
tracksReady={tracksReady}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View
|
<VideoProvider>
|
||||||
style={{
|
<View
|
||||||
display: "flex",
|
style={{
|
||||||
width: "100%",
|
flex: 1,
|
||||||
height: "100%",
|
backgroundColor: "black",
|
||||||
position: "relative",
|
height: "100%",
|
||||||
flexDirection: "column",
|
width: "100%",
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VlcPlayerView
|
|
||||||
ref={videoRef}
|
|
||||||
source={{
|
|
||||||
uri: stream?.url || "",
|
|
||||||
autoplay: true,
|
|
||||||
isNetwork: !offline,
|
|
||||||
startPosition,
|
|
||||||
externalSubtitles,
|
|
||||||
initOptions,
|
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
>
|
||||||
nowPlayingMetadata={nowPlayingMetadata}
|
<View
|
||||||
onVideoProgress={onProgress}
|
style={{
|
||||||
progressUpdateInterval={1000}
|
display: "flex",
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
width: "100%",
|
||||||
onVideoLoadEnd={() => {
|
height: "100%",
|
||||||
setIsVideoLoaded(true);
|
position: "relative",
|
||||||
}}
|
flexDirection: "column",
|
||||||
onVideoError={(e) => {
|
justifyContent: "center",
|
||||||
console.error("Video Error:", e.nativeEvent);
|
}}
|
||||||
Alert.alert(
|
>
|
||||||
t("player.error"),
|
<MpvPlayerView
|
||||||
t("player.an_error_occured_while_playing_the_video"),
|
ref={videoRef}
|
||||||
);
|
source={videoSource}
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
style={{ width: "100%", height: "100%" }}
|
||||||
}}
|
onProgress={onProgress}
|
||||||
onPipStarted={(e) => {
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
setIsPipMode(e.nativeEvent.pipStarted);
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
}}
|
onError={(e) => {
|
||||||
/>
|
console.error("Video Error:", e.nativeEvent);
|
||||||
</View>
|
Alert.alert(
|
||||||
{isMounted === true && item && !isPipMode && (
|
t("player.error"),
|
||||||
<Controls
|
t("player.an_error_occured_while_playing_the_video"),
|
||||||
mediaSource={stream?.mediaSource}
|
);
|
||||||
item={item}
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
videoRef={videoRef}
|
}}
|
||||||
togglePlay={togglePlay}
|
onTracksReady={() => {
|
||||||
isPlaying={isPlaying}
|
setTracksReady(true);
|
||||||
isSeeking={isSeeking}
|
}}
|
||||||
progress={progress}
|
/>
|
||||||
cacheProgress={cacheProgress}
|
</View>
|
||||||
isBuffering={isBuffering}
|
{isMounted === true && item && !isPipMode && (
|
||||||
showControls={showControls}
|
<Controls
|
||||||
setShowControls={setShowControls}
|
mediaSource={stream?.mediaSource}
|
||||||
isVideoLoaded={isVideoLoaded}
|
item={item}
|
||||||
startPictureInPicture={startPictureInPicture}
|
togglePlay={togglePlay}
|
||||||
play={play}
|
isPlaying={isPlaying}
|
||||||
pause={pause}
|
isSeeking={isSeeking}
|
||||||
seek={seek}
|
progress={progress}
|
||||||
enableTrickplay={true}
|
cacheProgress={cacheProgress}
|
||||||
getAudioTracks={getAudioTracks}
|
isBuffering={isBuffering}
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
showControls={showControls}
|
||||||
offline={offline}
|
setShowControls={setShowControls}
|
||||||
setSubtitleTrack={setSubtitleTrack}
|
startPictureInPicture={startPictureInPicture}
|
||||||
setSubtitleURL={setSubtitleURL}
|
play={play}
|
||||||
setAudioTrack={setAudioTrack}
|
pause={pause}
|
||||||
setVideoAspectRatio={setVideoAspectRatio}
|
seek={seek}
|
||||||
setVideoScaleFactor={setVideoScaleFactor}
|
enableTrickplay={true}
|
||||||
aspectRatio={aspectRatio}
|
offline={offline}
|
||||||
scaleFactor={scaleFactor}
|
aspectRatio={aspectRatio}
|
||||||
setAspectRatio={setAspectRatio}
|
scaleFactor={scaleFactor}
|
||||||
setScaleFactor={setScaleFactor}
|
setAspectRatio={setAspectRatio}
|
||||||
isVlc
|
setScaleFactor={setScaleFactor}
|
||||||
api={api}
|
api={api}
|
||||||
downloadedFiles={downloadedFiles}
|
downloadedFiles={downloadedFiles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</VideoProvider>
|
||||||
|
</PlayerProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,11 +46,10 @@ export type SelectedOptions = {
|
|||||||
interface ItemContentProps {
|
interface ItemContentProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
isOffline: boolean;
|
isOffline: boolean;
|
||||||
itemWithSources?: BaseItemDto | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||||
({ item, isOffline, itemWithSources }) => {
|
({ item, isOffline }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
@@ -72,7 +71,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
} = useDefaultPlaySettings(item!, settings);
|
} = useDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
@@ -87,7 +86,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource,
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
@@ -99,7 +98,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Platform.isTV && itemWithSources) {
|
if (!Platform.isTV && item) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item &&
|
item &&
|
||||||
@@ -109,7 +108,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className='flex flex-row items-center'>
|
<View className='flex flex-row items-center'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
<DownloadSingleItem item={item} size='large' />
|
||||||
)}
|
)}
|
||||||
{user?.Policy?.IsAdministrator && (
|
{user?.Policy?.IsAdministrator && (
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
@@ -126,7 +125,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
<DownloadSingleItem item={item} size='large' />
|
||||||
)}
|
)}
|
||||||
{user?.Policy?.IsAdministrator && (
|
{user?.Policy?.IsAdministrator && (
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
@@ -140,7 +139,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [item, navigation, user, itemWithSources]);
|
}, [item, navigation, user, item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -213,7 +212,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
<MediaSourceButton
|
<MediaSourceButton
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
setSelectedOptions={setSelectedOptions}
|
setSelectedOptions={setSelectedOptions}
|
||||||
item={itemWithSources}
|
item={item}
|
||||||
colors={itemColors}
|
colors={itemColors}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
@@ -280,7 +280,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
console.log("onPress");
|
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
@@ -474,52 +473,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// if (Platform.OS === "ios")
|
|
||||||
// return (
|
|
||||||
// <Host
|
|
||||||
// style={{
|
|
||||||
// height: 50,
|
|
||||||
// flex: 1,
|
|
||||||
// flexShrink: 0,
|
|
||||||
// }}
|
|
||||||
// >
|
|
||||||
// <Button
|
|
||||||
// variant='glassProminent'
|
|
||||||
// onPress={onPress}
|
|
||||||
// color={effectiveColors.primary}
|
|
||||||
// modifiers={[fixedSize()]}
|
|
||||||
// >
|
|
||||||
// <View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
|
|
||||||
// <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
|
||||||
// {runtimeTicksToMinutes(
|
|
||||||
// (item?.RunTimeTicks || 0) -
|
|
||||||
// (item?.UserData?.PlaybackPositionTicks || 0),
|
|
||||||
// )}
|
|
||||||
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
|
||||||
// </Animated.Text>
|
|
||||||
// <Animated.Text style={animatedTextStyle}>
|
|
||||||
// <Ionicons name='play-circle' size={24} />
|
|
||||||
// </Animated.Text>
|
|
||||||
// {client && (
|
|
||||||
// <Animated.Text style={animatedTextStyle}>
|
|
||||||
// <Feather name='cast' size={22} />
|
|
||||||
// <CastButton tintColor='transparent' />
|
|
||||||
// </Animated.Text>
|
|
||||||
// )}
|
|
||||||
// {!client && settings?.openInVLC && (
|
|
||||||
// <Animated.Text style={animatedTextStyle}>
|
|
||||||
// <MaterialCommunityIcons
|
|
||||||
// name='vlc'
|
|
||||||
// size={18}
|
|
||||||
// color={animatedTextStyle.color}
|
|
||||||
// />
|
|
||||||
// </Animated.Text>
|
|
||||||
// )}
|
|
||||||
// </View>
|
|
||||||
// </Button>
|
|
||||||
// </Host>
|
|
||||||
// );
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={!item}
|
disabled={!item}
|
||||||
@@ -569,15 +522,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<CastButton tintColor='transparent' />
|
<CastButton tintColor='transparent' />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
)}
|
||||||
{!client && settings?.openInVLC && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='vlc'
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -17,7 +17,6 @@ import Animated, {
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import type { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
@@ -50,7 +49,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const startColor = useSharedValue(effectiveColors);
|
const startColor = useSharedValue(effectiveColors);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const { settings } = useSettings();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
@@ -61,7 +59,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onPress = () => {
|
const onPress = () => {
|
||||||
console.log("onpress");
|
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
@@ -207,15 +204,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name='play-circle' size={24} />
|
<Ionicons name='play-circle' size={24} />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{settings?.openInVLC && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='vlc'
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -5,12 +5,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import {
|
|
||||||
OUTLINE_THICKNESS,
|
|
||||||
type OutlineThickness,
|
|
||||||
VLC_COLORS,
|
|
||||||
type VLCColor,
|
|
||||||
} from "@/constants/SubtitleConstants";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -92,84 +86,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
];
|
];
|
||||||
}, [settings?.subtitleMode, t, updateSettings]);
|
}, [settings?.subtitleMode, t, updateSettings]);
|
||||||
|
|
||||||
const textColorOptionGroups = useMemo(() => {
|
|
||||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
|
||||||
const options = colors.map((color) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: t(`home.settings.subtitles.colors.${color}`),
|
|
||||||
value: color,
|
|
||||||
selected: (settings?.vlcTextColor || "White") === color,
|
|
||||||
onPress: () => updateSettings({ vlcTextColor: color }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [{ options }];
|
|
||||||
}, [settings?.vlcTextColor, t, updateSettings]);
|
|
||||||
|
|
||||||
const backgroundColorOptionGroups = useMemo(() => {
|
|
||||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
|
||||||
const options = colors.map((color) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: t(`home.settings.subtitles.colors.${color}`),
|
|
||||||
value: color,
|
|
||||||
selected: (settings?.vlcBackgroundColor || "Black") === color,
|
|
||||||
onPress: () => updateSettings({ vlcBackgroundColor: color }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [{ options }];
|
|
||||||
}, [settings?.vlcBackgroundColor, t, updateSettings]);
|
|
||||||
|
|
||||||
const outlineColorOptionGroups = useMemo(() => {
|
|
||||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
|
||||||
const options = colors.map((color) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: t(`home.settings.subtitles.colors.${color}`),
|
|
||||||
value: color,
|
|
||||||
selected: (settings?.vlcOutlineColor || "Black") === color,
|
|
||||||
onPress: () => updateSettings({ vlcOutlineColor: color }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [{ options }];
|
|
||||||
}, [settings?.vlcOutlineColor, t, updateSettings]);
|
|
||||||
|
|
||||||
const outlineThicknessOptionGroups = useMemo(() => {
|
|
||||||
const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[];
|
|
||||||
const options = thicknesses.map((thickness) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: t(`home.settings.subtitles.thickness.${thickness}`),
|
|
||||||
value: thickness,
|
|
||||||
selected: (settings?.vlcOutlineThickness || "Normal") === thickness,
|
|
||||||
onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [{ options }];
|
|
||||||
}, [settings?.vlcOutlineThickness, t, updateSettings]);
|
|
||||||
|
|
||||||
const backgroundOpacityOptionGroups = useMemo(() => {
|
|
||||||
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
|
||||||
const options = opacities.map((opacity) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: `${Math.round((opacity / 255) * 100)}%`,
|
|
||||||
value: opacity,
|
|
||||||
selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity,
|
|
||||||
onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [{ options }];
|
|
||||||
}, [settings?.vlcBackgroundOpacity, updateSettings]);
|
|
||||||
|
|
||||||
const outlineOpacityOptionGroups = useMemo(() => {
|
|
||||||
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
|
||||||
const options = opacities.map((opacity) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: `${Math.round((opacity / 255) * 100)}%`,
|
|
||||||
value: opacity,
|
|
||||||
selected: (settings?.vlcOutlineOpacity ?? 255) === opacity,
|
|
||||||
onPress: () => updateSettings({ vlcOutlineOpacity: opacity }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [{ options }];
|
|
||||||
}, [settings?.vlcOutlineOpacity, updateSettings]);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -252,124 +168,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
|
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.subtitles.text_color")}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={textColorOptionGroups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(
|
|
||||||
`home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.subtitles.text_color")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.background_color")}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={backgroundColorOptionGroups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(
|
|
||||||
`home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.subtitles.background_color")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.outline_color")}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={outlineColorOptionGroups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(
|
|
||||||
`home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.subtitles.outline_color")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={outlineThicknessOptionGroups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(
|
|
||||||
`home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.subtitles.outline_thickness")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={backgroundOpacityOptionGroups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.subtitles.background_opacity")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={outlineOpacityOptionGroups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.subtitles.outline_opacity")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.bold_text")}>
|
|
||||||
<Switch
|
|
||||||
value={settings?.vlcIsBold ?? false}
|
|
||||||
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface BottomControlsProps {
|
|||||||
showRemoteBubble: boolean;
|
showRemoteBubble: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
isVlc: boolean;
|
|
||||||
showSkipButton: boolean;
|
showSkipButton: boolean;
|
||||||
showSkipCreditButton: boolean;
|
showSkipCreditButton: boolean;
|
||||||
skipIntro: () => void;
|
skipIntro: () => void;
|
||||||
@@ -66,7 +65,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showRemoteBubble,
|
showRemoteBubble,
|
||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
isVlc,
|
|
||||||
showSkipButton,
|
showSkipButton,
|
||||||
showSkipCreditButton,
|
showSkipCreditButton,
|
||||||
skipIntro,
|
skipIntro,
|
||||||
@@ -145,13 +143,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
settings.autoPlayEpisodeCount <
|
settings.autoPlayEpisodeCount <
|
||||||
settings.maxAutoPlayEpisodeCount.value) && (
|
settings.maxAutoPlayEpisodeCount.value) && (
|
||||||
<NextEpisodeCountDownButton
|
<NextEpisodeCountDownButton
|
||||||
show={
|
show={!nextItem ? false : remainingTime < 10000}
|
||||||
!nextItem
|
|
||||||
? false
|
|
||||||
: isVlc
|
|
||||||
? remainingTime < 10000
|
|
||||||
: remainingTime < 10
|
|
||||||
}
|
|
||||||
onFinish={handleNextEpisodeAutoPlay}
|
onFinish={handleNextEpisodeAutoPlay}
|
||||||
onPress={handleNextEpisodeManual}
|
onPress={handleNextEpisodeManual}
|
||||||
/>
|
/>
|
||||||
@@ -208,7 +200,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
remainingTime={remainingTime}
|
remainingTime={remainingTime}
|
||||||
isVlc={isVlc}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useLocalSearchParams, useRouter } from "expo-router";
|
|||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type FC,
|
type FC,
|
||||||
type MutableRefObject,
|
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -28,7 +27,6 @@ import { useHaptic } from "@/hooks/useHaptic";
|
|||||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
@@ -36,7 +34,6 @@ import { ticksToMs } from "@/utils/time";
|
|||||||
import { BottomControls } from "./BottomControls";
|
import { BottomControls } from "./BottomControls";
|
||||||
import { CenterControls } from "./CenterControls";
|
import { CenterControls } from "./CenterControls";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
import { ControlProvider } from "./contexts/ControlContext";
|
|
||||||
import { EpisodeList } from "./EpisodeList";
|
import { EpisodeList } from "./EpisodeList";
|
||||||
import { GestureOverlay } from "./GestureOverlay";
|
import { GestureOverlay } from "./GestureOverlay";
|
||||||
import { HeaderControls } from "./HeaderControls";
|
import { HeaderControls } from "./HeaderControls";
|
||||||
@@ -50,36 +47,27 @@ import { type AspectRatio } from "./VideoScalingModeSelector";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
|
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isSeeking: SharedValue<boolean>;
|
isSeeking: SharedValue<boolean>;
|
||||||
cacheProgress: SharedValue<number>;
|
cacheProgress: SharedValue<number>;
|
||||||
progress: SharedValue<number>;
|
progress: SharedValue<number>;
|
||||||
isBuffering: boolean;
|
isBuffering: boolean;
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
|
|
||||||
enableTrickplay?: boolean;
|
enableTrickplay?: boolean;
|
||||||
togglePlay: () => void;
|
togglePlay: () => void;
|
||||||
setShowControls: (shown: boolean) => void;
|
setShowControls: (shown: boolean) => void;
|
||||||
offline?: boolean;
|
offline?: boolean;
|
||||||
isVideoLoaded?: boolean;
|
|
||||||
mediaSource?: MediaSourceInfo | null;
|
mediaSource?: MediaSourceInfo | null;
|
||||||
seek: (ticks: number) => void;
|
seek: (ticks: number) => void;
|
||||||
startPictureInPicture?: () => Promise<void>;
|
startPictureInPicture?: () => Promise<void>;
|
||||||
play: () => void;
|
play: () => void;
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
|
||||||
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
|
||||||
setSubtitleURL?: (url: string, customName: string) => void;
|
|
||||||
setSubtitleTrack?: (index: number) => void;
|
|
||||||
setAudioTrack?: (index: number) => void;
|
|
||||||
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
|
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
|
||||||
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
|
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
|
||||||
aspectRatio?: AspectRatio;
|
aspectRatio?: AspectRatio;
|
||||||
scaleFactor?: ScaleFactor;
|
scaleFactor?: ScaleFactor;
|
||||||
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
||||||
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
|
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
|
||||||
isVlc?: boolean;
|
|
||||||
api?: Api | null;
|
api?: Api | null;
|
||||||
downloadedFiles?: DownloadedItem[];
|
downloadedFiles?: DownloadedItem[];
|
||||||
}
|
}
|
||||||
@@ -99,12 +87,6 @@ export const Controls: FC<Props> = ({
|
|||||||
showControls,
|
showControls,
|
||||||
setShowControls,
|
setShowControls,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
isVideoLoaded,
|
|
||||||
getAudioTracks,
|
|
||||||
getSubtitleTracks,
|
|
||||||
setSubtitleURL,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setAudioTrack,
|
|
||||||
setVideoAspectRatio,
|
setVideoAspectRatio,
|
||||||
setVideoScaleFactor,
|
setVideoScaleFactor,
|
||||||
aspectRatio = "default",
|
aspectRatio = "default",
|
||||||
@@ -112,7 +94,6 @@ export const Controls: FC<Props> = ({
|
|||||||
setAspectRatio,
|
setAspectRatio,
|
||||||
setScaleFactor,
|
setScaleFactor,
|
||||||
offline = false,
|
offline = false,
|
||||||
isVlc = false,
|
|
||||||
api = null,
|
api = null,
|
||||||
downloadedFiles = undefined,
|
downloadedFiles = undefined,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -194,17 +175,13 @@ export const Controls: FC<Props> = ({
|
|||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Initialize progress values
|
// Initialize progress values - MPV uses milliseconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
progress.value = isVlc
|
progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks);
|
||||||
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
|
max.value = ticksToMs(item.RunTimeTicks || 0);
|
||||||
: item?.UserData?.PlaybackPositionTicks || 0;
|
|
||||||
max.value = isVlc
|
|
||||||
? ticksToMs(item.RunTimeTicks || 0)
|
|
||||||
: item.RunTimeTicks || 0;
|
|
||||||
}
|
}
|
||||||
}, [item, isVlc, progress, max]);
|
}, [item, progress, max]);
|
||||||
|
|
||||||
// Navigation hooks
|
// Navigation hooks
|
||||||
const {
|
const {
|
||||||
@@ -215,7 +192,6 @@ export const Controls: FC<Props> = ({
|
|||||||
} = useVideoNavigation({
|
} = useVideoNavigation({
|
||||||
progress,
|
progress,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isVlc,
|
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
});
|
});
|
||||||
@@ -225,7 +201,6 @@ export const Controls: FC<Props> = ({
|
|||||||
progress,
|
progress,
|
||||||
max,
|
max,
|
||||||
isSeeking,
|
isSeeking,
|
||||||
isVlc,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleControls = useCallback(() => {
|
const toggleControls = useCallback(() => {
|
||||||
@@ -248,7 +223,6 @@ export const Controls: FC<Props> = ({
|
|||||||
progress,
|
progress,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
isVlc,
|
|
||||||
showControls,
|
showControls,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
seek,
|
seek,
|
||||||
@@ -273,7 +247,6 @@ export const Controls: FC<Props> = ({
|
|||||||
progress,
|
progress,
|
||||||
isSeeking,
|
isSeeking,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isVlc,
|
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
@@ -302,9 +275,8 @@ export const Controls: FC<Props> = ({
|
|||||||
: current.actual;
|
: current.actual;
|
||||||
} else {
|
} else {
|
||||||
// When not scrubbing, only update if progress changed significantly (1 second)
|
// When not scrubbing, only update if progress changed significantly (1 second)
|
||||||
const progressUnit = isVlc
|
// MPV uses milliseconds
|
||||||
? CONTROLS_CONSTANTS.PROGRESS_UNIT_MS
|
const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS;
|
||||||
: CONTROLS_CONSTANTS.PROGRESS_UNIT_TICKS;
|
|
||||||
const progressDiff = Math.abs(current.actual - effectiveProgress.value);
|
const progressDiff = Math.abs(current.actual - effectiveProgress.value);
|
||||||
if (progressDiff >= progressUnit) {
|
if (progressDiff >= progressUnit) {
|
||||||
effectiveProgress.value = current.actual;
|
effectiveProgress.value = current.actual;
|
||||||
@@ -325,7 +297,6 @@ export const Controls: FC<Props> = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
isVlc,
|
|
||||||
offline,
|
offline,
|
||||||
api,
|
api,
|
||||||
downloadedFiles,
|
downloadedFiles,
|
||||||
@@ -336,7 +307,6 @@ export const Controls: FC<Props> = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
isVlc,
|
|
||||||
offline,
|
offline,
|
||||||
api,
|
api,
|
||||||
downloadedFiles,
|
downloadedFiles,
|
||||||
@@ -359,12 +329,10 @@ export const Controls: FC<Props> = ({
|
|||||||
mediaSource: newMediaSource,
|
mediaSource: newMediaSource,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
} = getDefaultPlaySettings(
|
} = getDefaultPlaySettings(item, settings, {
|
||||||
item,
|
indexes: previousIndexes,
|
||||||
settings,
|
source: mediaSource ?? undefined,
|
||||||
previousIndexes,
|
});
|
||||||
mediaSource ?? undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
...(offline && { offline: "true" }),
|
...(offline && { offline: "true" }),
|
||||||
@@ -479,11 +447,7 @@ export const Controls: FC<Props> = ({
|
|||||||
}, [isPlaying, togglePlay]);
|
}, [isPlaying, togglePlay]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlProvider
|
<>
|
||||||
item={item}
|
|
||||||
mediaSource={mediaSource}
|
|
||||||
isVideoLoaded={isVideoLoaded}
|
|
||||||
>
|
|
||||||
{episodeView ? (
|
{episodeView ? (
|
||||||
<EpisodeList
|
<EpisodeList
|
||||||
item={item}
|
item={item}
|
||||||
@@ -515,11 +479,6 @@ export const Controls: FC<Props> = ({
|
|||||||
goToNextItem={goToNextItem}
|
goToNextItem={goToNextItem}
|
||||||
previousItem={previousItem}
|
previousItem={previousItem}
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
getAudioTracks={getAudioTracks}
|
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
|
||||||
setAudioTrack={setAudioTrack}
|
|
||||||
setSubtitleTrack={setSubtitleTrack}
|
|
||||||
setSubtitleURL={setSubtitleURL}
|
|
||||||
aspectRatio={aspectRatio}
|
aspectRatio={aspectRatio}
|
||||||
scaleFactor={scaleFactor}
|
scaleFactor={scaleFactor}
|
||||||
setAspectRatio={setAspectRatio}
|
setAspectRatio={setAspectRatio}
|
||||||
@@ -554,7 +513,6 @@ export const Controls: FC<Props> = ({
|
|||||||
showRemoteBubble={showRemoteBubble}
|
showRemoteBubble={showRemoteBubble}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
remainingTime={remainingTime}
|
remainingTime={remainingTime}
|
||||||
isVlc={isVlc}
|
|
||||||
showSkipButton={showSkipButton}
|
showSkipButton={showSkipButton}
|
||||||
showSkipCreditButton={showSkipCreditButton}
|
showSkipCreditButton={showSkipCreditButton}
|
||||||
skipIntro={skipIntro}
|
skipIntro={skipIntro}
|
||||||
@@ -582,6 +540,6 @@ export const Controls: FC<Props> = ({
|
|||||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||||
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
|
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
|
||||||
)}
|
)}
|
||||||
</ControlProvider>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ICON_SIZES } from "./constants";
|
import { ICON_SIZES } from "./constants";
|
||||||
import { VideoProvider } from "./contexts/VideoContext";
|
|
||||||
import DropdownView from "./dropdown/DropdownView";
|
import DropdownView from "./dropdown/DropdownView";
|
||||||
import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
|
import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
|
||||||
import {
|
import {
|
||||||
@@ -34,11 +33,6 @@ interface HeaderControlsProps {
|
|||||||
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
|
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
|
||||||
previousItem?: BaseItemDto | null;
|
previousItem?: BaseItemDto | null;
|
||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
getAudioTracks?: (() => Promise<any[] | null>) | (() => any[]);
|
|
||||||
getSubtitleTracks?: (() => Promise<any[] | null>) | (() => any[]);
|
|
||||||
setAudioTrack?: (index: number) => void;
|
|
||||||
setSubtitleTrack?: (index: number) => void;
|
|
||||||
setSubtitleURL?: (url: string, customName: string) => void;
|
|
||||||
aspectRatio?: AspectRatio;
|
aspectRatio?: AspectRatio;
|
||||||
scaleFactor?: ScaleFactor;
|
scaleFactor?: ScaleFactor;
|
||||||
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
||||||
@@ -58,11 +52,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
goToNextItem,
|
goToNextItem,
|
||||||
previousItem,
|
previousItem,
|
||||||
nextItem,
|
nextItem,
|
||||||
getAudioTracks,
|
|
||||||
getSubtitleTracks,
|
|
||||||
setAudioTrack,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setSubtitleURL,
|
|
||||||
aspectRatio = "default",
|
aspectRatio = "default",
|
||||||
scaleFactor = 1.0,
|
scaleFactor = 1.0,
|
||||||
setAspectRatio,
|
setAspectRatio,
|
||||||
@@ -113,35 +102,25 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
>
|
>
|
||||||
<View className='mr-auto' pointerEvents='box-none'>
|
<View className='mr-auto' pointerEvents='box-none'>
|
||||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||||
<VideoProvider
|
<View pointerEvents='auto'>
|
||||||
getAudioTracks={getAudioTracks}
|
<DropdownView />
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
</View>
|
||||||
setAudioTrack={setAudioTrack}
|
|
||||||
setSubtitleTrack={setSubtitleTrack}
|
|
||||||
setSubtitleURL={setSubtitleURL}
|
|
||||||
>
|
|
||||||
<View pointerEvents='auto'>
|
|
||||||
<DropdownView />
|
|
||||||
</View>
|
|
||||||
</VideoProvider>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
{!Platform.isTV &&
|
{!Platform.isTV && startPictureInPicture && (
|
||||||
(settings.defaultPlayer === VideoPlayer.VLC_4 ||
|
<TouchableOpacity
|
||||||
Platform.OS === "android") && (
|
onPress={startPictureInPicture}
|
||||||
<TouchableOpacity
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
onPress={startPictureInPicture}
|
>
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
<MaterialIcons
|
||||||
>
|
name='picture-in-picture'
|
||||||
<MaterialIcons
|
size={ICON_SIZES.HEADER}
|
||||||
name='picture-in-picture'
|
color='white'
|
||||||
size={ICON_SIZES.HEADER}
|
/>
|
||||||
color='white'
|
</TouchableOpacity>
|
||||||
/>
|
)}
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{item?.Type === "Episode" && (
|
{item?.Type === "Episode" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={switchOnEpisodeMode}
|
onPress={switchOnEpisodeMode}
|
||||||
|
|||||||
@@ -6,18 +6,20 @@ import { formatTimeString } from "@/utils/time";
|
|||||||
interface TimeDisplayProps {
|
interface TimeDisplayProps {
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
isVlc: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays current time and remaining time.
|
||||||
|
* MPV player uses milliseconds for time values.
|
||||||
|
*/
|
||||||
export const TimeDisplay: FC<TimeDisplayProps> = ({
|
export const TimeDisplay: FC<TimeDisplayProps> = ({
|
||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
isVlc,
|
|
||||||
}) => {
|
}) => {
|
||||||
const getFinishTime = () => {
|
const getFinishTime = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const remainingMs = isVlc ? remainingTime : remainingTime * 1000;
|
// remainingTime is in ms
|
||||||
const finishTime = new Date(now.getTime() + remainingMs);
|
const finishTime = new Date(now.getTime() + remainingTime);
|
||||||
return finishTime.toLocaleTimeString([], {
|
return finishTime.toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
@@ -28,11 +30,11 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View className='flex flex-row items-center justify-between mt-2'>
|
<View className='flex flex-row items-center justify-between mt-2'>
|
||||||
<Text className='text-[12px] text-neutral-400'>
|
<Text className='text-[12px] text-neutral-400'>
|
||||||
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
{formatTimeString(currentTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-col items-end'>
|
<View className='flex flex-col items-end'>
|
||||||
<Text className='text-[12px] text-neutral-400'>
|
<Text className='text-[12px] text-neutral-400'>
|
||||||
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
-{formatTimeString(remainingTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
||||||
ends at {getFinishTime()}
|
ends at {getFinishTime()}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import type React from "react";
|
|
||||||
import { createContext, type ReactNode, useContext } from "react";
|
|
||||||
|
|
||||||
interface ControlContextProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
mediaSource: MediaSourceInfo | null | undefined;
|
|
||||||
isVideoLoaded: boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ControlContext = createContext<ControlContextProps | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
interface ControlProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
item: BaseItemDto;
|
|
||||||
mediaSource: MediaSourceInfo | null | undefined;
|
|
||||||
isVideoLoaded: boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ControlProvider: React.FC<ControlProviderProps> = ({
|
|
||||||
children,
|
|
||||||
item,
|
|
||||||
mediaSource,
|
|
||||||
isVideoLoaded,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
|
|
||||||
{children}
|
|
||||||
</ControlContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useControlContext = () => {
|
|
||||||
const context = useContext(ControlContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error("useControlContext must be used within a ControlProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
107
components/video-player/controls/contexts/PlayerContext.tsx
Normal file
107
components/video-player/controls/contexts/PlayerContext.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
type MutableRefObject,
|
||||||
|
type ReactNode,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
|
import type { MpvPlayerViewRef } from "@/modules";
|
||||||
|
|
||||||
|
interface PlayerContextProps {
|
||||||
|
playerRef: MutableRefObject<MpvPlayerViewRef | null>;
|
||||||
|
item: BaseItemDto;
|
||||||
|
mediaSource: MediaSourceInfo | null | undefined;
|
||||||
|
isVideoLoaded: boolean;
|
||||||
|
tracksReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerContext = createContext<PlayerContextProps | undefined>(undefined);
|
||||||
|
|
||||||
|
interface PlayerProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
playerRef: MutableRefObject<MpvPlayerViewRef | null>;
|
||||||
|
item: BaseItemDto;
|
||||||
|
mediaSource: MediaSourceInfo | null | undefined;
|
||||||
|
isVideoLoaded: boolean;
|
||||||
|
tracksReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||||
|
children,
|
||||||
|
playerRef,
|
||||||
|
item,
|
||||||
|
mediaSource,
|
||||||
|
isVideoLoaded,
|
||||||
|
tracksReady,
|
||||||
|
}) => {
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ playerRef, item, mediaSource, isVideoLoaded, tracksReady }),
|
||||||
|
[playerRef, item, mediaSource, isVideoLoaded, tracksReady],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Core context hook
|
||||||
|
export const usePlayerContext = () => {
|
||||||
|
const context = useContext(PlayerContext);
|
||||||
|
if (!context)
|
||||||
|
throw new Error("usePlayerContext must be used within PlayerProvider");
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Player controls hook
|
||||||
|
export const usePlayerControls = () => {
|
||||||
|
const { playerRef } = usePlayerContext();
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Subtitle controls
|
||||||
|
getSubtitleTracks: async () => {
|
||||||
|
return playerRef.current?.getSubtitleTracks() ?? null;
|
||||||
|
},
|
||||||
|
setSubtitleTrack: (trackId: number) => {
|
||||||
|
playerRef.current?.setSubtitleTrack(trackId);
|
||||||
|
},
|
||||||
|
disableSubtitles: () => {
|
||||||
|
playerRef.current?.disableSubtitles();
|
||||||
|
},
|
||||||
|
addSubtitleFile: (url: string, select = true) => {
|
||||||
|
playerRef.current?.addSubtitleFile(url, select);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Audio controls
|
||||||
|
getAudioTracks: async () => {
|
||||||
|
return playerRef.current?.getAudioTracks() ?? null;
|
||||||
|
},
|
||||||
|
setAudioTrack: (trackId: number) => {
|
||||||
|
playerRef.current?.setAudioTrack(trackId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Playback controls
|
||||||
|
play: () => playerRef.current?.play(),
|
||||||
|
pause: () => playerRef.current?.pause(),
|
||||||
|
seekTo: (position: number) => playerRef.current?.seekTo(position),
|
||||||
|
seekBy: (offset: number) => playerRef.current?.seekBy(offset),
|
||||||
|
setSpeed: (speed: number) => playerRef.current?.setSpeed(speed),
|
||||||
|
|
||||||
|
// Subtitle positioning
|
||||||
|
setSubtitleScale: (scale: number) =>
|
||||||
|
playerRef.current?.setSubtitleScale(scale),
|
||||||
|
setSubtitlePosition: (position: number) =>
|
||||||
|
playerRef.current?.setSubtitlePosition(position),
|
||||||
|
setSubtitleMarginY: (margin: number) =>
|
||||||
|
playerRef.current?.setSubtitleMarginY(margin),
|
||||||
|
setSubtitleFontSize: (size: number) =>
|
||||||
|
playerRef.current?.setSubtitleFontSize(size),
|
||||||
|
|
||||||
|
// PiP
|
||||||
|
startPictureInPicture: () => playerRef.current?.startPictureInPicture(),
|
||||||
|
stopPictureInPicture: () => playerRef.current?.stopPictureInPicture(),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,69 @@
|
|||||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
/**
|
||||||
|
* VideoContext.tsx
|
||||||
|
*
|
||||||
|
* Manages subtitle and audio track state for the video player UI.
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* INDEX TYPES
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* We track two different indices for each track:
|
||||||
|
*
|
||||||
|
* 1. SERVER INDEX (sub.Index / track.index)
|
||||||
|
* - Jellyfin's server-side stream index
|
||||||
|
* - Used to report playback state to Jellyfin server
|
||||||
|
* - Allows Jellyfin to remember user's last selected tracks
|
||||||
|
* - Passed via router params (subtitleIndex, audioIndex)
|
||||||
|
* - Value of -1 means disabled/none
|
||||||
|
*
|
||||||
|
* 2. MPV INDEX (track.mpvIndex)
|
||||||
|
* - MPV's internal track ID for the loaded track
|
||||||
|
* - Used to actually switch tracks in the player
|
||||||
|
* - Only assigned to tracks that are loaded into MPV
|
||||||
|
* - Value of -1 means track is not in MPV (e.g., burned-in image sub)
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* SUBTITLE DELIVERY METHODS
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Jellyfin provides subtitles via different delivery methods:
|
||||||
|
* - Embed: Subtitle is embedded in the container (MKV, MP4, etc.)
|
||||||
|
* - Hls: Subtitle is delivered via HLS segments (during transcoding)
|
||||||
|
* - External: Subtitle is delivered as a separate file URL
|
||||||
|
* - Encode: Subtitle is burned into the video (image-based subs during transcode)
|
||||||
|
*
|
||||||
|
* Jellyfin also provides `IsTextSubtitleStream` boolean:
|
||||||
|
* - true: Text-based subtitle (SRT, ASS, VTT, etc.)
|
||||||
|
* - false: Image-based subtitle (PGS, VOBSUB, DVDSUB, etc.)
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* SUBTITLE TYPES AND HOW THEY'RE HANDLED
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* 1. TEXT-BASED SUBTITLES (IsTextSubtitleStream = true)
|
||||||
|
* - Direct Play: Loaded into MPV (embedded or via sub-add for external)
|
||||||
|
* - Transcoding: Delivered via HLS, loaded into MPV
|
||||||
|
* - Action: Use playerControls.setSubtitleTrack(mpvId)
|
||||||
|
*
|
||||||
|
* 2. IMAGE-BASED SUBTITLES (IsTextSubtitleStream = false)
|
||||||
|
* - Direct Play: Embedded ones are in MPV, external ones are filtered out
|
||||||
|
* - Transcoding: BURNED INTO VIDEO by Jellyfin (not in MPV track list)
|
||||||
|
* - Action: When transcoding, use replacePlayer() to request burn-in
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* MPV INDEX CALCULATION
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* We iterate through Jellyfin's subtitle list and assign MPV indices only to
|
||||||
|
* subtitles that are actually loaded into MPV:
|
||||||
|
*
|
||||||
|
* - isSubtitleInMpv = true: Subtitle is in MPV's track list, increment index
|
||||||
|
* - isSubtitleInMpv = false: Subtitle is NOT in MPV (e.g., image sub during
|
||||||
|
* transcode), do NOT increment index
|
||||||
|
*
|
||||||
|
* The order of subtitles in Jellyfin's MediaStreams matches the order in MPV.
|
||||||
|
*/
|
||||||
|
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
@@ -9,52 +74,29 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
import type { AudioTrack, SubtitleTrack } from "@/modules";
|
||||||
|
import {
|
||||||
|
isImageBasedSubtitle,
|
||||||
|
isSubtitleInMpv,
|
||||||
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import type { Track } from "../types";
|
import type { Track } from "../types";
|
||||||
import { useControlContext } from "./ControlContext";
|
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||||
|
|
||||||
interface VideoContextProps {
|
interface VideoContextProps {
|
||||||
audioTracks: Track[] | null;
|
|
||||||
subtitleTracks: Track[] | null;
|
subtitleTracks: Track[] | null;
|
||||||
setAudioTrack: ((index: number) => void) | undefined;
|
audioTracks: Track[] | null;
|
||||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
|
||||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoContext = createContext<VideoContextProps | undefined>(undefined);
|
const VideoContext = createContext<VideoContextProps | undefined>(undefined);
|
||||||
|
|
||||||
interface VideoProviderProps {
|
export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
children: ReactNode;
|
|
||||||
getAudioTracks:
|
|
||||||
| (() => Promise<TrackInfo[] | null>)
|
|
||||||
| (() => TrackInfo[])
|
|
||||||
| undefined;
|
|
||||||
getSubtitleTracks:
|
|
||||||
| (() => Promise<TrackInfo[] | null>)
|
|
||||||
| (() => TrackInfo[])
|
|
||||||
| undefined;
|
|
||||||
setAudioTrack: ((index: number) => void) | undefined;
|
|
||||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
|
||||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|
||||||
children,
|
children,
|
||||||
getSubtitleTracks,
|
|
||||||
getAudioTracks,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setSubtitleURL,
|
|
||||||
setAudioTrack,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
|
||||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||||
|
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||||
|
|
||||||
const ControlContext = useControlContext();
|
const { tracksReady, mediaSource } = usePlayerContext();
|
||||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
const playerControls = usePlayerControls();
|
||||||
const mediaSource = ControlContext?.mediaSource;
|
|
||||||
|
|
||||||
const allSubs =
|
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
|
||||||
|
|
||||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||||
useLocalSearchParams<{
|
useLocalSearchParams<{
|
||||||
@@ -66,185 +108,143 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
playbackPosition: string;
|
playbackPosition: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const onTextBasedSubtitle = useMemo(() => {
|
const allSubs =
|
||||||
return (
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||||
allSubs.find(
|
const allAudio =
|
||||||
(s) =>
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||||
s.Index?.toString() === subtitleIndex &&
|
|
||||||
(s.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||||
s.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
|
||||||
s.DeliveryMethod === SubtitleDeliveryMethod.External),
|
/**
|
||||||
) || subtitleIndex === "-1"
|
* Check if the currently selected subtitle is image-based.
|
||||||
|
* Used to determine if we need to refresh the player when changing subs.
|
||||||
|
*/
|
||||||
|
const isCurrentSubImageBased = useMemo(() => {
|
||||||
|
if (subtitleIndex === "-1") return false;
|
||||||
|
const currentSub = allSubs.find(
|
||||||
|
(s) => s.Index?.toString() === subtitleIndex,
|
||||||
);
|
);
|
||||||
|
return currentSub ? isImageBasedSubtitle(currentSub) : false;
|
||||||
}, [allSubs, subtitleIndex]);
|
}, [allSubs, subtitleIndex]);
|
||||||
|
|
||||||
const setPlayerParams = ({
|
/**
|
||||||
chosenAudioIndex = audioIndex,
|
* Refresh the player with new parameters.
|
||||||
chosenSubtitleIndex = subtitleIndex,
|
* This triggers Jellyfin to re-process the stream (e.g., burn in image subs).
|
||||||
}: {
|
*/
|
||||||
chosenAudioIndex?: string;
|
const replacePlayer = (params: {
|
||||||
chosenSubtitleIndex?: string;
|
audioIndex?: string;
|
||||||
|
subtitleIndex?: string;
|
||||||
}) => {
|
}) => {
|
||||||
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: itemId ?? "",
|
itemId: itemId ?? "",
|
||||||
audioIndex: chosenAudioIndex,
|
audioIndex: params.audioIndex ?? audioIndex,
|
||||||
subtitleIndex: chosenSubtitleIndex,
|
subtitleIndex: params.subtitleIndex ?? subtitleIndex,
|
||||||
mediaSourceId: mediaSource?.Id ?? "",
|
mediaSourceId: mediaSource?.Id ?? "",
|
||||||
bitrateValue: bitrateValue,
|
bitrateValue: bitrateValue,
|
||||||
playbackPosition: playbackPosition,
|
playbackPosition: playbackPosition,
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTrackParams = (
|
// Fetch tracks when ready
|
||||||
type: "audio" | "subtitle",
|
|
||||||
index: number,
|
|
||||||
serverIndex: number,
|
|
||||||
) => {
|
|
||||||
const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack;
|
|
||||||
const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex";
|
|
||||||
|
|
||||||
// If we're transcoding and we're going from a image based subtitle
|
|
||||||
// to a text based subtitle, we need to change the player params.
|
|
||||||
|
|
||||||
const shouldChangePlayerParams =
|
|
||||||
type === "subtitle" &&
|
|
||||||
mediaSource?.TranscodingUrl &&
|
|
||||||
!onTextBasedSubtitle;
|
|
||||||
|
|
||||||
console.log("Set player params", index, serverIndex);
|
|
||||||
if (shouldChangePlayerParams) {
|
|
||||||
setPlayerParams({
|
|
||||||
chosenSubtitleIndex: serverIndex.toString(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTrack?.(serverIndex);
|
|
||||||
router.setParams({
|
|
||||||
[paramKey]: serverIndex.toString(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!tracksReady) return;
|
||||||
|
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
if (getSubtitleTracks) {
|
const [subtitleData, audioData] = await Promise.all([
|
||||||
let subtitleData: TrackInfo[] | null = null;
|
playerControls.getSubtitleTracks().catch(() => null),
|
||||||
try {
|
playerControls.getAudioTracks().catch(() => null),
|
||||||
subtitleData = await getSubtitleTracks();
|
]);
|
||||||
} catch (error) {
|
|
||||||
console.log("[VideoContext] Failed to get subtitle tracks:", error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
|
|
||||||
if (
|
|
||||||
mediaSource?.TranscodingUrl &&
|
|
||||||
subtitleData &&
|
|
||||||
subtitleData.length > 1
|
|
||||||
) {
|
|
||||||
subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()];
|
|
||||||
}
|
|
||||||
|
|
||||||
let embedSubIndex = 1;
|
// Process subtitles - map Jellyfin indices to MPV track IDs
|
||||||
const processedSubs: Track[] = allSubs?.map((sub) => {
|
let mpvIndex = 0; // MPV track index counter (only incremented for subs in MPV)
|
||||||
/** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */
|
|
||||||
const shouldIncrement =
|
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
|
||||||
/** The index of subtitle inside VLC Player Itself */
|
|
||||||
const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1;
|
|
||||||
if (shouldIncrement) embedSubIndex++;
|
|
||||||
return {
|
|
||||||
name: sub.DisplayTitle || "Undefined Subtitle",
|
|
||||||
index: sub.Index ?? -1,
|
|
||||||
setTrack: () =>
|
|
||||||
shouldIncrement
|
|
||||||
? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1)
|
|
||||||
: setPlayerParams({
|
|
||||||
chosenSubtitleIndex: sub.Index?.toString(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 3: Restore the original order
|
const subs: Track[] = allSubs.map((sub) => {
|
||||||
const subtitles: Track[] = processedSubs.sort(
|
const inMpv = isSubtitleInMpv(sub, isTranscoding);
|
||||||
(a, b) => a.index - b.index,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add a "Disable Subtitles" option
|
// Get MPV track ID: only if this sub is actually in MPV's track list
|
||||||
subtitles.unshift({
|
const mpvId = inMpv
|
||||||
name: "Disable",
|
? ((subtitleData as SubtitleTrack[])?.[mpvIndex++]?.id ?? -1)
|
||||||
index: -1,
|
: -1;
|
||||||
setTrack: () =>
|
|
||||||
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
|
return {
|
||||||
? setTrackParams("subtitle", -1, -1)
|
name: sub.DisplayTitle || "Unknown",
|
||||||
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
|
index: sub.Index ?? -1, // Jellyfin server-side index
|
||||||
});
|
mpvIndex: mpvId, // MPV track ID (-1 if not in MPV)
|
||||||
setSubtitleTracks(subtitles);
|
setTrack: () => {
|
||||||
}
|
// Case 1: Transcoding + switching to/from image-based sub
|
||||||
if (getAudioTracks) {
|
// Need to refresh player so Jellyfin burns in the new sub
|
||||||
let audioData: TrackInfo[] | null = null;
|
if (
|
||||||
try {
|
isTranscoding &&
|
||||||
audioData = await getAudioTracks();
|
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
|
||||||
} catch (error) {
|
) {
|
||||||
console.log("[VideoContext] Failed to get audio tracks:", error);
|
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const allAudio =
|
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
// Case 2: Subtitle is in MPV - just switch tracks
|
||||||
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
if (inMpv && mpvId !== -1) {
|
||||||
if (!mediaSource?.TranscodingUrl) {
|
playerControls.setSubtitleTrack(mpvId);
|
||||||
const vlcIndex = audioData?.at(idx + 1)?.index ?? -1;
|
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||||
return {
|
return;
|
||||||
name: audio.DisplayTitle ?? "Undefined Audio",
|
}
|
||||||
index: audio.Index ?? -1,
|
|
||||||
setTrack: () =>
|
// Case 3: Fallback - refresh player
|
||||||
setTrackParams("audio", vlcIndex, audio.Index ?? -1),
|
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||||
};
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add "Disable" option at the beginning
|
||||||
|
subs.unshift({
|
||||||
|
name: "Disable",
|
||||||
|
index: -1,
|
||||||
|
setTrack: () => {
|
||||||
|
// If currently using image-based sub during transcode, need to refresh
|
||||||
|
if (isTranscoding && isCurrentSubImageBased) {
|
||||||
|
replacePlayer({ subtitleIndex: "-1" });
|
||||||
|
} else {
|
||||||
|
playerControls.setSubtitleTrack(-1);
|
||||||
|
router.setParams({ subtitleIndex: "-1" });
|
||||||
}
|
}
|
||||||
return {
|
},
|
||||||
name: audio.DisplayTitle ?? "Undefined Audio",
|
});
|
||||||
index: audio.Index ?? -1,
|
|
||||||
setTrack: () =>
|
|
||||||
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add a "Disable Audio" option if its not transcoding.
|
// Process audio tracks
|
||||||
if (!mediaSource?.TranscodingUrl) {
|
const audio: Track[] = allAudio.map((a, idx) => ({
|
||||||
audioTracks.unshift({
|
name: a.DisplayTitle || "Unknown",
|
||||||
name: "Disable",
|
index: a.Index ?? -1,
|
||||||
index: -1,
|
setTrack: () => {
|
||||||
setTrack: () => setTrackParams("audio", -1, -1),
|
// Transcoding: need full player refresh to change audio stream
|
||||||
});
|
if (isTranscoding) {
|
||||||
}
|
replacePlayer({ audioIndex: String(a.Index) });
|
||||||
setAudioTracks(audioTracks);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Direct play: just switch audio track in MPV
|
||||||
|
const mpvId = (audioData as AudioTrack[])?.[idx]?.id ?? idx + 1;
|
||||||
|
playerControls.setAudioTrack(mpvId);
|
||||||
|
router.setParams({ audioIndex: String(a.Index) });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||||
|
setAudioTracks(audio);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchTracks();
|
fetchTracks();
|
||||||
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
|
}, [tracksReady, mediaSource]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoContext.Provider
|
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||||
value={{
|
|
||||||
audioTracks,
|
|
||||||
subtitleTracks,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setSubtitleURL,
|
|
||||||
setAudioTrack,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</VideoContext.Provider>
|
</VideoContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useVideoContext = () => {
|
export const useVideoContext = () => {
|
||||||
const context = useContext(VideoContext);
|
const ctx = useContext(VideoContext);
|
||||||
if (context === undefined) {
|
if (!ctx)
|
||||||
throw new Error("useVideoContext must be used within a VideoProvider");
|
throw new Error("useVideoContext must be used within VideoProvider");
|
||||||
}
|
return ctx;
|
||||||
return context;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,17 +7,12 @@ import {
|
|||||||
type OptionGroup,
|
type OptionGroup,
|
||||||
PlatformDropdown,
|
PlatformDropdown,
|
||||||
} from "@/components/PlatformDropdown";
|
} from "@/components/PlatformDropdown";
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
import { usePlayerContext } from "../contexts/PlayerContext";
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
|
|
||||||
const DropdownView = () => {
|
const DropdownView = () => {
|
||||||
const videoContext = useVideoContext();
|
const { subtitleTracks, audioTracks } = useVideoContext();
|
||||||
const { subtitleTracks, audioTracks } = videoContext;
|
const { item, mediaSource } = usePlayerContext();
|
||||||
const ControlContext = useControlContext();
|
|
||||||
const [item, mediaSource] = [
|
|
||||||
ControlContext?.item,
|
|
||||||
ControlContext?.mediaSource,
|
|
||||||
];
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ interface UseRemoteControlProps {
|
|||||||
progress: SharedValue<number>;
|
progress: SharedValue<number>;
|
||||||
min: SharedValue<number>;
|
min: SharedValue<number>;
|
||||||
max: SharedValue<number>;
|
max: SharedValue<number>;
|
||||||
isVlc: boolean;
|
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
seek: (value: number) => void;
|
seek: (value: number) => void;
|
||||||
@@ -34,11 +33,14 @@ interface UseRemoteControlProps {
|
|||||||
handleSeekBackward: (seconds: number) => void;
|
handleSeekBackward: (seconds: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage TV remote control interactions.
|
||||||
|
* MPV player uses milliseconds for time values.
|
||||||
|
*/
|
||||||
export function useRemoteControl({
|
export function useRemoteControl({
|
||||||
progress,
|
progress,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
isVlc,
|
|
||||||
showControls,
|
showControls,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
seek,
|
seek,
|
||||||
@@ -61,21 +63,18 @@ export function useRemoteControl({
|
|||||||
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const SCRUB_INTERVAL = isVlc
|
// MPV uses ms
|
||||||
? CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS
|
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
|
||||||
: CONTROLS_CONSTANTS.SCRUB_INTERVAL_TICKS;
|
|
||||||
|
|
||||||
const updateTime = useCallback(
|
const updateTime = useCallback((progressValue: number) => {
|
||||||
(progressValue: number) => {
|
// Convert ms to ticks for calculation
|
||||||
const progressInTicks = isVlc ? msToTicks(progressValue) : progressValue;
|
const progressInTicks = msToTicks(progressValue);
|
||||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||||
const seconds = progressInSeconds % 60;
|
const seconds = progressInSeconds % 60;
|
||||||
setTime({ hours, minutes, seconds });
|
setTime({ hours, minutes, seconds });
|
||||||
},
|
}, []);
|
||||||
[isVlc],
|
|
||||||
);
|
|
||||||
|
|
||||||
// TV remote control handling (no-op on non-TV platforms)
|
// TV remote control handling (no-op on non-TV platforms)
|
||||||
useTVEventHandler((evt) => {
|
useTVEventHandler((evt) => {
|
||||||
@@ -102,7 +101,8 @@ export function useRemoteControl({
|
|||||||
Math.min(max.value, base + direction * SCRUB_INTERVAL),
|
Math.min(max.value, base + direction * SCRUB_INTERVAL),
|
||||||
);
|
);
|
||||||
remoteScrubProgress.value = updated;
|
remoteScrubProgress.value = updated;
|
||||||
const progressInTicks = isVlc ? msToTicks(updated) : updated;
|
// Convert ms to ticks for trickplay
|
||||||
|
const progressInTicks = msToTicks(updated);
|
||||||
calculateTrickplayUrl(progressInTicks);
|
calculateTrickplayUrl(progressInTicks);
|
||||||
updateTime(updated);
|
updateTime(updated);
|
||||||
break;
|
break;
|
||||||
@@ -111,9 +111,8 @@ export function useRemoteControl({
|
|||||||
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
|
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
|
||||||
progress.value = remoteScrubProgress.value;
|
progress.value = remoteScrubProgress.value;
|
||||||
|
|
||||||
const seekTarget = isVlc
|
// MPV uses ms, seek expects ms
|
||||||
? Math.max(0, remoteScrubProgress.value)
|
const seekTarget = Math.max(0, remoteScrubProgress.value);
|
||||||
: Math.max(0, ticksToSeconds(remoteScrubProgress.value));
|
|
||||||
|
|
||||||
seek(seekTarget);
|
seek(seekTarget);
|
||||||
if (isPlaying) play();
|
if (isPlaying) play();
|
||||||
|
|||||||
@@ -3,20 +3,22 @@ import type { SharedValue } from "react-native-reanimated";
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { secondsToMs, ticksToSeconds } from "@/utils/time";
|
import { secondsToMs } from "@/utils/time";
|
||||||
|
|
||||||
interface UseVideoNavigationProps {
|
interface UseVideoNavigationProps {
|
||||||
progress: SharedValue<number>;
|
progress: SharedValue<number>;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isVlc: boolean;
|
|
||||||
seek: (value: number) => void;
|
seek: (value: number) => void;
|
||||||
play: () => void;
|
play: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage video navigation (seeking forward/backward).
|
||||||
|
* MPV player uses milliseconds for time values.
|
||||||
|
*/
|
||||||
export function useVideoNavigation({
|
export function useVideoNavigation({
|
||||||
progress,
|
progress,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isVlc,
|
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
}: UseVideoNavigationProps) {
|
}: UseVideoNavigationProps) {
|
||||||
@@ -30,16 +32,15 @@ export function useVideoNavigation({
|
|||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
const newTime = isVlc
|
// MPV uses ms
|
||||||
? Math.max(0, curr - secondsToMs(seconds))
|
const newTime = Math.max(0, curr - secondsToMs(seconds));
|
||||||
: Math.max(0, ticksToSeconds(curr) - seconds);
|
|
||||||
seek(newTime);
|
seek(newTime);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isPlaying, isVlc, seek, progress],
|
[isPlaying, seek, progress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSeekForward = useCallback(
|
const handleSeekForward = useCallback(
|
||||||
@@ -48,16 +49,15 @@ export function useVideoNavigation({
|
|||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
const newTime = isVlc
|
// MPV uses ms
|
||||||
? curr + secondsToMs(seconds)
|
const newTime = curr + secondsToMs(seconds);
|
||||||
: ticksToSeconds(curr) + seconds;
|
|
||||||
seek(Math.max(0, newTime));
|
seek(Math.max(0, newTime));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isPlaying, isVlc, seek, progress],
|
[isPlaying, seek, progress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSkipBackward = useCallback(async () => {
|
const handleSkipBackward = useCallback(async () => {
|
||||||
@@ -69,9 +69,11 @@ export function useVideoNavigation({
|
|||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
const newTime = isVlc
|
// MPV uses ms
|
||||||
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
|
const newTime = Math.max(
|
||||||
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
|
0,
|
||||||
|
curr - secondsToMs(settings.rewindSkipTime),
|
||||||
|
);
|
||||||
seek(newTime);
|
seek(newTime);
|
||||||
if (wasPlayingRef.current) {
|
if (wasPlayingRef.current) {
|
||||||
play();
|
play();
|
||||||
@@ -80,7 +82,7 @@ export function useVideoNavigation({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
}
|
}
|
||||||
}, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]);
|
}, [settings, isPlaying, play, seek, progress, lightHapticFeedback]);
|
||||||
|
|
||||||
const handleSkipForward = useCallback(async () => {
|
const handleSkipForward = useCallback(async () => {
|
||||||
if (!settings?.forwardSkipTime) {
|
if (!settings?.forwardSkipTime) {
|
||||||
@@ -91,9 +93,8 @@ export function useVideoNavigation({
|
|||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
const newTime = isVlc
|
// MPV uses ms
|
||||||
? curr + secondsToMs(settings.forwardSkipTime)
|
const newTime = curr + secondsToMs(settings.forwardSkipTime);
|
||||||
: ticksToSeconds(curr) + settings.forwardSkipTime;
|
|
||||||
seek(Math.max(0, newTime));
|
seek(Math.max(0, newTime));
|
||||||
if (wasPlayingRef.current) {
|
if (wasPlayingRef.current) {
|
||||||
play();
|
play();
|
||||||
@@ -102,7 +103,7 @@ export function useVideoNavigation({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
}
|
}
|
||||||
}, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]);
|
}, [settings, isPlaying, play, seek, progress, lightHapticFeedback]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleSeekBackward,
|
handleSeekBackward,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ interface UseVideoSliderProps {
|
|||||||
progress: SharedValue<number>;
|
progress: SharedValue<number>;
|
||||||
isSeeking: SharedValue<boolean>;
|
isSeeking: SharedValue<boolean>;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isVlc: boolean;
|
|
||||||
seek: (value: number) => void;
|
seek: (value: number) => void;
|
||||||
play: () => void;
|
play: () => void;
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
@@ -16,11 +15,14 @@ interface UseVideoSliderProps {
|
|||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage video slider interactions.
|
||||||
|
* MPV player uses milliseconds for time values.
|
||||||
|
*/
|
||||||
export function useVideoSlider({
|
export function useVideoSlider({
|
||||||
progress,
|
progress,
|
||||||
isSeeking,
|
isSeeking,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isVlc,
|
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
@@ -62,21 +64,20 @@ export function useVideoSlider({
|
|||||||
setIsSliding(false);
|
setIsSliding(false);
|
||||||
isSeeking.value = false;
|
isSeeking.value = false;
|
||||||
progress.value = value;
|
progress.value = value;
|
||||||
const seekValue = Math.max(
|
// MPV uses ms, seek expects ms
|
||||||
0,
|
const seekValue = Math.max(0, Math.floor(value));
|
||||||
Math.floor(isVlc ? value : ticksToSeconds(value)),
|
|
||||||
);
|
|
||||||
seek(seekValue);
|
seek(seekValue);
|
||||||
if (wasPlayingRef.current) {
|
if (wasPlayingRef.current) {
|
||||||
play();
|
play();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isVlc, seek, play, progress, isSeeking],
|
[seek, play, progress, isSeeking],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSliderChange = useCallback(
|
const handleSliderChange = useCallback(
|
||||||
debounce((value: number) => {
|
debounce((value: number) => {
|
||||||
const progressInTicks = isVlc ? msToTicks(value) : value;
|
// Convert ms to ticks for trickplay
|
||||||
|
const progressInTicks = msToTicks(value);
|
||||||
calculateTrickplayUrl(progressInTicks);
|
calculateTrickplayUrl(progressInTicks);
|
||||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
@@ -84,7 +85,7 @@ export function useVideoSlider({
|
|||||||
const seconds = progressInSeconds % 60;
|
const seconds = progressInSeconds % 60;
|
||||||
setTime({ hours, minutes, seconds });
|
setTime({ hours, minutes, seconds });
|
||||||
}, CONTROLS_CONSTANTS.SLIDER_DEBOUNCE_MS),
|
}, CONTROLS_CONSTANTS.SLIDER_DEBOUNCE_MS),
|
||||||
[isVlc, calculateTrickplayUrl],
|
[calculateTrickplayUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,21 +4,18 @@ import {
|
|||||||
type SharedValue,
|
type SharedValue,
|
||||||
useAnimatedReaction,
|
useAnimatedReaction,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { ticksToSeconds } from "@/utils/time";
|
|
||||||
|
|
||||||
interface UseVideoTimeProps {
|
interface UseVideoTimeProps {
|
||||||
progress: SharedValue<number>;
|
progress: SharedValue<number>;
|
||||||
max: SharedValue<number>;
|
max: SharedValue<number>;
|
||||||
isSeeking: SharedValue<boolean>;
|
isSeeking: SharedValue<boolean>;
|
||||||
isVlc: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVideoTime({
|
/**
|
||||||
progress,
|
* Hook to manage video time display.
|
||||||
max,
|
* MPV player uses milliseconds for time values.
|
||||||
isSeeking,
|
*/
|
||||||
isVlc,
|
export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) {
|
||||||
}: UseVideoTimeProps) {
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
||||||
|
|
||||||
@@ -27,19 +24,16 @@ export function useVideoTime({
|
|||||||
|
|
||||||
const updateTimes = useCallback(
|
const updateTimes = useCallback(
|
||||||
(currentProgress: number, maxValue: number) => {
|
(currentProgress: number, maxValue: number) => {
|
||||||
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
|
// MPV uses milliseconds
|
||||||
const remaining = isVlc
|
const current = currentProgress;
|
||||||
? maxValue - currentProgress
|
const remaining = maxValue - currentProgress;
|
||||||
: ticksToSeconds(maxValue - currentProgress);
|
|
||||||
|
|
||||||
// Only update state if the displayed time actually changed (avoid sub-second updates)
|
// Only update state if the displayed time actually changed (avoid sub-second updates)
|
||||||
const currentSeconds = Math.floor(current / (isVlc ? 1000 : 1));
|
const currentSeconds = Math.floor(current / 1000);
|
||||||
const remainingSeconds = Math.floor(remaining / (isVlc ? 1000 : 1));
|
const remainingSeconds = Math.floor(remaining / 1000);
|
||||||
const lastCurrentSeconds = Math.floor(
|
const lastCurrentSeconds = Math.floor(lastCurrentTimeRef.current / 1000);
|
||||||
lastCurrentTimeRef.current / (isVlc ? 1000 : 1),
|
|
||||||
);
|
|
||||||
const lastRemainingSeconds = Math.floor(
|
const lastRemainingSeconds = Math.floor(
|
||||||
lastRemainingTimeRef.current / (isVlc ? 1000 : 1),
|
lastRemainingTimeRef.current / 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -52,7 +46,7 @@ export function useVideoTime({
|
|||||||
lastRemainingTimeRef.current = remaining;
|
lastRemainingTimeRef.current = remaining;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isVlc],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
playerRef: React.RefObject<VlcPlayerViewRef>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
|
|
||||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
|
||||||
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchTracks = async () => {
|
|
||||||
if (playerRef.current) {
|
|
||||||
try {
|
|
||||||
const audio = await playerRef.current.getAudioTracks();
|
|
||||||
const subtitles = await playerRef.current.getSubtitleTracks();
|
|
||||||
setAudioTracks(audio);
|
|
||||||
setSubtitleTracks(subtitles);
|
|
||||||
} catch (error) {
|
|
||||||
console.log("[VideoDebugInfo] Failed to fetch tracks:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchTracks();
|
|
||||||
}, [playerRef]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: insets.top,
|
|
||||||
left: insets.left + 8,
|
|
||||||
zIndex: 100,
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Text className='font-bold'>{t("player.playback_state")}</Text>
|
|
||||||
<Text className='font-bold mt-2.5'>{t("player.audio_tracks")}</Text>
|
|
||||||
{audioTracks?.map((track, index) => (
|
|
||||||
<Text key={index}>
|
|
||||||
{track.name} ({t("player.index")} {track.index})
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
<Text className='font-bold mt-2.5'>{t("player.subtitles_tracks")}</Text>
|
|
||||||
{subtitleTracks?.map((track, index) => (
|
|
||||||
<Text key={index}>
|
|
||||||
{track.name} ({t("player.index")} {track.index})
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
<TouchableOpacity
|
|
||||||
className='mt-2.5 bg-blue-500 p-2 rounded'
|
|
||||||
onPress={() => {
|
|
||||||
if (playerRef.current) {
|
|
||||||
playerRef.current
|
|
||||||
.getAudioTracks()
|
|
||||||
.then(setAudioTracks)
|
|
||||||
.catch((err) => {
|
|
||||||
console.log(
|
|
||||||
"[VideoDebugInfo] Failed to get audio tracks:",
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
playerRef.current
|
|
||||||
.getSubtitleTracks()
|
|
||||||
.then(setSubtitleTracks)
|
|
||||||
.catch((err) => {
|
|
||||||
console.log(
|
|
||||||
"[VideoDebugInfo] Failed to get subtitle tracks:",
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-white text-center'>
|
|
||||||
{t("player.refresh_tracks")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
export type VLCColor =
|
|
||||||
| "Black"
|
|
||||||
| "Gray"
|
|
||||||
| "Silver"
|
|
||||||
| "White"
|
|
||||||
| "Maroon"
|
|
||||||
| "Red"
|
|
||||||
| "Fuchsia"
|
|
||||||
| "Yellow"
|
|
||||||
| "Olive"
|
|
||||||
| "Green"
|
|
||||||
| "Teal"
|
|
||||||
| "Lime"
|
|
||||||
| "Purple"
|
|
||||||
| "Navy"
|
|
||||||
| "Blue"
|
|
||||||
| "Aqua";
|
|
||||||
|
|
||||||
export type OutlineThickness = "None" | "Thin" | "Normal" | "Thick";
|
|
||||||
|
|
||||||
export const VLC_COLORS: Record<VLCColor, number> = {
|
|
||||||
Black: 0,
|
|
||||||
Gray: 8421504,
|
|
||||||
Silver: 12632256,
|
|
||||||
White: 16777215,
|
|
||||||
Maroon: 8388608,
|
|
||||||
Red: 16711680,
|
|
||||||
Fuchsia: 16711935,
|
|
||||||
Yellow: 16776960,
|
|
||||||
Olive: 8421376,
|
|
||||||
Green: 32768,
|
|
||||||
Teal: 32896,
|
|
||||||
Lime: 65280,
|
|
||||||
Purple: 8388736,
|
|
||||||
Navy: 128,
|
|
||||||
Blue: 255,
|
|
||||||
Aqua: 65535,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OUTLINE_THICKNESS: Record<OutlineThickness, number> = {
|
|
||||||
None: 0,
|
|
||||||
Thin: 2,
|
|
||||||
Normal: 4,
|
|
||||||
Thick: 6,
|
|
||||||
};
|
|
||||||
@@ -5,12 +5,15 @@ import { useSegments } from "@/utils/segments";
|
|||||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
import { useHaptic } from "./useHaptic";
|
import { useHaptic } from "./useHaptic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to handle skipping credits in a media player.
|
||||||
|
* MPV player uses milliseconds for time.
|
||||||
|
*/
|
||||||
export const useCreditSkipper = (
|
export const useCreditSkipper = (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
seek: (time: number) => void,
|
seek: (ms: number) => void,
|
||||||
play: () => void,
|
play: () => void,
|
||||||
isVlc = false,
|
|
||||||
isOffline = false,
|
isOffline = false,
|
||||||
api: Api | null = null,
|
api: Api | null = null,
|
||||||
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||||
@@ -18,16 +21,11 @@ export const useCreditSkipper = (
|
|||||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
if (isVlc) {
|
// Convert ms to seconds for comparison with timestamps
|
||||||
currentTime = msToSeconds(currentTime);
|
const currentTimeSeconds = msToSeconds(currentTime);
|
||||||
}
|
|
||||||
|
|
||||||
const wrappedSeek = (seconds: number) => {
|
const wrappedSeek = (seconds: number) => {
|
||||||
if (isVlc) {
|
seek(secondsToMs(seconds));
|
||||||
seek(secondsToMs(seconds));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seek(seconds);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: segments } = useSegments(
|
const { data: segments } = useSegments(
|
||||||
@@ -41,11 +39,11 @@ export const useCreditSkipper = (
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (creditTimestamps) {
|
if (creditTimestamps) {
|
||||||
setShowSkipCreditButton(
|
setShowSkipCreditButton(
|
||||||
currentTime > creditTimestamps.startTime &&
|
currentTimeSeconds > creditTimestamps.startTime &&
|
||||||
currentTime < creditTimestamps.endTime,
|
currentTimeSeconds < creditTimestamps.endTime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [creditTimestamps, currentTime]);
|
}, [creditTimestamps, currentTimeSeconds]);
|
||||||
|
|
||||||
const skipCredit = useCallback(() => {
|
const skipCredit = useCallback(() => {
|
||||||
if (!creditTimestamps) return;
|
if (!creditTimestamps) return;
|
||||||
|
|||||||
@@ -1,51 +1,23 @@
|
|||||||
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import type { Settings } from "@/utils/atoms/settings";
|
import type { Settings } from "@/utils/atoms/settings";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
|
||||||
// Used only for initial play settings.
|
/**
|
||||||
const useDefaultPlaySettings = (
|
* React hook wrapper for getDefaultPlaySettings.
|
||||||
item: BaseItemDto,
|
* Used in UI components for initial playback (no previous track state).
|
||||||
settings: Settings | null,
|
*/
|
||||||
) => {
|
const useDefaultPlaySettings = (item: BaseItemDto, settings: Settings | null) =>
|
||||||
const playSettings = useMemo(() => {
|
useMemo(() => {
|
||||||
// 1. Get first media source
|
const { mediaSource, audioIndex, subtitleIndex, bitrate } =
|
||||||
const mediaSource = item.MediaSources?.[0];
|
getDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
// 2. Get default or preferred audio
|
|
||||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
|
||||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
|
||||||
(x) =>
|
|
||||||
x.Type === "Audio" &&
|
|
||||||
x.Language ===
|
|
||||||
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName,
|
|
||||||
)?.Index;
|
|
||||||
|
|
||||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
|
||||||
(x) => x.Type === "Audio",
|
|
||||||
)?.Index;
|
|
||||||
|
|
||||||
// 4. Get default bitrate from settings or fallback to max
|
|
||||||
let bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
|
||||||
// value undefined seems to get lost in settings. This is just a failsafe
|
|
||||||
if (bitrate.key === BITRATES[0].key) {
|
|
||||||
bitrate = BITRATES[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultAudioIndex:
|
defaultMediaSource: mediaSource,
|
||||||
preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex ?? undefined,
|
defaultAudioIndex: audioIndex,
|
||||||
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
defaultSubtitleIndex: subtitleIndex,
|
||||||
defaultMediaSource: mediaSource ?? undefined,
|
defaultBitrate: bitrate,
|
||||||
defaultBitrate: bitrate ?? undefined,
|
|
||||||
};
|
};
|
||||||
}, [
|
}, [item, settings]);
|
||||||
item.MediaSources,
|
|
||||||
settings?.defaultAudioLanguage,
|
|
||||||
settings?.defaultSubtitleLanguage,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return playSettings;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useDefaultPlaySettings;
|
export default useDefaultPlaySettings;
|
||||||
|
|||||||
@@ -7,31 +7,26 @@ import { useHaptic } from "./useHaptic";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook to handle skipping intros in a media player.
|
* Custom hook to handle skipping intros in a media player.
|
||||||
|
* MPV player uses milliseconds for time.
|
||||||
*
|
*
|
||||||
* @param {number} currentTime - The current playback time in seconds.
|
* @param {number} currentTime - The current playback time in milliseconds.
|
||||||
*/
|
*/
|
||||||
export const useIntroSkipper = (
|
export const useIntroSkipper = (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
seek: (ticks: number) => void,
|
seek: (ms: number) => void,
|
||||||
play: () => void,
|
play: () => void,
|
||||||
isVlc = false,
|
|
||||||
isOffline = false,
|
isOffline = false,
|
||||||
api: Api | null = null,
|
api: Api | null = null,
|
||||||
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||||
) => {
|
) => {
|
||||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||||
if (isVlc) {
|
// Convert ms to seconds for comparison with timestamps
|
||||||
currentTime = msToSeconds(currentTime);
|
const currentTimeSeconds = msToSeconds(currentTime);
|
||||||
}
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const wrappedSeek = (seconds: number) => {
|
const wrappedSeek = (seconds: number) => {
|
||||||
if (isVlc) {
|
seek(secondsToMs(seconds));
|
||||||
seek(secondsToMs(seconds));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seek(seconds);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: segments } = useSegments(
|
const { data: segments } = useSegments(
|
||||||
@@ -45,8 +40,8 @@ export const useIntroSkipper = (
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (introTimestamps) {
|
if (introTimestamps) {
|
||||||
const shouldShow =
|
const shouldShow =
|
||||||
currentTime > introTimestamps.startTime &&
|
currentTimeSeconds > introTimestamps.startTime &&
|
||||||
currentTime < introTimestamps.endTime;
|
currentTimeSeconds < introTimestamps.endTime;
|
||||||
|
|
||||||
setShowSkipButton(shouldShow);
|
setShowSkipButton(shouldShow);
|
||||||
} else {
|
} else {
|
||||||
@@ -54,7 +49,7 @@ export const useIntroSkipper = (
|
|||||||
setShowSkipButton(false);
|
setShowSkipButton(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [introTimestamps, currentTime, showSkipButton]);
|
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
|
||||||
|
|
||||||
const skipIntro = useCallback(() => {
|
const skipIntro = useCallback(() => {
|
||||||
if (!introTimestamps) return;
|
if (!introTimestamps) return;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { OrientationChangeEvent } from "expo-screen-orientation";
|
import type { OrientationChangeEvent } from "expo-screen-orientation";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import {
|
import {
|
||||||
addOrientationChangeListener,
|
addOrientationChangeListener,
|
||||||
@@ -53,27 +53,28 @@ export const useOrientation = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const lockOrientation = async (
|
const lockOrientation = useCallback(
|
||||||
lock: (typeof OrientationLock)[keyof typeof OrientationLock],
|
async (lock: (typeof OrientationLock)[keyof typeof OrientationLock]) => {
|
||||||
) => {
|
if (Platform.isTV) return;
|
||||||
if (Platform.isTV) return;
|
|
||||||
|
|
||||||
if (lock === OrientationLock.DEFAULT) {
|
if (lock === OrientationLock.DEFAULT) {
|
||||||
await unlockAsync();
|
await unlockAsync();
|
||||||
} else {
|
} else {
|
||||||
await lockAsync(lock);
|
await lockAsync(lock);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const unlockOrientationFn = async () => {
|
const unlockOrientation = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
await unlockAsync();
|
await unlockAsync();
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orientation,
|
orientation,
|
||||||
setOrientation,
|
setOrientation,
|
||||||
lockOrientation,
|
lockOrientation,
|
||||||
unlockOrientation: unlockOrientationFn,
|
unlockOrientation,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,8 +96,6 @@ export const useWebSocket = ({
|
|||||||
| Record<string, string>
|
| Record<string, string>
|
||||||
| undefined; // Arguments are Dictionary<string, string>
|
| undefined; // Arguments are Dictionary<string, string>
|
||||||
|
|
||||||
console.log("[WS] ~ ", lastMessage);
|
|
||||||
|
|
||||||
if (command === "PlayPause") {
|
if (command === "PlayPause") {
|
||||||
console.log("Command ~ PlayPause");
|
console.log("Command ~ PlayPause");
|
||||||
togglePlay();
|
togglePlay();
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import { ViewStyle } from "react-native";
|
|
||||||
|
|
||||||
export type PlaybackStatePayload = {
|
|
||||||
nativeEvent: {
|
|
||||||
target: number;
|
|
||||||
state: "Opening" | "Buffering" | "Playing" | "Paused" | "Error";
|
|
||||||
currentTime: number;
|
|
||||||
duration: number;
|
|
||||||
isBuffering: boolean;
|
|
||||||
isPlaying: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProgressUpdatePayload = {
|
|
||||||
nativeEvent: {
|
|
||||||
currentTime: number;
|
|
||||||
duration: number;
|
|
||||||
isPlaying: boolean;
|
|
||||||
isBuffering: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VideoLoadStartPayload = {
|
|
||||||
nativeEvent: {
|
|
||||||
target: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PipStartedPayload = {
|
|
||||||
nativeEvent: {
|
|
||||||
pipStarted: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VideoStateChangePayload = PlaybackStatePayload;
|
|
||||||
|
|
||||||
export type VideoProgressPayload = ProgressUpdatePayload;
|
|
||||||
|
|
||||||
export type VlcPlayerSource = {
|
|
||||||
uri: string;
|
|
||||||
type?: string;
|
|
||||||
isNetwork?: boolean;
|
|
||||||
autoplay?: boolean;
|
|
||||||
startPosition?: number;
|
|
||||||
externalSubtitles?: { name: string; DeliveryUrl: string }[];
|
|
||||||
initOptions?: any[];
|
|
||||||
mediaOptions?: { [key: string]: any };
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TrackInfo = {
|
|
||||||
name: string;
|
|
||||||
index: number;
|
|
||||||
language?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChapterInfo = {
|
|
||||||
name: string;
|
|
||||||
timeOffset: number;
|
|
||||||
duration: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NowPlayingMetadata = {
|
|
||||||
title?: string;
|
|
||||||
artist?: string;
|
|
||||||
albumTitle?: string;
|
|
||||||
artworkUri?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VlcPlayerViewProps = {
|
|
||||||
source: VlcPlayerSource;
|
|
||||||
style?: ViewStyle | ViewStyle[];
|
|
||||||
progressUpdateInterval?: number;
|
|
||||||
paused?: boolean;
|
|
||||||
muted?: boolean;
|
|
||||||
volume?: number;
|
|
||||||
videoAspectRatio?: string;
|
|
||||||
nowPlayingMetadata?: NowPlayingMetadata;
|
|
||||||
onVideoProgress?: (event: ProgressUpdatePayload) => void;
|
|
||||||
onVideoStateChange?: (event: PlaybackStatePayload) => void;
|
|
||||||
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
|
||||||
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
|
|
||||||
onVideoError?: (event: PlaybackStatePayload) => void;
|
|
||||||
onPipStarted?: (event: PipStartedPayload) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface VlcPlayerViewRef {
|
|
||||||
startPictureInPicture: () => Promise<void>;
|
|
||||||
play: () => Promise<void>;
|
|
||||||
pause: () => Promise<void>;
|
|
||||||
stop: () => Promise<void>;
|
|
||||||
seekTo: (time: number) => Promise<void>;
|
|
||||||
setAudioTrack: (trackIndex: number) => Promise<void>;
|
|
||||||
getAudioTracks: () => Promise<TrackInfo[] | null>;
|
|
||||||
setSubtitleTrack: (trackIndex: number) => Promise<void>;
|
|
||||||
getSubtitleTracks: () => Promise<TrackInfo[] | null>;
|
|
||||||
setSubtitleDelay: (delay: number) => Promise<void>;
|
|
||||||
setAudioDelay: (delay: number) => Promise<void>;
|
|
||||||
takeSnapshot: (path: string, width: number, height: number) => Promise<void>;
|
|
||||||
setRate: (rate: number) => Promise<void>;
|
|
||||||
nextChapter: () => Promise<void>;
|
|
||||||
previousChapter: () => Promise<void>;
|
|
||||||
getChapters: () => Promise<ChapterInfo[] | null>;
|
|
||||||
setVideoCropGeometry: (cropGeometry: string | null) => Promise<void>;
|
|
||||||
getVideoCropGeometry: () => Promise<string | null>;
|
|
||||||
setSubtitleURL: (url: string) => Promise<void>;
|
|
||||||
setVideoAspectRatio: (aspectRatio: string | null) => Promise<void>;
|
|
||||||
setVideoScaleFactor: (scaleFactor: number) => Promise<void>;
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { requireNativeViewManager } from "expo-modules-core";
|
|
||||||
import * as React from "react";
|
|
||||||
import { ViewStyle } from "react-native";
|
|
||||||
import type {
|
|
||||||
VlcPlayerSource,
|
|
||||||
VlcPlayerViewProps,
|
|
||||||
VlcPlayerViewRef,
|
|
||||||
} from "./VlcPlayer.types";
|
|
||||||
|
|
||||||
interface NativeViewRef extends VlcPlayerViewRef {
|
|
||||||
setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VLCViewManager = requireNativeViewManager("VlcPlayer");
|
|
||||||
|
|
||||||
// Create a forwarded ref version of the native view
|
|
||||||
const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>(
|
|
||||||
(props, ref) => {
|
|
||||||
return <VLCViewManager {...props} ref={ref} />;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|
||||||
(props, ref) => {
|
|
||||||
const nativeRef = React.useRef<NativeViewRef>(null);
|
|
||||||
|
|
||||||
React.useImperativeHandle(ref, () => ({
|
|
||||||
startPictureInPicture: async () => {
|
|
||||||
await nativeRef.current?.startPictureInPicture();
|
|
||||||
},
|
|
||||||
play: async () => {
|
|
||||||
await nativeRef.current?.play();
|
|
||||||
},
|
|
||||||
pause: async () => {
|
|
||||||
await nativeRef.current?.pause();
|
|
||||||
},
|
|
||||||
stop: async () => {
|
|
||||||
await nativeRef.current?.stop();
|
|
||||||
},
|
|
||||||
seekTo: async (time: number) => {
|
|
||||||
await nativeRef.current?.seekTo(time);
|
|
||||||
},
|
|
||||||
setAudioTrack: async (trackIndex: number) => {
|
|
||||||
await nativeRef.current?.setAudioTrack(trackIndex);
|
|
||||||
},
|
|
||||||
getAudioTracks: async () => {
|
|
||||||
const tracks = await nativeRef.current?.getAudioTracks();
|
|
||||||
return tracks ?? null;
|
|
||||||
},
|
|
||||||
setSubtitleTrack: async (trackIndex: number) => {
|
|
||||||
await nativeRef.current?.setSubtitleTrack(trackIndex);
|
|
||||||
},
|
|
||||||
getSubtitleTracks: async () => {
|
|
||||||
const tracks = await nativeRef.current?.getSubtitleTracks();
|
|
||||||
return tracks ?? null;
|
|
||||||
},
|
|
||||||
setSubtitleDelay: async (delay: number) => {
|
|
||||||
await nativeRef.current?.setSubtitleDelay(delay);
|
|
||||||
},
|
|
||||||
setAudioDelay: async (delay: number) => {
|
|
||||||
await nativeRef.current?.setAudioDelay(delay);
|
|
||||||
},
|
|
||||||
takeSnapshot: async (path: string, width: number, height: number) => {
|
|
||||||
await nativeRef.current?.takeSnapshot(path, width, height);
|
|
||||||
},
|
|
||||||
setRate: async (rate: number) => {
|
|
||||||
await nativeRef.current?.setRate(rate);
|
|
||||||
},
|
|
||||||
nextChapter: async () => {
|
|
||||||
await nativeRef.current?.nextChapter();
|
|
||||||
},
|
|
||||||
previousChapter: async () => {
|
|
||||||
await nativeRef.current?.previousChapter();
|
|
||||||
},
|
|
||||||
getChapters: async () => {
|
|
||||||
const chapters = await nativeRef.current?.getChapters();
|
|
||||||
return chapters ?? null;
|
|
||||||
},
|
|
||||||
setVideoCropGeometry: async (geometry: string | null) => {
|
|
||||||
await nativeRef.current?.setVideoCropGeometry(geometry);
|
|
||||||
},
|
|
||||||
getVideoCropGeometry: async () => {
|
|
||||||
const geometry = await nativeRef.current?.getVideoCropGeometry();
|
|
||||||
return geometry ?? null;
|
|
||||||
},
|
|
||||||
setSubtitleURL: async (url: string) => {
|
|
||||||
await nativeRef.current?.setSubtitleURL(url);
|
|
||||||
},
|
|
||||||
setVideoAspectRatio: async (aspectRatio: string | null) => {
|
|
||||||
await nativeRef.current?.setVideoAspectRatio(aspectRatio);
|
|
||||||
},
|
|
||||||
setVideoScaleFactor: async (scaleFactor: number) => {
|
|
||||||
await nativeRef.current?.setVideoScaleFactor(scaleFactor);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {
|
|
||||||
source,
|
|
||||||
style,
|
|
||||||
progressUpdateInterval = 500,
|
|
||||||
paused,
|
|
||||||
muted,
|
|
||||||
volume,
|
|
||||||
videoAspectRatio,
|
|
||||||
nowPlayingMetadata,
|
|
||||||
onVideoLoadStart,
|
|
||||||
onVideoStateChange,
|
|
||||||
onVideoProgress,
|
|
||||||
onVideoLoadEnd,
|
|
||||||
onVideoError,
|
|
||||||
onPipStarted,
|
|
||||||
...otherProps
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const processedSource: VlcPlayerSource =
|
|
||||||
typeof source === "string"
|
|
||||||
? ({ uri: source } as unknown as VlcPlayerSource)
|
|
||||||
: source;
|
|
||||||
|
|
||||||
if (processedSource.startPosition !== undefined) {
|
|
||||||
processedSource.startPosition = Math.floor(processedSource.startPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NativeView
|
|
||||||
{...otherProps}
|
|
||||||
ref={nativeRef}
|
|
||||||
source={processedSource}
|
|
||||||
style={[{ width: "100%", height: "100%" }, style as ViewStyle]}
|
|
||||||
progressUpdateInterval={progressUpdateInterval}
|
|
||||||
paused={paused}
|
|
||||||
muted={muted}
|
|
||||||
volume={volume}
|
|
||||||
videoAspectRatio={videoAspectRatio}
|
|
||||||
nowPlayingMetadata={nowPlayingMetadata}
|
|
||||||
onVideoLoadStart={onVideoLoadStart}
|
|
||||||
onVideoLoadEnd={onVideoLoadEnd}
|
|
||||||
onVideoStateChange={onVideoStateChange}
|
|
||||||
onVideoProgress={onVideoProgress}
|
|
||||||
onVideoError={onVideoError}
|
|
||||||
onPipStarted={onPipStarted}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export default VlcPlayerView;
|
|
||||||
@@ -1,17 +1,4 @@
|
|||||||
import type {
|
// Background Downloader
|
||||||
ChapterInfo,
|
|
||||||
PlaybackStatePayload,
|
|
||||||
ProgressUpdatePayload,
|
|
||||||
TrackInfo,
|
|
||||||
VideoLoadStartPayload,
|
|
||||||
VideoProgressPayload,
|
|
||||||
VideoStateChangePayload,
|
|
||||||
VlcPlayerSource,
|
|
||||||
VlcPlayerViewProps,
|
|
||||||
VlcPlayerViewRef,
|
|
||||||
} from "./VlcPlayer.types";
|
|
||||||
import VlcPlayerView from "./VlcPlayerView";
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ActiveDownload,
|
ActiveDownload,
|
||||||
DownloadCompleteEvent,
|
DownloadCompleteEvent,
|
||||||
@@ -19,23 +6,21 @@ export type {
|
|||||||
DownloadProgressEvent,
|
DownloadProgressEvent,
|
||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
} from "./background-downloader";
|
} from "./background-downloader";
|
||||||
// Background Downloader
|
|
||||||
export { default as BackgroundDownloader } from "./background-downloader";
|
export { default as BackgroundDownloader } from "./background-downloader";
|
||||||
|
// Type aliases for backward compatibility during migration
|
||||||
// Component
|
// These map old VLC type names to new MPV equivalents
|
||||||
export { VlcPlayerView };
|
|
||||||
|
|
||||||
// Component Types
|
|
||||||
export type { VlcPlayerViewProps, VlcPlayerViewRef };
|
|
||||||
|
|
||||||
// Media Types
|
|
||||||
export type { ChapterInfo, TrackInfo, VlcPlayerSource };
|
|
||||||
|
|
||||||
// Playback Events (alphabetically sorted)
|
|
||||||
export type {
|
export type {
|
||||||
PlaybackStatePayload,
|
AudioTrack,
|
||||||
ProgressUpdatePayload,
|
MpvPlayerViewProps,
|
||||||
VideoLoadStartPayload,
|
MpvPlayerViewRef,
|
||||||
VideoProgressPayload,
|
OnErrorEventPayload,
|
||||||
VideoStateChangePayload,
|
OnLoadEventPayload,
|
||||||
};
|
OnPlaybackStateChangePayload,
|
||||||
|
OnProgressEventPayload,
|
||||||
|
OnTracksReadyEventPayload,
|
||||||
|
SubtitleTrack,
|
||||||
|
SubtitleTrack as TrackInfo,
|
||||||
|
VideoSource,
|
||||||
|
} from "./mpv-player";
|
||||||
|
// MPV Player - Main exports
|
||||||
|
export { MpvPlayerView } from "./mpv-player";
|
||||||
|
|||||||
43
modules/mpv-player/android/build.gradle
Normal file
43
modules/mpv-player/android/build.gradle
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
apply plugin: 'com.android.library'
|
||||||
|
|
||||||
|
group = 'expo.modules.mpvplayer'
|
||||||
|
version = '0.7.6'
|
||||||
|
|
||||||
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||||
|
apply from: expoModulesCorePlugin
|
||||||
|
applyKotlinExpoModulesCorePlugin()
|
||||||
|
useCoreDependencies()
|
||||||
|
useExpoPublishing()
|
||||||
|
|
||||||
|
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
|
||||||
|
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
|
||||||
|
// Most of the time, you may like to manage the Android SDK versions yourself.
|
||||||
|
def useManagedAndroidSdkVersions = false
|
||||||
|
if (useManagedAndroidSdkVersions) {
|
||||||
|
useDefaultAndroidSdkVersions()
|
||||||
|
} else {
|
||||||
|
buildscript {
|
||||||
|
// Simple helper that allows the root project to override versions declared by this library.
|
||||||
|
ext.safeExtGet = { prop, fallback ->
|
||||||
|
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
project.android {
|
||||||
|
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
||||||
|
defaultConfig {
|
||||||
|
minSdkVersion safeExtGet("minSdkVersion", 24)
|
||||||
|
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "expo.modules.mpvplayer"
|
||||||
|
defaultConfig {
|
||||||
|
versionCode 1
|
||||||
|
versionName "0.7.6"
|
||||||
|
}
|
||||||
|
lintOptions {
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
class MpvPlayerModule : Module() {
|
||||||
|
// Each module class must implement the definition function. The definition consists of components
|
||||||
|
// that describes the module's functionality and behavior.
|
||||||
|
// See https://docs.expo.dev/modules/module-api for more details about available components.
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
// Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
|
||||||
|
// Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
|
||||||
|
// The module will be accessible from `requireNativeModule('MpvPlayer')` in JavaScript.
|
||||||
|
Name("MpvPlayer")
|
||||||
|
|
||||||
|
// Defines constant property on the module.
|
||||||
|
Constant("PI") {
|
||||||
|
Math.PI
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defines event names that the module can send to JavaScript.
|
||||||
|
Events("onChange")
|
||||||
|
|
||||||
|
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
|
||||||
|
Function("hello") {
|
||||||
|
"Hello world! 👋"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defines a JavaScript function that always returns a Promise and whose native code
|
||||||
|
// is by default dispatched on the different thread than the JavaScript runtime runs on.
|
||||||
|
AsyncFunction("setValueAsync") { value: String ->
|
||||||
|
// Send an event to JavaScript.
|
||||||
|
sendEvent("onChange", mapOf(
|
||||||
|
"value" to value
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enables the module to be used as a native view. Definition components that are accepted as part of
|
||||||
|
// the view definition: Prop, Events.
|
||||||
|
View(MpvPlayerView::class) {
|
||||||
|
// Defines a setter for the `url` prop.
|
||||||
|
Prop("url") { view: MpvPlayerView, url: URL ->
|
||||||
|
view.webView.loadUrl(url.toString())
|
||||||
|
}
|
||||||
|
// Defines an event that the view can send to JavaScript.
|
||||||
|
Events("onLoad")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import expo.modules.kotlin.AppContext
|
||||||
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
|
import expo.modules.kotlin.views.ExpoView
|
||||||
|
|
||||||
|
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||||
|
// Creates and initializes an event dispatcher for the `onLoad` event.
|
||||||
|
// The name of the event is inferred from the value and needs to match the event name defined in the module.
|
||||||
|
private val onLoad by EventDispatcher()
|
||||||
|
|
||||||
|
// Defines a WebView that will be used as the root subview.
|
||||||
|
internal val webView = WebView(context).apply {
|
||||||
|
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageFinished(view: WebView, url: String) {
|
||||||
|
// Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript.
|
||||||
|
onLoad(mapOf("url" to url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Adds the WebView to the view hierarchy.
|
||||||
|
addView(webView)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
modules/mpv-player/expo-module.config.json
Normal file
6
modules/mpv-player/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["apple"],
|
||||||
|
"apple": {
|
||||||
|
"modules": ["MpvPlayerModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
modules/mpv-player/index.ts
Normal file
2
modules/mpv-player/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./src/MpvPlayer.types";
|
||||||
|
export { default as MpvPlayerView } from "./src/MpvPlayerView";
|
||||||
245
modules/mpv-player/ios/IOSurfaceBufferPool.swift
Normal file
245
modules/mpv-player/ios/IOSurfaceBufferPool.swift
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreVideo
|
||||||
|
import Metal
|
||||||
|
import CoreMedia
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// Manages a pool of IOSurface-backed CVPixelBuffers that can be shared between Metal and AVFoundation
|
||||||
|
/// This enables zero-copy rendering where mpv renders to Metal textures that are directly usable by AVSampleBufferDisplayLayer
|
||||||
|
final class IOSurfaceBufferPool {
|
||||||
|
|
||||||
|
struct PooledBuffer {
|
||||||
|
let pixelBuffer: CVPixelBuffer
|
||||||
|
let texture: MTLTexture
|
||||||
|
let ioSurface: IOSurfaceRef
|
||||||
|
}
|
||||||
|
|
||||||
|
private let device: MTLDevice
|
||||||
|
private var pool: CVPixelBufferPool?
|
||||||
|
private var buffers: [PooledBuffer] = []
|
||||||
|
private var availableBuffers: [PooledBuffer] = []
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
private(set) var width: Int = 0
|
||||||
|
private(set) var height: Int = 0
|
||||||
|
private(set) var pixelFormat: OSType = kCVPixelFormatType_32BGRA
|
||||||
|
|
||||||
|
private let maxBufferCount: Int
|
||||||
|
|
||||||
|
init(device: MTLDevice, maxBufferCount: Int = 3) {
|
||||||
|
self.device = device
|
||||||
|
self.maxBufferCount = maxBufferCount
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure the pool for a specific video size and format
|
||||||
|
func configure(width: Int, height: Int, pixelFormat: OSType = kCVPixelFormatType_32BGRA) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
|
||||||
|
guard width > 0, height > 0 else { return false }
|
||||||
|
|
||||||
|
// Skip if already configured for this size
|
||||||
|
if self.width == width && self.height == height && self.pixelFormat == pixelFormat && pool != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing buffers
|
||||||
|
buffers.removeAll()
|
||||||
|
availableBuffers.removeAll()
|
||||||
|
pool = nil
|
||||||
|
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.pixelFormat = pixelFormat
|
||||||
|
|
||||||
|
// Create pixel buffer pool with IOSurface and Metal compatibility
|
||||||
|
let pixelBufferAttributes: [CFString: Any] = [
|
||||||
|
kCVPixelBufferPixelFormatTypeKey: pixelFormat,
|
||||||
|
kCVPixelBufferWidthKey: width,
|
||||||
|
kCVPixelBufferHeightKey: height,
|
||||||
|
kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
|
||||||
|
kCVPixelBufferMetalCompatibilityKey: true,
|
||||||
|
kCVPixelBufferCGImageCompatibilityKey: true,
|
||||||
|
kCVPixelBufferCGBitmapContextCompatibilityKey: true
|
||||||
|
]
|
||||||
|
|
||||||
|
let poolAttributes: [CFString: Any] = [
|
||||||
|
kCVPixelBufferPoolMinimumBufferCountKey: maxBufferCount
|
||||||
|
]
|
||||||
|
|
||||||
|
var newPool: CVPixelBufferPool?
|
||||||
|
let status = CVPixelBufferPoolCreate(
|
||||||
|
kCFAllocatorDefault,
|
||||||
|
poolAttributes as CFDictionary,
|
||||||
|
pixelBufferAttributes as CFDictionary,
|
||||||
|
&newPool
|
||||||
|
)
|
||||||
|
|
||||||
|
guard status == kCVReturnSuccess, let createdPool = newPool else {
|
||||||
|
Logger.shared.log("Failed to create IOSurface buffer pool: \(status)", type: "Error")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
pool = createdPool
|
||||||
|
|
||||||
|
// Pre-allocate buffers
|
||||||
|
for _ in 0..<maxBufferCount {
|
||||||
|
if let buffer = createPooledBuffer() {
|
||||||
|
buffers.append(buffer)
|
||||||
|
availableBuffers.append(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get an available buffer for rendering
|
||||||
|
func dequeueBuffer() -> PooledBuffer? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
|
||||||
|
if let buffer = availableBuffers.popLast() {
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create a new buffer if under limit
|
||||||
|
if buffers.count < maxBufferCount, let buffer = createPooledBuffer() {
|
||||||
|
buffers.append(buffer)
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// All buffers in use - create temporary one
|
||||||
|
return createPooledBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a buffer to the pool after use
|
||||||
|
func enqueueBuffer(_ buffer: PooledBuffer) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
|
||||||
|
if buffers.contains(where: { $0.pixelBuffer == buffer.pixelBuffer }) {
|
||||||
|
availableBuffers.append(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all buffers and reset the pool
|
||||||
|
func invalidate() {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
|
||||||
|
buffers.removeAll()
|
||||||
|
availableBuffers.removeAll()
|
||||||
|
pool = nil
|
||||||
|
width = 0
|
||||||
|
height = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createPooledBuffer() -> PooledBuffer? {
|
||||||
|
guard let pool = pool else { return nil }
|
||||||
|
|
||||||
|
var pixelBuffer: CVPixelBuffer?
|
||||||
|
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pixelBuffer)
|
||||||
|
|
||||||
|
guard status == kCVReturnSuccess, let buffer = pixelBuffer else {
|
||||||
|
Logger.shared.log("Failed to create pixel buffer from pool: \(status)", type: "Error")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get IOSurface from pixel buffer
|
||||||
|
guard let ioSurface = CVPixelBufferGetIOSurface(buffer)?.takeUnretainedValue() else {
|
||||||
|
Logger.shared.log("Failed to get IOSurface from pixel buffer", type: "Error")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Metal texture from IOSurface
|
||||||
|
let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(
|
||||||
|
pixelFormat: metalPixelFormat(for: pixelFormat),
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
mipmapped: false
|
||||||
|
)
|
||||||
|
textureDescriptor.usage = [.renderTarget, .shaderRead, .shaderWrite]
|
||||||
|
textureDescriptor.storageMode = .shared
|
||||||
|
|
||||||
|
guard let texture = device.makeTexture(descriptor: textureDescriptor, iosurface: ioSurface, plane: 0) else {
|
||||||
|
Logger.shared.log("Failed to create Metal texture from IOSurface", type: "Error")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return PooledBuffer(pixelBuffer: buffer, texture: texture, ioSurface: ioSurface)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metalPixelFormat(for cvFormat: OSType) -> MTLPixelFormat {
|
||||||
|
switch cvFormat {
|
||||||
|
case kCVPixelFormatType_32BGRA:
|
||||||
|
return .bgra8Unorm
|
||||||
|
case kCVPixelFormatType_32RGBA:
|
||||||
|
return .rgba8Unorm
|
||||||
|
case kCVPixelFormatType_64RGBAHalf:
|
||||||
|
return .rgba16Float
|
||||||
|
default:
|
||||||
|
return .bgra8Unorm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CMSampleBuffer Creation
|
||||||
|
|
||||||
|
extension IOSurfaceBufferPool {
|
||||||
|
|
||||||
|
/// Create a CMSampleBuffer from a pooled buffer for AVSampleBufferDisplayLayer
|
||||||
|
static func createSampleBuffer(
|
||||||
|
from pixelBuffer: CVPixelBuffer,
|
||||||
|
formatDescription: CMVideoFormatDescription,
|
||||||
|
presentationTime: CMTime
|
||||||
|
) -> CMSampleBuffer? {
|
||||||
|
var timing = CMSampleTimingInfo(
|
||||||
|
duration: .invalid,
|
||||||
|
presentationTimeStamp: presentationTime,
|
||||||
|
decodeTimeStamp: .invalid
|
||||||
|
)
|
||||||
|
|
||||||
|
var sampleBuffer: CMSampleBuffer?
|
||||||
|
let status = CMSampleBufferCreateForImageBuffer(
|
||||||
|
allocator: kCFAllocatorDefault,
|
||||||
|
imageBuffer: pixelBuffer,
|
||||||
|
dataReady: true,
|
||||||
|
makeDataReadyCallback: nil,
|
||||||
|
refcon: nil,
|
||||||
|
formatDescription: formatDescription,
|
||||||
|
sampleTiming: &timing,
|
||||||
|
sampleBufferOut: &sampleBuffer
|
||||||
|
)
|
||||||
|
|
||||||
|
guard status == noErr else {
|
||||||
|
Logger.shared.log("Failed to create sample buffer: \(status)", type: "Error")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return sampleBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a format description for the current pool configuration
|
||||||
|
func createFormatDescription() -> CMVideoFormatDescription? {
|
||||||
|
guard let buffer = dequeueBuffer() else { return nil }
|
||||||
|
defer { enqueueBuffer(buffer) }
|
||||||
|
|
||||||
|
var formatDescription: CMVideoFormatDescription?
|
||||||
|
let status = CMVideoFormatDescriptionCreateForImageBuffer(
|
||||||
|
allocator: kCFAllocatorDefault,
|
||||||
|
imageBuffer: buffer.pixelBuffer,
|
||||||
|
formatDescriptionOut: &formatDescription
|
||||||
|
)
|
||||||
|
|
||||||
|
guard status == noErr else {
|
||||||
|
Logger.shared.log("Failed to create format description: \(status)", type: "Error")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
162
modules/mpv-player/ios/Logger.swift
Normal file
162
modules/mpv-player/ios/Logger.swift
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class Logger {
|
||||||
|
static let shared = Logger()
|
||||||
|
|
||||||
|
struct LogEntry {
|
||||||
|
let message: String
|
||||||
|
let type: String
|
||||||
|
let timestamp: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "mpvkit.logger", attributes: .concurrent)
|
||||||
|
private var logs: [LogEntry] = []
|
||||||
|
private let logFileURL: URL
|
||||||
|
private let dateFormatter: DateFormatter
|
||||||
|
|
||||||
|
private let maxFileSize = 1024 * 512
|
||||||
|
private let maxLogEntries = 1000
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
|
logFileURL = tmpDir.appendingPathComponent("logs.txt")
|
||||||
|
|
||||||
|
dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
|
||||||
|
}
|
||||||
|
|
||||||
|
func log(_ message: String, type: String = "General") {
|
||||||
|
let entry = LogEntry(message: message, type: type, timestamp: Date())
|
||||||
|
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
self.logs.append(entry)
|
||||||
|
|
||||||
|
if self.logs.count > self.maxLogEntries {
|
||||||
|
self.logs.removeFirst(self.logs.count - self.maxLogEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.saveLogToFile(entry)
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
self.debugLog(entry)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: NSNotification.Name("LoggerNotification"),
|
||||||
|
object: nil,
|
||||||
|
userInfo: [
|
||||||
|
"message": message,
|
||||||
|
"type": type,
|
||||||
|
"timestamp": entry.timestamp
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLogs() -> String {
|
||||||
|
var result = ""
|
||||||
|
queue.sync {
|
||||||
|
result = logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
|
||||||
|
.joined(separator: "\n----\n")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLogsAsync() async -> String {
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
continuation.resume(returning: "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let result = self.logs.map { "[\(self.dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
|
||||||
|
.joined(separator: "\n----\n")
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearLogs() {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.logs.removeAll()
|
||||||
|
try? FileManager.default.removeItem(at: self.logFileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearLogsAsync() async {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
continuation.resume()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.logs.removeAll()
|
||||||
|
try? FileManager.default.removeItem(at: self.logFileURL)
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveLogToFile(_ log: LogEntry) {
|
||||||
|
let logString = "[\(dateFormatter.string(from: log.timestamp))] [\(log.type)] \(log.message)\n---\n"
|
||||||
|
|
||||||
|
guard let data = logString.data(using: .utf8) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
if FileManager.default.fileExists(atPath: logFileURL.path) {
|
||||||
|
let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path)
|
||||||
|
let fileSize = attributes[.size] as? UInt64 ?? 0
|
||||||
|
|
||||||
|
if fileSize + UInt64(data.count) > maxFileSize {
|
||||||
|
self.truncateLogFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let handle = try? FileHandle(forWritingTo: logFileURL) {
|
||||||
|
handle.seekToEndOfFile()
|
||||||
|
handle.write(data)
|
||||||
|
handle.closeFile()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try data.write(to: logFileURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? data.write(to: logFileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func truncateLogFile() {
|
||||||
|
do {
|
||||||
|
guard let content = try? String(contentsOf: logFileURL, encoding: .utf8),
|
||||||
|
!content.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = content.components(separatedBy: "\n---\n")
|
||||||
|
guard entries.count > 10 else { return }
|
||||||
|
|
||||||
|
let keepCount = entries.count / 2
|
||||||
|
let truncatedEntries = Array(entries.suffix(keepCount))
|
||||||
|
let truncatedContent = truncatedEntries.joined(separator: "\n---\n")
|
||||||
|
|
||||||
|
if let truncatedData = truncatedContent.data(using: .utf8) {
|
||||||
|
try truncatedData.write(to: logFileURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? FileManager.default.removeItem(at: logFileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private func debugLog(_ entry: LogEntry) {
|
||||||
|
let formattedMessage = "[\(dateFormatter.string(from: entry.timestamp))] [\(entry.type)] \(entry.message)"
|
||||||
|
NSLog("%@", formattedMessage)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
1107
modules/mpv-player/ios/MPVMetalRenderer.swift
Normal file
1107
modules/mpv-player/ios/MPVMetalRenderer.swift
Normal file
File diff suppressed because it is too large
Load Diff
28
modules/mpv-player/ios/MpvPlayer.podspec
Normal file
28
modules/mpv-player/ios/MpvPlayer.podspec
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'MpvPlayer'
|
||||||
|
s.version = '1.0.0'
|
||||||
|
s.summary = 'MPVKit for Expo'
|
||||||
|
s.description = 'MPVKit for Expo'
|
||||||
|
s.author = 'mpvkit'
|
||||||
|
s.homepage = 'https://github.com/mpvkit/MPVKit'
|
||||||
|
s.platforms = {
|
||||||
|
:ios => '15.1',
|
||||||
|
:tvos => '15.1'
|
||||||
|
}
|
||||||
|
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
|
||||||
|
s.static_framework = true
|
||||||
|
|
||||||
|
s.dependency 'ExpoModulesCore'
|
||||||
|
s.dependency 'MPVKit', '~> 0.40.0'
|
||||||
|
|
||||||
|
# Swift/Objective-C compatibility
|
||||||
|
s.pod_target_xcconfig = {
|
||||||
|
'DEFINES_MODULE' => 'YES',
|
||||||
|
# Strip debug symbols to avoid DWARF errors from MPVKit
|
||||||
|
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
|
||||||
|
'STRIP_INSTALLED_PRODUCT' => 'YES',
|
||||||
|
'DEPLOYMENT_POSTPROCESSING' => 'YES',
|
||||||
|
}
|
||||||
|
|
||||||
|
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||||
|
end
|
||||||
145
modules/mpv-player/ios/MpvPlayerModule.swift
Normal file
145
modules/mpv-player/ios/MpvPlayerModule.swift
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
|
public class MpvPlayerModule: Module {
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("MpvPlayer")
|
||||||
|
|
||||||
|
// Enables the module to be used as a native view
|
||||||
|
View(MpvPlayerView.self) {
|
||||||
|
// All video load options are passed via a single "source" prop
|
||||||
|
Prop("source") { (view: MpvPlayerView, source: [String: Any]?) in
|
||||||
|
guard let source = source,
|
||||||
|
let urlString = source["url"] as? String,
|
||||||
|
let videoURL = URL(string: urlString) else { return }
|
||||||
|
|
||||||
|
let config = VideoLoadConfig(
|
||||||
|
url: videoURL,
|
||||||
|
headers: source["headers"] as? [String: String],
|
||||||
|
externalSubtitles: source["externalSubtitles"] as? [String],
|
||||||
|
startPosition: source["startPosition"] as? Double,
|
||||||
|
autoplay: (source["autoplay"] as? Bool) ?? true,
|
||||||
|
initialSubtitleId: source["initialSubtitleId"] as? Int,
|
||||||
|
initialAudioId: source["initialAudioId"] as? Int
|
||||||
|
)
|
||||||
|
|
||||||
|
view.loadVideo(config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playback controls
|
||||||
|
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||||
|
view.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("pause") { (view: MpvPlayerView) in
|
||||||
|
view.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
||||||
|
view.seekTo(position: position)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("seekBy") { (view: MpvPlayerView, offset: Double) in
|
||||||
|
view.seekBy(offset: offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSpeed") { (view: MpvPlayerView, speed: Double) in
|
||||||
|
view.setSpeed(speed: speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getSpeed") { (view: MpvPlayerView) -> Double in
|
||||||
|
return view.getSpeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPaused") { (view: MpvPlayerView) -> Bool in
|
||||||
|
return view.isPaused()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentPosition") { (view: MpvPlayerView) -> Double in
|
||||||
|
return view.getCurrentPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getDuration") { (view: MpvPlayerView) -> Double in
|
||||||
|
return view.getDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picture in Picture
|
||||||
|
AsyncFunction("startPictureInPicture") { (view: MpvPlayerView) in
|
||||||
|
view.startPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("stopPictureInPicture") { (view: MpvPlayerView) in
|
||||||
|
view.stopPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPictureInPictureSupported") { (view: MpvPlayerView) -> Bool in
|
||||||
|
return view.isPictureInPictureSupported()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPictureInPictureActive") { (view: MpvPlayerView) -> Bool in
|
||||||
|
return view.isPictureInPictureActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle functions
|
||||||
|
AsyncFunction("getSubtitleTracks") { (view: MpvPlayerView) -> [[String: Any]] in
|
||||||
|
return view.getSubtitleTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleTrack") { (view: MpvPlayerView, trackId: Int) in
|
||||||
|
view.setSubtitleTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("disableSubtitles") { (view: MpvPlayerView) in
|
||||||
|
view.disableSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentSubtitleTrack") { (view: MpvPlayerView) -> Int in
|
||||||
|
return view.getCurrentSubtitleTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("addSubtitleFile") { (view: MpvPlayerView, url: String, select: Bool) in
|
||||||
|
view.addSubtitleFile(url: url, select: select)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle positioning
|
||||||
|
AsyncFunction("setSubtitlePosition") { (view: MpvPlayerView, position: Int) in
|
||||||
|
view.setSubtitlePosition(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleScale") { (view: MpvPlayerView, scale: Double) in
|
||||||
|
view.setSubtitleScale(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleMarginY") { (view: MpvPlayerView, margin: Int) in
|
||||||
|
view.setSubtitleMarginY(margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAlignX") { (view: MpvPlayerView, alignment: String) in
|
||||||
|
view.setSubtitleAlignX(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAlignY") { (view: MpvPlayerView, alignment: String) in
|
||||||
|
view.setSubtitleAlignY(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleFontSize") { (view: MpvPlayerView, size: Int) in
|
||||||
|
view.setSubtitleFontSize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio track functions
|
||||||
|
AsyncFunction("getAudioTracks") { (view: MpvPlayerView) -> [[String: Any]] in
|
||||||
|
return view.getAudioTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setAudioTrack") { (view: MpvPlayerView, trackId: Int) in
|
||||||
|
view.setAudioTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentAudioTrack") { (view: MpvPlayerView) -> Int in
|
||||||
|
return view.getCurrentAudioTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events that the view can send to JavaScript
|
||||||
|
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
380
modules/mpv-player/ios/MpvPlayerView.swift
Normal file
380
modules/mpv-player/ios/MpvPlayerView.swift
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import CoreMedia
|
||||||
|
import ExpoModulesCore
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Configuration for loading a video
|
||||||
|
struct VideoLoadConfig {
|
||||||
|
let url: URL
|
||||||
|
var headers: [String: String]?
|
||||||
|
var externalSubtitles: [String]?
|
||||||
|
var startPosition: Double?
|
||||||
|
var autoplay: Bool
|
||||||
|
/// MPV subtitle track ID to select on start (1-based, -1 to disable, nil to use default)
|
||||||
|
var initialSubtitleId: Int?
|
||||||
|
/// MPV audio track ID to select on start (1-based, nil to use default)
|
||||||
|
var initialAudioId: Int?
|
||||||
|
|
||||||
|
init(
|
||||||
|
url: URL,
|
||||||
|
headers: [String: String]? = nil,
|
||||||
|
externalSubtitles: [String]? = nil,
|
||||||
|
startPosition: Double? = nil,
|
||||||
|
autoplay: Bool = true,
|
||||||
|
initialSubtitleId: Int? = nil,
|
||||||
|
initialAudioId: Int? = nil
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.headers = headers
|
||||||
|
self.externalSubtitles = externalSubtitles
|
||||||
|
self.startPosition = startPosition
|
||||||
|
self.autoplay = autoplay
|
||||||
|
self.initialSubtitleId = initialSubtitleId
|
||||||
|
self.initialAudioId = initialAudioId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This view will be used as a native component. Make sure to inherit from `ExpoView`
|
||||||
|
// to apply the proper styling (e.g. border radius and shadows).
|
||||||
|
class MpvPlayerView: ExpoView {
|
||||||
|
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||||
|
private var renderer: MPVMetalRenderer?
|
||||||
|
private var videoContainer: UIView!
|
||||||
|
private var pipController: PiPController?
|
||||||
|
|
||||||
|
let onLoad = EventDispatcher()
|
||||||
|
let onPlaybackStateChange = EventDispatcher()
|
||||||
|
let onProgress = EventDispatcher()
|
||||||
|
let onError = EventDispatcher()
|
||||||
|
let onTracksReady = EventDispatcher()
|
||||||
|
|
||||||
|
private var currentURL: URL?
|
||||||
|
private var cachedPosition: Double = 0
|
||||||
|
private var cachedDuration: Double = 0
|
||||||
|
private var intendedPlayState: Bool = false
|
||||||
|
|
||||||
|
required init(appContext: AppContext? = nil) {
|
||||||
|
super.init(appContext: appContext)
|
||||||
|
setupView()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupView() {
|
||||||
|
clipsToBounds = true
|
||||||
|
backgroundColor = .black
|
||||||
|
|
||||||
|
videoContainer = UIView()
|
||||||
|
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
videoContainer.backgroundColor = .black
|
||||||
|
videoContainer.clipsToBounds = true
|
||||||
|
addSubview(videoContainer)
|
||||||
|
|
||||||
|
displayLayer.frame = bounds
|
||||||
|
displayLayer.videoGravity = .resizeAspect
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
displayLayer.wantsExtendedDynamicRangeContent = true
|
||||||
|
}
|
||||||
|
displayLayer.backgroundColor = UIColor.black.cgColor
|
||||||
|
videoContainer.layer.addSublayer(displayLayer)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
videoContainer.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
videoContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
videoContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
videoContainer.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
do {
|
||||||
|
renderer = try MPVMetalRenderer(displayLayer: displayLayer)
|
||||||
|
renderer?.delegate = self
|
||||||
|
try renderer?.start()
|
||||||
|
} catch MPVMetalRenderer.RendererError.metalNotSupported {
|
||||||
|
onError(["error": "Metal is not supported on this device"])
|
||||||
|
} catch {
|
||||||
|
onError(["error": "Failed to start renderer: \(error.localizedDescription)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup PiP
|
||||||
|
pipController = PiPController(sampleBufferDisplayLayer: displayLayer)
|
||||||
|
pipController?.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
CATransaction.begin()
|
||||||
|
CATransaction.setDisableActions(true)
|
||||||
|
displayLayer.frame = videoContainer.bounds
|
||||||
|
displayLayer.isHidden = false
|
||||||
|
displayLayer.opacity = 1.0
|
||||||
|
CATransaction.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadVideo(config: VideoLoadConfig) {
|
||||||
|
// Skip reload if same URL is already playing
|
||||||
|
if currentURL == config.url {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentURL = config.url
|
||||||
|
|
||||||
|
let preset = PlayerPreset(
|
||||||
|
id: .sdrRec709,
|
||||||
|
title: "Default",
|
||||||
|
summary: "Default playback preset",
|
||||||
|
stream: nil,
|
||||||
|
commands: []
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pass everything to the renderer
|
||||||
|
renderer?.load(
|
||||||
|
url: config.url,
|
||||||
|
with: preset,
|
||||||
|
headers: config.headers,
|
||||||
|
startPosition: config.startPosition,
|
||||||
|
externalSubtitles: config.externalSubtitles,
|
||||||
|
initialSubtitleId: config.initialSubtitleId,
|
||||||
|
initialAudioId: config.initialAudioId
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.autoplay {
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(["url": config.url.absoluteString])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience method for simple loads
|
||||||
|
func loadVideo(url: URL, headers: [String: String]? = nil) {
|
||||||
|
loadVideo(config: VideoLoadConfig(url: url, headers: headers))
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() {
|
||||||
|
intendedPlayState = true
|
||||||
|
renderer?.play()
|
||||||
|
pipController?.updatePlaybackState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
intendedPlayState = false
|
||||||
|
renderer?.pausePlayback()
|
||||||
|
pipController?.updatePlaybackState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func seekTo(position: Double) {
|
||||||
|
renderer?.seek(to: position)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seekBy(offset: Double) {
|
||||||
|
renderer?.seek(by: offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSpeed(speed: Double) {
|
||||||
|
renderer?.setSpeed(speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSpeed() -> Double {
|
||||||
|
return renderer?.getSpeed() ?? 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPaused() -> Bool {
|
||||||
|
return renderer?.isPausedState ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCurrentPosition() -> Double {
|
||||||
|
return cachedPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDuration() -> Double {
|
||||||
|
return cachedDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Picture in Picture
|
||||||
|
|
||||||
|
func startPictureInPicture() {
|
||||||
|
pipController?.startPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopPictureInPicture() {
|
||||||
|
pipController?.stopPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPictureInPictureSupported() -> Bool {
|
||||||
|
return pipController?.isPictureInPictureSupported ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPictureInPictureActive() -> Bool {
|
||||||
|
return pipController?.isPictureInPictureActive ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Controls
|
||||||
|
|
||||||
|
func getSubtitleTracks() -> [[String: Any]] {
|
||||||
|
return renderer?.getSubtitleTracks() ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleTrack(_ trackId: Int) {
|
||||||
|
renderer?.setSubtitleTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func disableSubtitles() {
|
||||||
|
renderer?.disableSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCurrentSubtitleTrack() -> Int {
|
||||||
|
return renderer?.getCurrentSubtitleTrack() ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSubtitleFile(url: String, select: Bool = true) {
|
||||||
|
renderer?.addSubtitleFile(url: url, select: select)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Track Controls
|
||||||
|
|
||||||
|
func getAudioTracks() -> [[String: Any]] {
|
||||||
|
return renderer?.getAudioTracks() ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAudioTrack(_ trackId: Int) {
|
||||||
|
renderer?.setAudioTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCurrentAudioTrack() -> Int {
|
||||||
|
return renderer?.getCurrentAudioTrack() ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Positioning
|
||||||
|
|
||||||
|
func setSubtitlePosition(_ position: Int) {
|
||||||
|
renderer?.setSubtitlePosition(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleScale(_ scale: Double) {
|
||||||
|
renderer?.setSubtitleScale(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleMarginY(_ margin: Int) {
|
||||||
|
renderer?.setSubtitleMarginY(margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleAlignX(_ alignment: String) {
|
||||||
|
renderer?.setSubtitleAlignX(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleAlignY(_ alignment: String) {
|
||||||
|
renderer?.setSubtitleAlignY(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleFontSize(_ size: Int) {
|
||||||
|
renderer?.setSubtitleFontSize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
pipController?.stopPictureInPicture()
|
||||||
|
renderer?.stop()
|
||||||
|
displayLayer.controlTimebase = nil
|
||||||
|
displayLayer.removeFromSuperlayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MPVMetalRendererDelegate
|
||||||
|
|
||||||
|
extension MpvPlayerView: MPVMetalRendererDelegate {
|
||||||
|
func renderer(_: MPVMetalRenderer, didUpdatePosition position: Double, duration: Double) {
|
||||||
|
cachedPosition = position
|
||||||
|
cachedDuration = duration
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if self.pipController?.isPictureInPictureActive == true {
|
||||||
|
self.pipController?.updatePlaybackState()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onProgress([
|
||||||
|
"position": position,
|
||||||
|
"duration": duration,
|
||||||
|
"progress": duration > 0 ? position / duration : 0,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderer(_: MPVMetalRenderer, didChangePause isPaused: Bool) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.onPlaybackStateChange([
|
||||||
|
"isPaused": isPaused,
|
||||||
|
"isPlaying": !isPaused,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderer(_: MPVMetalRenderer, didChangeLoading isLoading: Bool) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.onPlaybackStateChange([
|
||||||
|
"isLoading": isLoading,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderer(_: MPVMetalRenderer, didBecomeReadyToSeek: Bool) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.onPlaybackStateChange([
|
||||||
|
"isReadyToSeek": didBecomeReadyToSeek,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderer(_: MPVMetalRenderer, didBecomeTracksReady: Bool) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.onTracksReady([:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PiPControllerDelegate
|
||||||
|
|
||||||
|
extension MpvPlayerView: PiPControllerDelegate {
|
||||||
|
func pipController(_ controller: PiPController, willStartPictureInPicture: Bool) {
|
||||||
|
renderer?.syncTimebase()
|
||||||
|
pipController?.updatePlaybackState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) {
|
||||||
|
pipController?.updatePlaybackState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||||
|
renderer?.syncTimebase()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipController(_ controller: PiPController, didStopPictureInPicture: Bool) {
|
||||||
|
renderer?.syncTimebase()
|
||||||
|
pipController?.updatePlaybackState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||||
|
completionHandler(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipControllerPlay(_ controller: PiPController) {
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipControllerPause(_ controller: PiPController) {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipController(_ controller: PiPController, skipByInterval interval: CMTime) {
|
||||||
|
let seconds = CMTimeGetSeconds(interval)
|
||||||
|
let target = max(0, cachedPosition + seconds)
|
||||||
|
seekTo(position: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipControllerIsPlaying(_ controller: PiPController) -> Bool {
|
||||||
|
return intendedPlayState
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipControllerDuration(_ controller: PiPController) -> Double {
|
||||||
|
return getDuration()
|
||||||
|
}
|
||||||
|
}
|
||||||
172
modules/mpv-player/ios/PiPController.swift
Normal file
172
modules/mpv-player/ios/PiPController.swift
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import AVKit
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
protocol PiPControllerDelegate: AnyObject {
|
||||||
|
func pipController(_ controller: PiPController, willStartPictureInPicture: Bool)
|
||||||
|
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool)
|
||||||
|
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool)
|
||||||
|
func pipController(_ controller: PiPController, didStopPictureInPicture: Bool)
|
||||||
|
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void)
|
||||||
|
func pipControllerPlay(_ controller: PiPController)
|
||||||
|
func pipControllerPause(_ controller: PiPController)
|
||||||
|
func pipController(_ controller: PiPController, skipByInterval interval: CMTime)
|
||||||
|
func pipControllerIsPlaying(_ controller: PiPController) -> Bool
|
||||||
|
func pipControllerDuration(_ controller: PiPController) -> Double
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PiPController: NSObject {
|
||||||
|
private var pipController: AVPictureInPictureController?
|
||||||
|
private weak var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer?
|
||||||
|
|
||||||
|
weak var delegate: PiPControllerDelegate?
|
||||||
|
|
||||||
|
var isPictureInPictureSupported: Bool {
|
||||||
|
return AVPictureInPictureController.isPictureInPictureSupported()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPictureInPictureActive: Bool {
|
||||||
|
return pipController?.isPictureInPictureActive ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPictureInPicturePossible: Bool {
|
||||||
|
return pipController?.isPictureInPicturePossible ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
|
||||||
|
self.sampleBufferDisplayLayer = sampleBufferDisplayLayer
|
||||||
|
super.init()
|
||||||
|
setupPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupPictureInPicture() {
|
||||||
|
guard isPictureInPictureSupported,
|
||||||
|
let displayLayer = sampleBufferDisplayLayer else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentSource = AVPictureInPictureController.ContentSource(
|
||||||
|
sampleBufferDisplayLayer: displayLayer,
|
||||||
|
playbackDelegate: self
|
||||||
|
)
|
||||||
|
|
||||||
|
pipController = AVPictureInPictureController(contentSource: contentSource)
|
||||||
|
pipController?.delegate = self
|
||||||
|
pipController?.requiresLinearPlayback = false
|
||||||
|
#if !os(tvOS)
|
||||||
|
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func startPictureInPicture() {
|
||||||
|
guard let pipController = pipController,
|
||||||
|
pipController.isPictureInPicturePossible else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pipController.startPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopPictureInPicture() {
|
||||||
|
pipController?.stopPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidate() {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
pipController?.invalidatePlaybackState()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.pipController?.invalidatePlaybackState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePlaybackState() {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
pipController?.invalidatePlaybackState()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.pipController?.invalidatePlaybackState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVPictureInPictureControllerDelegate
|
||||||
|
|
||||||
|
extension PiPController: AVPictureInPictureControllerDelegate {
|
||||||
|
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
delegate?.pipController(self, willStartPictureInPicture: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
delegate?.pipController(self, didStartPictureInPicture: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
|
||||||
|
Logger.shared.log("Failed to start PiP: \(error.localizedDescription)", type: "Error")
|
||||||
|
delegate?.pipController(self, didStartPictureInPicture: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
delegate?.pipController(self, willStopPictureInPicture: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
delegate?.pipController(self, didStopPictureInPicture: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
|
||||||
|
delegate?.pipController(self, restoreUserInterfaceForPictureInPictureStop: completionHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
|
||||||
|
|
||||||
|
extension PiPController: AVPictureInPictureSampleBufferPlaybackDelegate {
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) {
|
||||||
|
if playing {
|
||||||
|
delegate?.pipControllerPlay(self)
|
||||||
|
} else {
|
||||||
|
delegate?.pipControllerPause(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) {
|
||||||
|
delegate?.pipController(self, skipByInterval: skipInterval)
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPlaying: Bool {
|
||||||
|
return delegate?.pipControllerIsPlaying(self) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeRangeForPlayback: CMTimeRange {
|
||||||
|
let duration = delegate?.pipControllerDuration(self) ?? 0
|
||||||
|
if duration > 0 {
|
||||||
|
let cmDuration = CMTime(seconds: duration, preferredTimescale: 1000)
|
||||||
|
return CMTimeRange(start: .zero, duration: cmDuration)
|
||||||
|
}
|
||||||
|
return CMTimeRange(start: .zero, duration: .positiveInfinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange {
|
||||||
|
return timeRangeForPlayback
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
|
||||||
|
return !isPlaying
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool, completion: @escaping () -> Void) {
|
||||||
|
if playing {
|
||||||
|
delegate?.pipControllerPlay(self)
|
||||||
|
} else {
|
||||||
|
delegate?.pipControllerPause(self)
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
40
modules/mpv-player/ios/PlayerPreset.swift
Normal file
40
modules/mpv-player/ios/PlayerPreset.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PlayerPreset: Identifiable, Hashable {
|
||||||
|
enum Identifier: String, CaseIterable {
|
||||||
|
case sdrRec709
|
||||||
|
case hdr10
|
||||||
|
case dolbyVisionP5
|
||||||
|
case dolbyVisionP8
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Stream: Hashable {
|
||||||
|
enum Source: Hashable {
|
||||||
|
case remote(URL)
|
||||||
|
case bundled(resource: String, withExtension: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
let source: Source
|
||||||
|
let note: String
|
||||||
|
|
||||||
|
func resolveURL() -> URL? {
|
||||||
|
switch source {
|
||||||
|
case .remote(let url):
|
||||||
|
return url
|
||||||
|
case .bundled(let resource, let ext):
|
||||||
|
return Bundle.main.url(forResource: resource, withExtension: ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let id: Identifier
|
||||||
|
let title: String
|
||||||
|
let summary: String
|
||||||
|
let stream: Stream?
|
||||||
|
let commands: [[String]]
|
||||||
|
|
||||||
|
static var presets: [PlayerPreset] {
|
||||||
|
let list: [PlayerPreset] = []
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
97
modules/mpv-player/src/MpvPlayer.types.ts
Normal file
97
modules/mpv-player/src/MpvPlayer.types.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
export type OnLoadEventPayload = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnPlaybackStateChangePayload = {
|
||||||
|
isPaused?: boolean;
|
||||||
|
isPlaying?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isReadyToSeek?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnProgressEventPayload = {
|
||||||
|
position: number;
|
||||||
|
duration: number;
|
||||||
|
progress: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnErrorEventPayload = {
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||||
|
|
||||||
|
export type VideoSource = {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
externalSubtitles?: string[];
|
||||||
|
startPosition?: number;
|
||||||
|
autoplay?: boolean;
|
||||||
|
/** MPV subtitle track ID to select on start (1-based, -1 to disable) */
|
||||||
|
initialSubtitleId?: number;
|
||||||
|
/** MPV audio track ID to select on start (1-based) */
|
||||||
|
initialAudioId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MpvPlayerViewProps = {
|
||||||
|
source?: VideoSource;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
|
||||||
|
onPlaybackStateChange?: (event: {
|
||||||
|
nativeEvent: OnPlaybackStateChangePayload;
|
||||||
|
}) => void;
|
||||||
|
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
|
||||||
|
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
|
||||||
|
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MpvPlayerViewRef {
|
||||||
|
play: () => Promise<void>;
|
||||||
|
pause: () => Promise<void>;
|
||||||
|
seekTo: (position: number) => Promise<void>;
|
||||||
|
seekBy: (offset: number) => Promise<void>;
|
||||||
|
setSpeed: (speed: number) => Promise<void>;
|
||||||
|
getSpeed: () => Promise<number>;
|
||||||
|
isPaused: () => Promise<boolean>;
|
||||||
|
getCurrentPosition: () => Promise<number>;
|
||||||
|
getDuration: () => Promise<number>;
|
||||||
|
startPictureInPicture: () => Promise<void>;
|
||||||
|
stopPictureInPicture: () => Promise<void>;
|
||||||
|
isPictureInPictureSupported: () => Promise<boolean>;
|
||||||
|
isPictureInPictureActive: () => Promise<boolean>;
|
||||||
|
// Subtitle controls
|
||||||
|
getSubtitleTracks: () => Promise<SubtitleTrack[]>;
|
||||||
|
setSubtitleTrack: (trackId: number) => Promise<void>;
|
||||||
|
disableSubtitles: () => Promise<void>;
|
||||||
|
getCurrentSubtitleTrack: () => Promise<number>;
|
||||||
|
addSubtitleFile: (url: string, select?: boolean) => Promise<void>;
|
||||||
|
// Subtitle positioning
|
||||||
|
setSubtitlePosition: (position: number) => Promise<void>;
|
||||||
|
setSubtitleScale: (scale: number) => Promise<void>;
|
||||||
|
setSubtitleMarginY: (margin: number) => Promise<void>;
|
||||||
|
setSubtitleAlignX: (alignment: "left" | "center" | "right") => Promise<void>;
|
||||||
|
setSubtitleAlignY: (alignment: "top" | "center" | "bottom") => Promise<void>;
|
||||||
|
setSubtitleFontSize: (size: number) => Promise<void>;
|
||||||
|
// Audio controls
|
||||||
|
getAudioTracks: () => Promise<AudioTrack[]>;
|
||||||
|
setAudioTrack: (trackId: number) => Promise<void>;
|
||||||
|
getCurrentAudioTrack: () => Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubtitleTrack = {
|
||||||
|
id: number;
|
||||||
|
title?: string;
|
||||||
|
lang?: string;
|
||||||
|
selected?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AudioTrack = {
|
||||||
|
id: number;
|
||||||
|
title?: string;
|
||||||
|
lang?: string;
|
||||||
|
codec?: string;
|
||||||
|
channels?: number;
|
||||||
|
selected?: boolean;
|
||||||
|
};
|
||||||
102
modules/mpv-player/src/MpvPlayerView.tsx
Normal file
102
modules/mpv-player/src/MpvPlayerView.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { requireNativeView } from "expo";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useImperativeHandle, useRef } from "react";
|
||||||
|
|
||||||
|
import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
|
||||||
|
|
||||||
|
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
||||||
|
requireNativeView("MpvPlayer");
|
||||||
|
|
||||||
|
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||||
|
function MpvPlayerView(props, ref) {
|
||||||
|
const nativeRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
play: async () => {
|
||||||
|
await nativeRef.current?.play();
|
||||||
|
},
|
||||||
|
pause: async () => {
|
||||||
|
await nativeRef.current?.pause();
|
||||||
|
},
|
||||||
|
seekTo: async (position: number) => {
|
||||||
|
await nativeRef.current?.seekTo(position);
|
||||||
|
},
|
||||||
|
seekBy: async (offset: number) => {
|
||||||
|
await nativeRef.current?.seekBy(offset);
|
||||||
|
},
|
||||||
|
setSpeed: async (speed: number) => {
|
||||||
|
await nativeRef.current?.setSpeed(speed);
|
||||||
|
},
|
||||||
|
getSpeed: async () => {
|
||||||
|
return (await nativeRef.current?.getSpeed()) ?? 1.0;
|
||||||
|
},
|
||||||
|
isPaused: async () => {
|
||||||
|
return (await nativeRef.current?.isPaused()) ?? true;
|
||||||
|
},
|
||||||
|
getCurrentPosition: async () => {
|
||||||
|
return (await nativeRef.current?.getCurrentPosition()) ?? 0;
|
||||||
|
},
|
||||||
|
getDuration: async () => {
|
||||||
|
return (await nativeRef.current?.getDuration()) ?? 0;
|
||||||
|
},
|
||||||
|
startPictureInPicture: async () => {
|
||||||
|
await nativeRef.current?.startPictureInPicture();
|
||||||
|
},
|
||||||
|
stopPictureInPicture: async () => {
|
||||||
|
await nativeRef.current?.stopPictureInPicture();
|
||||||
|
},
|
||||||
|
isPictureInPictureSupported: async () => {
|
||||||
|
return (
|
||||||
|
(await nativeRef.current?.isPictureInPictureSupported()) ?? false
|
||||||
|
);
|
||||||
|
},
|
||||||
|
isPictureInPictureActive: async () => {
|
||||||
|
return (await nativeRef.current?.isPictureInPictureActive()) ?? false;
|
||||||
|
},
|
||||||
|
getSubtitleTracks: async () => {
|
||||||
|
return (await nativeRef.current?.getSubtitleTracks()) ?? [];
|
||||||
|
},
|
||||||
|
setSubtitleTrack: async (trackId: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleTrack(trackId);
|
||||||
|
},
|
||||||
|
disableSubtitles: async () => {
|
||||||
|
await nativeRef.current?.disableSubtitles();
|
||||||
|
},
|
||||||
|
getCurrentSubtitleTrack: async () => {
|
||||||
|
return (await nativeRef.current?.getCurrentSubtitleTrack()) ?? 0;
|
||||||
|
},
|
||||||
|
addSubtitleFile: async (url: string, select = true) => {
|
||||||
|
await nativeRef.current?.addSubtitleFile(url, select);
|
||||||
|
},
|
||||||
|
setSubtitlePosition: async (position: number) => {
|
||||||
|
await nativeRef.current?.setSubtitlePosition(position);
|
||||||
|
},
|
||||||
|
setSubtitleScale: async (scale: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleScale(scale);
|
||||||
|
},
|
||||||
|
setSubtitleMarginY: async (margin: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleMarginY(margin);
|
||||||
|
},
|
||||||
|
setSubtitleAlignX: async (alignment: "left" | "center" | "right") => {
|
||||||
|
await nativeRef.current?.setSubtitleAlignX(alignment);
|
||||||
|
},
|
||||||
|
setSubtitleAlignY: async (alignment: "top" | "center" | "bottom") => {
|
||||||
|
await nativeRef.current?.setSubtitleAlignY(alignment);
|
||||||
|
},
|
||||||
|
setSubtitleFontSize: async (size: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleFontSize(size);
|
||||||
|
},
|
||||||
|
getAudioTracks: async () => {
|
||||||
|
return (await nativeRef.current?.getAudioTracks()) ?? [];
|
||||||
|
},
|
||||||
|
setAudioTrack: async (trackId: number) => {
|
||||||
|
await nativeRef.current?.setAudioTrack(trackId);
|
||||||
|
},
|
||||||
|
getCurrentAudioTrack: async () => {
|
||||||
|
return (await nativeRef.current?.getCurrentAudioTrack()) ?? 0;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return <NativeView ref={nativeRef} {...props} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
2
modules/mpv-player/src/index.ts
Normal file
2
modules/mpv-player/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./MpvPlayer.types";
|
||||||
|
export { default as MpvPlayerView } from "./MpvPlayerView";
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"platforms": ["ios", "tvos"],
|
|
||||||
"ios": {
|
|
||||||
"modules": ["VlcPlayer4Module"],
|
|
||||||
"appDelegateSubscribers": ["AppLifecycleDelegate"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import ExpoModulesCore
|
|
||||||
|
|
||||||
protocol SimpleAppLifecycleListener {
|
|
||||||
func applicationDidEnterBackground() -> Void
|
|
||||||
func applicationDidEnterForeground() -> Void
|
|
||||||
}
|
|
||||||
|
|
||||||
public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
|
|
||||||
public func applicationDidBecomeActive(_ application: UIApplication) {
|
|
||||||
// The app has become active.
|
|
||||||
}
|
|
||||||
|
|
||||||
public func applicationWillResignActive(_ application: UIApplication) {
|
|
||||||
// The app is about to become inactive.
|
|
||||||
}
|
|
||||||
|
|
||||||
public func applicationDidEnterBackground(_ application: UIApplication) {
|
|
||||||
VLCManager.shared.listeners.forEach { listener in
|
|
||||||
listener.applicationDidEnterBackground()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func applicationWillEnterForeground(_ application: UIApplication) {
|
|
||||||
VLCManager.shared.listeners.forEach { listener in
|
|
||||||
listener.applicationDidEnterForeground()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func applicationWillTerminate(_ application: UIApplication) {
|
|
||||||
// The app is about to terminate.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
class VLCManager {
|
|
||||||
static let shared = VLCManager()
|
|
||||||
var listeners: [SimpleAppLifecycleListener] = []
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
Pod::Spec.new do |s|
|
|
||||||
s.name = 'VlcPlayer4'
|
|
||||||
s.version = '4.0.0a10'
|
|
||||||
s.summary = 'A sample project summary'
|
|
||||||
s.description = 'A sample project description'
|
|
||||||
s.author = ''
|
|
||||||
s.homepage = 'https://docs.expo.dev/modules/'
|
|
||||||
s.platforms = { :ios => '13.4', :tvos => '16' }
|
|
||||||
s.source = { git: '' }
|
|
||||||
s.static_framework = true
|
|
||||||
|
|
||||||
s.dependency 'ExpoModulesCore'
|
|
||||||
s.ios.dependency 'VLCKit', s.version
|
|
||||||
s.tvos.dependency 'VLCKit', s.version
|
|
||||||
|
|
||||||
# Swift/Objective-C compatibility
|
|
||||||
s.pod_target_xcconfig = {
|
|
||||||
'DEFINES_MODULE' => 'YES',
|
|
||||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
|
||||||
}
|
|
||||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
|
||||||
end
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import ExpoModulesCore
|
|
||||||
|
|
||||||
public class VlcPlayer4Module: Module {
|
|
||||||
public func definition() -> ModuleDefinition {
|
|
||||||
Name("VlcPlayer4")
|
|
||||||
View(VlcPlayer4View.self) {
|
|
||||||
Prop("source") { (view: VlcPlayer4View, source: [String: Any]) in
|
|
||||||
view.setSource(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
Prop("paused") { (view: VlcPlayer4View, paused: Bool) in
|
|
||||||
if paused {
|
|
||||||
view.pause()
|
|
||||||
} else {
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Events(
|
|
||||||
"onPlaybackStateChanged",
|
|
||||||
"onVideoStateChange",
|
|
||||||
"onVideoLoadStart",
|
|
||||||
"onVideoLoadEnd",
|
|
||||||
"onVideoProgress",
|
|
||||||
"onVideoError",
|
|
||||||
"onPipStarted"
|
|
||||||
)
|
|
||||||
|
|
||||||
AsyncFunction("startPictureInPicture") { (view: VlcPlayer4View) in
|
|
||||||
view.startPictureInPicture()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("play") { (view: VlcPlayer4View) in
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("pause") { (view: VlcPlayer4View) in
|
|
||||||
view.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("stop") { (view: VlcPlayer4View) in
|
|
||||||
view.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("seekTo") { (view: VlcPlayer4View, time: Int32) in
|
|
||||||
view.seekTo(time)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setAudioTrack") { (view: VlcPlayer4View, trackIndex: Int) in
|
|
||||||
view.setAudioTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getAudioTracks") { (view: VlcPlayer4View) -> [[String: Any]]? in
|
|
||||||
return view.getAudioTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleTrack") { (view: VlcPlayer4View, trackIndex: Int) in
|
|
||||||
view.setSubtitleTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getSubtitleTracks") { (view: VlcPlayer4View) -> [[String: Any]]? in
|
|
||||||
return view.getSubtitleTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleURL") {
|
|
||||||
(view: VlcPlayer4View, url: String, name: String) in
|
|
||||||
view.setSubtitleURL(url, name: name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
import ExpoModulesCore
|
|
||||||
import UIKit
|
|
||||||
import VLCKit
|
|
||||||
import os
|
|
||||||
|
|
||||||
public class VLCPlayerView: UIView {
|
|
||||||
func setupView(parent: UIView) {
|
|
||||||
self.backgroundColor = .black
|
|
||||||
self.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
self.leadingAnchor.constraint(equalTo: parent.leadingAnchor),
|
|
||||||
self.trailingAnchor.constraint(equalTo: parent.trailingAnchor),
|
|
||||||
self.topAnchor.constraint(equalTo: parent.topAnchor),
|
|
||||||
self.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
public override func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
for subview in subviews {
|
|
||||||
subview.frame = bounds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class VLCPlayerWrapper: NSObject {
|
|
||||||
private var lastProgressCall = Date().timeIntervalSince1970
|
|
||||||
public var player: VLCMediaPlayer = VLCMediaPlayer()
|
|
||||||
private var updatePlayerState: (() -> Void)?
|
|
||||||
private var updateVideoProgress: (() -> Void)?
|
|
||||||
private var playerView: VLCPlayerView = VLCPlayerView()
|
|
||||||
public weak var pipController: VLCPictureInPictureWindowControlling?
|
|
||||||
|
|
||||||
override public init() {
|
|
||||||
super.init()
|
|
||||||
player.delegate = self
|
|
||||||
player.drawable = self
|
|
||||||
player.scaleFactor = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setup(
|
|
||||||
parent: UIView,
|
|
||||||
updatePlayerState: (() -> Void)?,
|
|
||||||
updateVideoProgress: (() -> Void)?
|
|
||||||
) {
|
|
||||||
self.updatePlayerState = updatePlayerState
|
|
||||||
self.updateVideoProgress = updateVideoProgress
|
|
||||||
|
|
||||||
player.delegate = self
|
|
||||||
parent.addSubview(playerView)
|
|
||||||
playerView.setupView(parent: parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func getPlayerView() -> UIView {
|
|
||||||
return playerView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - VLCPictureInPictureDrawable
|
|
||||||
extension VLCPlayerWrapper: VLCPictureInPictureDrawable {
|
|
||||||
public func mediaController() -> (any VLCPictureInPictureMediaControlling)! {
|
|
||||||
return self
|
|
||||||
}
|
|
||||||
|
|
||||||
public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)!
|
|
||||||
{
|
|
||||||
return { [weak self] controller in
|
|
||||||
self?.pipController = controller
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - VLCPictureInPictureMediaControlling
|
|
||||||
extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling {
|
|
||||||
func mediaTime() -> Int64 {
|
|
||||||
return player.time.value?.int64Value ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func mediaLength() -> Int64 {
|
|
||||||
return player.media?.length.value?.int64Value ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func play() {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
func pause() {
|
|
||||||
player.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
func seek(by offset: Int64, completion: @escaping () -> Void) {
|
|
||||||
player.jump(withOffset: Int32(offset), completion: completion)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isMediaSeekable() -> Bool {
|
|
||||||
return player.isSeekable
|
|
||||||
}
|
|
||||||
|
|
||||||
func isMediaPlaying() -> Bool {
|
|
||||||
return player.isPlaying
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - VLCDrawable
|
|
||||||
extension VLCPlayerWrapper: VLCDrawable {
|
|
||||||
public func addSubview(_ view: UIView) {
|
|
||||||
playerView.addSubview(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func bounds() -> CGRect {
|
|
||||||
return playerView.bounds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - VLCMediaPlayerDelegate
|
|
||||||
extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
|
|
||||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
let timeNow = Date().timeIntervalSince1970
|
|
||||||
if timeNow - self.lastProgressCall >= 1 {
|
|
||||||
self.lastProgressCall = timeNow
|
|
||||||
self.updateVideoProgress?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.updatePlayerState?()
|
|
||||||
|
|
||||||
guard let pipController = self.pipController else { return }
|
|
||||||
pipController.invalidatePlaybackState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class VlcPlayer4View: ExpoView {
|
|
||||||
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayer4View")
|
|
||||||
|
|
||||||
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
|
|
||||||
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
|
||||||
private var isPaused: Bool = false
|
|
||||||
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
|
||||||
private var startPosition: Int32 = 0
|
|
||||||
private var externalTrack: [String: String]?
|
|
||||||
private var isStopping: Bool = false // Define isStopping here
|
|
||||||
private var externalSubtitles: [[String: String]]?
|
|
||||||
var hasSource = false
|
|
||||||
var initialSeekPerformed = false
|
|
||||||
// A flag variable determinging if we should perform the initial seek. Its either transcoding or offline playback. that makes
|
|
||||||
var shouldPerformInitialSeek: Bool = false
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
required init(appContext: AppContext? = nil) {
|
|
||||||
super.init(appContext: appContext)
|
|
||||||
setupVLC()
|
|
||||||
setupNotifications()
|
|
||||||
VLCManager.shared.listeners.append(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Setup
|
|
||||||
private func setupVLC() {
|
|
||||||
vlc.setup(
|
|
||||||
parent: self,
|
|
||||||
updatePlayerState: updatePlayerState,
|
|
||||||
updateVideoProgress: updateVideoProgress
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workaround: When playing an HLS video for the first time, seeking to a specific time immediately can cause a crash.
|
|
||||||
// To avoid this, we wait until the video has started playing before performing the initial seek.
|
|
||||||
func performInitialSeek() {
|
|
||||||
guard !initialSeekPerformed,
|
|
||||||
startPosition > 0,
|
|
||||||
shouldPerformInitialSeek,
|
|
||||||
vlc.player.isSeekable else { return }
|
|
||||||
|
|
||||||
initialSeekPerformed = true
|
|
||||||
logger.debug("First time update, performing initial seek to \(self.startPosition) seconds")
|
|
||||||
vlc.player.time = VLCTime(int: startPosition * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupNotifications() {
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self, selector: #selector(applicationWillResignActive),
|
|
||||||
name: UIApplication.willResignActiveNotification, object: nil)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self, selector: #selector(applicationDidBecomeActive),
|
|
||||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Methods
|
|
||||||
func startPictureInPicture() {
|
|
||||||
self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in
|
|
||||||
self.onPipStarted?(["pipStarted": isStarted])
|
|
||||||
}
|
|
||||||
self.vlc.pipController?.startPictureInPicture()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func play() {
|
|
||||||
self.vlc.player.play()
|
|
||||||
self.isPaused = false
|
|
||||||
logger.debug("Play")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func pause() {
|
|
||||||
self.vlc.player.pause()
|
|
||||||
self.isPaused = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func seekTo(_ time: Int32) {
|
|
||||||
let wasPlaying = vlc.player.isPlaying
|
|
||||||
if wasPlaying {
|
|
||||||
self.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let duration = vlc.player.media?.length.intValue {
|
|
||||||
logger.debug("Seeking to time: \(time) Video Duration \(duration)")
|
|
||||||
|
|
||||||
// If the specified time is greater than the duration, seek to the end
|
|
||||||
let seekTime = time > duration ? duration - 1000 : time
|
|
||||||
vlc.player.time = VLCTime(int: seekTime)
|
|
||||||
self.updatePlayerState()
|
|
||||||
|
|
||||||
// Let mediaPlayerStateChanged handle play state change
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
if wasPlaying {
|
|
||||||
self.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.error("Unable to retrieve video duration")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSource(_ source: [String: Any]) {
|
|
||||||
logger.debug("Setting source...")
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
if self.hasSource {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
|
||||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
|
||||||
let initOptions: [String] = source["initOptions"] as? [String] ?? []
|
|
||||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
|
||||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
|
||||||
|
|
||||||
for item in initOptions {
|
|
||||||
let option = item.components(separatedBy: "=")
|
|
||||||
mediaOptions.updateValue(
|
|
||||||
option[1], forKey: option[0].replacingOccurrences(of: "--", with: ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
|
||||||
logger.error("Invalid or empty URI")
|
|
||||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let autoplay = source["autoplay"] as? Bool ?? false
|
|
||||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
|
||||||
|
|
||||||
// Set shouldPeformIntial based on isTranscoding and is not a network stream
|
|
||||||
self.shouldPerformInitialSeek = uri.contains("m3u8") || !isNetwork
|
|
||||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
|
||||||
|
|
||||||
let media: VLCMedia!
|
|
||||||
if isNetwork {
|
|
||||||
logger.debug("Loading network file: \(uri)")
|
|
||||||
media = VLCMedia(url: URL(string: uri)!)
|
|
||||||
} else {
|
|
||||||
logger.debug("Loading local file: \(uri)")
|
|
||||||
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
|
||||||
media = VLCMedia(url: url)
|
|
||||||
} else {
|
|
||||||
media = VLCMedia(path: uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Media options: \(mediaOptions)")
|
|
||||||
media.addOptions(mediaOptions)
|
|
||||||
|
|
||||||
self.vlc.player.media = media
|
|
||||||
self.setInitialExternalSubtitles()
|
|
||||||
self.hasSource = true
|
|
||||||
if autoplay {
|
|
||||||
logger.info("Playing...")
|
|
||||||
// The Video is not transcoding so it its safe to seek to the start position.
|
|
||||||
if !self.shouldPerformInitialSeek {
|
|
||||||
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
|
|
||||||
}
|
|
||||||
self.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
|
||||||
print("Setting audio track: \(trackIndex)")
|
|
||||||
let track = self.vlc.player.audioTracks[trackIndex]
|
|
||||||
track.isSelectedExclusively = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
|
||||||
return vlc.player.audioTracks.enumerated().map {
|
|
||||||
return ["name": $1.trackName, "index": $0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
|
||||||
logger.debug("Attempting to set subtitle track to index: \(trackIndex)")
|
|
||||||
if trackIndex == -1 {
|
|
||||||
logger.debug("Disabling all subtitles")
|
|
||||||
for track in self.vlc.player.textTracks {
|
|
||||||
track.isSelected = false
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let track = self.vlc.player.textTracks[trackIndex]
|
|
||||||
track.isSelectedExclusively = true;
|
|
||||||
logger.debug("Current subtitle track index after setting: \(track.trackName)")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
|
||||||
guard let url = URL(string: subtitleURL) else {
|
|
||||||
logger.error("Invalid subtitle URL")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
|
||||||
if result == 0 {
|
|
||||||
let internalName = "Track \(self.customSubtitles.count)"
|
|
||||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
|
||||||
logger.debug("Subtitle added with result: \(result) \(internalName)")
|
|
||||||
} else {
|
|
||||||
logger.debug("Failed to add subtitle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
|
||||||
if self.vlc.player.textTracks.count == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
|
|
||||||
|
|
||||||
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
|
|
||||||
if let customSubtitle = customSubtitles.first(where: {
|
|
||||||
$0.internalName == track.trackName
|
|
||||||
}) {
|
|
||||||
return ["name": customSubtitle.originalName, "index": index]
|
|
||||||
} else {
|
|
||||||
return ["name": track.trackName, "index": index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Subtitle tracks: \(tracks)")
|
|
||||||
return tracks
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func stop(completion: (() -> Void)? = nil) {
|
|
||||||
logger.debug("Stopping media...")
|
|
||||||
guard !isStopping else {
|
|
||||||
completion?()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isStopping = true
|
|
||||||
|
|
||||||
// If we're not on the main thread, dispatch to main thread
|
|
||||||
if !Thread.isMainThread {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.performStop(completion: completion)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
performStop(completion: completion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
|
|
||||||
@objc private func applicationWillResignActive() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func applicationDidBecomeActive() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setInitialExternalSubtitles() {
|
|
||||||
if let externalSubtitles = self.externalSubtitles {
|
|
||||||
for subtitle in externalSubtitles {
|
|
||||||
if let subtitleName = subtitle["name"],
|
|
||||||
let subtitleURL = subtitle["DeliveryUrl"]
|
|
||||||
{
|
|
||||||
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
|
|
||||||
self.setSubtitleURL(subtitleURL, name: subtitleName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performStop(completion: (() -> Void)? = nil) {
|
|
||||||
// Stop the media player
|
|
||||||
vlc.player.stop()
|
|
||||||
|
|
||||||
// Remove observer
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
|
|
||||||
// Clear the video view
|
|
||||||
vlc.getPlayerView().removeFromSuperview()
|
|
||||||
|
|
||||||
isStopping = false
|
|
||||||
completion?()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateVideoProgress() {
|
|
||||||
guard self.vlc.player.media != nil else { return }
|
|
||||||
|
|
||||||
let currentTimeMs = self.vlc.player.time.intValue
|
|
||||||
let durationMs = self.vlc.player.media?.length.intValue ?? 0
|
|
||||||
|
|
||||||
logger.debug("Current time: \(currentTimeMs)")
|
|
||||||
self.onVideoProgress?([
|
|
||||||
"currentTime": currentTimeMs,
|
|
||||||
"duration": durationMs,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updatePlayerState() {
|
|
||||||
let player = self.vlc.player
|
|
||||||
if player.isPlaying {
|
|
||||||
performInitialSeek()
|
|
||||||
}
|
|
||||||
self.onVideoStateChange?([
|
|
||||||
"target": self.reactTag ?? NSNull(),
|
|
||||||
"currentTime": player.time.intValue,
|
|
||||||
"duration": player.media?.length.intValue ?? 0,
|
|
||||||
"error": false,
|
|
||||||
"isPlaying": player.isPlaying,
|
|
||||||
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
|
|
||||||
"state": player.state.description,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Expo Events
|
|
||||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoError: RCTDirectEventBlock?
|
|
||||||
@objc var onPipStarted: RCTDirectEventBlock?
|
|
||||||
|
|
||||||
// MARK: - Deinitialization
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
logger.debug("Deinitialization")
|
|
||||||
performStop()
|
|
||||||
VLCManager.shared.listeners.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - SimpleAppLifecycleListener
|
|
||||||
extension VlcPlayer4View: SimpleAppLifecycleListener {
|
|
||||||
func applicationDidEnterBackground() {
|
|
||||||
logger.debug("Entering background")
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationDidEnterForeground() {
|
|
||||||
logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)")
|
|
||||||
if !self.vlc.getPlayerView().isDescendant(of: self) {
|
|
||||||
logger.debug("Player view is missing. Adding back as subview")
|
|
||||||
self.addSubview(self.vlc.getPlayerView())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current solution to fixing black screen when re-entering application
|
|
||||||
if let videoTrack = self.vlc.player.videoTracks.first(where: { $0.isSelected == true }),
|
|
||||||
!self.vlc.isMediaPlaying()
|
|
||||||
{
|
|
||||||
videoTrack.isSelected = false
|
|
||||||
videoTrack.isSelectedExclusively = true
|
|
||||||
self.vlc.player.play()
|
|
||||||
self.vlc.player.pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VLCMediaPlayerState {
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .opening: return "Opening"
|
|
||||||
case .buffering: return "Buffering"
|
|
||||||
case .playing: return "Playing"
|
|
||||||
case .paused: return "Paused"
|
|
||||||
case .stopped: return "Stopped"
|
|
||||||
case .error: return "Error"
|
|
||||||
case .stopping: return "Stopping"
|
|
||||||
@unknown default: return "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { requireNativeModule } from "expo-modules-core";
|
|
||||||
|
|
||||||
// It loads the native module object from the JSI or falls back to
|
|
||||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
|
||||||
export default requireNativeModule("VlcPlayer4");
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id 'com.android.library'
|
|
||||||
id 'kotlin-android'
|
|
||||||
id 'kotlin-kapt'
|
|
||||||
}
|
|
||||||
|
|
||||||
group = 'expo.modules.vlcplayer'
|
|
||||||
version = '0.6.0'
|
|
||||||
|
|
||||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
||||||
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
|
||||||
|
|
||||||
apply from: expoModulesCorePlugin
|
|
||||||
|
|
||||||
applyKotlinExpoModulesCorePlugin()
|
|
||||||
useDefaultAndroidSdkVersions()
|
|
||||||
useCoreDependencies()
|
|
||||||
useExpoPublishing()
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace "expo.modules.vlcplayer"
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
|
|
||||||
lintOptions {
|
|
||||||
abortOnError false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation 'org.videolan.android:libvlc-all:3.6.0'
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
|
||||||
kotlinOptions {
|
|
||||||
freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package expo.modules.vlcplayer
|
|
||||||
|
|
||||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
|
||||||
|
|
||||||
// TODO: Creating a separate package class and adding this as a lifecycle listener did not work...
|
|
||||||
// https://docs.expo.dev/modules/android-lifecycle-listeners/
|
|
||||||
object VLCManager: ReactActivityLifecycleListener {
|
|
||||||
val listeners: MutableList<ReactActivityLifecycleListener> = mutableListOf()
|
|
||||||
// override fun onCreate(activity: Activity?, savedInstanceState: Bundle?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onCreate(activity, savedInstanceState)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onResume(activity: Activity?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onResume(activity)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onPause(activity: Activity?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onPause(activity)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onUserLeaveHint(activity: Activity?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onUserLeaveHint(activity)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onDestroy(activity: Activity?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onDestroy(activity)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package expo.modules.vlcplayer
|
|
||||||
|
|
||||||
import androidx.core.os.bundleOf
|
|
||||||
import expo.modules.kotlin.modules.Module
|
|
||||||
import expo.modules.kotlin.modules.ModuleDefinition
|
|
||||||
|
|
||||||
class VlcPlayerModule : Module() {
|
|
||||||
override fun definition() = ModuleDefinition {
|
|
||||||
Name("VlcPlayer")
|
|
||||||
|
|
||||||
OnActivityEntersForeground {
|
|
||||||
VLCManager.listeners.forEach {
|
|
||||||
it.onResume(appContext.currentActivity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OnActivityEntersBackground {
|
|
||||||
VLCManager.listeners.forEach {
|
|
||||||
it.onPause(appContext.currentActivity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
View(VlcPlayerView::class) {
|
|
||||||
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
|
|
||||||
view.setSource(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
Prop("paused") { view: VlcPlayerView, paused: Boolean ->
|
|
||||||
if (paused) {
|
|
||||||
view.pause()
|
|
||||||
} else {
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Events(
|
|
||||||
"onPlaybackStateChanged",
|
|
||||||
"onVideoStateChange",
|
|
||||||
"onVideoLoadStart",
|
|
||||||
"onVideoLoadEnd",
|
|
||||||
"onVideoProgress",
|
|
||||||
"onVideoError",
|
|
||||||
"onPipStarted"
|
|
||||||
)
|
|
||||||
|
|
||||||
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
|
|
||||||
view.startPictureInPicture()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("play") { view: VlcPlayerView ->
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("pause") { view: VlcPlayerView ->
|
|
||||||
view.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("stop") { view: VlcPlayerView ->
|
|
||||||
view.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("seekTo") { view: VlcPlayerView, time: Int ->
|
|
||||||
view.seekTo(time)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setAudioTrack") { view: VlcPlayerView, trackIndex: Int ->
|
|
||||||
view.setAudioTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getAudioTracks") { view: VlcPlayerView ->
|
|
||||||
view.getAudioTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleTrack") { view: VlcPlayerView, trackIndex: Int ->
|
|
||||||
view.setSubtitleTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getSubtitleTracks") { view: VlcPlayerView ->
|
|
||||||
view.getSubtitleTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String ->
|
|
||||||
view.setSubtitleURL(url, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setVideoAspectRatio") { view: VlcPlayerView, aspectRatio: String? ->
|
|
||||||
view.setVideoAspectRatio(aspectRatio)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setVideoScaleFactor") { view: VlcPlayerView, scaleFactor: Float ->
|
|
||||||
view.setVideoScaleFactor(scaleFactor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
package expo.modules.vlcplayer
|
|
||||||
|
|
||||||
import android.R
|
|
||||||
import android.app.Activity
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
|
||||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
import android.app.PictureInPictureParams
|
|
||||||
import android.app.RemoteAction
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContextWrapper
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.graphics.drawable.Icon
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.View
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleObserver
|
|
||||||
import androidx.lifecycle.OnLifecycleEvent
|
|
||||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
|
||||||
import expo.modules.core.logging.LogHandlers
|
|
||||||
import expo.modules.core.logging.Logger
|
|
||||||
import expo.modules.kotlin.AppContext
|
|
||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
||||||
import expo.modules.kotlin.views.ExpoView
|
|
||||||
import org.videolan.libvlc.LibVLC
|
|
||||||
import org.videolan.libvlc.Media
|
|
||||||
import org.videolan.libvlc.MediaPlayer
|
|
||||||
import org.videolan.libvlc.interfaces.IMedia
|
|
||||||
import org.videolan.libvlc.util.VLCVideoLayout
|
|
||||||
|
|
||||||
|
|
||||||
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener, ReactActivityLifecycleListener {
|
|
||||||
private val log = Logger(listOf(LogHandlers.createOSLogHandler(this::class.simpleName!!)))
|
|
||||||
private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION"
|
|
||||||
private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION"
|
|
||||||
private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION"
|
|
||||||
|
|
||||||
private var libVLC: LibVLC? = null
|
|
||||||
private var mediaPlayer: MediaPlayer? = null
|
|
||||||
private lateinit var videoLayout: VLCVideoLayout
|
|
||||||
private var isPaused: Boolean = false
|
|
||||||
private var lastReportedState: Int? = null
|
|
||||||
private var lastReportedIsPlaying: Boolean? = null
|
|
||||||
private var media : Media? = null
|
|
||||||
private var timeLeft: Long? = null
|
|
||||||
|
|
||||||
private val onVideoProgress by EventDispatcher()
|
|
||||||
private val onVideoStateChange by EventDispatcher()
|
|
||||||
private val onVideoLoadEnd by EventDispatcher()
|
|
||||||
private val onPipStarted by EventDispatcher()
|
|
||||||
|
|
||||||
private var startPosition: Int? = 0
|
|
||||||
private var isMediaReady: Boolean = false
|
|
||||||
private var externalTrack: Map<String, String>? = null
|
|
||||||
private var externalSubtitles: List<Map<String, String>>? = null
|
|
||||||
var hasSource: Boolean = false
|
|
||||||
|
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
|
||||||
private val updateInterval = 1000L // 1 second
|
|
||||||
private val updateProgressRunnable = object : Runnable {
|
|
||||||
override fun run() {
|
|
||||||
updateVideoProgress()
|
|
||||||
handler.postDelayed(this, updateInterval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private val currentActivity get() = context.findActivity()
|
|
||||||
private val actions: MutableList<RemoteAction> = mutableListOf()
|
|
||||||
private val remoteActionFilter = IntentFilter()
|
|
||||||
private val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
|
|
||||||
private val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
|
|
||||||
private val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
|
|
||||||
private var actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
when (intent?.action) {
|
|
||||||
PIP_PLAY_PAUSE_ACTION -> {
|
|
||||||
if (isPaused) play() else pause()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
setupPipActions()
|
|
||||||
currentActivity.setPictureInPictureParams(getPipParams()!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
|
|
||||||
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var pipChangeListener: (PictureInPictureModeChangedInfo) -> Unit = { info ->
|
|
||||||
if (!info.isInPictureInPictureMode && mediaPlayer?.isPlaying == true) {
|
|
||||||
log.debug("Exiting PiP")
|
|
||||||
timeLeft = mediaPlayer?.time
|
|
||||||
pause()
|
|
||||||
|
|
||||||
// Setting the media after reattaching the view allows for a fast video view render
|
|
||||||
if (mediaPlayer?.vlcVout?.areViewsAttached() == false) {
|
|
||||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
|
||||||
mediaPlayer?.media = media
|
|
||||||
mediaPlayer?.play()
|
|
||||||
timeLeft?.let { mediaPlayer?.time = it }
|
|
||||||
mediaPlayer?.pause()
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onPipStarted(mapOf(
|
|
||||||
"pipStarted" to info.isInPictureInPictureMode
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
VLCManager.listeners.add(this)
|
|
||||||
setupView()
|
|
||||||
setupPiP()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupView() {
|
|
||||||
log.debug("Setting up view")
|
|
||||||
setBackgroundColor(android.graphics.Color.WHITE)
|
|
||||||
videoLayout = VLCVideoLayout(context).apply {
|
|
||||||
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
|
||||||
}
|
|
||||||
videoLayout.keepScreenOn = true
|
|
||||||
addView(videoLayout)
|
|
||||||
log.debug("View setup complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupPiP() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
|
|
||||||
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
|
|
||||||
remoteActionFilter.addAction(PIP_REWIND_ACTION)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
currentActivity.registerReceiver(
|
|
||||||
actionReceiver,
|
|
||||||
remoteActionFilter,
|
|
||||||
Context.RECEIVER_NOT_EXPORTED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
setupPipActions()
|
|
||||||
currentActivity.apply {
|
|
||||||
setPictureInPictureParams(getPipParams()!!)
|
|
||||||
addOnPictureInPictureModeChangedListener(pipChangeListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
private fun setupPipActions() {
|
|
||||||
actions.clear()
|
|
||||||
actions.addAll(
|
|
||||||
listOf(
|
|
||||||
RemoteAction(
|
|
||||||
Icon.createWithResource(context, R.drawable.ic_media_rew),
|
|
||||||
"Rewind",
|
|
||||||
"Rewind Video",
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
0,
|
|
||||||
rewindIntent,
|
|
||||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
),
|
|
||||||
RemoteAction(
|
|
||||||
if (isPaused) Icon.createWithResource(context, R.drawable.ic_media_play)
|
|
||||||
else Icon.createWithResource(context, R.drawable.ic_media_pause),
|
|
||||||
"Play",
|
|
||||||
"Play Video",
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
if (isPaused) 0 else 1,
|
|
||||||
playPauseIntent,
|
|
||||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
),
|
|
||||||
RemoteAction(
|
|
||||||
Icon.createWithResource(context, R.drawable.ic_media_ff),
|
|
||||||
"Skip",
|
|
||||||
"Skip Forward",
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
0,
|
|
||||||
forwardIntent,
|
|
||||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPipParams(): PictureInPictureParams? {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
var builder = PictureInPictureParams.Builder()
|
|
||||||
.setActions(actions)
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
builder = builder.setAutoEnterEnabled(true)
|
|
||||||
}
|
|
||||||
return builder.build()
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSource(source: Map<String, Any>) {
|
|
||||||
log.debug("setting source $source")
|
|
||||||
if (hasSource) {
|
|
||||||
log.debug("Source already set. Ignoring.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
|
|
||||||
val autoplay = source["autoplay"] as? Boolean ?: false
|
|
||||||
val isNetwork = source["isNetwork"] as? Boolean ?: false
|
|
||||||
externalTrack = source["externalTrack"] as? Map<String, String>
|
|
||||||
externalSubtitles = source["externalSubtitles"] as? List<Map<String, String>>
|
|
||||||
startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0
|
|
||||||
|
|
||||||
val initOptions = source["initOptions"] as? MutableList<String> ?: mutableListOf()
|
|
||||||
initOptions.add("--start-time=$startPosition")
|
|
||||||
|
|
||||||
|
|
||||||
val uri = source["uri"] as? String
|
|
||||||
|
|
||||||
// Handle video load start event
|
|
||||||
// onVideoLoadStart?.invoke(mapOf("target" to reactTag ?: "null"))
|
|
||||||
|
|
||||||
libVLC = LibVLC(context, initOptions)
|
|
||||||
mediaPlayer = MediaPlayer(libVLC)
|
|
||||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
|
||||||
mediaPlayer?.setEventListener(this)
|
|
||||||
|
|
||||||
log.debug("Loading network file: $uri")
|
|
||||||
media = Media(libVLC, Uri.parse(uri))
|
|
||||||
mediaPlayer?.media = media
|
|
||||||
|
|
||||||
log.debug("Debug: Media options: $mediaOptions")
|
|
||||||
// media.addOptions(mediaOptions)
|
|
||||||
|
|
||||||
// Set initial external subtitles immediately like iOS
|
|
||||||
setInitialExternalSubtitles()
|
|
||||||
|
|
||||||
hasSource = true
|
|
||||||
|
|
||||||
if (autoplay) {
|
|
||||||
log.debug("Playing...")
|
|
||||||
play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startPictureInPicture() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
currentActivity.enterPictureInPictureMode(getPipParams()!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun play() {
|
|
||||||
mediaPlayer?.play()
|
|
||||||
isPaused = false
|
|
||||||
handler.post(updateProgressRunnable) // Start updating progress
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pause() {
|
|
||||||
mediaPlayer?.pause()
|
|
||||||
isPaused = true
|
|
||||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
mediaPlayer?.stop()
|
|
||||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekTo(time: Int) {
|
|
||||||
mediaPlayer?.let { player ->
|
|
||||||
val wasPlaying = player.isPlaying
|
|
||||||
if (wasPlaying) {
|
|
||||||
player.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
val duration = player.length.toInt()
|
|
||||||
val seekTime = if (time > duration) duration - 1000 else time
|
|
||||||
player.time = seekTime.toLong()
|
|
||||||
|
|
||||||
if (wasPlaying) {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAudioTrack(trackIndex: Int) {
|
|
||||||
mediaPlayer?.setAudioTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAudioTracks(): List<Map<String, Any>>? {
|
|
||||||
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
|
|
||||||
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
|
|
||||||
|
|
||||||
return trackDescriptions.map { trackDescription ->
|
|
||||||
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSubtitleTrack(trackIndex: Int) {
|
|
||||||
mediaPlayer?.setSpuTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fun getSubtitleTracks(): List<Map<String, Any>>? {
|
|
||||||
// return mediaPlayer?.getSpuTracks()?.map { trackDescription ->
|
|
||||||
// mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
fun getSubtitleTracks(): List<Map<String, Any>>? {
|
|
||||||
val subtitleTracks = mediaPlayer?.spuTracks?.map { trackDescription ->
|
|
||||||
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug statement to print the result
|
|
||||||
log.debug("Subtitle Tracks: $subtitleTracks")
|
|
||||||
|
|
||||||
return subtitleTracks
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSubtitleURL(subtitleURL: String, name: String) {
|
|
||||||
log.debug("Setting subtitle URL: $subtitleURL, name: $name")
|
|
||||||
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setVideoAspectRatio(aspectRatio: String?) {
|
|
||||||
log.debug("Setting video aspect ratio: $aspectRatio")
|
|
||||||
mediaPlayer?.aspectRatio = aspectRatio
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setVideoScaleFactor(scaleFactor: Float) {
|
|
||||||
log.debug("Setting video scale factor: $scaleFactor")
|
|
||||||
mediaPlayer?.scale = scaleFactor
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setInitialExternalSubtitles() {
|
|
||||||
externalSubtitles?.let { subtitles ->
|
|
||||||
for (subtitle in subtitles) {
|
|
||||||
val subtitleName = subtitle["name"]
|
|
||||||
val subtitleURL = subtitle["DeliveryUrl"]
|
|
||||||
if (!subtitleName.isNullOrEmpty() && !subtitleURL.isNullOrEmpty()) {
|
|
||||||
log.debug("Setting external subtitle: $subtitleName $subtitleURL")
|
|
||||||
setSubtitleURL(subtitleURL, subtitleName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
|
||||||
log.debug("onDetachedFromWindow")
|
|
||||||
super.onDetachedFromWindow()
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
currentActivity.setPictureInPictureParams(
|
|
||||||
PictureInPictureParams.Builder()
|
|
||||||
.setAutoEnterEnabled(false)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
currentActivity.unregisterReceiver(actionReceiver)
|
|
||||||
}
|
|
||||||
currentActivity.removeOnPictureInPictureModeChangedListener(pipChangeListener)
|
|
||||||
VLCManager.listeners.clear()
|
|
||||||
|
|
||||||
mediaPlayer?.stop()
|
|
||||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
|
||||||
|
|
||||||
media?.release()
|
|
||||||
mediaPlayer?.release()
|
|
||||||
libVLC?.release()
|
|
||||||
mediaPlayer = null
|
|
||||||
media = null
|
|
||||||
libVLC = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEvent(event: MediaPlayer.Event) {
|
|
||||||
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
|
|
||||||
when (event.type) {
|
|
||||||
MediaPlayer.Event.Playing,
|
|
||||||
MediaPlayer.Event.Paused,
|
|
||||||
MediaPlayer.Event.Stopped,
|
|
||||||
MediaPlayer.Event.Buffering,
|
|
||||||
MediaPlayer.Event.EndReached,
|
|
||||||
MediaPlayer.Event.EncounteredError -> updatePlayerState(event)
|
|
||||||
MediaPlayer.Event.TimeChanged -> {
|
|
||||||
// Do nothing here, as we are updating progress every 1 second
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updatePlayerState(event: MediaPlayer.Event) {
|
|
||||||
val player = mediaPlayer ?: return
|
|
||||||
val currentState = event.type
|
|
||||||
|
|
||||||
val stateInfo = mutableMapOf<String, Any>(
|
|
||||||
"target" to "null", // Replace with actual target if needed
|
|
||||||
"currentTime" to player.time.toInt(),
|
|
||||||
"duration" to (player.media?.duration?.toInt() ?: 0),
|
|
||||||
"error" to false,
|
|
||||||
"isPlaying" to (currentState == MediaPlayer.Event.Playing),
|
|
||||||
"isBuffering" to (!player.isPlaying && currentState == MediaPlayer.Event.Buffering)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Todo: make enum - string to prevent this when statement from becoming exhaustive
|
|
||||||
when (currentState) {
|
|
||||||
MediaPlayer.Event.Playing ->
|
|
||||||
stateInfo["state"] = "Playing"
|
|
||||||
MediaPlayer.Event.Paused ->
|
|
||||||
stateInfo["state"] = "Paused"
|
|
||||||
MediaPlayer.Event.Buffering ->
|
|
||||||
stateInfo["state"] = "Buffering"
|
|
||||||
MediaPlayer.Event.EncounteredError -> {
|
|
||||||
stateInfo["state"] = "Error"
|
|
||||||
onVideoLoadEnd(stateInfo);
|
|
||||||
}
|
|
||||||
MediaPlayer.Event.Opening ->
|
|
||||||
stateInfo["state"] = "Opening"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
|
|
||||||
lastReportedState = currentState
|
|
||||||
lastReportedIsPlaying = player.isPlaying
|
|
||||||
onVideoStateChange(stateInfo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun updateVideoProgress() {
|
|
||||||
val player = mediaPlayer ?: return
|
|
||||||
|
|
||||||
val currentTimeMs = player.time.toInt()
|
|
||||||
val durationMs = player.media?.duration?.toInt() ?: 0
|
|
||||||
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
|
|
||||||
// Set subtitle URL if available
|
|
||||||
if (player.isPlaying && !isMediaReady) {
|
|
||||||
isMediaReady = true
|
|
||||||
externalTrack?.let {
|
|
||||||
val name = it["name"]
|
|
||||||
val deliveryUrl = it["DeliveryUrl"] ?: ""
|
|
||||||
if (!name.isNullOrEmpty() && !deliveryUrl.isNullOrEmpty()) {
|
|
||||||
setSubtitleURL(deliveryUrl, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onVideoProgress(mapOf(
|
|
||||||
"currentTime" to currentTimeMs,
|
|
||||||
"duration" to durationMs
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause(activity: Activity?) {
|
|
||||||
log.debug("Pausing activity...")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override fun onResume(activity: Activity?) {
|
|
||||||
log.debug("Resuming activity...")
|
|
||||||
if (isPaused) play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun Context.findActivity(): androidx.activity.ComponentActivity {
|
|
||||||
var context = this
|
|
||||||
while (context is ContextWrapper) {
|
|
||||||
if (context is androidx.activity.ComponentActivity) return context
|
|
||||||
context = context.baseContext
|
|
||||||
}
|
|
||||||
throw IllegalStateException("Failed to find ComponentActivity")
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"platforms": ["ios", "tvos", "android", "web"],
|
|
||||||
"ios": {
|
|
||||||
"modules": ["VlcPlayerModule"]
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
Pod::Spec.new do |s|
|
|
||||||
s.name = 'VlcPlayer'
|
|
||||||
s.version = '3.6.1b1'
|
|
||||||
s.summary = 'A sample project summary'
|
|
||||||
s.description = 'A sample project description'
|
|
||||||
s.author = ''
|
|
||||||
s.homepage = 'https://docs.expo.dev/modules/'
|
|
||||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
|
||||||
s.source = { git: '' }
|
|
||||||
s.static_framework = true
|
|
||||||
|
|
||||||
s.dependency 'ExpoModulesCore'
|
|
||||||
s.ios.dependency 'MobileVLCKit', s.version
|
|
||||||
s.tvos.dependency 'TVVLCKit', s.version
|
|
||||||
|
|
||||||
# Swift/Objective-C compatibility
|
|
||||||
s.pod_target_xcconfig = {
|
|
||||||
'DEFINES_MODULE' => 'YES',
|
|
||||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
|
||||||
}
|
|
||||||
|
|
||||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
|
||||||
end
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import ExpoModulesCore
|
|
||||||
|
|
||||||
public class VlcPlayerModule: Module {
|
|
||||||
public func definition() -> ModuleDefinition {
|
|
||||||
Name("VlcPlayer")
|
|
||||||
View(VlcPlayerView.self) {
|
|
||||||
Prop("source") { (view: VlcPlayerView, source: [String: Any]) in
|
|
||||||
view.setSource(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
Prop("paused") { (view: VlcPlayerView, paused: Bool) in
|
|
||||||
if paused {
|
|
||||||
view.pause()
|
|
||||||
} else {
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Prop("nowPlayingMetadata") { (view: VlcPlayerView, metadata: [String: String]?) in
|
|
||||||
if let metadata = metadata {
|
|
||||||
view.setNowPlayingMetadata(metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Events(
|
|
||||||
"onPlaybackStateChanged",
|
|
||||||
"onVideoStateChange",
|
|
||||||
"onVideoLoadStart",
|
|
||||||
"onVideoLoadEnd",
|
|
||||||
"onVideoProgress",
|
|
||||||
"onVideoError",
|
|
||||||
"onPipStarted"
|
|
||||||
)
|
|
||||||
|
|
||||||
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
|
|
||||||
view.startPictureInPicture()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("play") { (view: VlcPlayerView) in
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("pause") { (view: VlcPlayerView) in
|
|
||||||
view.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("stop") { (view: VlcPlayerView) in
|
|
||||||
view.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("seekTo") { (view: VlcPlayerView, time: Int32) in
|
|
||||||
view.seekTo(time)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setAudioTrack") { (view: VlcPlayerView, trackIndex: Int) in
|
|
||||||
view.setAudioTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getAudioTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
|
|
||||||
return view.getAudioTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleURL") { (view: VlcPlayerView, url: String, name: String) in
|
|
||||||
view.setSubtitleURL(url, name: name)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleTrack") { (view: VlcPlayerView, trackIndex: Int) in
|
|
||||||
view.setSubtitleTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setVideoAspectRatio") { (view: VlcPlayerView, aspectRatio: String?) in
|
|
||||||
view.setVideoAspectRatio(aspectRatio)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setVideoScaleFactor") { (view: VlcPlayerView, scaleFactor: Float) in
|
|
||||||
view.setVideoScaleFactor(scaleFactor)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getSubtitleTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
|
|
||||||
return view.getSubtitleTracks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,718 +0,0 @@
|
|||||||
import ExpoModulesCore
|
|
||||||
import MediaPlayer
|
|
||||||
import AVFoundation
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
import TVVLCKit
|
|
||||||
#else
|
|
||||||
import MobileVLCKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
class VlcPlayerView: ExpoView {
|
|
||||||
private var mediaPlayer: VLCMediaPlayer?
|
|
||||||
private var videoView: UIView?
|
|
||||||
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
|
||||||
private var isPaused: Bool = false
|
|
||||||
private var currentGeometryCString: [CChar]?
|
|
||||||
private var lastReportedState: VLCMediaPlayerState?
|
|
||||||
private var lastReportedIsPlaying: Bool?
|
|
||||||
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
|
||||||
private var startPosition: Int32 = 0
|
|
||||||
private var externalSubtitles: [[String: String]]?
|
|
||||||
private var externalTrack: [String: String]?
|
|
||||||
private var progressTimer: DispatchSourceTimer?
|
|
||||||
private var isStopping: Bool = false // Define isStopping here
|
|
||||||
private var lastProgressCall = Date().timeIntervalSince1970
|
|
||||||
var hasSource = false
|
|
||||||
var isTranscoding = false
|
|
||||||
private var initialSeekPerformed: Bool = false
|
|
||||||
private var nowPlayingMetadata: [String: String]?
|
|
||||||
private var artworkImage: UIImage?
|
|
||||||
private var artworkDownloadTask: URLSessionDataTask?
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
|
|
||||||
required init(appContext: AppContext? = nil) {
|
|
||||||
super.init(appContext: appContext)
|
|
||||||
setupView()
|
|
||||||
setupNotifications()
|
|
||||||
setupRemoteCommandCenter()
|
|
||||||
setupAudioSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Setup
|
|
||||||
|
|
||||||
private func setupView() {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.backgroundColor = .black
|
|
||||||
self.videoView = UIView()
|
|
||||||
self.videoView?.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
if let videoView = self.videoView {
|
|
||||||
self.addSubview(videoView)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
|
||||||
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
|
||||||
videoView.topAnchor.constraint(equalTo: self.topAnchor),
|
|
||||||
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupNotifications() {
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self, selector: #selector(applicationWillResignActive),
|
|
||||||
name: UIApplication.willResignActiveNotification, object: nil)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self, selector: #selector(applicationDidBecomeActive),
|
|
||||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self, selector: #selector(handleAudioSessionInterruption),
|
|
||||||
name: AVAudioSession.interruptionNotification, object: nil)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupAudioSession() {
|
|
||||||
#if !os(tvOS)
|
|
||||||
do {
|
|
||||||
let audioSession = AVAudioSession.sharedInstance()
|
|
||||||
try audioSession.setCategory(.playback, mode: .moviePlayback, options: [])
|
|
||||||
try audioSession.setActive(true)
|
|
||||||
print("Audio session configured for media controls")
|
|
||||||
} catch {
|
|
||||||
print("Failed to setup audio session: \(error)")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupRemoteCommandCenter() {
|
|
||||||
#if !os(tvOS)
|
|
||||||
let commandCenter = MPRemoteCommandCenter.shared()
|
|
||||||
|
|
||||||
// Play command
|
|
||||||
commandCenter.playCommand.isEnabled = true
|
|
||||||
commandCenter.playCommand.addTarget { [weak self] _ in
|
|
||||||
self?.play()
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pause command
|
|
||||||
commandCenter.pauseCommand.isEnabled = true
|
|
||||||
commandCenter.pauseCommand.addTarget { [weak self] _ in
|
|
||||||
self?.pause()
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle play/pause command
|
|
||||||
commandCenter.togglePlayPauseCommand.isEnabled = true
|
|
||||||
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
|
||||||
guard let self = self, let player = self.mediaPlayer else {
|
|
||||||
return .commandFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
if player.isPlaying {
|
|
||||||
self.pause()
|
|
||||||
} else {
|
|
||||||
self.play()
|
|
||||||
}
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seek forward command
|
|
||||||
commandCenter.skipForwardCommand.isEnabled = true
|
|
||||||
commandCenter.skipForwardCommand.preferredIntervals = [15]
|
|
||||||
commandCenter.skipForwardCommand.addTarget { [weak self] event in
|
|
||||||
guard let self = self, let player = self.mediaPlayer else {
|
|
||||||
return .commandFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
|
|
||||||
let currentTime = player.time.intValue
|
|
||||||
self.seekTo(currentTime + Int32(skipInterval * 1000))
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seek backward command
|
|
||||||
commandCenter.skipBackwardCommand.isEnabled = true
|
|
||||||
commandCenter.skipBackwardCommand.preferredIntervals = [15]
|
|
||||||
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
|
|
||||||
guard let self = self, let player = self.mediaPlayer else {
|
|
||||||
return .commandFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
|
|
||||||
let currentTime = player.time.intValue
|
|
||||||
self.seekTo(max(0, currentTime - Int32(skipInterval * 1000)))
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change playback position command (scrubbing)
|
|
||||||
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
|
||||||
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
|
||||||
guard let self = self,
|
|
||||||
let event = event as? MPChangePlaybackPositionCommandEvent else {
|
|
||||||
return .commandFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
let positionTime = event.positionTime
|
|
||||||
self.seekTo(Int32(positionTime * 1000))
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Remote command center configured")
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cleanupRemoteCommandCenter() {
|
|
||||||
#if !os(tvOS)
|
|
||||||
let commandCenter = MPRemoteCommandCenter.shared()
|
|
||||||
|
|
||||||
// Remove all command targets to prevent memory leaks
|
|
||||||
commandCenter.playCommand.removeTarget(nil)
|
|
||||||
commandCenter.pauseCommand.removeTarget(nil)
|
|
||||||
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
|
||||||
commandCenter.skipForwardCommand.removeTarget(nil)
|
|
||||||
commandCenter.skipBackwardCommand.removeTarget(nil)
|
|
||||||
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
|
||||||
|
|
||||||
// Disable commands
|
|
||||||
commandCenter.playCommand.isEnabled = false
|
|
||||||
commandCenter.pauseCommand.isEnabled = false
|
|
||||||
commandCenter.togglePlayPauseCommand.isEnabled = false
|
|
||||||
commandCenter.skipForwardCommand.isEnabled = false
|
|
||||||
commandCenter.skipBackwardCommand.isEnabled = false
|
|
||||||
commandCenter.changePlaybackPositionCommand.isEnabled = false
|
|
||||||
|
|
||||||
print("Remote command center cleaned up")
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Methods
|
|
||||||
func startPictureInPicture() {}
|
|
||||||
|
|
||||||
@objc func play() {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.mediaPlayer?.play()
|
|
||||||
self.isPaused = false
|
|
||||||
self.updateNowPlayingInfo()
|
|
||||||
print("Play")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func pause() {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.mediaPlayer?.pause()
|
|
||||||
self.isPaused = true
|
|
||||||
self.updateNowPlayingInfo()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
|
||||||
#if !os(tvOS)
|
|
||||||
guard let userInfo = notification.userInfo,
|
|
||||||
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
||||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch type {
|
|
||||||
case .began:
|
|
||||||
// Interruption began - pause the video
|
|
||||||
print("Audio session interrupted - pausing video")
|
|
||||||
self.pause()
|
|
||||||
|
|
||||||
case .ended:
|
|
||||||
// Interruption ended - check if we should resume
|
|
||||||
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
|
|
||||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
|
||||||
if options.contains(.shouldResume) {
|
|
||||||
print("Audio session interruption ended - can resume")
|
|
||||||
// Don't auto-resume - let user manually resume playback
|
|
||||||
} else {
|
|
||||||
print("Audio session interruption ended - should not resume")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@unknown default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func seekTo(_ time: Int32) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
guard let player = self.mediaPlayer else { return }
|
|
||||||
|
|
||||||
let wasPlaying = player.isPlaying
|
|
||||||
if wasPlaying {
|
|
||||||
player.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let duration = player.media?.length.intValue {
|
|
||||||
print("Seeking to time: \(time) Video Duration \(duration)")
|
|
||||||
|
|
||||||
// If the specified time is greater than the duration, seek to the end
|
|
||||||
let seekTime = time > duration ? duration - 1000 : time
|
|
||||||
player.time = VLCTime(int: seekTime)
|
|
||||||
if wasPlaying {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
self.updatePlayerState()
|
|
||||||
self.updateNowPlayingInfo()
|
|
||||||
} else {
|
|
||||||
print("Error: Unable to retrieve video duration")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSource(_ source: [String: Any]) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
if self.hasSource {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
|
||||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
|
||||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
|
||||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
|
||||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
|
||||||
|
|
||||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
|
||||||
print("Error: Invalid or empty URI")
|
|
||||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.isTranscoding = uri.contains("m3u8")
|
|
||||||
|
|
||||||
if !self.isTranscoding, self.startPosition > 0 {
|
|
||||||
initOptions.append("--start-time=\(self.startPosition)")
|
|
||||||
}
|
|
||||||
|
|
||||||
let autoplay = source["autoplay"] as? Bool ?? false
|
|
||||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
|
||||||
|
|
||||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
|
||||||
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
|
|
||||||
self.mediaPlayer?.delegate = self
|
|
||||||
self.mediaPlayer?.drawable = self.videoView
|
|
||||||
self.mediaPlayer?.scaleFactor = 0
|
|
||||||
self.initialSeekPerformed = false
|
|
||||||
|
|
||||||
let media: VLCMedia
|
|
||||||
if isNetwork {
|
|
||||||
print("Loading network file: \(uri)")
|
|
||||||
media = VLCMedia(url: URL(string: uri)!)
|
|
||||||
} else {
|
|
||||||
print("Loading local file: \(uri)")
|
|
||||||
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
|
||||||
media = VLCMedia(url: url)
|
|
||||||
} else {
|
|
||||||
media = VLCMedia(path: uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Debug: Media options: \(mediaOptions)")
|
|
||||||
media.addOptions(mediaOptions)
|
|
||||||
|
|
||||||
self.mediaPlayer?.media = media
|
|
||||||
self.setInitialExternalSubtitles()
|
|
||||||
self.hasSource = true
|
|
||||||
if autoplay {
|
|
||||||
print("Playing...")
|
|
||||||
self.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
|
||||||
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
|
||||||
guard let trackNames = mediaPlayer?.audioTrackNames,
|
|
||||||
let trackIndexes = mediaPlayer?.audioTrackIndexes
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return zip(trackNames, trackIndexes).map { name, index in
|
|
||||||
return ["name": name, "index": index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
|
||||||
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
|
|
||||||
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
|
|
||||||
print(
|
|
||||||
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
|
||||||
guard let url = URL(string: subtitleURL) else {
|
|
||||||
print("Error: Invalid subtitle URL")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
|
||||||
if let result = result {
|
|
||||||
let internalName = "Track \(self.customSubtitles.count)"
|
|
||||||
print("Subtitle added with result: \(result) \(internalName)")
|
|
||||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
|
||||||
} else {
|
|
||||||
print("Failed to add subtitle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setInitialExternalSubtitles() {
|
|
||||||
if let externalSubtitles = self.externalSubtitles {
|
|
||||||
for subtitle in externalSubtitles {
|
|
||||||
if let subtitleName = subtitle["name"],
|
|
||||||
let subtitleURL = subtitle["DeliveryUrl"]
|
|
||||||
{
|
|
||||||
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
|
|
||||||
self.setSubtitleURL(subtitleURL, name: subtitleName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
|
||||||
guard let mediaPlayer = self.mediaPlayer else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let count = mediaPlayer.numberOfSubtitlesTracks
|
|
||||||
print("Debug: Number of subtitle tracks: \(count)")
|
|
||||||
|
|
||||||
guard count > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var tracks: [[String: Any]] = []
|
|
||||||
|
|
||||||
if let names = mediaPlayer.videoSubTitlesNames as? [String],
|
|
||||||
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
|
|
||||||
{
|
|
||||||
for (index, name) in zip(indexes, names) {
|
|
||||||
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
|
|
||||||
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
|
|
||||||
} else {
|
|
||||||
tracks.append(["name": name, "index": index.intValue])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Debug: Subtitle tracks: \(tracks)")
|
|
||||||
return tracks
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setVideoAspectRatio(_ aspectRatio: String?) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let aspectRatio = aspectRatio {
|
|
||||||
// Convert String to C string for VLC
|
|
||||||
let cString = strdup(aspectRatio)
|
|
||||||
self.mediaPlayer?.videoAspectRatio = cString
|
|
||||||
} else {
|
|
||||||
// Reset to default (let VLC determine aspect ratio)
|
|
||||||
self.mediaPlayer?.videoAspectRatio = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setVideoScaleFactor(_ scaleFactor: Float) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.mediaPlayer?.scaleFactor = scaleFactor
|
|
||||||
print("Set video scale factor: \(scaleFactor)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setNowPlayingMetadata(_ metadata: [String: String]) {
|
|
||||||
// Cancel any existing artwork download to prevent race conditions
|
|
||||||
artworkDownloadTask?.cancel()
|
|
||||||
artworkDownloadTask = nil
|
|
||||||
|
|
||||||
self.nowPlayingMetadata = metadata
|
|
||||||
print("[NowPlaying] Metadata received: \(metadata)")
|
|
||||||
|
|
||||||
// Load artwork asynchronously if provided
|
|
||||||
if let artworkUri = metadata["artworkUri"], let url = URL(string: artworkUri) {
|
|
||||||
print("[NowPlaying] Loading artwork from: \(artworkUri)")
|
|
||||||
artworkDownloadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
if let error = error as NSError?, error.code == NSURLErrorCancelled {
|
|
||||||
print("[NowPlaying] Artwork download cancelled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let error = error {
|
|
||||||
print("[NowPlaying] Artwork loading error: \(error)")
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.updateNowPlayingInfo()
|
|
||||||
}
|
|
||||||
} else if let data = data, let image = UIImage(data: data) {
|
|
||||||
print("[NowPlaying] Artwork loaded successfully, size: \(image.size)")
|
|
||||||
self.artworkImage = image
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.updateNowPlayingInfo()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
print("[NowPlaying] Failed to create image from data")
|
|
||||||
// Update Now Playing info without artwork on failure
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.updateNowPlayingInfo()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
artworkDownloadTask?.resume()
|
|
||||||
} else {
|
|
||||||
// No artwork URI provided - update immediately
|
|
||||||
print("[NowPlaying] No artwork URI provided")
|
|
||||||
artworkImage = nil
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.updateNowPlayingInfo()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func stop(completion: (() -> Void)? = nil) {
|
|
||||||
guard !isStopping else {
|
|
||||||
completion?()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isStopping = true
|
|
||||||
|
|
||||||
// If we're not on the main thread, dispatch to main thread
|
|
||||||
if !Thread.isMainThread {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.performStop(completion: completion)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
performStop(completion: completion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
|
|
||||||
@objc private func applicationWillResignActive() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func applicationDidBecomeActive() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performStop(completion: (() -> Void)? = nil) {
|
|
||||||
// Stop the media player
|
|
||||||
mediaPlayer?.stop()
|
|
||||||
|
|
||||||
// Cancel any in-flight artwork downloads
|
|
||||||
artworkDownloadTask?.cancel()
|
|
||||||
artworkDownloadTask = nil
|
|
||||||
artworkImage = nil
|
|
||||||
|
|
||||||
// Cleanup remote command center targets
|
|
||||||
cleanupRemoteCommandCenter()
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
// Deactivate audio session to allow other apps to use audio
|
|
||||||
do {
|
|
||||||
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
||||||
print("Audio session deactivated")
|
|
||||||
} catch {
|
|
||||||
print("Failed to deactivate audio session: \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear Now Playing info
|
|
||||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Remove observer
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
|
|
||||||
// Clear the video view
|
|
||||||
videoView?.removeFromSuperview()
|
|
||||||
videoView = nil
|
|
||||||
|
|
||||||
// Release the media player
|
|
||||||
mediaPlayer?.delegate = nil
|
|
||||||
mediaPlayer = nil
|
|
||||||
|
|
||||||
isStopping = false
|
|
||||||
completion?()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateVideoProgress() {
|
|
||||||
guard let player = self.mediaPlayer else { return }
|
|
||||||
|
|
||||||
let currentTimeMs = player.time.intValue
|
|
||||||
let durationMs = player.media?.length.intValue ?? 0
|
|
||||||
|
|
||||||
|
|
||||||
print("Debug: Current time: \(currentTimeMs)")
|
|
||||||
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
|
||||||
if self.isTranscoding, !self.initialSeekPerformed, self.startPosition > 0 {
|
|
||||||
player.time = VLCTime(int: self.startPosition * 1000)
|
|
||||||
self.initialSeekPerformed = true
|
|
||||||
}
|
|
||||||
self.onVideoProgress?([
|
|
||||||
"currentTime": currentTimeMs,
|
|
||||||
"duration": durationMs,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Now Playing info to sync elapsed playback time
|
|
||||||
// iOS needs periodic updates to keep progress indicator in sync
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.updateNowPlayingInfo()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateNowPlayingInfo() {
|
|
||||||
#if !os(tvOS)
|
|
||||||
guard let player = self.mediaPlayer else { return }
|
|
||||||
|
|
||||||
var nowPlayingInfo = [String: Any]()
|
|
||||||
|
|
||||||
// Playback rate (0.0 = paused, 1.0 = playing at normal speed)
|
|
||||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.isPlaying ? player.rate : 0.0
|
|
||||||
|
|
||||||
// Current playback time in seconds
|
|
||||||
let currentTimeSeconds = Double(player.time.intValue) / 1000.0
|
|
||||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTimeSeconds
|
|
||||||
|
|
||||||
// Total duration in seconds
|
|
||||||
if let duration = player.media?.length.intValue {
|
|
||||||
let durationSeconds = Double(duration) / 1000.0
|
|
||||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = durationSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add metadata if available
|
|
||||||
if let metadata = self.nowPlayingMetadata {
|
|
||||||
if let title = metadata["title"] {
|
|
||||||
nowPlayingInfo[MPMediaItemPropertyTitle] = title
|
|
||||||
print("[NowPlaying] Setting title: \(title)")
|
|
||||||
}
|
|
||||||
if let artist = metadata["artist"] {
|
|
||||||
nowPlayingInfo[MPMediaItemPropertyArtist] = artist
|
|
||||||
print("[NowPlaying] Setting artist: \(artist)")
|
|
||||||
}
|
|
||||||
if let albumTitle = metadata["albumTitle"] {
|
|
||||||
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = albumTitle
|
|
||||||
print("[NowPlaying] Setting album: \(albumTitle)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add artwork if available
|
|
||||||
if let artwork = self.artworkImage {
|
|
||||||
print("[NowPlaying] Setting artwork with size: \(artwork.size)")
|
|
||||||
let artworkItem = MPMediaItemArtwork(boundsSize: artwork.size) { _ in
|
|
||||||
return artwork
|
|
||||||
}
|
|
||||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = artworkItem
|
|
||||||
}
|
|
||||||
|
|
||||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Expo Events
|
|
||||||
|
|
||||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoError: RCTDirectEventBlock?
|
|
||||||
@objc var onPipStarted: RCTDirectEventBlock?
|
|
||||||
|
|
||||||
// MARK: - Deinitialization
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
performStop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VlcPlayerView: VLCMediaPlayerDelegate {
|
|
||||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
|
||||||
// self?.updateVideoProgress()
|
|
||||||
let timeNow = Date().timeIntervalSince1970
|
|
||||||
if timeNow - lastProgressCall >= 1 {
|
|
||||||
lastProgressCall = timeNow
|
|
||||||
updateVideoProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
|
||||||
self.updatePlayerState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updatePlayerState() {
|
|
||||||
guard let player = self.mediaPlayer else { return }
|
|
||||||
let currentState = player.state
|
|
||||||
|
|
||||||
var stateInfo: [String: Any] = [
|
|
||||||
"target": self.reactTag ?? NSNull(),
|
|
||||||
"currentTime": player.time.intValue,
|
|
||||||
"duration": player.media?.length.intValue ?? 0,
|
|
||||||
"error": false,
|
|
||||||
]
|
|
||||||
|
|
||||||
if player.isPlaying {
|
|
||||||
stateInfo["isPlaying"] = true
|
|
||||||
stateInfo["isBuffering"] = false
|
|
||||||
stateInfo["state"] = "Playing"
|
|
||||||
} else {
|
|
||||||
stateInfo["isPlaying"] = false
|
|
||||||
stateInfo["state"] = "Paused"
|
|
||||||
}
|
|
||||||
|
|
||||||
if player.state == VLCMediaPlayerState.buffering {
|
|
||||||
stateInfo["isBuffering"] = true
|
|
||||||
stateInfo["state"] = "Buffering"
|
|
||||||
} else if player.state == VLCMediaPlayerState.error {
|
|
||||||
print("player.state ~ error")
|
|
||||||
stateInfo["state"] = "Error"
|
|
||||||
self.onVideoLoadEnd?(stateInfo)
|
|
||||||
} else if player.state == VLCMediaPlayerState.opening {
|
|
||||||
print("player.state ~ opening")
|
|
||||||
stateInfo["state"] = "Opening"
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.lastReportedState != currentState
|
|
||||||
|| self.lastReportedIsPlaying != player.isPlaying
|
|
||||||
{
|
|
||||||
self.lastReportedState = currentState
|
|
||||||
self.lastReportedIsPlaying = player.isPlaying
|
|
||||||
self.onVideoStateChange?(stateInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VlcPlayerView: VLCMediaDelegate {
|
|
||||||
// Implement VLCMediaDelegate methods if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VLCMediaPlayerState {
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .opening: return "Opening"
|
|
||||||
case .buffering: return "Buffering"
|
|
||||||
case .playing: return "Playing"
|
|
||||||
case .paused: return "Paused"
|
|
||||||
case .stopped: return "Stopped"
|
|
||||||
case .ended: return "Ended"
|
|
||||||
case .error: return "Error"
|
|
||||||
case .esAdded: return "ESAdded"
|
|
||||||
@unknown default: return "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { requireNativeModule } from "expo-modules-core";
|
|
||||||
|
|
||||||
// It loads the native module object from the JSI or falls back to
|
|
||||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
|
||||||
export default requireNativeModule("VlcPlayer");
|
|
||||||
@@ -96,7 +96,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
newWebSocket.onmessage = (e) => {
|
newWebSocket.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(e.data);
|
const message = JSON.parse(e.data);
|
||||||
console.log("[WS] Received message:", message);
|
|
||||||
setLastMessage(message); // Store the last message in context
|
setLastMessage(message); // Store the last message in context
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error parsing WebSocket message:", error);
|
console.error("Error parsing WebSocket message:", error);
|
||||||
@@ -124,12 +123,10 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const handlePlayCommand = useCallback(
|
const handlePlayCommand = useCallback(
|
||||||
(data: any) => {
|
(data: any) => {
|
||||||
if (!data || !data.ItemIds || !data.ItemIds.length) {
|
if (!data || !data.ItemIds || !data.ItemIds.length) {
|
||||||
console.warn("[WS] Received Play command with no items");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemId = data.ItemIds[0];
|
const itemId = data.ItemIds[0];
|
||||||
console.log(`[WS] Handling Play command for item: ${itemId}`);
|
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/player/direct-player",
|
pathname: "/(auth)/player/direct-player",
|
||||||
|
|||||||
@@ -130,10 +130,9 @@ export type HomeSectionLatestResolver = {
|
|||||||
includeItemTypes?: Array<BaseItemKind>;
|
includeItemTypes?: Array<BaseItemKind>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Video player enum - currently only MPV is supported
|
||||||
export enum VideoPlayer {
|
export enum VideoPlayer {
|
||||||
// NATIVE, //todo: changes will make this a lot more easier to implement if we want. delete if not wanted
|
MPV = 0,
|
||||||
VLC_3 = 0,
|
|
||||||
VLC_4 = 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Settings = {
|
export type Settings = {
|
||||||
@@ -143,7 +142,6 @@ export type Settings = {
|
|||||||
preferedLanguage?: string;
|
preferedLanguage?: string;
|
||||||
searchEngine: "Marlin" | "Jellyfin";
|
searchEngine: "Marlin" | "Jellyfin";
|
||||||
marlinServerUrl?: string;
|
marlinServerUrl?: string;
|
||||||
openInVLC?: boolean;
|
|
||||||
downloadQuality?: DownloadOption;
|
downloadQuality?: DownloadOption;
|
||||||
defaultBitrate?: Bitrate;
|
defaultBitrate?: Bitrate;
|
||||||
libraryOptions: LibraryOptions;
|
libraryOptions: LibraryOptions;
|
||||||
@@ -164,16 +162,14 @@ export type Settings = {
|
|||||||
jellyseerrServerUrl?: string;
|
jellyseerrServerUrl?: string;
|
||||||
hiddenLibraries?: string[];
|
hiddenLibraries?: string[];
|
||||||
enableH265ForChromecast: boolean;
|
enableH265ForChromecast: boolean;
|
||||||
defaultPlayer: VideoPlayer;
|
|
||||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||||
autoPlayEpisodeCount: number;
|
autoPlayEpisodeCount: number;
|
||||||
vlcTextColor?: string;
|
// MPV subtitle settings
|
||||||
vlcBackgroundColor?: string;
|
mpvSubtitleScale?: number;
|
||||||
vlcOutlineColor?: string;
|
mpvSubtitleMarginY?: number;
|
||||||
vlcOutlineThickness?: string;
|
mpvSubtitleAlignX?: "left" | "center" | "right";
|
||||||
vlcBackgroundOpacity?: number;
|
mpvSubtitleAlignY?: "top" | "center" | "bottom";
|
||||||
vlcOutlineOpacity?: number;
|
mpvSubtitleFontSize?: number;
|
||||||
vlcIsBold?: boolean;
|
|
||||||
// Gesture controls
|
// Gesture controls
|
||||||
enableHorizontalSwipeSkip: boolean;
|
enableHorizontalSwipeSkip: boolean;
|
||||||
enableLeftSideBrightnessSwipe: boolean;
|
enableLeftSideBrightnessSwipe: boolean;
|
||||||
@@ -201,7 +197,6 @@ export const defaultValues: Settings = {
|
|||||||
preferedLanguage: undefined,
|
preferedLanguage: undefined,
|
||||||
searchEngine: "Jellyfin",
|
searchEngine: "Jellyfin",
|
||||||
marlinServerUrl: "",
|
marlinServerUrl: "",
|
||||||
openInVLC: false,
|
|
||||||
downloadQuality: DownloadOptions[0],
|
downloadQuality: DownloadOptions[0],
|
||||||
defaultBitrate: BITRATES[0],
|
defaultBitrate: BITRATES[0],
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
@@ -228,16 +223,14 @@ export const defaultValues: Settings = {
|
|||||||
jellyseerrServerUrl: undefined,
|
jellyseerrServerUrl: undefined,
|
||||||
hiddenLibraries: [],
|
hiddenLibraries: [],
|
||||||
enableH265ForChromecast: false,
|
enableH265ForChromecast: false,
|
||||||
defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android
|
|
||||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||||
autoPlayEpisodeCount: 0,
|
autoPlayEpisodeCount: 0,
|
||||||
vlcTextColor: undefined,
|
// MPV subtitle defaults
|
||||||
vlcBackgroundColor: undefined,
|
mpvSubtitleScale: undefined,
|
||||||
vlcOutlineColor: undefined,
|
mpvSubtitleMarginY: undefined,
|
||||||
vlcOutlineThickness: undefined,
|
mpvSubtitleAlignX: undefined,
|
||||||
vlcBackgroundOpacity: undefined,
|
mpvSubtitleAlignY: undefined,
|
||||||
vlcOutlineOpacity: undefined,
|
mpvSubtitleFontSize: undefined,
|
||||||
vlcIsBold: undefined,
|
|
||||||
// Gesture controls
|
// Gesture controls
|
||||||
enableHorizontalSwipeSkip: true,
|
enableHorizontalSwipeSkip: true,
|
||||||
enableLeftSideBrightnessSwipe: true,
|
enableLeftSideBrightnessSwipe: true,
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
// utils/getDefaultPlaySettings.ts
|
/**
|
||||||
|
* getDefaultPlaySettings.ts
|
||||||
|
*
|
||||||
|
* Determines default audio/subtitle tracks and bitrate for playback.
|
||||||
|
*
|
||||||
|
* Two use cases:
|
||||||
|
* 1. INITIAL PLAY: No previous state, uses media defaults + user language preferences
|
||||||
|
* 2. SEQUENTIAL PLAY: Has previous state (e.g., next episode), uses StreamRanker
|
||||||
|
* to find matching tracks in the new media
|
||||||
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
@@ -12,86 +21,83 @@ import {
|
|||||||
SubtitleStreamRanker,
|
SubtitleStreamRanker,
|
||||||
} from "../streamRanker";
|
} from "../streamRanker";
|
||||||
|
|
||||||
interface PlaySettings {
|
export interface PlaySettings {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
bitrate: (typeof BITRATES)[0];
|
bitrate: (typeof BITRATES)[0];
|
||||||
mediaSource?: MediaSourceInfo | null;
|
mediaSource?: MediaSourceInfo | null;
|
||||||
audioIndex?: number | undefined;
|
|
||||||
subtitleIndex?: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface previousIndexes {
|
|
||||||
audioIndex?: number;
|
audioIndex?: number;
|
||||||
subtitleIndex?: number;
|
subtitleIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrackOptions {
|
export interface PreviousIndexes {
|
||||||
DefaultAudioStreamIndex: number | undefined;
|
audioIndex?: number;
|
||||||
DefaultSubtitleStreamIndex: number | undefined;
|
subtitleIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used getting default values for the next player.
|
/**
|
||||||
|
* Get default play settings for an item.
|
||||||
|
*
|
||||||
|
* @param item - The media item to play
|
||||||
|
* @param settings - User settings (language preferences, bitrate, etc.)
|
||||||
|
* @param previous - Optional previous track selections to carry over (for sequential play)
|
||||||
|
*/
|
||||||
export function getDefaultPlaySettings(
|
export function getDefaultPlaySettings(
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
settings: Settings,
|
settings: Settings | null,
|
||||||
previousIndexes?: previousIndexes,
|
previous?: { indexes?: PreviousIndexes; source?: MediaSourceInfo },
|
||||||
previousSource?: MediaSourceInfo,
|
|
||||||
): PlaySettings {
|
): PlaySettings {
|
||||||
if (item.Type === "Program") {
|
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
||||||
return {
|
|
||||||
item,
|
|
||||||
bitrate: BITRATES[0],
|
|
||||||
mediaSource: undefined,
|
|
||||||
audioIndex: undefined,
|
|
||||||
subtitleIndex: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Get first media source
|
// Live TV programs don't have media sources
|
||||||
|
if (item.Type === "Program") {
|
||||||
|
return { item, bitrate };
|
||||||
|
}
|
||||||
|
|
||||||
const mediaSource = item.MediaSources?.[0];
|
const mediaSource = item.MediaSources?.[0];
|
||||||
|
const streams = mediaSource?.MediaStreams ?? [];
|
||||||
|
|
||||||
// We prefer the previous track over the default track.
|
// Start with media source defaults
|
||||||
const trackOptions: TrackOptions = {
|
let audioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||||
DefaultAudioStreamIndex: mediaSource?.DefaultAudioStreamIndex ?? -1,
|
let subtitleIndex = mediaSource?.DefaultSubtitleStreamIndex ?? -1;
|
||||||
DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mediaStreams = mediaSource?.MediaStreams ?? [];
|
// Try to match previous selections (sequential play)
|
||||||
if (settings?.rememberSubtitleSelections && previousIndexes) {
|
if (previous?.indexes && previous?.source && settings) {
|
||||||
if (previousIndexes.subtitleIndex !== undefined && previousSource) {
|
if (
|
||||||
const subtitleRanker = new SubtitleStreamRanker();
|
settings.rememberSubtitleSelections &&
|
||||||
const ranker = new StreamRanker(subtitleRanker);
|
previous.indexes.subtitleIndex !== undefined
|
||||||
|
) {
|
||||||
|
const ranker = new StreamRanker(new SubtitleStreamRanker());
|
||||||
|
const result = { DefaultSubtitleStreamIndex: subtitleIndex };
|
||||||
ranker.rankStream(
|
ranker.rankStream(
|
||||||
previousIndexes.subtitleIndex,
|
previous.indexes.subtitleIndex,
|
||||||
previousSource,
|
previous.source,
|
||||||
mediaStreams,
|
streams,
|
||||||
trackOptions,
|
result,
|
||||||
);
|
);
|
||||||
|
subtitleIndex = result.DefaultSubtitleStreamIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
settings.rememberAudioSelections &&
|
||||||
|
previous.indexes.audioIndex !== undefined
|
||||||
|
) {
|
||||||
|
const ranker = new StreamRanker(new AudioStreamRanker());
|
||||||
|
const result = { DefaultAudioStreamIndex: audioIndex };
|
||||||
|
ranker.rankStream(
|
||||||
|
previous.indexes.audioIndex,
|
||||||
|
previous.source,
|
||||||
|
streams,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
audioIndex = result.DefaultAudioStreamIndex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings?.rememberAudioSelections && previousIndexes) {
|
|
||||||
if (previousIndexes.audioIndex !== undefined && previousSource) {
|
|
||||||
const audioRanker = new AudioStreamRanker();
|
|
||||||
const ranker = new StreamRanker(audioRanker);
|
|
||||||
ranker.rankStream(
|
|
||||||
previousIndexes.audioIndex,
|
|
||||||
previousSource,
|
|
||||||
mediaStreams,
|
|
||||||
trackOptions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Get default bitrate from settings or fallback to max
|
|
||||||
const bitrate = settings.defaultBitrate ?? BITRATES[0];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item,
|
item,
|
||||||
bitrate,
|
bitrate,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
audioIndex: trackOptions.DefaultAudioStreamIndex,
|
audioIndex: audioIndex ?? undefined,
|
||||||
subtitleIndex: trackOptions.DefaultSubtitleStreamIndex,
|
subtitleIndex: subtitleIndex ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import type { Settings } from "@/utils/atoms/settings";
|
import type { Settings } from "../../atoms/settings";
|
||||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
import { generateDeviceProfile } from "../../profiles/native";
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
interface PostCapabilitiesParams {
|
interface PostCapabilitiesParams {
|
||||||
|
|||||||
115
utils/jellyfin/subtitleUtils.ts
Normal file
115
utils/jellyfin/subtitleUtils.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Subtitle utility functions for mapping between Jellyfin and MPV track indices.
|
||||||
|
*
|
||||||
|
* Jellyfin uses server-side indices (e.g., 3, 4, 5 for subtitles in MediaStreams).
|
||||||
|
* MPV uses its own track IDs starting from 1, only counting tracks loaded into MPV.
|
||||||
|
*
|
||||||
|
* Image-based subtitles (PGS, VOBSUB) during transcoding are burned into the video
|
||||||
|
* and NOT available in MPV's track list.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type MediaSourceInfo,
|
||||||
|
type MediaStream,
|
||||||
|
SubtitleDeliveryMethod,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
/** Check if subtitle is image-based (PGS, VOBSUB, etc.) */
|
||||||
|
export const isImageBasedSubtitle = (sub: MediaStream): boolean =>
|
||||||
|
sub.IsTextSubtitleStream === false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a subtitle will be available in MPV's track list.
|
||||||
|
*
|
||||||
|
* A subtitle is in MPV if:
|
||||||
|
* - Delivery is Embed/Hls/External AND not an image-based sub during transcode
|
||||||
|
*/
|
||||||
|
export const isSubtitleInMpv = (
|
||||||
|
sub: MediaStream,
|
||||||
|
isTranscoding: boolean,
|
||||||
|
): boolean => {
|
||||||
|
// During transcoding, image-based subs are burned in, not in MPV
|
||||||
|
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed/Hls/External methods mean the sub is loaded into MPV
|
||||||
|
return (
|
||||||
|
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||||
|
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||||
|
sub.DeliveryMethod === SubtitleDeliveryMethod.External
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the MPV track ID for a given Jellyfin subtitle index.
|
||||||
|
*
|
||||||
|
* MPV track IDs are 1-based and only count subtitles that are actually in MPV.
|
||||||
|
* We iterate through all subtitles, counting only those in MPV, until we find
|
||||||
|
* the one matching the Jellyfin index.
|
||||||
|
*
|
||||||
|
* @param mediaSource - The media source containing subtitle streams
|
||||||
|
* @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled)
|
||||||
|
* @param isTranscoding - Whether the stream is being transcoded
|
||||||
|
* @returns MPV track ID (1-based), or -1 if disabled, or undefined if not in MPV
|
||||||
|
*/
|
||||||
|
export const getMpvSubtitleId = (
|
||||||
|
mediaSource: MediaSourceInfo | null | undefined,
|
||||||
|
jellyfinSubtitleIndex: number | undefined,
|
||||||
|
isTranscoding: boolean,
|
||||||
|
): number | undefined => {
|
||||||
|
// -1 or undefined means disabled
|
||||||
|
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSubs =
|
||||||
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||||
|
|
||||||
|
// Find the subtitle with the matching Jellyfin index
|
||||||
|
const targetSub = allSubs.find((s) => s.Index === jellyfinSubtitleIndex);
|
||||||
|
|
||||||
|
// If the target subtitle isn't in MPV (e.g., image-based during transcode), return undefined
|
||||||
|
if (!targetSub || !isSubtitleInMpv(targetSub, isTranscoding)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count MPV track position (1-based)
|
||||||
|
let mpvIndex = 0;
|
||||||
|
for (const sub of allSubs) {
|
||||||
|
if (isSubtitleInMpv(sub, isTranscoding)) {
|
||||||
|
mpvIndex++;
|
||||||
|
if (sub.Index === jellyfinSubtitleIndex) {
|
||||||
|
return mpvIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the MPV track ID for a given Jellyfin audio index.
|
||||||
|
*
|
||||||
|
* Audio tracks are simpler - they're always in MPV (no burn-in like image subs).
|
||||||
|
* MPV track IDs are 1-based.
|
||||||
|
*
|
||||||
|
* @param mediaSource - The media source containing audio streams
|
||||||
|
* @param jellyfinAudioIndex - The Jellyfin server-side audio index
|
||||||
|
* @returns MPV track ID (1-based), or undefined if not found
|
||||||
|
*/
|
||||||
|
export const getMpvAudioId = (
|
||||||
|
mediaSource: MediaSourceInfo | null | undefined,
|
||||||
|
jellyfinAudioIndex: number | undefined,
|
||||||
|
): number | undefined => {
|
||||||
|
if (jellyfinAudioIndex === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allAudio =
|
||||||
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||||
|
|
||||||
|
// Find position in audio list (1-based for MPV)
|
||||||
|
const position = allAudio.findIndex((a) => a.Index === jellyfinAudioIndex);
|
||||||
|
return position >= 0 ? position + 1 : undefined;
|
||||||
|
};
|
||||||
Submodule utils/jellyseerr updated: 4401b16414...fc6a9e952c
6
utils/profiles/native.d.ts
vendored
6
utils/profiles/native.d.ts
vendored
@@ -4,8 +4,4 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface DeviceProfileOptions {
|
export function generateDeviceProfile(): any;
|
||||||
transcode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateDeviceProfile(options?: DeviceProfileOptions): any;
|
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
import MediaTypes from "../../constants/MediaTypes";
|
import MediaTypes from "../../constants/MediaTypes";
|
||||||
import { getSubtitleProfiles } from "./subtitles";
|
import { getSubtitleProfiles } from "./subtitles";
|
||||||
|
|
||||||
export const generateDeviceProfile = ({ transcode = false } = {}) => {
|
export const generateDeviceProfile = () => {
|
||||||
/**
|
/**
|
||||||
* Device profile for Native video player
|
* Device profile for Native video player
|
||||||
*/
|
*/
|
||||||
const profile = {
|
const profile = {
|
||||||
Name: `1. Vlc Player${transcode ? " (Transcoding)" : ""}`,
|
Name: `1. MPV Player`,
|
||||||
MaxStaticBitrate: 999_999_999,
|
MaxStaticBitrate: 999_999_999,
|
||||||
MaxStreamingBitrate: 999_999_999,
|
MaxStreamingBitrate: 999_999_999,
|
||||||
CodecProfiles: [
|
CodecProfiles: [
|
||||||
@@ -48,7 +48,7 @@ export const generateDeviceProfile = ({ transcode = false } = {}) => {
|
|||||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||||
VideoCodec:
|
VideoCodec:
|
||||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||||
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts",
|
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts,truehd",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Type: MediaTypes.Audio,
|
Type: MediaTypes.Audio,
|
||||||
@@ -75,7 +75,7 @@ export const generateDeviceProfile = ({ transcode = false } = {}) => {
|
|||||||
MaxAudioChannels: "2",
|
MaxAudioChannels: "2",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
SubtitleProfiles: getSubtitleProfiles(transcode ? "hls" : "External"),
|
SubtitleProfiles: getSubtitleProfiles(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
|
|||||||
@@ -4,26 +4,19 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const COMMON_SUBTITLE_PROFILES = [
|
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
|
||||||
// Official formats
|
// because MPV cannot load them externally over HTTP
|
||||||
|
const IMAGE_BASED_FORMATS = [
|
||||||
{ Format: "dvdsub", Method: "Embed" },
|
"dvdsub",
|
||||||
{ Format: "dvdsub", Method: "Encode" },
|
"idx",
|
||||||
|
"pgs",
|
||||||
{ Format: "idx", Method: "Embed" },
|
"pgssub",
|
||||||
{ Format: "idx", Method: "Encode" },
|
"teletext",
|
||||||
|
"vobsub",
|
||||||
{ Format: "pgs", Method: "Embed" },
|
|
||||||
{ Format: "pgs", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "pgssub", Method: "Embed" },
|
|
||||||
{ Format: "pgssub", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "teletext", Method: "Embed" },
|
|
||||||
{ Format: "teletext", Method: "Encode" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const VARYING_SUBTITLE_FORMATS = [
|
// Text-based formats - these can be loaded externally by MPV
|
||||||
|
const TEXT_BASED_FORMATS = [
|
||||||
"webvtt",
|
"webvtt",
|
||||||
"vtt",
|
"vtt",
|
||||||
"srt",
|
"srt",
|
||||||
@@ -46,11 +39,23 @@ const VARYING_SUBTITLE_FORMATS = [
|
|||||||
"xsub",
|
"xsub",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const getSubtitleProfiles = (secondaryMethod) => {
|
export const getSubtitleProfiles = () => {
|
||||||
const profiles = [...COMMON_SUBTITLE_PROFILES];
|
const profiles = [];
|
||||||
for (const format of VARYING_SUBTITLE_FORMATS) {
|
|
||||||
|
// Image-based formats: Embed or Encode (burn-in), NOT External
|
||||||
|
for (const format of IMAGE_BASED_FORMATS) {
|
||||||
profiles.push({ Format: format, Method: "Embed" });
|
profiles.push({ Format: format, Method: "Embed" });
|
||||||
profiles.push({ Format: format, Method: secondaryMethod });
|
profiles.push({ Format: format, Method: "Encode" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Text-based formats: Embed or External
|
||||||
|
for (const format of TEXT_BASED_FORMATS) {
|
||||||
|
profiles.push({ Format: format, Method: "Embed" });
|
||||||
|
profiles.push({ Format: format, Method: "External" });
|
||||||
|
}
|
||||||
|
|
||||||
return profiles;
|
return profiles;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export for use in player filtering
|
||||||
|
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;
|
||||||
|
|||||||
Reference in New Issue
Block a user