Compare commits

...

5 Commits

Author SHA1 Message Date
herrrta
505ef39ee7 ios VLCKit 4.0 & All platform PiP support 2025-02-12 23:21:24 -05:00
Théo FORTIN
e71d5cc176 feat: Add default quality setting (#509) 2025-02-12 08:32:26 +01:00
Fredrik Burmester
74e57bbd88 fix: add contributor avatars to readme (#512) 2025-02-12 08:31:55 +01:00
Fredrik Burmester
76eaeb9820 chore 2025-02-12 08:31:38 +01:00
Fredrik Burmester
9a70f98dd5 chore 2025-02-12 08:25:36 +01:00
22 changed files with 626 additions and 370 deletions

View File

@@ -43,6 +43,7 @@ body:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.26.0
- 0.25.0
- 0.24.0
- 0.23.0

View File

@@ -38,7 +38,7 @@ Chromecast support is still in development, and we're working on improving it. C
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
- Auto log in to Jellyseerr without the user having to do anythin
- Choose the default languages
- Choose the default languages
- Set download method and search provider
- Customize homescreen
- And more...
@@ -68,7 +68,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
## 🚀 Getting Started
@@ -123,7 +123,85 @@ Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmest
## ✨ Acknowledgements
I'd like to thank the following people and projects for their contributions to Streamyfin:
### Core Developers
Thanks to the following contributors for their significant contributions:
<table>
<tr
style="
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
"
>
<td align="center">
<a href="https://github.com/Alexk2309">
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Alexk2309</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/herrrta">
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@herrrta</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/lostb1t">
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@lostb1t</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Simon-Eklundh">
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Simon-Eklundh</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/topiga">
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@topiga</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/simoncaron">
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@simoncaron</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/jakequade">
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@jakequade</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Ryan0204">
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Ryan0204</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/retardgerman">
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@retardgerman</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/whoopsi-daisy">
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@whoopsi-daisy</b></sub>
</a>
</td>
</tr>
</table>
And all other developers who have contributed to Streamyfin, thank you for your contributions.
I'd also like to thank the following people and projects for their contributions to Streamyfin:
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.

View File

@@ -2,24 +2,19 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.25.0",
"version": "0.26.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
"jsEngine": "hermes",
"assetBundlePatterns": [
"**/*"
],
"assetBundlePatterns": ["**/*"],
"ios": {
"requireFullScreen": true,
"infoPlist": {
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
"UIBackgroundModes": [
"audio",
"fetch"
],
"UIBackgroundModes": ["audio", "fetch"],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
@@ -36,7 +31,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 50,
"versionCode": 52,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png"
},
@@ -118,7 +113,7 @@
"./plugins/withChangeNativeAndroidTextToWhite.js"
],
[
"./plugins/withGoogleCastActivity.js"
"./plugins/withAndroidManifest.js"
],
[
"./plugins/withTrustLocalCerts.js"
@@ -155,4 +150,4 @@
},
"newArchEnabled": false
}
}
}

View File

@@ -3,12 +3,11 @@ import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player";
import {
PipStartedPayload,
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
@@ -18,13 +17,10 @@ const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
@@ -42,14 +38,12 @@ import React, {
} from "react";
import {
Alert,
BackHandler,
View,
AppState,
AppStateStatus,
Platform,
} from "react-native";
import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -67,6 +61,7 @@ export default function page() {
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
@@ -190,37 +185,23 @@ export default function page() {
lightHapticFeedback();
if (isPlaying) {
await videoRef.current?.pause();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: true,
playMethod: stream.url?.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
} else {
videoRef.current?.play();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
}
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
}, [
isPlaying,
@@ -232,13 +213,13 @@ export default function page() {
subtitleIndex,
mediaSourceId,
offline,
progress.value,
progress,
]);
const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.value);
const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
@@ -273,8 +254,7 @@ export default function page() {
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent;
@@ -282,7 +262,7 @@ export default function page() {
setIsBuffering(false);
}
progress.value = currentTime;
progress.set(currentTime);
if (offline) return;
@@ -301,7 +281,7 @@ export default function page() {
playSessionId: stream.sessionId,
});
},
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
[item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex]
);
useWebSocket({
@@ -311,6 +291,11 @@ export default function page() {
offline,
});
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted)
}, [])
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
@@ -340,25 +325,13 @@ export default function page() {
: 0;
}, [item]);
useFocusEffect(
React.useCallback(() => {
return async () => {
stop();
};
}, [])
);
const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.match(/inactive|background/) && nextAppState === "active") {
// Handle app coming to the foreground
} else if (nextAppState.match(/inactive|background/)) {
// Handle app going to the background
if (videoRef.current && videoRef.current.pause) {
videoRef.current.pause();
}
// Handle app going to the background
if (nextAppState.match(/inactive|background/)) {
_setShowControls(false)
}
setAppState(nextAppState);
};
@@ -373,10 +346,9 @@ export default function page() {
// Cleanup the event listener when the component is unmounted
subscription.remove();
};
}, [appState]);
}, [appState, isPipStarted, isPlaying]);
// Preselection of audio and subtitle tracks.
if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
@@ -410,7 +382,7 @@ export default function page() {
};
}
if (chosenAudioTrack)
if (chosenAudioTrack)
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} else {
// Transcoded playback CASE
@@ -466,6 +438,7 @@ export default function page() {
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted}
onVideoLoadStart={() => {}}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
@@ -496,6 +469,7 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
@@ -512,23 +486,4 @@ export default function page() {
)}
</View>
);
}
export function usePoster(
item: BaseItemDto,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}
}

View File

@@ -66,7 +66,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(settings?.defaultBitrate ?? {
key: "Max",
value: undefined,
});
@@ -194,10 +194,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
for (const item of items) {
if (itemsNotDownloaded.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
item,
settings!
));
const defaults = getDefaultPlaySettings(item, settings!);
mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex;
// Keep using the selected bitrate for consistency across all downloads
}
const res = await getStreamUrl({

View File

@@ -1,5 +1,6 @@
import { Platform } from "react-native";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { BitrateSelector, BITRATES } from "@/components/BitrateSelector";
import {
BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync,
@@ -163,6 +164,32 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.hide_libraries")}
showArrow
/>
<ListItem
title="Default quality"
disabled={pluginSettings?.defaultBitrate?.locked}
>
<Dropdown
data={BITRATES}
disabled={pluginSettings?.defaultBitrate?.locked}
keyExtractor={(item) => item.key}
titleExtractor={(item) => item.key}
selected={settings.defaultBitrate}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings.defaultBitrate?.key}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
}
label={t("home.settings.other.quality")}
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
/>
</ListItem>
<ListItem
title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked}

View File

@@ -24,7 +24,7 @@ import {
ticksToMs,
ticksToSeconds,
} from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import {Ionicons, MaterialIcons} from "@expo/vector-icons";
import {
BaseItemDto,
MediaSourceInfo,
@@ -35,7 +35,7 @@ import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { TouchableOpacity, useWindowDimensions, View } from "react-native";
import {Platform, TouchableOpacity, useWindowDimensions, View} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
@@ -75,6 +75,7 @@ interface Props {
isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture: () => Promise<void>;
play: (() => Promise<void>) | (() => void);
pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
@@ -91,6 +92,7 @@ const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({
item,
seek,
startPictureInPicture,
play,
pause,
togglePlay,
@@ -212,6 +214,8 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(),
}).toString();
stop()
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
@@ -250,6 +254,8 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(),
}).toString();
stop()
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
@@ -413,6 +419,8 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(),
}).toString();
stop()
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
@@ -499,6 +507,15 @@ export const Controls: React.FC<Props> = ({
);
}, [trickPlayUrl, trickplayInfo, time]);
const onClose = async () => {
stop()
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
};
return (
<ControlProvider
item={item}
@@ -551,6 +568,19 @@ export const Controls: React.FC<Props> = ({
</View>
<View className="flex flex-row items-center space-x-2 ">
{!Platform.isTV && (
<TouchableOpacity
onPress={startPictureInPicture}
>
<MaterialIcons
name="picture-in-picture"
size={24}
color="white"
style={{ opacity: showControls ? 1 : 0 }}
/>
</TouchableOpacity>
)}
{item?.Type === "Episode" && !offline && (
<TouchableOpacity
onPress={() => {
@@ -592,13 +622,7 @@ export const Controls: React.FC<Props> = ({
</TouchableOpacity>
{/* )} */}
<TouchableOpacity
onPress={async () => {
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
}}
onPress={onClose}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />

View File

@@ -32,20 +32,20 @@
}
},
"production": {
"channel": "0.25.0",
"channel": "0.26.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.25.0",
"channel": "0.26.0",
"android": {
"buildType": "apk",
"image": "latest"
}
},
"production-apk-tv": {
"channel": "0.25.0",
"channel": "0.26.0",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -28,8 +28,8 @@ const useDefaultPlaySettings = (
(x) => x.Type === "Audio"
)?.Index;
// 4. Get default bitrate
const bitrate = BITRATES[0];
// 4. Get default bitrate from settings or fallback to max
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
return {
defaultAudioIndex:

View File

@@ -26,9 +26,14 @@ class VlcPlayerModule : Module() {
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError"
"onVideoError",
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
view.startPictureInPicture()
}
AsyncFunction("play") { view: VlcPlayerView ->
view.play()
}

View File

@@ -1,23 +1,40 @@
package expo.modules.vlcplayer
import android.R
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.Handler
import android.os.Looper
import android.util.Log
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import androidx.core.app.ComponentActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleObserver
import android.net.Uri
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
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.interfaces.IMedia
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 {
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
@@ -30,6 +47,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
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
@@ -44,9 +62,32 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
handler.postDelayed(this, updateInterval)
}
}
private val currentActivity get() = context.findActivity()
private val actions: MutableList<RemoteAction> = mutableListOf()
private val actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
PIP_PLAY_PAUSE_ACTION -> if (isPaused) play() else pause()
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
}
}
}
init {
setupView()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setupPipActions()
currentActivity.apply {
setPictureInPictureParams(getPipParams()!!)
addOnPictureInPictureModeChangedListener { info ->
onPipStarted(mapOf(
"pipStarted" to info.isInPictureInPictureMode
))
}
}
}
}
private fun setupView() {
@@ -59,6 +100,76 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
Log.d("VlcPlayerView", "View setup complete")
}
@RequiresApi(Build.VERSION_CODES.O)
private fun setupPipActions() {
val remoteActionFilter = IntentFilter()
val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
remoteActionFilter.addAction(PIP_REWIND_ACTION)
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(
Icon.createWithResource(context, R.drawable.ic_media_play),
"Play",
"Play Video",
PendingIntent.getBroadcast(
context,
0,
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
)
)
)
)
ContextCompat.registerReceiver(
context,
actionReceiver,
remoteActionFilter,
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
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>) {
if (hasSource) {
mediaPlayer?.attachViews(videoLayout, null, false, false)
@@ -112,6 +223,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
}
fun startPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
currentActivity.enterPictureInPictureMode(getPipParams()!!)
}
}
fun play() {
mediaPlayer?.play()
isPaused = false
@@ -283,4 +400,13 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
));
}
}
}
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,7 +1,6 @@
import {
NativeModulesProxy,
EventEmitter,
Subscription,
EventSubscription,
} from "expo-modules-core";
import VlcPlayerModule from "./src/VlcPlayerModule";
@@ -19,13 +18,11 @@ import {
VlcPlayerViewRef,
} from "./src/VlcPlayer.types";
const emitter = new EventEmitter(
VlcPlayerModule ?? NativeModulesProxy.VlcPlayer
);
const emitter = new EventEmitter(VlcPlayerModule);
export function addPlaybackStateListener(
listener: (event: PlaybackStatePayload) => void
): Subscription {
): EventSubscription {
return emitter.addListener<PlaybackStatePayload>(
"onPlaybackStateChanged",
listener
@@ -34,7 +31,7 @@ export function addPlaybackStateListener(
export function addVideoLoadStartListener(
listener: (event: VideoLoadStartPayload) => void
): Subscription {
): EventSubscription {
return emitter.addListener<VideoLoadStartPayload>(
"onVideoLoadStart",
listener
@@ -43,7 +40,7 @@ export function addVideoLoadStartListener(
export function addVideoStateChangeListener(
listener: (event: VideoStateChangePayload) => void
): Subscription {
): EventSubscription {
return emitter.addListener<VideoStateChangePayload>(
"onVideoStateChange",
listener
@@ -52,7 +49,7 @@ export function addVideoStateChangeListener(
export function addVideoProgressListener(
listener: (event: VideoProgressPayload) => void
): Subscription {
): EventSubscription {
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
}

View File

@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'VlcPlayer'
s.version = '1.0.0'
s.version = '4.0.0a10'
s.summary = 'A sample project summary'
s.description = 'A sample project description'
s.author = ''
@@ -10,8 +10,8 @@ Pod::Spec.new do |s|
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.ios.dependency 'MobileVLCKit', '~> 3.6.1b1'
s.tvos.dependency 'TVVLCKit', '~> 3.6.1b1'
s.ios.dependency 'VLCKit', s.version
s.tvos.dependency 'VLCKit', s.version
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {

View File

@@ -16,27 +16,20 @@ public class VlcPlayerModule: Module {
}
}
// Prop("muted") { (view: VlcPlayerView, muted: Bool) in
// view.setMuted(muted)
// }
// Prop("volume") { (view: VlcPlayerView, volume: Int) in
// view.setVolume(volume)
// }
// Prop("videoAspectRatio") { (view: VlcPlayerView, ratio: String) in
// view.setVideoAspectRatio(ratio)
// }
Events(
"onPlaybackStateChanged",
"onVideoStateChange",
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError"
"onVideoError",
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
view.startPictureInPicture()
}
AsyncFunction("play") { (view: VlcPlayerView) in
view.play()
}
@@ -69,14 +62,6 @@ public class VlcPlayerModule: Module {
return view.getSubtitleTracks()
}
// AsyncFunction("setVideoCropGeometry") { (view: VlcPlayerView, geometry: String?) in
// view.setVideoCropGeometry(geometry)
// }
// AsyncFunction("getVideoCropGeometry") { (view: VlcPlayerView) -> String? in
// return view.getVideoCropGeometry()
// }
AsyncFunction("setSubtitleURL") {
(view: VlcPlayerView, url: String, name: String) in
view.setSubtitleURL(url, name: name)

View File

@@ -1,54 +1,168 @@
import ExpoModulesCore
#if os(tvOS)
import TVVLCKit
#else
import MobileVLCKit
#endif
import VLCKit
import UIKit
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: (() -> ())?
private var updateVideoProgress: (() -> ())?
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: (() -> ())?,
updateVideoProgress: (() -> ())?
) {
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 () -> ()) {
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) {
let timeNow = Date().timeIntervalSince1970
if timeNow - lastProgressCall >= 1 {
lastProgressCall = timeNow
updateVideoProgress?()
}
}
func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) {
self.updatePlayerState?()
guard let pipController = self.pipController else { return }
DispatchQueue.main.async(execute: {
pipController.invalidatePlaybackState()
})
}
}
// MARK: - VLCMediaDelegate
extension VLCPlayerWrapper: VLCMediaDelegate {
// Implement VLCMediaDelegate methods if needed
}
class VlcPlayerView: ExpoView {
private var mediaPlayer: VLCMediaPlayer?
private var videoView: UIView?
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
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 isMediaReady: Bool = false
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
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
setupVLC()
setupNotifications()
}
// 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 setupVLC() {
vlc.setup(
parent: self,
updatePlayerState: updatePlayerState,
updateVideoProgress: updateVideoProgress
)
}
private func setupNotifications() {
@@ -61,37 +175,44 @@ class VlcPlayerView: ExpoView {
}
// MARK: - Public Methods
func startPictureInPicture() {
self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in
self.onPipStarted?(["pipStarted": isStarted])
}
self.vlc.pipController?.startPictureInPicture()
}
@objc func play() {
self.mediaPlayer?.play()
self.vlc.player.play()
self.isPaused = false
print("Play")
}
@objc func pause() {
self.mediaPlayer?.pause()
self.vlc.player.pause()
self.isPaused = true
}
@objc func seekTo(_ time: Int32) {
guard let player = self.mediaPlayer else { return }
let wasPlaying = player.isPlaying
let wasPlaying = vlc.player.isPlaying
if wasPlaying {
self.pause()
}
if let duration = player.media?.length.intValue {
if let duration = vlc.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 {
self.play()
}
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 {
print("Error: Unable to retrieve video duration")
}
@@ -104,11 +225,15 @@ class VlcPlayerView: ExpoView {
return
}
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
self.externalTrack = source["externalTrack"] as? [String: String]
var initOptions = source["initOptions"] as? [Any] ?? []
let initOptions: [String] = source["initOptions"] as? [String] ?? []
self.startPosition = source["startPosition"] as? Int32 ?? 0
initOptions.append("--start-time=\(self.startPosition)")
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 {
print("Error: Invalid or empty URI")
@@ -120,12 +245,8 @@ class VlcPlayerView: ExpoView {
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
let media: VLCMedia
let media: VLCMedia!
if isNetwork {
print("Loading network file: \(uri)")
media = VLCMedia(url: URL(string: uri)!)
@@ -141,38 +262,33 @@ class VlcPlayerView: ExpoView {
print("Debug: Media options: \(mediaOptions)")
media.addOptions(mediaOptions)
self.mediaPlayer?.media = media
self.vlc.player.media = media
self.hasSource = true
if autoplay {
print("Playing...")
self.play()
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
}
}
}
@objc func setAudioTrack(_ trackIndex: Int) {
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
let track = self.vlc.player.audioTracks[trackIndex]
track.isSelectedExclusively = true;
}
@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]
return vlc.player.audioTracks.enumerated().map {
return ["name": $1.trackName, "index": $0 ]
}
}
@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)"
)
let track = self.vlc.player.textTracks[trackIndex]
track.isSelectedExclusively = true;
print("Debug: Current subtitle track index after setting: \(track.trackName)")
}
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
@@ -180,9 +296,9 @@ class VlcPlayerView: ExpoView {
print("Error: Invalid subtitle URL")
return
}
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: true)
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
if let result = result {
if result > 0 {
let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name))
@@ -192,51 +308,34 @@ class VlcPlayerView: ExpoView {
}
@objc func getSubtitleTracks() -> [[String: Any]]? {
guard let mediaPlayer = self.mediaPlayer else {
if self.vlc.player.textTracks.count == 0 {
return nil
}
let count = mediaPlayer.numberOfSubtitlesTracks
print("Debug: Number of subtitle tracks: \(count)")
print("Debug: Number of subtitle tracks: \(self.vlc.player.textTracks.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])
}
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 ]
}
}
print("Debug: Subtitle tracks: \(tracks)")
return tracks
print("Debug: Subtitle tracks: \(tracks)")
return tracks
}
private func setSubtitleTrackByName(_ trackName: String) {
guard let mediaPlayer = self.mediaPlayer else { return }
// Get the subtitle tracks and their indexes
if let names = mediaPlayer.videoSubTitlesNames as? [String],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
{
for (index, name) in zip(indexes, names) {
if name.starts(with: trackName) {
let trackIndex = index.intValue
print("Track Index setting to: \(trackIndex)")
setSubtitleTrack(trackIndex)
return
}
for track in self.vlc.player.textTracks {
if (track.trackName.starts(with: trackName)) {
print("Track Index setting to: \(track.trackName)")
track.isSelectedExclusively = true
return
}
}
print("Track not found for name: \(trackName)")
}
@@ -269,32 +368,27 @@ class VlcPlayerView: ExpoView {
private func performStop(completion: (() -> Void)? = nil) {
// Stop the media player
mediaPlayer?.stop()
vlc.player.stop()
// Remove observer
NotificationCenter.default.removeObserver(self)
// Clear the video view
videoView?.removeFromSuperview()
videoView = nil
// Release the media player
mediaPlayer?.delegate = nil
mediaPlayer = nil
vlc.getPlayerView().removeFromSuperview()
isStopping = false
completion?()
}
private func updateVideoProgress() {
guard let player = self.mediaPlayer else { return }
guard let media = self.vlc.player.media else { return }
let currentTimeMs = player.time.intValue
let durationMs = player.media?.length.intValue ?? 0
let currentTimeMs = self.vlc.player.time.intValue
let durationMs = self.vlc.player.media?.length.intValue ?? 0
print("Debug: Current time: \(currentTimeMs)")
if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady {
if !self.isMediaReady {
self.isMediaReady = true
// Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
@@ -304,21 +398,34 @@ class VlcPlayerView: ExpoView {
}
}
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
private func updatePlayerState() {
let player = self.vlc.player
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
@@ -327,67 +434,6 @@ class VlcPlayerView: ExpoView {
}
}
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 {
@@ -396,9 +442,7 @@ extension VLCMediaPlayerState {
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

@@ -24,6 +24,12 @@ export type VideoLoadStartPayload = {
};
};
export type PipStartedPayload = {
nativeEvent: {
pipStarted: boolean;
};
};
export type VideoStateChangePayload = PlaybackStatePayload;
export type VideoProgressPayload = ProgressUpdatePayload;
@@ -64,9 +70,11 @@ export type VlcPlayerViewProps = {
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>;

View File

@@ -23,6 +23,9 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
const nativeRef = React.useRef<NativeViewRef>(null);
React.useImperativeHandle(ref, () => ({
startPictureInPicture: async () => {
await nativeRef.current?.startPictureInPicture()
},
play: async () => {
await nativeRef.current?.play();
},
@@ -96,6 +99,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoProgress,
onVideoLoadEnd,
onVideoError,
onPipStarted,
...otherProps
} = props;
@@ -122,6 +126,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoStateChange={onVideoStateChange}
onVideoProgress={onVideoProgress}
onVideoError={onVideoError}
onPipStarted={onPipStarted}
/>
);
}

View File

@@ -0,0 +1,38 @@
const { withAndroidManifest: NativeAndroidManifest } = require("@expo/config-plugins");
const withAndroidManifest = (config) =>
NativeAndroidManifest(config, async (config) => {
const mainApplication = config.modResults.manifest.application[0];
// Initialize activity array if it doesn't exist
if (!mainApplication.activity) {
mainApplication.activity = [];
}
const googleCastActivityExists = mainApplication.activity.some(activity =>
activity.$?.["android:name"] === "com.reactnative.googlecast.RNGCExpandedControllerActivity"
);
// Only add the activity if it doesn't already exist
if (!googleCastActivityExists) {
mainApplication.activity.push({
$: {
"android:name": "com.reactnative.googlecast.RNGCExpandedControllerActivity",
"android:theme": "@style/Theme.MaterialComponents.NoActionBar",
"android:launchMode": "singleTask",
},
});
}
const mainActivity = mainApplication.activity.find(activity =>
activity.$?.["android:name"] === ".MainActivity"
);
if (mainActivity) {
mainActivity.$["android:supportsPictureInPicture"] = "true"
}
return config;
});
module.exports = withAndroidManifest;

View File

@@ -1,34 +0,0 @@
const { withAndroidManifest } = require("@expo/config-plugins");
const withGoogleCastActivity = (config) =>
withAndroidManifest(config, async (config) => {
const mainApplication = config.modResults.manifest.application[0];
// Initialize activity array if it doesn't exist
if (!mainApplication.activity) {
mainApplication.activity = [];
}
// Check if the activity already exists
const activityExists = mainApplication.activity.some(
(activity) =>
activity.$?.["android:name"] ===
"com.reactnative.googlecast.RNGCExpandedControllerActivity"
);
// Only add the activity if it doesn't already exist
if (!activityExists) {
mainApplication.activity.push({
$: {
"android:name":
"com.reactnative.googlecast.RNGCExpandedControllerActivity",
"android:theme": "@style/Theme.MaterialComponents.NoActionBar",
"android:launchMode": "singleTask",
},
});
}
return config;
});
module.exports = withGoogleCastActivity;

View File

@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.25.0" },
clientInfo: { name: "Streamyfin", version: "0.26.0" },
deviceInfo: {
name: deviceName,
id,
@@ -109,7 +109,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.25.0"`,
}, DeviceId="${deviceId}", Version="0.26.0"`,
};
}, [deviceId]);

View File

@@ -11,6 +11,7 @@ import {
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
import { Bitrate, BITRATES } from "@/components/BitrateSelector";
import { apiAtom } from "@/providers/JellyfinProvider";
import { writeInfoLog } from "@/utils/log";
@@ -122,6 +123,7 @@ export type Settings = {
marlinServerUrl?: string;
openInVLC?: boolean;
downloadQuality?: DownloadOption;
defaultBitrate?: Bitrate;
libraryOptions: LibraryOptions;
defaultAudioLanguage: CultureDto | null;
playDefaultAudioTrack: boolean;
@@ -168,6 +170,7 @@ const defaultValues: Settings = {
marlinServerUrl: "",
openInVLC: false,
downloadQuality: DownloadOptions[0],
defaultBitrate: BITRATES[0],
libraryOptions: {
display: "list",
cardStyle: "detailed",

View File

@@ -92,10 +92,8 @@ export function getDefaultPlaySettings(
}
}
// 4. Get default bitrate
const bitrate = BITRATES.sort(
(a, b) => (b.value || Infinity) - (a.value || Infinity)
)[0];
// 4. Get default bitrate from settings or fallback to max
const bitrate = settings.defaultBitrate ?? BITRATES[0];
return {
item,