Compare commits

...

9 Commits

Author SHA1 Message Date
Fredrik Burmester
e1769cbd59 wip 2025-12-08 10:44:42 +01:00
Fredrik Burmester
2b0e238799 wip 2025-12-07 12:36:37 +01:00
Alex Kim
b573a25203 Commit 2025-12-07 04:20:29 +11:00
Alex Kim
074222050a Working 2025-12-07 02:22:09 +11:00
Alex Kim
7135be198a Done? 2025-12-07 01:43:47 +11:00
Alex Kim
2648877eb8 working subs 2025-12-07 01:19:21 +11:00
Alex Kim
bc78346760 done 2025-12-06 05:28:30 +11:00
Alex Kim
c76d7eb877 Working 2025-12-06 04:56:48 +11:00
Alex Kim
36655bba43 init 2025-12-06 02:02:26 +11:00
71 changed files with 3514 additions and 3739 deletions

View File

@@ -1,4 +1,3 @@
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
import { useLocalSearchParams } from "expo-router";
import type React from "react";
import { useEffect } from "react";
@@ -21,14 +20,8 @@ const Page: React.FC = () => {
const { offline } = useLocalSearchParams() as { offline?: string };
const isOffline = offline === "true";
const { data: item, isError } = useItemQuery(id, false, undefined, [
ItemFields.MediaSources,
ItemFields.MediaSourceCount,
ItemFields.MediaStreams,
]);
// preload media sources
const { data: itemWithSources } = useItemQuery(id, false, undefined, []);
// Fetch item with all fields including MediaSources
const { data: item, isError } = useItemQuery(id, isOffline, undefined, []);
const opacity = useSharedValue(1);
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-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</Animated.View>
{item && (
<ItemContent
item={item}
isOffline={isOffline}
itemWithSources={itemWithSources}
/>
)}
{item && <ItemContent item={item} isOffline={isOffline} />}
</View>
);
};

View File

@@ -1,7 +1,33 @@
import { Stack } from "expo-router";
import { useEffect } from "react";
import { AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { useOrientation } from "@/hooks/useOrientation";
import { useSettings } from "@/utils/atoms/settings";
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 (
<>
<SystemBars hidden />

View File

@@ -22,35 +22,34 @@ import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import {
OUTLINE_THICKNESS,
OutlineThickness,
VLC_COLORS,
VLCColor,
} from "@/constants/SubtitleConstants";
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules";
import type {
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types";
import {
MpvPlayerView,
type MpvPlayerViewRef,
type OnPlaybackStateChangePayload,
type OnProgressEventPayload,
type VideoSource,
} from "@/modules";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import {
getMpvAudioId,
getMpvSubtitleId,
} from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
const videoRef = useRef<MpvPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
@@ -58,7 +57,7 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false);
const [isPipMode, _setIsPipMode] = useState(false);
const [aspectRatio, setAspectRatio] = useState<
"default" | "16:9" | "4:3" | "1:1" | "21:9"
>("default");
@@ -69,6 +68,7 @@ export default function page() {
const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [tracksReady, setTracksReady] = useState(false);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
@@ -111,7 +111,6 @@ export default function page() {
playbackPosition?: string;
}>();
const { settings } = useSettings();
const { lockOrientation, unlockOrientation } = useOrientation();
const offline = offlineStr === "true";
const playbackManager = usePlaybackManager();
@@ -174,16 +173,6 @@ export default function page() {
}
}, [itemId, offline, api, user?.Id]);
useEffect(() => {
if (settings?.defaultVideoOrientation) {
lockOrientation(settings.defaultVideoOrientation);
}
return () => {
unlockOrientation();
};
}, [settings?.defaultVideoOrientation]);
interface Stream {
mediaSource: MediaSourceInfo;
sessionId: string;
@@ -230,8 +219,6 @@ export default function page() {
return;
}
const native = generateDeviceProfile();
const transcoding = generateDeviceProfile({ transcode: true });
const res = await getStreamUrl({
api,
item,
@@ -241,7 +228,7 @@ export default function page() {
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: bitrateValue ? transcoding : native,
deviceProfile: generateDeviceProfile(),
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
@@ -325,7 +312,8 @@ export default function page() {
});
reportPlaybackStopped();
setIsPlaybackStopped(true);
videoRef.current?.stop();
// MPV doesn't have a stop method, use pause instead
videoRef.current?.pause();
revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]);
@@ -338,6 +326,7 @@ export default function page() {
const currentPlayStateInfo = useCallback(() => {
if (!stream || !item?.Id) return;
return {
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
@@ -380,10 +369,13 @@ export default function page() {
);
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
async (data: { nativeEvent: OnProgressEventPayload }) => {
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) {
setIsBuffering(false);
}
@@ -429,6 +421,46 @@ export default function page() {
return ticksToSeconds(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 () => {
if (Platform.isTV) return;
@@ -509,10 +541,12 @@ export default function page() {
});
const onPlaybackStateChanged = useCallback(
async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
async (e: { nativeEvent: OnPlaybackStateChangePayload }) => {
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
if (playing) {
setIsPlaying(true);
setIsBuffering(false);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
@@ -522,7 +556,7 @@ export default function page() {
return;
}
if (state === "Paused") {
if (isPaused) {
setIsPlaying(false);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
@@ -533,87 +567,13 @@ export default function page() {
return;
}
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
if (isLoading) {
setIsBuffering(true);
}
},
[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);
// Add useEffect to handle mounting
@@ -626,6 +586,7 @@ export default function page() {
const startPictureInPicture = useCallback(async () => {
return videoRef.current?.startPictureInPicture?.();
}, []);
const play = useCallback(() => {
videoRef.current?.play?.();
}, []);
@@ -635,69 +596,40 @@ export default function page() {
}, []);
const seek = useCallback((position: number) => {
videoRef.current?.seekTo?.(position);
}, []);
const getAudioTracks = useCallback(async () => {
return videoRef.current?.getAudioTracks?.() || null;
// MPV expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000);
}, []);
const getSubtitleTracks = useCallback(async () => {
return videoRef.current?.getSubtitleTracks?.() || null;
}, []);
// Apply MPV subtitle settings when video loads
useEffect(() => {
if (!isVideoLoaded || !videoRef.current) return;
const setSubtitleTrack = useCallback((index: number) => {
videoRef.current?.setSubtitleTrack?.(index);
}, []);
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
// Note: VlcPlayer type only expects url parameter
videoRef.current?.setSubtitleURL?.(url);
}, []);
const setAudioTrack = useCallback((index: number) => {
videoRef.current?.setAudioTrack?.(index);
}, []);
const setVideoAspectRatio = useCallback(
async (aspectRatio: string | null) => {
return (
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
Promise.resolve()
);
},
[],
);
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,
const applySubtitleSettings = async () => {
if (settings.mpvSubtitleScale !== undefined) {
await videoRef.current?.setSubtitleScale(settings.mpvSubtitleScale);
}
if (settings.mpvSubtitleMarginY !== undefined) {
await videoRef.current?.setSubtitleMarginY(settings.mpvSubtitleMarginY);
}
if (settings.mpvSubtitleAlignX !== undefined) {
await videoRef.current?.setSubtitleAlignX(settings.mpvSubtitleAlignX);
}
if (settings.mpvSubtitleAlignY !== undefined) {
await videoRef.current?.setSubtitleAlignY(settings.mpvSubtitleAlignY);
}
if (settings.mpvSubtitleFontSize !== undefined) {
await videoRef.current?.setSubtitleFontSize(
settings.mpvSubtitleFontSize,
);
}
// Apply subtitle size from general settings
if (settings.subtitleSize) {
await videoRef.current?.setSubtitleFontSize(settings.subtitleSize);
}
};
}, [item, api]);
applySubtitleSettings();
}, [isVideoLoaded, settings]);
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) {
@@ -708,7 +640,7 @@ export default function page() {
);
}
// Then show loader while either side is still fetching or data isnt present
// Then show loader while either side is still fetching or data isn't present
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
// …loader UI…
return (
@@ -726,91 +658,80 @@ export default function page() {
);
return (
<View
style={{
flex: 1,
backgroundColor: "black",
height: "100%",
width: "100%",
}}
<PlayerProvider
playerRef={videoRef}
item={item}
mediaSource={stream?.mediaSource}
isVideoLoaded={isVideoLoaded}
tracksReady={tracksReady}
>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
}}
>
<VlcPlayerView
ref={videoRef}
source={{
uri: stream?.url || "",
autoplay: true,
isNetwork: !offline,
startPosition,
externalSubtitles,
initOptions,
<VideoProvider>
<View
style={{
flex: 1,
backgroundColor: "black",
height: "100%",
width: "100%",
}}
style={{ width: "100%", height: "100%" }}
nowPlayingMetadata={nowPlayingMetadata}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
onPipStarted={(e) => {
setIsPipMode(e.nativeEvent.pipStarted);
}}
/>
</View>
{isMounted === true && item && !isPipMode && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
offline={offline}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
setAudioTrack={setAudioTrack}
setVideoAspectRatio={setVideoAspectRatio}
setVideoScaleFactor={setVideoScaleFactor}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc
api={api}
downloadedFiles={downloadedFiles}
/>
)}
</View>
>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
}}
>
<MpvPlayerView
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged}
onLoad={() => setIsVideoLoaded(true)}
onError={(e) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
onTracksReady={() => {
setTracksReady(true);
}}
/>
</View>
{isMounted === true && item && !isPipMode && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
offline={offline}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
api={api}
downloadedFiles={downloadedFiles}
/>
)}
</View>
</VideoProvider>
</PlayerProvider>
);
}

View File

@@ -46,11 +46,10 @@ export type SelectedOptions = {
interface ItemContentProps {
item: BaseItemDto;
isOffline: boolean;
itemWithSources?: BaseItemDto | null;
}
export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline, itemWithSources }) => {
({ item, isOffline }) => {
const [api] = useAtom(apiAtom);
const { settings } = useSettings();
const { orientation } = useOrientation();
@@ -72,7 +71,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(item!, settings);
} = useDefaultPlaySettings(item, settings);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
@@ -87,7 +86,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
@@ -99,7 +98,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
]);
useEffect(() => {
if (!Platform.isTV && itemWithSources) {
if (!Platform.isTV && item) {
navigation.setOptions({
headerRight: () =>
item &&
@@ -109,7 +108,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
{item.Type !== "Program" && (
<View className='flex flex-row items-center'>
{!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' />
<DownloadSingleItem item={item} size='large' />
)}
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
@@ -126,7 +125,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
{item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' />
<DownloadSingleItem item={item} size='large' />
)}
{user?.Policy?.IsAdministrator && (
<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(() => {
if (item) {
@@ -213,7 +212,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
<MediaSourceButton
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
item={itemWithSources}
item={item}
colors={itemColors}
/>
)}

View File

@@ -1,5 +1,5 @@
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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
@@ -280,7 +280,6 @@ export const PlayButton: React.FC<Props> = ({
]);
const onPress = useCallback(async () => {
console.log("onPress");
if (!item) return;
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 (
<TouchableOpacity
disabled={!item}
@@ -569,15 +522,6 @@ export const PlayButton: React.FC<Props> = ({
<CastButton tintColor='transparent' />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>

View File

@@ -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 { useRouter } from "expo-router";
import { useAtom } from "jotai";
@@ -17,7 +17,6 @@ import Animated, {
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
@@ -50,7 +49,6 @@ export const PlayButton: React.FC<Props> = ({
const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
@@ -61,7 +59,6 @@ export const PlayButton: React.FC<Props> = ({
);
const onPress = () => {
console.log("onpress");
if (!item) return;
lightHapticFeedback();
@@ -207,15 +204,6 @@ export const PlayButton: React.FC<Props> = ({
<Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>

View File

@@ -5,12 +5,6 @@ import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
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 { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
@@ -92,84 +86,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
];
}, [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 (!settings) return null;
@@ -252,124 +168,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
/>
</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>
</View>
);

View File

@@ -18,7 +18,6 @@ interface BottomControlsProps {
showRemoteBubble: boolean;
currentTime: number;
remainingTime: number;
isVlc: boolean;
showSkipButton: boolean;
showSkipCreditButton: boolean;
skipIntro: () => void;
@@ -66,7 +65,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
showRemoteBubble,
currentTime,
remainingTime,
isVlc,
showSkipButton,
showSkipCreditButton,
skipIntro,
@@ -145,13 +143,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
settings.autoPlayEpisodeCount <
settings.maxAutoPlayEpisodeCount.value) && (
<NextEpisodeCountDownButton
show={
!nextItem
? false
: isVlc
? remainingTime < 10000
: remainingTime < 10
}
show={!nextItem ? false : remainingTime < 10000}
onFinish={handleNextEpisodeAutoPlay}
onPress={handleNextEpisodeManual}
/>
@@ -208,7 +200,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
<TimeDisplay
currentTime={currentTime}
remainingTime={remainingTime}
isVlc={isVlc}
/>
</View>
</View>

View File

@@ -7,7 +7,6 @@ import { useLocalSearchParams, useRouter } from "expo-router";
import {
type Dispatch,
type FC,
type MutableRefObject,
type SetStateAction,
useCallback,
useEffect,
@@ -28,7 +27,6 @@ import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
@@ -36,7 +34,6 @@ import { ticksToMs } from "@/utils/time";
import { BottomControls } from "./BottomControls";
import { CenterControls } from "./CenterControls";
import { CONTROLS_CONSTANTS } from "./constants";
import { ControlProvider } from "./contexts/ControlContext";
import { EpisodeList } from "./EpisodeList";
import { GestureOverlay } from "./GestureOverlay";
import { HeaderControls } from "./HeaderControls";
@@ -50,36 +47,27 @@ import { type AspectRatio } from "./VideoScalingModeSelector";
interface Props {
item: BaseItemDto;
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
progress: SharedValue<number>;
isBuffering: boolean;
showControls: boolean;
enableTrickplay?: boolean;
togglePlay: () => void;
setShowControls: (shown: boolean) => void;
offline?: boolean;
isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture?: () => Promise<void>;
play: () => 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>;
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
aspectRatio?: AspectRatio;
scaleFactor?: ScaleFactor;
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
isVlc?: boolean;
api?: Api | null;
downloadedFiles?: DownloadedItem[];
}
@@ -99,12 +87,6 @@ export const Controls: FC<Props> = ({
showControls,
setShowControls,
mediaSource,
isVideoLoaded,
getAudioTracks,
getSubtitleTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
setVideoAspectRatio,
setVideoScaleFactor,
aspectRatio = "default",
@@ -112,7 +94,6 @@ export const Controls: FC<Props> = ({
setAspectRatio,
setScaleFactor,
offline = false,
isVlc = false,
api = null,
downloadedFiles = undefined,
}) => {
@@ -194,17 +175,13 @@ export const Controls: FC<Props> = ({
zIndex: 10,
}));
// Initialize progress values
// Initialize progress values - MPV uses milliseconds
useEffect(() => {
if (item) {
progress.value = isVlc
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
: item?.UserData?.PlaybackPositionTicks || 0;
max.value = isVlc
? ticksToMs(item.RunTimeTicks || 0)
: item.RunTimeTicks || 0;
progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks);
max.value = ticksToMs(item.RunTimeTicks || 0);
}
}, [item, isVlc, progress, max]);
}, [item, progress, max]);
// Navigation hooks
const {
@@ -215,7 +192,6 @@ export const Controls: FC<Props> = ({
} = useVideoNavigation({
progress,
isPlaying,
isVlc,
seek,
play,
});
@@ -225,7 +201,6 @@ export const Controls: FC<Props> = ({
progress,
max,
isSeeking,
isVlc,
});
const toggleControls = useCallback(() => {
@@ -248,7 +223,6 @@ export const Controls: FC<Props> = ({
progress,
min,
max,
isVlc,
showControls,
isPlaying,
seek,
@@ -273,7 +247,6 @@ export const Controls: FC<Props> = ({
progress,
isSeeking,
isPlaying,
isVlc,
seek,
play,
pause,
@@ -302,9 +275,8 @@ export const Controls: FC<Props> = ({
: current.actual;
} else {
// When not scrubbing, only update if progress changed significantly (1 second)
const progressUnit = isVlc
? CONTROLS_CONSTANTS.PROGRESS_UNIT_MS
: CONTROLS_CONSTANTS.PROGRESS_UNIT_TICKS;
// MPV uses milliseconds
const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS;
const progressDiff = Math.abs(current.actual - effectiveProgress.value);
if (progressDiff >= progressUnit) {
effectiveProgress.value = current.actual;
@@ -325,7 +297,6 @@ export const Controls: FC<Props> = ({
currentTime,
seek,
play,
isVlc,
offline,
api,
downloadedFiles,
@@ -336,7 +307,6 @@ export const Controls: FC<Props> = ({
currentTime,
seek,
play,
isVlc,
offline,
api,
downloadedFiles,
@@ -359,12 +329,10 @@ export const Controls: FC<Props> = ({
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
item,
settings,
previousIndexes,
mediaSource ?? undefined,
);
} = getDefaultPlaySettings(item, settings, {
indexes: previousIndexes,
source: mediaSource ?? undefined,
});
const queryParams = new URLSearchParams({
...(offline && { offline: "true" }),
@@ -479,11 +447,7 @@ export const Controls: FC<Props> = ({
}, [isPlaying, togglePlay]);
return (
<ControlProvider
item={item}
mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded}
>
<>
{episodeView ? (
<EpisodeList
item={item}
@@ -515,11 +479,6 @@ export const Controls: FC<Props> = ({
goToNextItem={goToNextItem}
previousItem={previousItem}
nextItem={nextItem}
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
@@ -554,7 +513,6 @@ export const Controls: FC<Props> = ({
showRemoteBubble={showRemoteBubble}
currentTime={currentTime}
remainingTime={remainingTime}
isVlc={isVlc}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
skipIntro={skipIntro}
@@ -582,6 +540,6 @@ export const Controls: FC<Props> = ({
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
)}
</ControlProvider>
</>
);
};

View File

@@ -13,9 +13,8 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useHaptic } from "@/hooks/useHaptic";
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
import { useSettings } from "@/utils/atoms/settings";
import { ICON_SIZES } from "./constants";
import { VideoProvider } from "./contexts/VideoContext";
import DropdownView from "./dropdown/DropdownView";
import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
import {
@@ -34,11 +33,6 @@ interface HeaderControlsProps {
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
previousItem?: 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;
scaleFactor?: ScaleFactor;
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
@@ -58,11 +52,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
goToNextItem,
previousItem,
nextItem,
getAudioTracks,
getSubtitleTracks,
setAudioTrack,
setSubtitleTrack,
setSubtitleURL,
aspectRatio = "default",
scaleFactor = 1.0,
setAspectRatio,
@@ -113,35 +102,25 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
>
<View className='mr-auto' pointerEvents='box-none'>
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
<View pointerEvents='auto'>
<DropdownView />
</View>
</VideoProvider>
<View pointerEvents='auto'>
<DropdownView />
</View>
)}
</View>
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV &&
(settings.defaultPlayer === VideoPlayer.VLC_4 ||
Platform.OS === "android") && (
<TouchableOpacity
onPress={startPictureInPicture}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<MaterialIcons
name='picture-in-picture'
size={ICON_SIZES.HEADER}
color='white'
/>
</TouchableOpacity>
)}
{!Platform.isTV && startPictureInPicture && (
<TouchableOpacity
onPress={startPictureInPicture}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<MaterialIcons
name='picture-in-picture'
size={ICON_SIZES.HEADER}
color='white'
/>
</TouchableOpacity>
)}
{item?.Type === "Episode" && (
<TouchableOpacity
onPress={switchOnEpisodeMode}

View File

@@ -6,18 +6,20 @@ import { formatTimeString } from "@/utils/time";
interface TimeDisplayProps {
currentTime: number;
remainingTime: number;
isVlc: boolean;
}
/**
* Displays current time and remaining time.
* MPV player uses milliseconds for time values.
*/
export const TimeDisplay: FC<TimeDisplayProps> = ({
currentTime,
remainingTime,
isVlc,
}) => {
const getFinishTime = () => {
const now = new Date();
const remainingMs = isVlc ? remainingTime : remainingTime * 1000;
const finishTime = new Date(now.getTime() + remainingMs);
// remainingTime is in ms
const finishTime = new Date(now.getTime() + remainingTime);
return finishTime.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
@@ -28,11 +30,11 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
return (
<View className='flex flex-row items-center justify-between mt-2'>
<Text className='text-[12px] text-neutral-400'>
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
{formatTimeString(currentTime, "ms")}
</Text>
<View className='flex flex-col items-end'>
<Text className='text-[12px] text-neutral-400'>
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
-{formatTimeString(remainingTime, "ms")}
</Text>
<Text className='text-[10px] text-neutral-500 opacity-70'>
ends at {getFinishTime()}

View File

@@ -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;
};

View 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(),
};
};

View File

@@ -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 type React from "react";
import {
@@ -9,52 +74,29 @@ import {
useMemo,
useState,
} 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 { useControlContext } from "./ControlContext";
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
interface VideoContextProps {
audioTracks: Track[] | null;
subtitleTracks: Track[] | null;
setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
audioTracks: Track[] | null;
}
const VideoContext = createContext<VideoContextProps | undefined>(undefined);
interface VideoProviderProps {
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> = ({
export const VideoProvider: React.FC<{ children: ReactNode }> = ({
children,
getSubtitleTracks,
getAudioTracks,
setSubtitleTrack,
setSubtitleURL,
setAudioTrack,
}) => {
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const ControlContext = useControlContext();
const isVideoLoaded = ControlContext?.isVideoLoaded;
const mediaSource = ControlContext?.mediaSource;
const allSubs =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
const { tracksReady, mediaSource } = usePlayerContext();
const playerControls = usePlayerControls();
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
useLocalSearchParams<{
@@ -66,185 +108,143 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
playbackPosition: string;
}>();
const onTextBasedSubtitle = useMemo(() => {
return (
allSubs.find(
(s) =>
s.Index?.toString() === subtitleIndex &&
(s.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
s.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
s.DeliveryMethod === SubtitleDeliveryMethod.External),
) || subtitleIndex === "-1"
const allSubs =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
/**
* 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]);
const setPlayerParams = ({
chosenAudioIndex = audioIndex,
chosenSubtitleIndex = subtitleIndex,
}: {
chosenAudioIndex?: string;
chosenSubtitleIndex?: string;
/**
* Refresh the player with new parameters.
* This triggers Jellyfin to re-process the stream (e.g., burn in image subs).
*/
const replacePlayer = (params: {
audioIndex?: string;
subtitleIndex?: string;
}) => {
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
const queryParams = new URLSearchParams({
itemId: itemId ?? "",
audioIndex: chosenAudioIndex,
subtitleIndex: chosenSubtitleIndex,
audioIndex: params.audioIndex ?? audioIndex,
subtitleIndex: params.subtitleIndex ?? subtitleIndex,
mediaSourceId: mediaSource?.Id ?? "",
bitrateValue: bitrateValue,
playbackPosition: playbackPosition,
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
};
const setTrackParams = (
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(),
});
};
// Fetch tracks when ready
useEffect(() => {
if (!tracksReady) return;
const fetchTracks = async () => {
if (getSubtitleTracks) {
let subtitleData: TrackInfo[] | null = null;
try {
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()];
}
const [subtitleData, audioData] = await Promise.all([
playerControls.getSubtitleTracks().catch(() => null),
playerControls.getAudioTracks().catch(() => null),
]);
let embedSubIndex = 1;
const processedSubs: Track[] = allSubs?.map((sub) => {
/** 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(),
}),
};
});
// Process subtitles - map Jellyfin indices to MPV track IDs
let mpvIndex = 0; // MPV track index counter (only incremented for subs in MPV)
// Step 3: Restore the original order
const subtitles: Track[] = processedSubs.sort(
(a, b) => a.index - b.index,
);
const subs: Track[] = allSubs.map((sub) => {
const inMpv = isSubtitleInMpv(sub, isTranscoding);
// Add a "Disable Subtitles" option
subtitles.unshift({
name: "Disable",
index: -1,
setTrack: () =>
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
? setTrackParams("subtitle", -1, -1)
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
});
setSubtitleTracks(subtitles);
}
if (getAudioTracks) {
let audioData: TrackInfo[] | null = null;
try {
audioData = await getAudioTracks();
} catch (error) {
console.log("[VideoContext] Failed to get audio tracks:", error);
return;
}
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
if (!mediaSource?.TranscodingUrl) {
const vlcIndex = audioData?.at(idx + 1)?.index ?? -1;
return {
name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1,
setTrack: () =>
setTrackParams("audio", vlcIndex, audio.Index ?? -1),
};
// Get MPV track ID: only if this sub is actually in MPV's track list
const mpvId = inMpv
? ((subtitleData as SubtitleTrack[])?.[mpvIndex++]?.id ?? -1)
: -1;
return {
name: sub.DisplayTitle || "Unknown",
index: sub.Index ?? -1, // Jellyfin server-side index
mpvIndex: mpvId, // MPV track ID (-1 if not in MPV)
setTrack: () => {
// Case 1: Transcoding + switching to/from image-based sub
// Need to refresh player so Jellyfin burns in the new sub
if (
isTranscoding &&
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
) {
replacePlayer({ subtitleIndex: String(sub.Index) });
return;
}
// Case 2: Subtitle is in MPV - just switch tracks
if (inMpv && mpvId !== -1) {
playerControls.setSubtitleTrack(mpvId);
router.setParams({ subtitleIndex: String(sub.Index) });
return;
}
// Case 3: Fallback - refresh player
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.
if (!mediaSource?.TranscodingUrl) {
audioTracks.unshift({
name: "Disable",
index: -1,
setTrack: () => setTrackParams("audio", -1, -1),
});
}
setAudioTracks(audioTracks);
}
// Process audio tracks
const audio: Track[] = allAudio.map((a, idx) => ({
name: a.DisplayTitle || "Unknown",
index: a.Index ?? -1,
setTrack: () => {
// Transcoding: need full player refresh to change audio stream
if (isTranscoding) {
replacePlayer({ audioIndex: String(a.Index) });
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();
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
}, [tracksReady, mediaSource]);
return (
<VideoContext.Provider
value={{
audioTracks,
subtitleTracks,
setSubtitleTrack,
setSubtitleURL,
setAudioTrack,
}}
>
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
{children}
</VideoContext.Provider>
);
};
export const useVideoContext = () => {
const context = useContext(VideoContext);
if (context === undefined) {
throw new Error("useVideoContext must be used within a VideoProvider");
}
return context;
const ctx = useContext(VideoContext);
if (!ctx)
throw new Error("useVideoContext must be used within VideoProvider");
return ctx;
};

View File

@@ -7,17 +7,12 @@ import {
type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
import { useControlContext } from "../contexts/ControlContext";
import { usePlayerContext } from "../contexts/PlayerContext";
import { useVideoContext } from "../contexts/VideoContext";
const DropdownView = () => {
const videoContext = useVideoContext();
const { subtitleTracks, audioTracks } = videoContext;
const ControlContext = useControlContext();
const [item, mediaSource] = [
ControlContext?.item,
ControlContext?.mediaSource,
];
const { subtitleTracks, audioTracks } = useVideoContext();
const { item, mediaSource } = usePlayerContext();
const router = useRouter();
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =

View File

@@ -22,7 +22,6 @@ interface UseRemoteControlProps {
progress: SharedValue<number>;
min: SharedValue<number>;
max: SharedValue<number>;
isVlc: boolean;
showControls: boolean;
isPlaying: boolean;
seek: (value: number) => void;
@@ -34,11 +33,14 @@ interface UseRemoteControlProps {
handleSeekBackward: (seconds: number) => void;
}
/**
* Hook to manage TV remote control interactions.
* MPV player uses milliseconds for time values.
*/
export function useRemoteControl({
progress,
min,
max,
isVlc,
showControls,
isPlaying,
seek,
@@ -61,21 +63,18 @@ export function useRemoteControl({
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const SCRUB_INTERVAL = isVlc
? CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS
: CONTROLS_CONSTANTS.SCRUB_INTERVAL_TICKS;
// MPV uses ms
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
const updateTime = useCallback(
(progressValue: number) => {
const progressInTicks = isVlc ? msToTicks(progressValue) : progressValue;
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
},
[isVlc],
);
const updateTime = useCallback((progressValue: number) => {
// Convert ms to ticks for calculation
const progressInTicks = msToTicks(progressValue);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
}, []);
// TV remote control handling (no-op on non-TV platforms)
useTVEventHandler((evt) => {
@@ -102,7 +101,8 @@ export function useRemoteControl({
Math.min(max.value, base + direction * SCRUB_INTERVAL),
);
remoteScrubProgress.value = updated;
const progressInTicks = isVlc ? msToTicks(updated) : updated;
// Convert ms to ticks for trickplay
const progressInTicks = msToTicks(updated);
calculateTrickplayUrl(progressInTicks);
updateTime(updated);
break;
@@ -111,9 +111,8 @@ export function useRemoteControl({
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
progress.value = remoteScrubProgress.value;
const seekTarget = isVlc
? Math.max(0, remoteScrubProgress.value)
: Math.max(0, ticksToSeconds(remoteScrubProgress.value));
// MPV uses ms, seek expects ms
const seekTarget = Math.max(0, remoteScrubProgress.value);
seek(seekTarget);
if (isPlaying) play();

View File

@@ -3,20 +3,22 @@ import type { SharedValue } from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import { useSettings } from "@/utils/atoms/settings";
import { writeToLog } from "@/utils/log";
import { secondsToMs, ticksToSeconds } from "@/utils/time";
import { secondsToMs } from "@/utils/time";
interface UseVideoNavigationProps {
progress: SharedValue<number>;
isPlaying: boolean;
isVlc: boolean;
seek: (value: number) => void;
play: () => void;
}
/**
* Hook to manage video navigation (seeking forward/backward).
* MPV player uses milliseconds for time values.
*/
export function useVideoNavigation({
progress,
isPlaying,
isVlc,
seek,
play,
}: UseVideoNavigationProps) {
@@ -30,16 +32,15 @@ export function useVideoNavigation({
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? Math.max(0, curr - secondsToMs(seconds))
: Math.max(0, ticksToSeconds(curr) - seconds);
// MPV uses ms
const newTime = Math.max(0, curr - secondsToMs(seconds));
seek(newTime);
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
},
[isPlaying, isVlc, seek, progress],
[isPlaying, seek, progress],
);
const handleSeekForward = useCallback(
@@ -48,16 +49,15 @@ export function useVideoNavigation({
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(seconds)
: ticksToSeconds(curr) + seconds;
// MPV uses ms
const newTime = curr + secondsToMs(seconds);
seek(Math.max(0, newTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
},
[isPlaying, isVlc, seek, progress],
[isPlaying, seek, progress],
);
const handleSkipBackward = useCallback(async () => {
@@ -69,9 +69,11 @@ export function useVideoNavigation({
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
// MPV uses ms
const newTime = Math.max(
0,
curr - secondsToMs(settings.rewindSkipTime),
);
seek(newTime);
if (wasPlayingRef.current) {
play();
@@ -80,7 +82,7 @@ export function useVideoNavigation({
} catch (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 () => {
if (!settings?.forwardSkipTime) {
@@ -91,9 +93,8 @@ export function useVideoNavigation({
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(settings.forwardSkipTime)
: ticksToSeconds(curr) + settings.forwardSkipTime;
// MPV uses ms
const newTime = curr + secondsToMs(settings.forwardSkipTime);
seek(Math.max(0, newTime));
if (wasPlayingRef.current) {
play();
@@ -102,7 +103,7 @@ export function useVideoNavigation({
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]);
}, [settings, isPlaying, play, seek, progress, lightHapticFeedback]);
return {
handleSeekBackward,

View File

@@ -8,7 +8,6 @@ interface UseVideoSliderProps {
progress: SharedValue<number>;
isSeeking: SharedValue<boolean>;
isPlaying: boolean;
isVlc: boolean;
seek: (value: number) => void;
play: () => void;
pause: () => void;
@@ -16,11 +15,14 @@ interface UseVideoSliderProps {
showControls: boolean;
}
/**
* Hook to manage video slider interactions.
* MPV player uses milliseconds for time values.
*/
export function useVideoSlider({
progress,
isSeeking,
isPlaying,
isVlc,
seek,
play,
pause,
@@ -62,21 +64,20 @@ export function useVideoSlider({
setIsSliding(false);
isSeeking.value = false;
progress.value = value;
const seekValue = Math.max(
0,
Math.floor(isVlc ? value : ticksToSeconds(value)),
);
// MPV uses ms, seek expects ms
const seekValue = Math.max(0, Math.floor(value));
seek(seekValue);
if (wasPlayingRef.current) {
play();
}
},
[isVlc, seek, play, progress, isSeeking],
[seek, play, progress, isSeeking],
);
const handleSliderChange = useCallback(
debounce((value: number) => {
const progressInTicks = isVlc ? msToTicks(value) : value;
// Convert ms to ticks for trickplay
const progressInTicks = msToTicks(value);
calculateTrickplayUrl(progressInTicks);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
@@ -84,7 +85,7 @@ export function useVideoSlider({
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
}, CONTROLS_CONSTANTS.SLIDER_DEBOUNCE_MS),
[isVlc, calculateTrickplayUrl],
[calculateTrickplayUrl],
);
return {

View File

@@ -4,21 +4,18 @@ import {
type SharedValue,
useAnimatedReaction,
} from "react-native-reanimated";
import { ticksToSeconds } from "@/utils/time";
interface UseVideoTimeProps {
progress: SharedValue<number>;
max: SharedValue<number>;
isSeeking: SharedValue<boolean>;
isVlc: boolean;
}
export function useVideoTime({
progress,
max,
isSeeking,
isVlc,
}: UseVideoTimeProps) {
/**
* Hook to manage video time display.
* MPV player uses milliseconds for time values.
*/
export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) {
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
@@ -27,19 +24,16 @@ export function useVideoTime({
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
const remaining = isVlc
? maxValue - currentProgress
: ticksToSeconds(maxValue - currentProgress);
// MPV uses milliseconds
const current = currentProgress;
const remaining = maxValue - currentProgress;
// Only update state if the displayed time actually changed (avoid sub-second updates)
const currentSeconds = Math.floor(current / (isVlc ? 1000 : 1));
const remainingSeconds = Math.floor(remaining / (isVlc ? 1000 : 1));
const lastCurrentSeconds = Math.floor(
lastCurrentTimeRef.current / (isVlc ? 1000 : 1),
);
const currentSeconds = Math.floor(current / 1000);
const remainingSeconds = Math.floor(remaining / 1000);
const lastCurrentSeconds = Math.floor(lastCurrentTimeRef.current / 1000);
const lastRemainingSeconds = Math.floor(
lastRemainingTimeRef.current / (isVlc ? 1000 : 1),
lastRemainingTimeRef.current / 1000,
);
if (
@@ -52,7 +46,7 @@ export function useVideoTime({
lastRemainingTimeRef.current = remaining;
}
},
[isVlc],
[],
);
useAnimatedReaction(

View File

@@ -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>
);
};

View File

@@ -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,
};

View File

@@ -5,12 +5,15 @@ import { useSegments } from "@/utils/segments";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic";
/**
* Custom hook to handle skipping credits in a media player.
* MPV player uses milliseconds for time.
*/
export const useCreditSkipper = (
itemId: string,
currentTime: number,
seek: (time: number) => void,
seek: (ms: number) => void,
play: () => void,
isVlc = false,
isOffline = false,
api: Api | null = null,
downloadedFiles: DownloadedItem[] | undefined = undefined,
@@ -18,16 +21,11 @@ export const useCreditSkipper = (
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
const lightHapticFeedback = useHaptic("light");
if (isVlc) {
currentTime = msToSeconds(currentTime);
}
// Convert ms to seconds for comparison with timestamps
const currentTimeSeconds = msToSeconds(currentTime);
const wrappedSeek = (seconds: number) => {
if (isVlc) {
seek(secondsToMs(seconds));
return;
}
seek(seconds);
seek(secondsToMs(seconds));
};
const { data: segments } = useSegments(
@@ -41,11 +39,11 @@ export const useCreditSkipper = (
useEffect(() => {
if (creditTimestamps) {
setShowSkipCreditButton(
currentTime > creditTimestamps.startTime &&
currentTime < creditTimestamps.endTime,
currentTimeSeconds > creditTimestamps.startTime &&
currentTimeSeconds < creditTimestamps.endTime,
);
}
}, [creditTimestamps, currentTime]);
}, [creditTimestamps, currentTimeSeconds]);
const skipCredit = useCallback(() => {
if (!creditTimestamps) return;

View File

@@ -1,51 +1,23 @@
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
import { BITRATES } from "@/components/BitrateSelector";
import type { Settings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
// Used only for initial play settings.
const useDefaultPlaySettings = (
item: BaseItemDto,
settings: Settings | null,
) => {
const playSettings = useMemo(() => {
// 1. Get first media source
const mediaSource = item.MediaSources?.[0];
// 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];
}
/**
* React hook wrapper for getDefaultPlaySettings.
* Used in UI components for initial playback (no previous track state).
*/
const useDefaultPlaySettings = (item: BaseItemDto, settings: Settings | null) =>
useMemo(() => {
const { mediaSource, audioIndex, subtitleIndex, bitrate } =
getDefaultPlaySettings(item, settings);
return {
defaultAudioIndex:
preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex ?? undefined,
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
defaultMediaSource: mediaSource ?? undefined,
defaultBitrate: bitrate ?? undefined,
defaultMediaSource: mediaSource,
defaultAudioIndex: audioIndex,
defaultSubtitleIndex: subtitleIndex,
defaultBitrate: bitrate,
};
}, [
item.MediaSources,
settings?.defaultAudioLanguage,
settings?.defaultSubtitleLanguage,
]);
return playSettings;
};
}, [item, settings]);
export default useDefaultPlaySettings;

View File

@@ -7,31 +7,26 @@ import { useHaptic } from "./useHaptic";
/**
* 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 = (
itemId: string,
currentTime: number,
seek: (ticks: number) => void,
seek: (ms: number) => void,
play: () => void,
isVlc = false,
isOffline = false,
api: Api | null = null,
downloadedFiles: DownloadedItem[] | undefined = undefined,
) => {
const [showSkipButton, setShowSkipButton] = useState(false);
if (isVlc) {
currentTime = msToSeconds(currentTime);
}
// Convert ms to seconds for comparison with timestamps
const currentTimeSeconds = msToSeconds(currentTime);
const lightHapticFeedback = useHaptic("light");
const wrappedSeek = (seconds: number) => {
if (isVlc) {
seek(secondsToMs(seconds));
return;
}
seek(seconds);
seek(secondsToMs(seconds));
};
const { data: segments } = useSegments(
@@ -45,8 +40,8 @@ export const useIntroSkipper = (
useEffect(() => {
if (introTimestamps) {
const shouldShow =
currentTime > introTimestamps.startTime &&
currentTime < introTimestamps.endTime;
currentTimeSeconds > introTimestamps.startTime &&
currentTimeSeconds < introTimestamps.endTime;
setShowSkipButton(shouldShow);
} else {
@@ -54,7 +49,7 @@ export const useIntroSkipper = (
setShowSkipButton(false);
}
}
}, [introTimestamps, currentTime, showSkipButton]);
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
const skipIntro = useCallback(() => {
if (!introTimestamps) return;

View File

@@ -1,5 +1,5 @@
import type { OrientationChangeEvent } from "expo-screen-orientation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Platform } from "react-native";
import {
addOrientationChangeListener,
@@ -53,27 +53,28 @@ export const useOrientation = () => {
};
}, []);
const lockOrientation = async (
lock: (typeof OrientationLock)[keyof typeof OrientationLock],
) => {
if (Platform.isTV) return;
const lockOrientation = useCallback(
async (lock: (typeof OrientationLock)[keyof typeof OrientationLock]) => {
if (Platform.isTV) return;
if (lock === OrientationLock.DEFAULT) {
await unlockAsync();
} else {
await lockAsync(lock);
}
};
if (lock === OrientationLock.DEFAULT) {
await unlockAsync();
} else {
await lockAsync(lock);
}
},
[],
);
const unlockOrientationFn = async () => {
const unlockOrientation = useCallback(async () => {
if (Platform.isTV) return;
await unlockAsync();
};
}, []);
return {
orientation,
setOrientation,
lockOrientation,
unlockOrientation: unlockOrientationFn,
unlockOrientation,
};
};

View File

@@ -96,8 +96,6 @@ export const useWebSocket = ({
| Record<string, string>
| undefined; // Arguments are Dictionary<string, string>
console.log("[WS] ~ ", lastMessage);
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
togglePlay();

View File

@@ -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>;
}

View File

@@ -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;

View File

@@ -1,17 +1,4 @@
import type {
ChapterInfo,
PlaybackStatePayload,
ProgressUpdatePayload,
TrackInfo,
VideoLoadStartPayload,
VideoProgressPayload,
VideoStateChangePayload,
VlcPlayerSource,
VlcPlayerViewProps,
VlcPlayerViewRef,
} from "./VlcPlayer.types";
import VlcPlayerView from "./VlcPlayerView";
// Background Downloader
export type {
ActiveDownload,
DownloadCompleteEvent,
@@ -19,23 +6,21 @@ export type {
DownloadProgressEvent,
DownloadStartedEvent,
} from "./background-downloader";
// Background Downloader
export { default as BackgroundDownloader } from "./background-downloader";
// Component
export { VlcPlayerView };
// Component Types
export type { VlcPlayerViewProps, VlcPlayerViewRef };
// Media Types
export type { ChapterInfo, TrackInfo, VlcPlayerSource };
// Playback Events (alphabetically sorted)
// Type aliases for backward compatibility during migration
// These map old VLC type names to new MPV equivalents
export type {
PlaybackStatePayload,
ProgressUpdatePayload,
VideoLoadStartPayload,
VideoProgressPayload,
VideoStateChangePayload,
};
AudioTrack,
MpvPlayerViewProps,
MpvPlayerViewRef,
OnErrorEventPayload,
OnLoadEventPayload,
OnPlaybackStateChangePayload,
OnProgressEventPayload,
OnTracksReadyEventPayload,
SubtitleTrack,
SubtitleTrack as TrackInfo,
VideoSource,
} from "./mpv-player";
// MPV Player - Main exports
export { MpvPlayerView } from "./mpv-player";

View 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
}
}

View File

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

View File

@@ -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)
}
}

View File

@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["MpvPlayerModule"]
}
}

View File

@@ -0,0 +1,2 @@
export * from "./src/MpvPlayer.types";
export { default as MpvPlayerView } from "./src/MpvPlayerView";

View 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
}
}

View 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
}

File diff suppressed because it is too large Load Diff

View 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

View 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")
}
}
}

View 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()
}
}

View 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()
}
}

View 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
}
}

View 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;
};

View 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} />;
},
);

View File

@@ -0,0 +1,2 @@
export * from "./MpvPlayer.types";
export { default as MpvPlayerView } from "./MpvPlayerView";

View File

@@ -1,7 +0,0 @@
{
"platforms": ["ios", "tvos"],
"ios": {
"modules": ["VlcPlayer4Module"],
"appDelegateSubscribers": ["AppLifecycleDelegate"]
}
}

View File

@@ -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.
}
}

View File

@@ -1,4 +0,0 @@
class VLCManager {
static let shared = VLCManager()
var listeners: [SimpleAppLifecycleListener] = []
}

View File

@@ -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

View File

@@ -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)
}
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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");

View File

@@ -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"
}
}

View File

@@ -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)
// }
// }
}

View File

@@ -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)
}
}
}
}

View File

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

View File

@@ -1,9 +0,0 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
"modules": ["VlcPlayerModule"]
},
"android": {
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
}
}

View File

@@ -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

View File

@@ -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()
}
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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");

View File

@@ -96,7 +96,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
newWebSocket.onmessage = (e) => {
try {
const message = JSON.parse(e.data);
console.log("[WS] Received message:", message);
setLastMessage(message); // Store the last message in context
} catch (error) {
console.error("Error parsing WebSocket message:", error);
@@ -124,12 +123,10 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const handlePlayCommand = useCallback(
(data: any) => {
if (!data || !data.ItemIds || !data.ItemIds.length) {
console.warn("[WS] Received Play command with no items");
return;
}
const itemId = data.ItemIds[0];
console.log(`[WS] Handling Play command for item: ${itemId}`);
router.push({
pathname: "/(auth)/player/direct-player",

View File

@@ -130,10 +130,9 @@ export type HomeSectionLatestResolver = {
includeItemTypes?: Array<BaseItemKind>;
};
// Video player enum - currently only MPV is supported
export enum VideoPlayer {
// NATIVE, //todo: changes will make this a lot more easier to implement if we want. delete if not wanted
VLC_3 = 0,
VLC_4 = 1,
MPV = 0,
}
export type Settings = {
@@ -143,7 +142,6 @@ export type Settings = {
preferedLanguage?: string;
searchEngine: "Marlin" | "Jellyfin";
marlinServerUrl?: string;
openInVLC?: boolean;
downloadQuality?: DownloadOption;
defaultBitrate?: Bitrate;
libraryOptions: LibraryOptions;
@@ -164,16 +162,14 @@ export type Settings = {
jellyseerrServerUrl?: string;
hiddenLibraries?: string[];
enableH265ForChromecast: boolean;
defaultPlayer: VideoPlayer;
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
autoPlayEpisodeCount: number;
vlcTextColor?: string;
vlcBackgroundColor?: string;
vlcOutlineColor?: string;
vlcOutlineThickness?: string;
vlcBackgroundOpacity?: number;
vlcOutlineOpacity?: number;
vlcIsBold?: boolean;
// MPV subtitle settings
mpvSubtitleScale?: number;
mpvSubtitleMarginY?: number;
mpvSubtitleAlignX?: "left" | "center" | "right";
mpvSubtitleAlignY?: "top" | "center" | "bottom";
mpvSubtitleFontSize?: number;
// Gesture controls
enableHorizontalSwipeSkip: boolean;
enableLeftSideBrightnessSwipe: boolean;
@@ -201,7 +197,6 @@ export const defaultValues: Settings = {
preferedLanguage: undefined,
searchEngine: "Jellyfin",
marlinServerUrl: "",
openInVLC: false,
downloadQuality: DownloadOptions[0],
defaultBitrate: BITRATES[0],
libraryOptions: {
@@ -228,16 +223,14 @@ export const defaultValues: Settings = {
jellyseerrServerUrl: undefined,
hiddenLibraries: [],
enableH265ForChromecast: false,
defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
autoPlayEpisodeCount: 0,
vlcTextColor: undefined,
vlcBackgroundColor: undefined,
vlcOutlineColor: undefined,
vlcOutlineThickness: undefined,
vlcBackgroundOpacity: undefined,
vlcOutlineOpacity: undefined,
vlcIsBold: undefined,
// MPV subtitle defaults
mpvSubtitleScale: undefined,
mpvSubtitleMarginY: undefined,
mpvSubtitleAlignX: undefined,
mpvSubtitleAlignY: undefined,
mpvSubtitleFontSize: undefined,
// Gesture controls
enableHorizontalSwipeSkip: true,
enableLeftSideBrightnessSwipe: true,

View File

@@ -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 {
BaseItemDto,
@@ -12,86 +21,83 @@ import {
SubtitleStreamRanker,
} from "../streamRanker";
interface PlaySettings {
export interface PlaySettings {
item: BaseItemDto;
bitrate: (typeof BITRATES)[0];
mediaSource?: MediaSourceInfo | null;
audioIndex?: number | undefined;
subtitleIndex?: number | undefined;
}
export interface previousIndexes {
audioIndex?: number;
subtitleIndex?: number;
}
interface TrackOptions {
DefaultAudioStreamIndex: number | undefined;
DefaultSubtitleStreamIndex: number | undefined;
export interface PreviousIndexes {
audioIndex?: number;
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(
item: BaseItemDto,
settings: Settings,
previousIndexes?: previousIndexes,
previousSource?: MediaSourceInfo,
settings: Settings | null,
previous?: { indexes?: PreviousIndexes; source?: MediaSourceInfo },
): PlaySettings {
if (item.Type === "Program") {
return {
item,
bitrate: BITRATES[0],
mediaSource: undefined,
audioIndex: undefined,
subtitleIndex: undefined,
};
}
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
// 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 streams = mediaSource?.MediaStreams ?? [];
// We prefer the previous track over the default track.
const trackOptions: TrackOptions = {
DefaultAudioStreamIndex: mediaSource?.DefaultAudioStreamIndex ?? -1,
DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
};
// Start with media source defaults
let audioIndex = mediaSource?.DefaultAudioStreamIndex;
let subtitleIndex = mediaSource?.DefaultSubtitleStreamIndex ?? -1;
const mediaStreams = mediaSource?.MediaStreams ?? [];
if (settings?.rememberSubtitleSelections && previousIndexes) {
if (previousIndexes.subtitleIndex !== undefined && previousSource) {
const subtitleRanker = new SubtitleStreamRanker();
const ranker = new StreamRanker(subtitleRanker);
// Try to match previous selections (sequential play)
if (previous?.indexes && previous?.source && settings) {
if (
settings.rememberSubtitleSelections &&
previous.indexes.subtitleIndex !== undefined
) {
const ranker = new StreamRanker(new SubtitleStreamRanker());
const result = { DefaultSubtitleStreamIndex: subtitleIndex };
ranker.rankStream(
previousIndexes.subtitleIndex,
previousSource,
mediaStreams,
trackOptions,
previous.indexes.subtitleIndex,
previous.source,
streams,
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 {
item,
bitrate,
mediaSource,
audioIndex: trackOptions.DefaultAudioStreamIndex,
subtitleIndex: trackOptions.DefaultSubtitleStreamIndex,
audioIndex: audioIndex ?? undefined,
subtitleIndex: subtitleIndex ?? undefined,
};
}

View File

@@ -1,7 +1,7 @@
import type { Api } from "@jellyfin/sdk";
import type { AxiosResponse } from "axios";
import type { Settings } from "@/utils/atoms/settings";
import { generateDeviceProfile } from "@/utils/profiles/native";
import type { Settings } from "../../atoms/settings";
import { generateDeviceProfile } from "../../profiles/native";
import { getAuthHeaders } from "../jellyfin";
interface PostCapabilitiesParams {

View 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;
};

View File

@@ -4,8 +4,4 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export interface DeviceProfileOptions {
transcode?: boolean;
}
export function generateDeviceProfile(options?: DeviceProfileOptions): any;
export function generateDeviceProfile(): any;

View File

@@ -6,12 +6,12 @@
import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles";
export const generateDeviceProfile = ({ transcode = false } = {}) => {
export const generateDeviceProfile = () => {
/**
* Device profile for Native video player
*/
const profile = {
Name: `1. Vlc Player${transcode ? " (Transcoding)" : ""}`,
Name: `1. MPV Player`,
MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 999_999_999,
CodecProfiles: [
@@ -48,7 +48,7 @@ export const generateDeviceProfile = ({ transcode = false } = {}) => {
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
VideoCodec:
"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,
@@ -75,7 +75,7 @@ export const generateDeviceProfile = ({ transcode = false } = {}) => {
MaxAudioChannels: "2",
},
],
SubtitleProfiles: getSubtitleProfiles(transcode ? "hls" : "External"),
SubtitleProfiles: getSubtitleProfiles(),
};
return profile;

View File

@@ -4,26 +4,19 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
const COMMON_SUBTITLE_PROFILES = [
// Official formats
{ Format: "dvdsub", Method: "Embed" },
{ Format: "dvdsub", Method: "Encode" },
{ Format: "idx", Method: "Embed" },
{ Format: "idx", Method: "Encode" },
{ Format: "pgs", Method: "Embed" },
{ Format: "pgs", Method: "Encode" },
{ Format: "pgssub", Method: "Embed" },
{ Format: "pgssub", Method: "Encode" },
{ Format: "teletext", Method: "Embed" },
{ Format: "teletext", Method: "Encode" },
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
// because MPV cannot load them externally over HTTP
const IMAGE_BASED_FORMATS = [
"dvdsub",
"idx",
"pgs",
"pgssub",
"teletext",
"vobsub",
];
const VARYING_SUBTITLE_FORMATS = [
// Text-based formats - these can be loaded externally by MPV
const TEXT_BASED_FORMATS = [
"webvtt",
"vtt",
"srt",
@@ -46,11 +39,23 @@ const VARYING_SUBTITLE_FORMATS = [
"xsub",
];
export const getSubtitleProfiles = (secondaryMethod) => {
const profiles = [...COMMON_SUBTITLE_PROFILES];
for (const format of VARYING_SUBTITLE_FORMATS) {
export const getSubtitleProfiles = () => {
const profiles = [];
// 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: 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;
};
// Export for use in player filtering
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;