diff --git a/.gitignore b/.gitignore
index c098c3a5..63fdb823 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ npm-debug.*
*.mobileprovision
*.orig.*
web-build/
+modules/vlc-player/android/build
# macOS
.DS_Store
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 00000000..26d33521
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml
new file mode 100644
index 00000000..b81700b5
--- /dev/null
+++ b/.idea/caches/deviceStreaming.xml
@@ -0,0 +1,329 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 00000000..639900d1
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 00000000..ba6d5c31
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/streamyfin.iml b/.idea/streamyfin.iml
new file mode 100644
index 00000000..d6ebd480
--- /dev/null
+++ b/.idea/streamyfin.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 00000000..35eb1ddf
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 22480b68..4571e3a1 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -11,5 +11,7 @@
},
"[swift]": {
"editor.defaultFormatter": "sswg.swift-lang"
- }
+ },
+ "java.configuration.updateBuildConfiguration": "interactive",
+ "java.compile.nullAnalysis.mode": "automatic"
}
diff --git a/components/video-player/player.android.tsx b/components/video-player/player.android.tsx
index 92f857c8..6f4bd52f 100644
--- a/components/video-player/player.android.tsx
+++ b/components/video-player/player.android.tsx
@@ -1,18 +1,22 @@
+import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/Controls";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useWebSocket } from "@/hooks/useWebsockets";
-import { TrackInfo } from "@/modules/vlc-player";
+import { VlcPlayerView } from "@/modules/vlc-player";
+import {
+ PlaybackStatePayload,
+ ProgressUpdatePayload,
+ VlcPlayerViewRef,
+} from "@/modules/vlc-player/src/VlcPlayer.types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
-import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
+import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native";
-import android from "@/utils/profiles/android";
-import { secondsToTicks } from "@/utils/secondsToTicks";
+import { msToTicks, ticksToMs } from "@/utils/time";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
@@ -21,32 +25,26 @@ import {
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
-import { useFocusEffect, useLocalSearchParams } from "expo-router";
+import { useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
-import { Pressable, useWindowDimensions, View } from "react-native";
+import { Alert, Pressable, useWindowDimensions, View } from "react-native";
+import { SystemBars } from "react-native-edge-to-edge";
import { useSharedValue } from "react-native-reanimated";
-import Video, {
- OnProgressData,
- SelectedTrack,
- SelectedTrackType,
- VideoRef,
-} from "react-native-video";
const Player = () => {
- const api = useAtomValue(apiAtom);
+ const videoRef = useRef(null);
const user = useAtomValue(userAtom);
- const [settings] = useSettings();
- const videoRef = useRef(null);
+ const api = useAtomValue(apiAtom);
- const firstTime = useRef(true);
- const dimensions = useWindowDimensions();
+ const windowDimensions = useWindowDimensions();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
+ const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
@@ -67,12 +65,10 @@ const Player = () => {
}>();
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
- const subtitleIndex = subtitleIndexStr
- ? parseInt(subtitleIndexStr, 10)
- : undefined;
+ const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const bitrateValue = bitrateValueStr
? parseInt(bitrateValueStr, 10)
- : undefined;
+ : BITRATES[0].value;
const {
data: item,
@@ -81,15 +77,7 @@ const Player = () => {
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
- if (!api) {
- throw new Error("No api");
- }
-
- if (!itemId) {
- console.warn("No itemId");
- return null;
- }
-
+ if (!api) return;
const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user?.Id,
@@ -97,6 +85,7 @@ const Player = () => {
return res.data;
},
+ enabled: !!itemId && !!api,
staleTime: 0,
});
@@ -110,20 +99,11 @@ const Player = () => {
itemId,
audioIndex,
subtitleIndex,
- bitrateValue,
- user,
mediaSourceId,
+ bitrateValue,
],
queryFn: async () => {
- if (!api) {
- throw new Error("No api");
- }
-
- if (!item) {
- console.warn("No item", itemId, item);
- return null;
- }
-
+ if (!api) return;
const res = await getStreamUrl({
api,
item,
@@ -133,17 +113,16 @@ const Player = () => {
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
- deviceProfile: android,
+ deviceProfile: native,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
- if (!sessionId || !mediaSource || !url) {
- console.warn("No sessionId or mediaSource or url", url);
- return null;
- }
+ if (!sessionId || !mediaSource || !url) return null;
+
+ console.log(url);
return {
mediaSource,
@@ -151,43 +130,44 @@ const Player = () => {
url,
};
},
- enabled: !!item,
+ enabled: !!itemId && !!api && !!item,
staleTime: 0,
});
- const poster = usePoster(item, api);
- const videoSource = useVideoSource(item, api, poster, stream?.url);
-
const togglePlay = useCallback(
- async (ticks: number) => {
+ async (ms: number) => {
+ if (!api || !stream) return;
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
- videoRef.current?.pause();
- await getPlaystateApi(api!).onPlaybackProgress({
+ await videoRef.current?.pause();
+
+ await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
- positionTicks: Math.floor(ticks),
+ positionTicks: msToTicks(ms),
isPaused: true,
- playMethod: stream?.url.includes("m3u8")
+ playMethod: stream.url?.includes("m3u8")
? "Transcode"
: "DirectStream",
- playSessionId: stream?.sessionId,
+ playSessionId: stream.sessionId,
});
+ console.log("ACtually marked as paused");
} else {
- videoRef.current?.resume();
- await getPlaystateApi(api!).onPlaybackProgress({
+ videoRef.current?.play();
+ await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
- positionTicks: Math.floor(ticks),
+ positionTicks: msToTicks(ms),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
- playSessionId: stream?.sessionId,
+ playSessionId: stream.sessionId,
});
}
},
@@ -195,9 +175,8 @@ const Player = () => {
isPlaying,
api,
item,
- videoRef,
- settings,
stream,
+ videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
@@ -205,7 +184,7 @@ const Player = () => {
);
const play = useCallback(() => {
- videoRef.current?.resume();
+ videoRef.current?.play();
reportPlaybackStart();
}, [videoRef]);
@@ -215,89 +194,60 @@ const Player = () => {
const stop = useCallback(() => {
setIsPlaybackStopped(true);
- videoRef.current?.pause();
+ videoRef.current?.stop();
reportPlaybackStopped();
}, [videoRef]);
- const seek = useCallback(
- (seconds: number) => {
- videoRef.current?.seek(seconds);
- },
- [videoRef]
- );
-
const reportPlaybackStopped = async () => {
- if (!item?.Id) return;
+ const currentTimeInTicks = msToTicks(progress.value);
+
await getPlaystateApi(api!).onPlaybackStopped({
- itemId: item.Id,
+ itemId: item?.Id!,
mediaSourceId: mediaSourceId,
- positionTicks: Math.floor(progress.value),
- playSessionId: stream?.sessionId,
+ positionTicks: currentTimeInTicks,
+ playSessionId: stream?.sessionId!,
});
};
const reportPlaybackStart = async () => {
- if (!item?.Id) return;
- await getPlaystateApi(api!).onPlaybackStart({
- itemId: item.Id,
+ if (!api || !stream) return;
+ await getPlaystateApi(api).onPlaybackStart({
+ itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
- playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
- playSessionId: stream?.sessionId,
+ playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
});
};
const onProgress = useCallback(
- async (data: OnProgressData) => {
+ async (data: ProgressUpdatePayload) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
+ if (!item?.Id || !api || !stream) return;
- const ticks = secondsToTicks(data.currentTime);
+ const { currentTime } = data.nativeEvent;
- progress.value = ticks;
- cacheProgress.value = secondsToTicks(data.playableDuration);
-
- // TODO: Use this when streaming with HLS url, but NOT when direct playing
- // TODO: since playable duration is always 0 then.
- // setIsBuffering(data.playableDuration === 0);
-
- if (!item?.Id || data.currentTime === 0) {
- return;
+ if (isBuffering) {
+ setIsBuffering(false);
}
- await getPlaystateApi(api!).onPlaybackProgress({
+ progress.value = currentTime;
+ const currentTimeInTicks = msToTicks(currentTime);
+
+ await getPlaystateApi(api).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
- positionTicks: Math.round(ticks),
+ positionTicks: Math.floor(currentTimeInTicks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
- playSessionId: stream?.sessionId,
+ playSessionId: stream.sessionId,
});
},
- [
- item,
- isPlaying,
- api,
- isPlaybackStopped,
- isSeeking,
- stream,
- mediaSourceId,
- audioIndex,
- subtitleIndex,
- ]
- );
-
- useFocusEffect(
- useCallback(() => {
- play();
-
- return () => {
- stop();
- };
- }, [play, stop])
+ [item?.Id, isPlaying, api, isPlaybackStopped]
);
useOrientation();
@@ -310,38 +260,25 @@ const Player = () => {
stopPlayback: stop,
});
- const [selectedTextTrack, setSelectedTextTrack] = useState<
- SelectedTrack | undefined
- >();
+ const onPlaybackStateChanged = (e: PlaybackStatePayload) => {
+ const { state, isBuffering, isPlaying } = e.nativeEvent;
- const [embededTextTracks, setEmbededTextTracks] = useState<
- {
- index: number;
- language?: string | undefined;
- selected?: boolean | undefined;
- title?: string | undefined;
- type: any;
- }[]
- >([]);
+ if (state === "Playing") {
+ setIsPlaying(true);
+ return;
+ }
- const [audioTracks, setAudioTracks] = useState([]);
- const [selectedAudioTrack, setSelectedAudioTrack] = useState<
- SelectedTrack | undefined
- >(undefined);
+ if (state === "Paused") {
+ setIsPlaying(false);
+ return;
+ }
- const getAudioTracks = (): TrackInfo[] => {
- return audioTracks.map((t) => ({
- name: t.name,
- index: t.index,
- }));
- };
-
- const getSubtitleTracks = (): TrackInfo[] => {
- return embededTextTracks.map((t) => ({
- name: t.title ?? "",
- index: t.index,
- language: t.language,
- }));
+ if (isPlaying) {
+ setIsPlaying(true);
+ setIsBuffering(false);
+ } else if (isBuffering) {
+ setIsBuffering(true);
+ }
};
if (isLoadingItem || isLoadingStreamUrl)
@@ -358,88 +295,60 @@ const Player = () => {
);
+ if (!stream || !item) return null;
+
+ const startPosition = item?.UserData?.PlaybackPositionTicks
+ ? ticksToMs(item.UserData.PlaybackPositionTicks)
+ : 0;
+
return (
{
setShowControls(!showControls);
}}
- style={{
- flex: 1,
- height: "100%",
- width: "100%",
- position: "absolute",
- top: 0,
- left: 0,
- zIndex: 0,
- }}
+ className="absolute z-0 h-full w-full"
>
- {videoSource ? (
-
- {item && (
+ {videoRef.current && (
{
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
- seek={seek}
- play={play}
- pause={pause}
- getSubtitleTracks={getSubtitleTracks}
- setSubtitleTrack={(i) =>
- setSelectedTextTrack({
- type: SelectedTrackType.INDEX,
- value: i,
- })
- }
- getAudioTracks={getAudioTracks}
- setAudioTrack={(i) => {
- console.log("setAudioTrack ~", i);
- setSelectedAudioTrack({
- type: SelectedTrackType.INDEX,
- value: i,
- });
- }}
+ isVideoLoaded={isVideoLoaded}
+ play={videoRef.current?.play}
+ pause={videoRef.current?.pause}
+ seek={videoRef.current?.seekTo}
+ enableTrickplay={true}
+ getAudioTracks={videoRef.current?.getAudioTracks}
+ getSubtitleTracks={videoRef.current?.getSubtitleTracks}
+ offline={false}
+ setSubtitleTrack={videoRef.current.setSubtitleTrack}
+ setSubtitleURL={videoRef.current.setSubtitleURL}
+ setAudioTrack={videoRef.current.setAudioTrack}
+ stop={stop}
+ isVlc
/>
)}
@@ -475,7 +379,7 @@ const Player = () => {
};
export function usePoster(
- item: BaseItemDto | null | undefined,
+ item: BaseItemDto,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
@@ -493,37 +397,4 @@ export function usePoster(
return poster ?? undefined;
}
-export function useVideoSource(
- item: BaseItemDto | null | undefined,
- api: Api | null,
- poster: string | undefined,
- url?: string | null
-) {
- const videoSource = useMemo(() => {
- if (!item || !api || !url) {
- return null;
- }
-
- const startPosition = item?.UserData?.PlaybackPositionTicks
- ? Math.round(item.UserData.PlaybackPositionTicks / 10000)
- : 0;
-
- return {
- uri: url,
- isNetwork: true,
- startPosition,
- headers: getAuthHeaders(api),
- metadata: {
- artist: item?.AlbumArtist ?? undefined,
- title: item?.Name || "Unknown",
- description: item?.Overview ?? undefined,
- imageUri: poster,
- subtitle: item?.Album ?? undefined,
- },
- };
- }, [item, api, poster, url]);
-
- return videoSource;
-}
-
export default Player;
diff --git a/modules/vlc-player/android/build.gradle b/modules/vlc-player/android/build.gradle
new file mode 100644
index 00000000..53fe8c57
--- /dev/null
+++ b/modules/vlc-player/android/build.gradle
@@ -0,0 +1,70 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-kapt'
+
+group = 'expo.modules.vlcplayer'
+version = '0.6.0'
+
+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 {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath "com.android.tools.build:gradle:7.1.3"
+ }
+ }
+ project.android {
+ compileSdkVersion safeExtGet("compileSdkVersion", 34)
+ defaultConfig {
+ minSdkVersion safeExtGet("minSdkVersion", 21)
+ targetSdkVersion safeExtGet("targetSdkVersion", 34)
+ }
+ }
+}
+
+dependencies {
+ implementation 'org.videolan.android:libvlc-all:4.0.0-eap15'
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:1.5.31"
+}
+
+android {
+ namespace "expo.modules.vlcplayer"
+ compileSdkVersion 34
+ defaultConfig {
+ minSdkVersion 21
+ targetSdkVersion 34
+ versionCode 1
+ versionName "0.6.0"
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
+
+tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
+ kotlinOptions {
+ freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
+ jvmTarget = "17"
+ }
+}
\ No newline at end of file
diff --git a/modules/vlc-player/android/src/main/AndroidManifest.xml b/modules/vlc-player/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..bdae66c8
--- /dev/null
+++ b/modules/vlc-player/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt
new file mode 100644
index 00000000..124e37f4
--- /dev/null
+++ b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt
@@ -0,0 +1,69 @@
+package expo.modules.vlcplayer
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class VlcPlayerModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("VlcPlayer")
+
+ View(VlcPlayerView::class) {
+ Prop("source") { view: VlcPlayerView, source: Map ->
+ view.setSource(source)
+ }
+
+ Prop("paused") { view: VlcPlayerView, paused: Boolean ->
+ if (paused) {
+ view.pause()
+ } else {
+ view.play()
+ }
+ }
+
+ Events(
+ "onPlaybackStateChanged",
+ "onVideoStateChange",
+ "onVideoLoadStart",
+ "onVideoLoadEnd",
+ "onVideoProgress",
+ "onVideoError"
+ )
+
+ 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 -> List