mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
1 Commits
v0.4.1
...
fix/no-chr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86dfcc6b7f |
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,26 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone15Pro]
|
||||
- OS: [e.g. iOS18]
|
||||
- Version [e.g. 0.3.1]
|
||||
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -26,7 +26,3 @@ Streamyfin.app
|
||||
/android
|
||||
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
development.apk
|
||||
Streamyfin.apk
|
||||
Streamyfin.ipa
|
||||
|
||||
34
README.md
34
README.md
@@ -12,40 +12,23 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
|
||||
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
|
||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||
- 🔗 Connect to your Jellyfin instance: Easily link your Jellyfin server and access your media library.
|
||||
- 📱 Native video player: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
|
||||
- 📥 Download media (Experimental): Save your media locally and watch it offline.
|
||||
- 📡 Chromecast media (Experimental): Cast your media to any Chromecast-enabled device.
|
||||
|
||||
## 🧪 Experimental Features
|
||||
|
||||
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them.
|
||||
|
||||
### Downloading
|
||||
## 🛠️ TestFlight (pending review)
|
||||
|
||||
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
|
||||
## Get it now
|
||||
|
||||
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB">
|
||||
<img height=75 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/>
|
||||
</a>
|
||||
|
||||
### TestFlight
|
||||
|
||||
Get the latest updates by using the TestFlight version of the app.
|
||||
Soon iOS users can test Streamyfin in beta via TestFlight. To join the beta program, click the link below.
|
||||
|
||||
<a href="https://testflight.apple.com/join/CWBaAAK2">
|
||||
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
|
||||
</a>
|
||||
|
||||
### Play Store Open Beta
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin">
|
||||
<img height=75 alt="Get the beta on Google Play" src="./assets/en_badge_web_generic.png"/>
|
||||
</a>
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
@@ -104,11 +87,6 @@ If you have questions or need support, feel free to reach out:
|
||||
|
||||
- GitHub Issues: Report bugs or request features here.
|
||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||
-
|
||||
|
||||
## Support
|
||||
|
||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
|
||||
## 📝 Credits
|
||||
|
||||
|
||||
87
app.json
87
app.json
@@ -2,52 +2,35 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.4.1",
|
||||
"orientation": "default",
|
||||
"version": "0.0.6",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
"userInterfaceStyle": "dark",
|
||||
"splash": {
|
||||
"image": "./assets/images/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#29164B"
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"jsEngine": "hermes",
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"requireFullScreen": true,
|
||||
"userInterfaceStyle": "dark",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||
"UIBackgroundModes": [
|
||||
"audio"
|
||||
],
|
||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true,
|
||||
"NSExceptionDomains": {
|
||||
"*": {
|
||||
"NSExceptionAllowsInsecureHTTPLoads": true
|
||||
}
|
||||
}
|
||||
}
|
||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone."
|
||||
},
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 14,
|
||||
"jsEngine": "jsc",
|
||||
"userInterfaceStyle": "light",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png"
|
||||
"foregroundImage": "./assets/images/icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin",
|
||||
"permissions": [
|
||||
"android.permission.FOREGROUND_SERVICE",
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
||||
]
|
||||
"package": "com.fredrikburmester.streamyfin"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
@@ -59,34 +42,35 @@
|
||||
"expo-font",
|
||||
"react-native-compressor",
|
||||
"@config-plugins/ffmpeg-kit-react-native",
|
||||
[
|
||||
"react-native-google-cast",
|
||||
{
|
||||
"useDefaultExpandedMediaControls": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
"enableNotificationControls": true,
|
||||
"enableBackgroundAudio": true,
|
||||
"androidExtensions": {
|
||||
"useExoplayerRtsp": false,
|
||||
"useExoplayerSmoothStreaming": false,
|
||||
"useExoplayerHls": true,
|
||||
"useExoplayerHls": false,
|
||||
"useExoplayerDash": false
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-vlc-media-player",
|
||||
{
|
||||
"ios": {
|
||||
"includeVLCKit": false
|
||||
},
|
||||
"android": {
|
||||
"legacyJetifier": false
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"deploymentTarget": "14.0"
|
||||
},
|
||||
"ios": { "deploymentTarget": "14.0" },
|
||||
"android": {
|
||||
"minSdkVersion": 24,
|
||||
"usesCleartextTraffic": true,
|
||||
"packagingOptions": {
|
||||
"jniLibs": {
|
||||
"useLegacyPackaging": true
|
||||
@@ -94,20 +78,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-screen-orientation",
|
||||
{
|
||||
"initialOrientation": "DEFAULT"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-sensors",
|
||||
{
|
||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
||||
}
|
||||
],
|
||||
"expo-video"
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
@@ -120,12 +91,6 @@
|
||||
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||
}
|
||||
},
|
||||
"owner": "fredrikburmester",
|
||||
"runtimeVersion": {
|
||||
"policy": "appVersion"
|
||||
},
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||
}
|
||||
"owner": "fredrikburmester"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { router, Tabs } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import React from "react";
|
||||
|
||||
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { TouchableOpacity } from "react-native";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
|
||||
export default function TabLayout() {
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android") {
|
||||
NavigationBar.setBackgroundColorAsync("#121212");
|
||||
NavigationBar.setBorderColorAsync("#121212");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
|
||||
@@ -241,7 +241,7 @@ export default function index() {
|
||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4 pb-24 gap-y-4">
|
||||
<View className="flex flex-col py-4 gap-y-4">
|
||||
<View>
|
||||
<Text className="px-4 text-2xl font-bold mb-2">
|
||||
Continue Watching
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function search() {
|
||||
|
||||
return (
|
||||
<ScrollView keyboardDismissMode="on-drag">
|
||||
<View className="flex flex-col pt-2 pb-20">
|
||||
<View className="flex flex-col py-2">
|
||||
<View className="mb-4 px-4">
|
||||
<Input
|
||||
autoCorrect={false}
|
||||
|
||||
@@ -3,15 +3,12 @@ import { Loading } from "@/components/Loading";
|
||||
import MoviePoster from "@/components/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BaseItemDto,
|
||||
ItemSortBy,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
@@ -26,21 +23,16 @@ const page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("CollectionId", collectionId);
|
||||
}, [collectionId]);
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
ids: [collectionId],
|
||||
});
|
||||
const data = response.data.Items?.[0];
|
||||
return data;
|
||||
},
|
||||
queryFn: async () =>
|
||||
(api &&
|
||||
(
|
||||
await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
})
|
||||
).data.Items?.find((item) => item.Id == collectionId)) ||
|
||||
null,
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
@@ -53,84 +45,40 @@ const page: React.FC = () => {
|
||||
}>({
|
||||
queryKey: ["collection-items", collectionId, startIndex],
|
||||
queryFn: async () => {
|
||||
if (!api || !collectionId)
|
||||
return {
|
||||
Items: [],
|
||||
TotalRecordCount: 0,
|
||||
};
|
||||
if (!api) return [];
|
||||
|
||||
const sortBy: ItemSortBy[] = [];
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/Users/${user?.Id}/Items`,
|
||||
{
|
||||
params: {
|
||||
SortBy:
|
||||
collection?.CollectionType === "movies"
|
||||
? "SortName,ProductionYear"
|
||||
: "SortName",
|
||||
SortOrder: "Ascending",
|
||||
IncludeItemTypes:
|
||||
collection?.CollectionType === "movies" ? "Movie" : "Series",
|
||||
Recursive: true,
|
||||
Fields:
|
||||
collection?.CollectionType === "movies"
|
||||
? "PrimaryImageAspectRatio,MediaSourceCount"
|
||||
: "PrimaryImageAspectRatio",
|
||||
ImageTypeLimit: 1,
|
||||
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
|
||||
ParentId: collectionId,
|
||||
Limit: 100,
|
||||
StartIndex: startIndex,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
switch (collection?.CollectionType) {
|
||||
case "movies":
|
||||
sortBy.push("SortName", "ProductionYear");
|
||||
break;
|
||||
case "boxsets":
|
||||
sortBy.push("IsFolder", "SortName");
|
||||
break;
|
||||
default:
|
||||
sortBy.push("SortName");
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
limit: 100,
|
||||
startIndex,
|
||||
sortBy,
|
||||
sortOrder: ["Ascending"],
|
||||
});
|
||||
|
||||
const data = response.data.Items;
|
||||
|
||||
return {
|
||||
Items: data || [],
|
||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
||||
};
|
||||
return response.data || [];
|
||||
},
|
||||
enabled: !!collectionId && !!api,
|
||||
enabled: !!collection && !!api,
|
||||
});
|
||||
// const { data, isLoading, isError } = useQuery<{
|
||||
// Items: BaseItemDto[];
|
||||
// TotalRecordCount: number;
|
||||
// }>({
|
||||
// queryKey: ["collection-items", collectionId, startIndex],
|
||||
// queryFn: async () => {
|
||||
// if (!api) return [];
|
||||
|
||||
// const response = await api.axiosInstance.get(
|
||||
// `${api.basePath}/Users/${user?.Id}/Items`,
|
||||
// {
|
||||
// params: {
|
||||
// SortBy:
|
||||
// collection?.CollectionType === "movies"
|
||||
// ? "SortName,ProductionYear"
|
||||
// : "SortName",
|
||||
// SortOrder: "Ascending",
|
||||
// IncludeItemTypes:
|
||||
// collection?.CollectionType === "movies" ? "Movie" : "Series",
|
||||
// Recursive: true,
|
||||
// Fields:
|
||||
// collection?.CollectionType === "movies"
|
||||
// ? "PrimaryImageAspectRatio,MediaSourceCount"
|
||||
// : "PrimaryImageAspectRatio",
|
||||
// ImageTypeLimit: 1,
|
||||
// EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
|
||||
// ParentId: collectionId,
|
||||
// Limit: 100,
|
||||
// StartIndex: startIndex,
|
||||
// },
|
||||
// headers: {
|
||||
// Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
// },
|
||||
// },
|
||||
// );
|
||||
|
||||
// return response.data || [];
|
||||
// },
|
||||
// enabled: !!collection && !!api,
|
||||
// });
|
||||
|
||||
const totalItems = useMemo(() => {
|
||||
return data?.TotalRecordCount;
|
||||
@@ -143,8 +91,7 @@ const page: React.FC = () => {
|
||||
<Text className="font-bold text-3xl mb-2">{collection?.Name}</Text>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "}
|
||||
{totalItems}
|
||||
{startIndex + 1}-{startIndex + 100} of {totalItems}
|
||||
</Text>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<TouchableOpacity
|
||||
@@ -178,7 +125,7 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex flex-row flex-wrap">
|
||||
{data?.Items?.map((item: BaseItemDto, index: number) => (
|
||||
{data?.Items?.map((item: any, index: number) => (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
maxWidth: "33%",
|
||||
@@ -187,12 +134,10 @@ const page: React.FC = () => {
|
||||
}}
|
||||
key={index}
|
||||
onPress={() => {
|
||||
if (item?.Type === "Series") {
|
||||
router.push(`/series/${item.Id}/page`);
|
||||
} else if (item.IsFolder) {
|
||||
router.push(`/collections/${item?.Id}/page`);
|
||||
} else {
|
||||
if (collection?.CollectionType === "movies") {
|
||||
router.push(`/items/${item.Id}/page`);
|
||||
} else if (collection?.CollectionType === "tvshows") {
|
||||
router.push(`/series/${item.Id}/page`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -16,18 +16,14 @@ import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { router } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
|
||||
const downloads: React.FC = () => {
|
||||
const [process, setProcess] = useAtom(runningProcesses);
|
||||
|
||||
const { data: downloadedFiles, isLoading } = useQuery({
|
||||
queryKey: ["downloaded_files", process?.item.Id],
|
||||
queryKey: ["downloaded_files"],
|
||||
queryFn: async () =>
|
||||
JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
||||
) as BaseItemDto[],
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const movies = useMemo(
|
||||
@@ -45,6 +41,8 @@ const downloads: React.FC = () => {
|
||||
return Object.values(series);
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const [process, setProcess] = useAtom(runningProcesses);
|
||||
|
||||
const eta = useMemo(() => {
|
||||
const length = process?.item?.RunTimeTicks || 0;
|
||||
|
||||
@@ -79,7 +77,7 @@ const downloads: React.FC = () => {
|
||||
<View>
|
||||
<Text className="font-semibold">{process.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{process.item.Type}</Text>
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-red-600">
|
||||
<Text className="text-xs">
|
||||
{process.progress.toFixed(0)}%
|
||||
</Text>
|
||||
@@ -99,7 +97,7 @@ const downloads: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
<View
|
||||
className={`
|
||||
absolute bottom-0 left-0 h-1 bg-purple-600
|
||||
absolute bottom-0 left-0 h-1 bg-red-600
|
||||
`}
|
||||
style={{
|
||||
width: process.progress
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
@@ -22,43 +22,16 @@ import { ParallaxScrollView } from "../../../../components/ParallaxPage";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios12 from "@/utils/profiles/ios12";
|
||||
import { currentlyPlayingItemAtom } from "@/components/CurrentlyPlayingBar";
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
const { id } = local as { id: string };
|
||||
|
||||
const [playbackURL, setPlaybackURL] = useState<string | null>(null);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
|
||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const { data: item, isLoading: l1 } = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () =>
|
||||
@@ -87,85 +60,6 @@ const page: React.FC = () => {
|
||||
[item],
|
||||
);
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
queryKey: ["sessionData", item?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item?.Id) return null;
|
||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return playbackData.data;
|
||||
},
|
||||
enabled: !!item?.Id && !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: playbackUrl } = useQuery({
|
||||
queryKey: [
|
||||
"playbackUrl",
|
||||
item?.Id,
|
||||
maxBitrate,
|
||||
castDevice,
|
||||
selectedAudioStream,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !sessionData) return null;
|
||||
|
||||
const url = await getStreamUrl({
|
||||
api,
|
||||
userId: user.Id,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
sessionData,
|
||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
|
||||
audioStreamIndex: selectedAudioStream,
|
||||
subtitleStreamIndex: selectedSubtitleStream,
|
||||
});
|
||||
|
||||
console.log("Transcode URL: ", url);
|
||||
|
||||
return url;
|
||||
},
|
||||
enabled: !!sessionData,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
const client = useRemoteMediaClient();
|
||||
|
||||
const onPressPlay = useCallback(async () => {
|
||||
if (!playbackUrl || !item) return;
|
||||
|
||||
if (chromecastReady && client) {
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: playbackUrl,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCp({
|
||||
item,
|
||||
playbackUrl,
|
||||
});
|
||||
}
|
||||
}, [playbackUrl, item]);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
@@ -205,7 +99,7 @@ const page: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col px-4 pt-4">
|
||||
<View className="flex flex-col px-4 mb-4 pt-4">
|
||||
<View className="flex flex-col">
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
@@ -222,6 +116,7 @@ const page: React.FC = () => {
|
||||
<Text className="text-center font-bold text-2xl mr-2">
|
||||
{item?.Name}
|
||||
</Text>
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
<View>
|
||||
<View className="flex flex-row items-center self-center">
|
||||
@@ -246,6 +141,7 @@ const page: React.FC = () => {
|
||||
<Text className="text-center font-bold text-2xl mr-2">
|
||||
{item?.Name}
|
||||
</Text>
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
<Text className="text-center opacity-50">
|
||||
{item?.ProductionYear}
|
||||
@@ -255,43 +151,20 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
||||
{playbackUrl ? (
|
||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
||||
) : (
|
||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
||||
{playbackURL && (
|
||||
<DownloadItem item={item} playbackURL={playbackURL} />
|
||||
)}
|
||||
<PlayedStatus item={item} />
|
||||
<Chromecast />
|
||||
// <Chromecast />
|
||||
</View>
|
||||
<Text>{item.Overview}</Text>
|
||||
</View>
|
||||
<View className="flex flex-col p-4 w-full">
|
||||
<View className="flex flex-row items-center space-x-2 w-full">
|
||||
<BitrateSelector
|
||||
onChange={(val) => setMaxBitrate(val)}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
<AudioTrackSelector
|
||||
item={item}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
item={item}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex flex-row items-center justify-between w-full">
|
||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||
<PlayButton
|
||||
item={item}
|
||||
chromecastReady={false}
|
||||
onPress={onPressPlay}
|
||||
className="grow"
|
||||
/>
|
||||
<NextEpisodeButton item={item} className="ml-2" />
|
||||
</View>
|
||||
<View className="flex flex-col p-4">
|
||||
<VideoPlayer
|
||||
itemId={item.Id}
|
||||
onChangePlaybackURL={(val) => {
|
||||
setPlaybackURL(val);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<ScrollView horizontal className="flex px-4 mb-4">
|
||||
<View className="flex flex-row space-x-2 ">
|
||||
|
||||
@@ -10,21 +10,12 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const params = useLocalSearchParams();
|
||||
const { id: seriesId, seasonIndex } = params as {
|
||||
id: string;
|
||||
seasonIndex: string;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (seriesId) {
|
||||
console.log("seasonIndex", seasonIndex);
|
||||
}
|
||||
}, [seriesId]);
|
||||
const { id: seriesId } = params as { id: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -38,7 +29,7 @@ const page: React.FC = () => {
|
||||
itemId: seriesId,
|
||||
}),
|
||||
enabled: !!seriesId && !!api,
|
||||
staleTime: 60,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
@@ -93,7 +84,7 @@ const page: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4 pb-24">
|
||||
<View className="flex flex-col pt-4 pb-12">
|
||||
<View className="px-4 py-4">
|
||||
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
||||
<Text className="">{item?.Overview}</Text>
|
||||
|
||||
@@ -64,17 +64,19 @@ export default function settings() {
|
||||
<Text className="font-bold text-2xl">Logs</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
{logs?.map((log, index) => (
|
||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||
<View
|
||||
key={index}
|
||||
className="bg-neutral-800 border border-neutral-900 rounded p-2"
|
||||
>
|
||||
<Text
|
||||
className={`
|
||||
mb-1
|
||||
${log.level === "INFO" && "text-blue-500"}
|
||||
${log.level === "ERROR" && "text-red-500"}
|
||||
`}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
<Text className="text-xs">{log.message}</Text>
|
||||
<Text>{log.message}</Text>
|
||||
</View>
|
||||
))}
|
||||
{logs?.length === 0 && (
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useFonts } from "expo-font";
|
||||
import { Stack } from "expo-router";
|
||||
import { router, Stack } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import "react-native-reanimated";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||
import { TouchableOpacity } from "react-native";
|
||||
|
||||
import Feather from "@expo/vector-icons/Feather";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@@ -44,30 +45,6 @@ export default function RootLayout() {
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((info) => {
|
||||
setOrientation(info);
|
||||
});
|
||||
|
||||
// subscribe to future changes
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(evt) => {
|
||||
setOrientation(evt.orientationInfo.orientation);
|
||||
},
|
||||
);
|
||||
|
||||
// return a clean up function to unsubscribe from notifications
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
@@ -76,7 +53,7 @@ export default function RootLayout() {
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<JotaiProvider>
|
||||
<JellyfinProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<StatusBar style="auto" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
@@ -91,8 +68,12 @@ export default function RootLayout() {
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Settings",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
presentation: "modal",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => router.back()}>
|
||||
<Feather name="x-circle" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -100,8 +81,14 @@ export default function RootLayout() {
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Downloads",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/player/offline/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -116,7 +103,7 @@ export default function RootLayout() {
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerStyle: { backgroundColor: "transparent" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
@@ -133,7 +120,6 @@ export default function RootLayout() {
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<CurrentlyPlayingBar />
|
||||
</ThemeProvider>
|
||||
</JellyfinProvider>
|
||||
</JotaiProvider>
|
||||
|
||||
158
app/login.tsx
158
app/login.tsx
@@ -1,158 +0,0 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { AxiosError } from "axios";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { KeyboardAvoidingView, Platform, View } from "react-native";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const CredentialsSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
});
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const { setServer, login, removeServer } = useJellyfin();
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const [serverURL, setServerURL] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [credentials, setCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
}>({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
if (result.success) {
|
||||
await login(credentials.username, credentials.password);
|
||||
}
|
||||
} catch (error) {
|
||||
const e = error as AxiosError | z.ZodError;
|
||||
if (e instanceof z.ZodError) {
|
||||
setError("An error occured.");
|
||||
} else {
|
||||
if (e.response?.status === 401) {
|
||||
setError("Invalid credentials.");
|
||||
} else {
|
||||
setError(
|
||||
"A network error occurred. Did you enter the correct server URL?",
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = (url: string) => {
|
||||
setServer({ address: url.trim() });
|
||||
};
|
||||
|
||||
if (api?.basePath) {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View className="flex flex-col px-4 justify-center h-full gap-y-2">
|
||||
<View>
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="opacity-50 mb-2">Server: {api.basePath}</Text>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
setServerURL("");
|
||||
}}
|
||||
justify="between"
|
||||
iconLeft={
|
||||
<Ionicons name="arrow-back-outline" size={18} color={"white"} />
|
||||
}
|
||||
>
|
||||
Change server
|
||||
</Button>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold">Log in</Text>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
autoFocus
|
||||
secureTextEntry={false}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="username"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="Password"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className="text-red-600 mb-2">{error}</Text>
|
||||
|
||||
<Button onPress={handleLogin} loading={loading}>
|
||||
Log in
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View className="flex flex-col px-4 justify-center h-full">
|
||||
<View className="flex flex-col gap-y-2">
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="opacity-50">Enter a server adress</Text>
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="http(s)://..."
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Button onPress={() => handleConnect(serverURL)}>Connect</Button>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB |
@@ -1,80 +0,0 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { tc } from "@/utils/textTools";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
onChange: (value: number) => void;
|
||||
selected: number;
|
||||
}
|
||||
|
||||
export const AudioTrackSelector: React.FC<Props> = ({
|
||||
item,
|
||||
onChange,
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const audioStreams = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[item],
|
||||
);
|
||||
|
||||
const selectedAudioSteam = useMemo(
|
||||
() => audioStreams?.find((x) => x.Index === selected),
|
||||
[audioStreams, selected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const index = item.MediaSources?.[0].DefaultAudioStreamIndex;
|
||||
if (index !== undefined && index !== null) onChange(index);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="">
|
||||
{tc(selectedAudioSteam?.DisplayTitle, 13)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
|
||||
{audioStreams?.map((audio, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
if (audio.Index !== null && audio.Index !== undefined)
|
||||
onChange(audio.Index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{audio.DisplayTitle}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { atom, useAtom } from "jotai";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
value: number | undefined;
|
||||
};
|
||||
|
||||
const BITRATES: Bitrate[] = [
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
value: 4000000,
|
||||
},
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
},
|
||||
];
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
onChange: (value: Bitrate) => void;
|
||||
selected: Bitrate;
|
||||
}
|
||||
|
||||
export const BitrateSelector: React.FC<Props> = ({
|
||||
onChange,
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
||||
{BITRATES?.map((b, index: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={index.toString()}
|
||||
onSelect={() => {
|
||||
onChange(b);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,7 @@ interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
children?: string | ReactNode;
|
||||
children?: string;
|
||||
loading?: boolean;
|
||||
color?: "purple" | "red" | "black";
|
||||
iconRight?: ReactNode;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
CastButton,
|
||||
useCastDevice,
|
||||
useDevices,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import GoogleCast from "react-native-google-cast";
|
||||
// import React, { useEffect } from "react";
|
||||
// import {
|
||||
// CastButton,
|
||||
// useCastDevice,
|
||||
// useDevices,
|
||||
// useRemoteMediaClient,
|
||||
// } from "react-native-google-cast";
|
||||
// import GoogleCast from "react-native-google-cast";
|
||||
|
||||
type Props = {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -15,25 +14,22 @@ type Props = {
|
||||
};
|
||||
|
||||
export const Chromecast: React.FC<Props> = () => {
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const devices = useDevices();
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||
// const client = useRemoteMediaClient();
|
||||
// const castDevice = useCastDevice();
|
||||
// const devices = useDevices();
|
||||
// const sessionManager = GoogleCast.getSessionManager();
|
||||
// const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!discoveryManager) {
|
||||
return;
|
||||
}
|
||||
// useEffect(() => {
|
||||
// (async () => {
|
||||
// if (!discoveryManager) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
await discoveryManager.startDiscovery();
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
// await discoveryManager.startDiscovery();
|
||||
// })();
|
||||
// }, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
|
||||
return (
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
<CastButton style={{ tintColor: "white", height: 48, width: 48 }} />
|
||||
</View>
|
||||
);
|
||||
// return <CastButton style={{ tintColor: "white", height: 48, width: 48 }} />;
|
||||
return <></>;
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
className={`absolute bottom-0 left-0 h-1 bg-purple-600 w-full`}
|
||||
className={`absolute bottom-0 left-0 h-1 bg-red-600 w-full`}
|
||||
></View>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
||||
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
|
||||
export const currentlyPlayingItemAtom = atom<{
|
||||
item: BaseItemDto;
|
||||
playbackUrl: string;
|
||||
} | null>(null);
|
||||
|
||||
export const CurrentlyPlayingBar: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const segments = useSegments();
|
||||
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const [paused, setPaused] = useState(true);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const aBottom = useSharedValue(0);
|
||||
const aPadding = useSharedValue(0);
|
||||
const aHeight = useSharedValue(100);
|
||||
const router = useRouter();
|
||||
const animatedOuterStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
bottom: withTiming(aBottom.value, { duration: 500 }),
|
||||
height: withTiming(aHeight.value, { duration: 500 }),
|
||||
padding: withTiming(aPadding.value, { duration: 500 }),
|
||||
};
|
||||
});
|
||||
|
||||
const aPaddingBottom = useSharedValue(30);
|
||||
const aPaddingInner = useSharedValue(12);
|
||||
const aBorderRadiusBottom = useSharedValue(12);
|
||||
const animatedInnerStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
padding: withTiming(aPaddingInner.value, { duration: 500 }),
|
||||
paddingBottom: withTiming(aPaddingBottom.value, { duration: 500 }),
|
||||
borderBottomLeftRadius: withTiming(aBorderRadiusBottom.value, {
|
||||
duration: 500,
|
||||
}),
|
||||
borderBottomRightRadius: withTiming(aBorderRadiusBottom.value, {
|
||||
duration: 500,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (segments.find((s) => s.includes("tabs"))) {
|
||||
// Tab screen - i.e. home
|
||||
aBottom.value = Platform.OS === "ios" ? 78 : 50;
|
||||
aHeight.value = 80;
|
||||
aPadding.value = 8;
|
||||
aPaddingBottom.value = 8;
|
||||
aPaddingInner.value = 8;
|
||||
} else {
|
||||
// Inside a normal screen
|
||||
aBottom.value = Platform.OS === "ios" ? 0 : 0;
|
||||
aHeight.value = Platform.OS === "ios" ? 110 : 80;
|
||||
aPadding.value = Platform.OS === "ios" ? 0 : 8;
|
||||
aPaddingInner.value = Platform.OS === "ios" ? 12 : 8;
|
||||
aPaddingBottom.value = Platform.OS === "ios" ? 40 : 12;
|
||||
}
|
||||
}, [segments]);
|
||||
|
||||
const { data: item } = useQuery({
|
||||
queryKey: ["item", cp?.item.Id],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: cp?.item.Id,
|
||||
}),
|
||||
enabled: !!cp?.item.Id && !!api,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
queryKey: ["sessionData", cp?.item.Id],
|
||||
queryFn: async () => {
|
||||
if (!cp?.item.Id) return null;
|
||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: cp?.item.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
return playbackData.data;
|
||||
},
|
||||
enabled: !!cp?.item.Id && !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const onProgress = useCallback(
|
||||
({ currentTime }: OnProgressData) => {
|
||||
if (!currentTime || !sessionData?.PlaySessionId || paused) return;
|
||||
const newProgress = currentTime * 10000000;
|
||||
setProgress(newProgress);
|
||||
reportPlaybackProgress({
|
||||
api,
|
||||
itemId: cp?.item.Id,
|
||||
positionTicks: newProgress,
|
||||
sessionId: sessionData.PlaySessionId,
|
||||
});
|
||||
},
|
||||
[sessionData?.PlaySessionId, item, api, paused],
|
||||
);
|
||||
|
||||
const play = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.resume();
|
||||
setPaused(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause();
|
||||
setPaused(true);
|
||||
|
||||
if (progress > 0)
|
||||
reportPlaybackStopped({
|
||||
api,
|
||||
itemId: item?.Id,
|
||||
positionTicks: progress,
|
||||
sessionId: sessionData?.PlaySessionId,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp", item?.SeriesId],
|
||||
refetchType: "all",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["episodes"],
|
||||
refetchType: "all",
|
||||
});
|
||||
}, [api, item, progress, sessionData, queryClient]);
|
||||
|
||||
const startPosition = useMemo(
|
||||
() =>
|
||||
item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0,
|
||||
[item],
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
}),
|
||||
[item],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (cp?.playbackUrl) {
|
||||
play();
|
||||
}
|
||||
}, [cp?.playbackUrl]);
|
||||
|
||||
if (!cp) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[animatedOuterStyle]}
|
||||
className="absolute left-0 w-screen"
|
||||
>
|
||||
<BlurView
|
||||
intensity={Platform.OS === "android" ? 60 : 100}
|
||||
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
|
||||
className={`h-full w-full rounded-xl overflow-hidden ${Platform.OS === "android" && "bg-black"}`}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{ padding: 8, borderTopLeftRadius: 12, borderTopEndRadius: 12 },
|
||||
animatedInnerStyle,
|
||||
]}
|
||||
className="h-full w-full flex flex-row items-center justify-between overflow-hidden"
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-4 shrink">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
videoRef.current?.presentFullscreenPlayer();
|
||||
}}
|
||||
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
||||
${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
|
||||
`}
|
||||
>
|
||||
{cp.playbackUrl && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
allowsExternalPlayback
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
playWhenInactive={true}
|
||||
playInBackground={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
controls={false}
|
||||
pictureInPicture={true}
|
||||
poster={
|
||||
backdropUrl && item?.Type === "Audio"
|
||||
? backdropUrl
|
||||
: undefined
|
||||
}
|
||||
paused={paused}
|
||||
onProgress={(e) => onProgress(e)}
|
||||
subtitleStyle={{
|
||||
fontSize: 16,
|
||||
}}
|
||||
source={{
|
||||
uri: cp.playbackUrl,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
}}
|
||||
onBuffer={(e) =>
|
||||
e.isBuffering ? console.log("Buffering...") : null
|
||||
}
|
||||
onPlaybackStateChanged={(e) => {
|
||||
if (e.isPlaying) {
|
||||
setPaused(false);
|
||||
} else if (e.isSeeking) {
|
||||
return;
|
||||
} else {
|
||||
setPaused(true);
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.log(e);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"Video playback error: " + JSON.stringify(e),
|
||||
);
|
||||
}}
|
||||
renderLoader={
|
||||
item?.Type !== "Audio" && (
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View className="shrink text-xs">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/${item?.Id}/page`);
|
||||
}}
|
||||
>
|
||||
<Text>{item?.Name}</Text>
|
||||
</TouchableOpacity>
|
||||
{item?.SeriesName ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/series/${item.SeriesId}/page`);
|
||||
}}
|
||||
className="text-xs opacity-50"
|
||||
>
|
||||
<Text>{item.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View>
|
||||
<Text className="text-xs opacity-50">
|
||||
{item?.ProductionYear}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (paused) play();
|
||||
else pause();
|
||||
}}
|
||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||
>
|
||||
{paused ? (
|
||||
<Ionicons name="play" size={24} color="white" />
|
||||
) : (
|
||||
<Ionicons name="pause" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setCp(null);
|
||||
}}
|
||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
@@ -16,12 +16,12 @@ import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
|
||||
|
||||
type DownloadProps = {
|
||||
item: BaseItemDto;
|
||||
playbackUrl: string;
|
||||
playbackURL: string;
|
||||
};
|
||||
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
item,
|
||||
playbackUrl,
|
||||
playbackURL,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -30,7 +30,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
const { downloadMedia, isDownloading, error, cancelDownload } =
|
||||
useDownloadMedia(api, user?.Id);
|
||||
|
||||
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
|
||||
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(playbackURL, item);
|
||||
|
||||
const { data: playbackInfo, isLoading } = useQuery({
|
||||
queryKey: ["playbackInfo", item.Id],
|
||||
@@ -64,16 +64,12 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
}, [process]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
</View>
|
||||
);
|
||||
return <ActivityIndicator size={"small"} color={"white"} />;
|
||||
}
|
||||
|
||||
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
|
||||
return (
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center opacity-50">
|
||||
<View style={{ opacity: 0.5 }}>
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
</View>
|
||||
);
|
||||
@@ -81,22 +77,21 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
|
||||
if (process && process.item.Id !== item.Id!) {
|
||||
return (
|
||||
<TouchableOpacity onPress={() => {}}>
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center opacity-50">
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => {}} style={{ opacity: 0.5 }}>
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (process) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
return (
|
||||
<View>
|
||||
{process ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
cancelRemuxing();
|
||||
}}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
{process.progress === 0 ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
@@ -123,32 +118,27 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
<Text>{process.speed.toFixed(2)}x</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
} else if (downloaded) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
startRemuxing();
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
</TouchableOpacity>
|
||||
) : downloaded ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(
|
||||
`/(auth)/player/offline/page?url=${item.Id}.mp4&itemId=${item.Id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="cloud-download" size={26} color="#16a34a" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// downloadFile();
|
||||
startRemuxing();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useVideoPlayer, VideoView } from "expo-video";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
PixelRatio,
|
||||
StyleSheet,
|
||||
View,
|
||||
Button,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
|
||||
const videoSource =
|
||||
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
||||
|
||||
interface Props {
|
||||
videoSource: string;
|
||||
}
|
||||
|
||||
export const NewVideoPlayer: React.FC<Props> = ({ videoSource }) => {
|
||||
const ref = useRef<VideoView | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(true);
|
||||
const player = useVideoPlayer(videoSource, (player) => {
|
||||
player.loop = true;
|
||||
player.play();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = player.addListener("playingChange", (isPlaying) => {
|
||||
setIsPlaying(isPlaying);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
ref.current?.enterFullscreen();
|
||||
}}
|
||||
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
||||
`}
|
||||
>
|
||||
<VideoView
|
||||
ref={ref}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
player={player}
|
||||
allowsFullscreen
|
||||
allowsPictureInPicture
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -29,9 +29,15 @@ export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
|
||||
uri: url,
|
||||
isNetwork: false,
|
||||
}}
|
||||
controls
|
||||
ref={videoRef}
|
||||
onError={onError}
|
||||
ignoreSilentSwitch="ignore"
|
||||
resizeMode="contain"
|
||||
reportBandwidth
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: 16 / 9,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,14 +32,14 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[2, 1, 1],
|
||||
[2, 1, 1]
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -61,7 +61,7 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
||||
onPress={() => router.back()}
|
||||
className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900"
|
||||
style={{
|
||||
top: inset.top + 17,
|
||||
top: inset.top,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
@@ -89,9 +89,7 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
||||
>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<View className="flex-1 overflow-hidden bg-black pb-24">
|
||||
{children}
|
||||
</View>
|
||||
<View className="flex-1 overflow-hidden bg-black">{children}</View>
|
||||
</Animated.ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Button } from "./Button";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
onPress: () => void;
|
||||
chromecastReady: boolean;
|
||||
}
|
||||
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
onPress,
|
||||
chromecastReady,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
onPress={onPress}
|
||||
iconRight={
|
||||
chromecastReady ? (
|
||||
<Feather name="cast" size={20} color="white" />
|
||||
) : (
|
||||
<Ionicons name="play-circle" size={24} color="white" />
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -47,9 +47,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="checkmark-circle" size={26} color="white" />
|
||||
</View>
|
||||
<Ionicons name="checkmark-circle" size={26} color="white" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
@@ -63,9 +61,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="checkmark-circle-outline" size={26} color="white" />
|
||||
</View>
|
||||
<Ionicons name="checkmark-circle-outline" size={26} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { tc } from "@/utils/textTools";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
onChange: (value: number) => void;
|
||||
selected: number;
|
||||
}
|
||||
|
||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
item,
|
||||
onChange,
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const subtitleStreams = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.[0].MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
) ?? [],
|
||||
[item],
|
||||
);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
() => subtitleStreams.find((x) => x.Index === selected),
|
||||
[subtitleStreams, selected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const index = item.MediaSources?.[0].DefaultSubtitleStreamIndex;
|
||||
if (index !== undefined && index !== null) {
|
||||
onChange(index);
|
||||
} else {
|
||||
// Get first subtitle stream
|
||||
const firstSubtitle = subtitleStreams.find((x) => x.Index !== undefined);
|
||||
if (firstSubtitle?.Index !== undefined) {
|
||||
onChange(firstSubtitle.Index);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (subtitleStreams.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="">
|
||||
{tc(selectedSubtitleSteam?.DisplayTitle, 13)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
|
||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
if (subtitle.Index !== undefined && subtitle.Index !== null)
|
||||
onChange(subtitle.Index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{subtitle.DisplayTitle}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -23,14 +23,32 @@ import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { currentlyPlayingItemAtom } from "./CurrentlyPlayingBar";
|
||||
|
||||
type VideoPlayerProps = {
|
||||
itemId: string;
|
||||
onChangePlaybackURL: (url: string | null) => void;
|
||||
};
|
||||
|
||||
export const OldVideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const BITRATES = [
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
value: 4000000,
|
||||
},
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
},
|
||||
];
|
||||
|
||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
itemId,
|
||||
onChangePlaybackURL,
|
||||
}) => {
|
||||
@@ -176,8 +194,6 @@ export const OldVideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
});
|
||||
}, [item, client, playbackURL]);
|
||||
|
||||
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
|
||||
useEffect(() => {
|
||||
videoRef.current?.pause();
|
||||
}, []);
|
||||
@@ -247,15 +263,14 @@ export const OldVideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
<Button
|
||||
disabled={!enableVideo}
|
||||
onPress={() => {
|
||||
// if (chromecastReady) {
|
||||
// cast();
|
||||
// } else {
|
||||
// setTimeout(() => {
|
||||
// if (!videoRef.current) return;
|
||||
// videoRef.current.presentFullscreenPlayer();
|
||||
// }, 1000);
|
||||
// }
|
||||
if (item) setCp(item);
|
||||
if (chromecastReady) {
|
||||
cast();
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
if (!videoRef.current) return;
|
||||
videoRef.current.presentFullscreenPlayer();
|
||||
}, 1000);
|
||||
}
|
||||
}}
|
||||
iconRight={
|
||||
chromecastReady ? (
|
||||
@@ -1,22 +1,15 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import { TextInputProps, TextProps } from "react-native";
|
||||
import { TextInput } from "react-native";
|
||||
export function Input(props: TextInputProps) {
|
||||
const { style, ...otherProps } = props;
|
||||
const inputRef = React.useRef<TextInput>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
{...otherProps}
|
||||
placeholderTextColor={"#9CA3AF"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router } from "expo-router";
|
||||
import { TouchableOpacity } from "react-native";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useCallback } from "react";
|
||||
import { useRef, useMemo, useState } from "react";
|
||||
import Video, { VideoRef } from "react-native-video";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentlyPlayingItemAtom } from "../CurrentlyPlayingBar";
|
||||
|
||||
export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
// const fetchFileSize = async () => {
|
||||
// try {
|
||||
// const filePath = `${FileSystem.documentDirectory}/${item.Id}.mp4`;
|
||||
// const info = await FileSystem.getInfoAsync(filePath);
|
||||
// return info.exists ? info.size : null;
|
||||
// } catch (e) {
|
||||
// console.log(e);
|
||||
// return null;
|
||||
// }
|
||||
// };
|
||||
const openFile = () => {
|
||||
videoRef.current?.presentFullscreenPlayer();
|
||||
};
|
||||
|
||||
// const { data: fileSize } = useQuery({
|
||||
// queryKey: ["fileSize", item?.Id],
|
||||
// queryFn: fetchFileSize,
|
||||
// });
|
||||
|
||||
const openFile = useCallback(() => {
|
||||
setCp({
|
||||
item,
|
||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
const fileUrl = useMemo(() => {
|
||||
return `${FileSystem.documentDirectory}/${item.Id}.mp4`;
|
||||
}, [item]);
|
||||
|
||||
const options = [
|
||||
@@ -59,12 +45,6 @@ export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
<Text className=" text-xs opacity-50">
|
||||
Episode {item.IndexNumber}
|
||||
</Text>
|
||||
{/* <Text className=" text-xs opacity-50">
|
||||
Size:{" "}
|
||||
{fileSize
|
||||
? `${(fileSize / 1000000).toFixed(0)} MB`
|
||||
: "Calculating..."}{" "}
|
||||
</Text> */}
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
@@ -92,6 +72,26 @@ export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
))}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
|
||||
<Video
|
||||
style={{ width: 0, height: 0 }}
|
||||
source={{
|
||||
uri: fileUrl,
|
||||
isNetwork: false,
|
||||
}}
|
||||
controls
|
||||
onFullscreenPlayerDidDismiss={() => {
|
||||
setIsPlaying(false);
|
||||
videoRef.current?.pause();
|
||||
}}
|
||||
onFullscreenPlayerDidPresent={() => {
|
||||
setIsPlaying(true);
|
||||
videoRef.current?.resume();
|
||||
}}
|
||||
ref={videoRef}
|
||||
resizeMode="contain"
|
||||
paused={!isPlaying}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,34 +3,30 @@ import { Text } from "../common/Text";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { router } from "expo-router";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import Video, {
|
||||
OnBufferData,
|
||||
OnPlaybackStateChangedData,
|
||||
OnProgressData,
|
||||
OnVideoErrorData,
|
||||
VideoRef,
|
||||
} from "react-native-video";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useCallback } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentlyPlayingItemAtom } from "../CurrentlyPlayingBar";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
// const fetchFileSize = async () => {
|
||||
// const filePath = `${FileSystem.documentDirectory}/${item.Id}.mp4`;
|
||||
// const info = await FileSystem.getInfoAsync(filePath);
|
||||
// return info.exists ? info.size : null;
|
||||
// };
|
||||
const openFile = () => {
|
||||
videoRef.current?.presentFullscreenPlayer();
|
||||
};
|
||||
|
||||
// const { data: fileSize } = useQuery({
|
||||
// queryKey: ["fileSize", item?.Id],
|
||||
// queryFn: fetchFileSize,
|
||||
// });
|
||||
|
||||
const openFile = useCallback(() => {
|
||||
setCp({
|
||||
item,
|
||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
const fileUrl = useMemo(() => {
|
||||
return `${FileSystem.documentDirectory}/${item.Id}.mp4`;
|
||||
}, [item]);
|
||||
|
||||
const options = [
|
||||
@@ -53,17 +49,11 @@ export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
||||
>
|
||||
<Text className=" font-bold">{item.Name}</Text>
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Text className=" text-xs opacity-50">{item.ProductionYear}</Text>
|
||||
<Text className=" text-xs opacity-50">
|
||||
{runtimeTicksToMinutes(item.RunTimeTicks)}
|
||||
</Text>
|
||||
{/* <Text className=" text-xs opacity-50">
|
||||
Size:{" "}
|
||||
{fileSize
|
||||
? `${(fileSize / 1000000).toFixed(0)} MB`
|
||||
: "Calculating..."}{" "}
|
||||
</Text>*/}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
@@ -92,6 +82,26 @@ export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
))}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
|
||||
<Video
|
||||
style={{ width: 0, height: 0 }}
|
||||
source={{
|
||||
uri: fileUrl,
|
||||
isNetwork: false,
|
||||
}}
|
||||
controls
|
||||
onFullscreenPlayerDidDismiss={() => {
|
||||
setIsPlaying(false);
|
||||
videoRef.current?.pause();
|
||||
}}
|
||||
onFullscreenPlayerDidPresent={() => {
|
||||
setIsPlaying(true);
|
||||
videoRef.current?.resume();
|
||||
}}
|
||||
ref={videoRef}
|
||||
resizeMode="contain"
|
||||
paused={!isPlaying}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Button } from "../Button";
|
||||
import { useRouter } from "expo-router";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
type?: "next" | "previous";
|
||||
}
|
||||
|
||||
export const NextEpisodeButton: React.FC<Props> = ({
|
||||
item,
|
||||
type = "next",
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
// const { data: seasons } = useQuery({
|
||||
// queryKey: ["seasons", item.SeriesId],
|
||||
// queryFn: async () => {
|
||||
// if (
|
||||
// !api ||
|
||||
// !user?.Id ||
|
||||
// !item?.Id ||
|
||||
// !item?.SeriesId ||
|
||||
// !item?.IndexNumber
|
||||
// )
|
||||
// return [];
|
||||
|
||||
// const response = await getItemsApi(api).getItems({
|
||||
// parentId: item?.SeriesId,
|
||||
// });
|
||||
|
||||
// console.log("seasons ~", type, response.data);
|
||||
|
||||
// return (response.data.Items as BaseItemDto[]) ?? [];
|
||||
// },
|
||||
// enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
|
||||
// });
|
||||
|
||||
// const nextSeason = useMemo(() => {
|
||||
// if (!seasons) return null;
|
||||
// const currentSeasonIndex = seasons.findIndex(
|
||||
// (season) => season.Id === item.SeasonId,
|
||||
// );
|
||||
|
||||
// if (currentSeasonIndex === seasons.length - 1) return null;
|
||||
|
||||
// return seasons[currentSeasonIndex + 1];
|
||||
// }, [seasons]);
|
||||
|
||||
const { data: nextEpisode } = useQuery({
|
||||
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
|
||||
queryFn: async () => {
|
||||
if (
|
||||
!api ||
|
||||
!user?.Id ||
|
||||
!item?.Id ||
|
||||
!item?.ParentId ||
|
||||
!item?.IndexNumber
|
||||
)
|
||||
return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
parentId: item?.ParentId,
|
||||
limit: 1,
|
||||
startIndex: type === "next" ? item.IndexNumber : item.IndexNumber - 2,
|
||||
});
|
||||
|
||||
console.log("NextEpisode ~", type, response.data);
|
||||
|
||||
return (response.data.Items?.[0] as BaseItemDto) || null;
|
||||
},
|
||||
enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
|
||||
});
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
if (!nextEpisode) return true;
|
||||
if (nextEpisode.Id === item.Id) return true;
|
||||
return false;
|
||||
}, [nextEpisode, type]);
|
||||
|
||||
if (item.Type !== "Episode") return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
onPress={() => router.replace(`/items/${nextEpisode?.Id}/page`)}
|
||||
className={`h-12 aspect-square`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{type === "next" ? (
|
||||
<Ionicons name="chevron-forward" size={24} color="white" />
|
||||
) : (
|
||||
<Ionicons name="chevron-back" size={24} color="white" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +1,26 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
};
|
||||
|
||||
export const seasonIndexAtom = atom<number>(1);
|
||||
|
||||
export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [seasonIndex, setSeasonIndex] = useAtom(seasonIndexAtom);
|
||||
|
||||
const router = useRouter();
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
||||
const [selectedSeasonId, setSelectedSeasonId] = useState<string | null>(null);
|
||||
|
||||
const { data: seasons } = useQuery({
|
||||
queryKey: ["seasons", item.Id],
|
||||
@@ -40,7 +38,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.Items;
|
||||
@@ -48,12 +46,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
enabled: !!api && !!user?.Id && !!item.Id,
|
||||
});
|
||||
|
||||
const selectedSeasonId: string | null = useMemo(
|
||||
() =>
|
||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||
[seasons, seasonIndex],
|
||||
);
|
||||
|
||||
const { data: episodes } = useQuery({
|
||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||
queryFn: async () => {
|
||||
@@ -70,7 +62,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.Items as BaseItemDto[];
|
||||
@@ -78,13 +70,22 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!seasons || seasons.length === 0) return;
|
||||
|
||||
setSelectedSeasonId(
|
||||
seasons.find((season: any) => season.IndexNumber === 1)?.Id
|
||||
);
|
||||
setSelectedSeason(1);
|
||||
}, [seasons]);
|
||||
|
||||
return (
|
||||
<View className="mb-2">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-row px-4">
|
||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>Season {seasonIndex}</Text>
|
||||
<Text>Season {selectedSeason}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
@@ -102,7 +103,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
<DropdownMenu.Item
|
||||
key={season.Name}
|
||||
onSelect={() => {
|
||||
setSeasonIndex(season.IndexNumber);
|
||||
setSelectedSeason(season.IndexNumber);
|
||||
setSelectedSeasonId(season.Id);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
||||
|
||||
@@ -12,5 +12,5 @@ export const Colors = {
|
||||
tint: tintColorDark,
|
||||
icon: "#9BA1A6",
|
||||
tabIconDefault: "#9BA1A6",
|
||||
tabIconSelected: "#9333ea",
|
||||
tabIconSelected: "#EE4B2B",
|
||||
};
|
||||
|
||||
14
eas.json
14
eas.json
@@ -20,19 +20,7 @@
|
||||
"simulator": true
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.4.1",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.4.1",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
}
|
||||
}
|
||||
"production": {}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
|
||||
@@ -25,9 +25,7 @@
|
||||
"@react-navigation/native": "^6.0.2",
|
||||
"@tanstack/react-query": "^5.51.16",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"axios": "^1.7.3",
|
||||
"expo": "~51.0.26",
|
||||
"expo-blur": "~13.0.2",
|
||||
"expo-build-properties": "~0.12.5",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-dev-client": "~4.0.22",
|
||||
@@ -37,15 +35,10 @@
|
||||
"expo-image": "~1.12.13",
|
||||
"expo-keep-awake": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-navigation-bar": "~3.0.7",
|
||||
"expo-router": "~3.5.21",
|
||||
"expo-screen-orientation": "~7.0.5",
|
||||
"expo-sensors": "~13.0.9",
|
||||
"expo-splash-screen": "~0.27.5",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"expo-system-ui": "~3.0.7",
|
||||
"expo-updates": "~0.25.22",
|
||||
"expo-video": "^1.2.4",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"jotai": "^2.9.1",
|
||||
@@ -57,7 +50,6 @@
|
||||
"react-native-compressor": "^1.8.25",
|
||||
"react-native-gesture-handler": "~2.16.1",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-google-cast": "^4.8.2",
|
||||
"react-native-ios-context-menu": "^2.5.1",
|
||||
"react-native-ios-utilities": "^4.4.5",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
@@ -67,6 +59,7 @@
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"react-native-video": "^6.4.3",
|
||||
"react-native-vlc-media-player": "^1.0.67",
|
||||
"react-native-web": "~0.19.10",
|
||||
"tailwindcss": "3.3.2",
|
||||
"uuid": "^10.0.0",
|
||||
|
||||
@@ -12,7 +12,6 @@ import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Platform } from "react-native";
|
||||
import uuid from "react-native-uuid";
|
||||
|
||||
interface Server {
|
||||
@@ -31,7 +30,7 @@ interface JellyfinContextValue {
|
||||
}
|
||||
|
||||
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const getOrSetDeviceId = async () => {
|
||||
@@ -57,8 +56,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "1.0.0" },
|
||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||
}),
|
||||
deviceInfo: { name: "iOS", id },
|
||||
})
|
||||
);
|
||||
})();
|
||||
}, []);
|
||||
@@ -67,8 +66,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const [user, setUser] = useAtom(userAtom);
|
||||
|
||||
const discoverServers = async (url: string): Promise<Server[]> => {
|
||||
const servers =
|
||||
await jellyfin?.discovery.getRecommendedServerCandidates(url);
|
||||
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
|
||||
url
|
||||
);
|
||||
return servers?.map((server) => ({ address: server.address })) || [];
|
||||
};
|
||||
|
||||
@@ -144,7 +144,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const token = await AsyncStorage.getItem("token");
|
||||
const serverUrl = await AsyncStorage.getItem("serverUrl");
|
||||
const user = JSON.parse(
|
||||
(await AsyncStorage.getItem("user")) as string,
|
||||
(await AsyncStorage.getItem("user")) as string
|
||||
) as UserDto;
|
||||
|
||||
if (serverUrl && token && user.Id && jellyfin) {
|
||||
|
||||
@@ -14,8 +14,6 @@ export const getStreamUrl = async ({
|
||||
maxStreamingBitrate,
|
||||
sessionData,
|
||||
deviceProfile = ios12,
|
||||
audioStreamIndex = 0,
|
||||
subtitleStreamIndex = 0,
|
||||
}: {
|
||||
api: Api | null | undefined;
|
||||
item: BaseItemDto | null | undefined;
|
||||
@@ -24,8 +22,6 @@ export const getStreamUrl = async ({
|
||||
maxStreamingBitrate?: number;
|
||||
sessionData: PlaybackInfoResponse;
|
||||
deviceProfile: any;
|
||||
audioStreamIndex?: number;
|
||||
subtitleStreamIndex?: number;
|
||||
}) => {
|
||||
if (!api || !userId || !item?.Id) {
|
||||
return null;
|
||||
@@ -44,8 +40,6 @@ export const getStreamUrl = async ({
|
||||
AutoOpenLiveStream: true,
|
||||
MediaSourceId: itemId,
|
||||
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
|
||||
AudioStreamIndex: audioStreamIndex,
|
||||
SubtitleStreamIndex: subtitleStreamIndex,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
@@ -64,28 +58,8 @@ export const getStreamUrl = async ({
|
||||
}
|
||||
|
||||
if (mediaSource.SupportsDirectPlay) {
|
||||
if (item.MediaType === "Video") {
|
||||
console.log("Using direct stream for video!");
|
||||
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
|
||||
} else if (item.MediaType === "Audio") {
|
||||
console.log("Using direct stream for audio!");
|
||||
const searchParams = new URLSearchParams({
|
||||
UserId: userId,
|
||||
DeviceId: api.deviceInfo.id,
|
||||
MaxStreamingBitrate: "140000000",
|
||||
Container:
|
||||
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
||||
TranscodingContainer: "mp4",
|
||||
TranscodingProtocol: "hls",
|
||||
AudioCodec: "aac",
|
||||
api_key: api.accessToken,
|
||||
PlaySessionId: sessionData.PlaySessionId,
|
||||
StartTimeTicks: "0",
|
||||
EnableRedirection: "true",
|
||||
EnableRemoteMedia: "false",
|
||||
});
|
||||
return `${api.basePath}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
||||
}
|
||||
console.log("Using direct stream!");
|
||||
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
|
||||
}
|
||||
|
||||
console.log("Using transcoded stream!");
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
/*
|
||||
* Truncate a text longer than a certain length
|
||||
*/
|
||||
export const tc = (text: string | null | undefined, length: number = 20) => {
|
||||
if (!text) return "";
|
||||
return text.length > length ? text.substr(0, length) + "..." : text;
|
||||
};
|
||||
Reference in New Issue
Block a user