Compare commits

...

114 Commits

Author SHA1 Message Date
Fredrik Burmester
8b6c7a7603 fix: incorrect matrix 2024-12-09 15:53:53 +01:00
Fredrik Burmester
5a07eccd9b fix: remove black background in some logo images 2024-12-09 15:39:23 +01:00
Fredrik Burmester
4dff26e8c3 Merge pull request #270 from Alexk2309/change/default-subtitle-size-for-different-platforms
Changed default subtitle size depending platform
2024-12-09 15:04:30 +01:00
Alex Kim
ee2edda507 Changed default subtitle size depending platform 2024-12-10 01:03:09 +11:00
Fredrik Burmester
9e6a8424db Merge pull request #269 from Alexk2309/fix/next-up-episodes-not-showing-for-some-series
Fix for next-up not showing up for some episodes of a series.
2024-12-09 15:02:11 +01:00
Alex Kim
d37ecc1bef Added fix for change 2024-12-10 00:51:47 +11:00
Fredrik Burmester
e70fd3ee45 Merge pull request #268 from Alexk2309/fix/default-subtitles-not-showing
Fix default subtitles not working on app.
2024-12-09 08:55:56 +01:00
Alex Kim
16e93513e2 Fixed issue 2024-12-09 05:51:04 +11:00
Fredrik Burmester
b0c506f85d Merge pull request #267 from Alexk2309/hotfix/small-ui-changes
Changed trickplay debounce to 10ms and added padding for EpisodeList
2024-12-08 19:22:15 +01:00
Alex Kim
b762aff6e2 Changed trickplay debounce to 10ms and added padding for EpisodeList 2024-12-09 04:46:09 +11:00
Fredrik Burmester
75639c4424 Merge pull request #266 from Alexk2309/hotfix/bug-fixes-for-player
Hotfix/bug fixes for player
2024-12-08 18:14:17 +01:00
Fredrik Burmester
4606ce1834 chore: update deps 2024-12-08 18:13:57 +01:00
Alex Kim
44bde8f41e Fixed more bugs 2024-12-09 04:12:13 +11:00
Alex Kim
828edad749 Added padding on right side only 2024-12-09 04:07:56 +11:00
Alex Kim
f842c8a41f Episode list fix rendering 2024-12-09 04:01:59 +11:00
Alex Kim
4d38573973 Fixed rubber banding issue 2024-12-09 03:38:22 +11:00
Alex Kim
785e3b6859 Stop websocket on page exit for transcoded player 2024-12-09 02:56:27 +11:00
Alex Kim
40b3304f9b Fixed socket not closing on exit 2024-12-09 02:48:36 +11:00
Fredrik Burmester
abf1b343cd Merge pull request #265 from herrrta/fix/delete-type
Fix delete by show file type
2024-12-08 16:37:21 +01:00
herrrta
e427802aae Fix delete by show file type 2024-12-08 10:34:03 -05:00
Fredrik Burmester
684e671750 fix: design issues regarding downloads 2024-12-08 16:29:17 +01:00
Fredrik Burmester
5e9b28f2eb fix: type errors and design 2024-12-08 15:59:03 +01:00
Alex Kim
1d4c56265f Made sure changes are saved when changing episode list 2024-12-09 01:54:30 +11:00
Alex Kim
1102df8384 Added fixes for opacity style 2024-12-09 01:06:32 +11:00
Fredrik Burmester
15073f47db Merge pull request #264 from Alexk2309/feature/episode-list-in-player
Feature/episode list in player
2024-12-08 14:16:58 +01:00
Alex Kim
15f32bca6c Removed useless file 2024-12-09 00:13:41 +11:00
Alex Kim
108c5f9bab Merged websocket PR 2024-12-09 00:11:19 +11:00
Fredrik Burmester
24d781050f Merge pull request #263 from fredrikburmester/fix/global-websockets-with-vlc
fix: websockets now work globally with vlc and transcoded player
2024-12-08 14:02:13 +01:00
Alex Kim
353ebf3b0c Removed opacity for unselected items 2024-12-09 00:00:03 +11:00
Fredrik Burmester
c8b16f947d fix: increase max streaming bitrate for HUGE files 2024-12-08 13:59:36 +01:00
Fredrik Burmester
bd24f59199 fix: websockets now work globally with vlc and transcoded player
does not disconnect and reconnect every time you open and close the player
2024-12-08 13:59:16 +01:00
Alex Kim
a6b49c42cf Added style changes 2024-12-08 23:50:59 +11:00
Fredrik Burmester
5afb677b3a chore 2024-12-08 11:57:47 +01:00
Alex Kim
65d3da155f Fixed style issue for devices with bottom safe area 2024-12-08 18:34:20 +11:00
Alex Kim
d616574232 Added scroll to episode when going in player mode 2024-12-08 18:25:10 +11:00
Alex Kim
b8b083abe2 Added correct starting season index 2024-12-08 18:14:41 +11:00
Alex Kim
49a1bffcf5 Added style changes for episode list 2024-12-08 18:03:06 +11:00
Alex Kim
cb6c716830 Fixed playbutton showing up on current Episode 2024-12-08 17:26:48 +11:00
Alex Kim
a725af114c Fixed playbutton showing up on current Episode 2024-12-08 17:26:17 +11:00
Alex Kim
5b290fd667 Got season dropdown to start working 2024-12-08 17:18:44 +11:00
Alex Kim
de4f60f564 WIP 2024-12-08 07:44:35 +11:00
Alex Kim
a4cd3ea600 WIP 2024-12-08 07:15:34 +11:00
Fredrik Burmester
3db12bd76a Merge pull request #261 from Alexk2309/fix/refactor-vlc-media-player
Refactored perfomance change for IOS
2024-12-07 16:53:02 +01:00
Alex Kim
26305c2983 Used debouncing for trick play and stop rendering trickplay, while not sliding 2024-12-08 02:48:23 +11:00
Alex Kim
9c02fa2e72 Refactored perfomance change for IOS 2024-12-07 23:04:21 +11:00
Fredrik Burmester
b08ec474a4 Merge pull request #259 from herrrta/fix/remux-download
Fix queue being downloaded all at once
2024-12-07 09:28:49 +01:00
Fredrik Burmester
416fb24ac0 Merge pull request #257 from Alexk2309/fix/pause-video-when-exiting-ios
Made pause video on app exit and added some performance changes for android
2024-12-07 09:28:08 +01:00
Alex Kim
0d2b15e5af Removed unneccessary print statement 2024-12-07 18:17:28 +11:00
Alex Kim
ef036cb362 Fixed for android and added some peformance changes for android 2024-12-07 18:11:41 +11:00
herrrta
006e457d23 # Fix queue being downloaded all at once 2024-12-06 19:54:43 -05:00
Alex Kim
832a717585 Improve performance of android version 2024-12-07 06:35:25 +11:00
Alex Kim
39f86a9eb1 Added android fix 2024-12-07 06:01:04 +11:00
Alex Kim
38445c6959 Fixed issue for IOS and android 2024-12-07 05:41:46 +11:00
Alex Kim
24320541c7 Made pause video on app exit on IOS 2024-12-07 04:15:16 +11:00
Fredrik Burmester
ee4e9fe347 Merge pull request #256 from Alexk2309/fix/optimize-direct-video-player-ios
Optimized direct player for IOS
2024-12-06 17:28:43 +01:00
Alex Kim
6d43b34f66 Optimized direct player for IOS 2024-12-07 03:14:25 +11:00
retardgerman
63cf7eb622 specified versions in dropdown menu 2024-12-06 14:16:53 +01:00
Fredrik Burmester
32130f1a9c Merge pull request #255 from Alexk2309/feature/prefetch-trick-play-on-video-start
Prefetch trick-play images
2024-12-06 08:39:01 +01:00
Fredrik Burmester
7f458f2f0b Merge pull request #254 from Alexk2309/hotfix/fixed-bugs-for-new-controls-ui
Bug fixes for controls
2024-12-06 08:38:48 +01:00
Alex Kim
6ec6c6daa0 Added feature to prefetch trick-play images on video start rather than downloading it while scrubbing 2024-12-06 17:41:42 +11:00
Alex Kim
02a48fd958 Refactored code, so that way the skip intro button is not using absolute positioning 2024-12-06 16:42:58 +11:00
Alex Kim
04c4dfd13a Fixed bugs for skip intro button still being able to be clicked once it is gone past the time frame 2024-12-06 15:08:33 +11:00
retardgerman
40bdb10653 add dropdown menu for App version 2024-12-05 22:59:30 +01:00
Fredrik Burmester
f16c486bfb Merge pull request #252 from Alexk2309/hotfix/control-ui-fix-skip-intro-button-overlapping-with-safe-area
Fixed skip intro button overlapping with safe area
2024-12-05 20:35:27 +01:00
Alex Kim
19fc00e314 Fixed skip intro button overlapping with safe area 2024-12-06 06:14:53 +11:00
Fredrik Burmester
c51965016c Merge pull request #251 from Alexk2309/feature/intergration-for-ios-file-provider
File System Support
2024-12-05 19:28:27 +01:00
Alex Kim
3bcf73f0dd Changed app ios settings 2024-12-06 05:11:26 +11:00
retardgerman
1ecef4be67 Update bug_report.yml 2024-12-05 18:29:59 +01:00
Fredrik Burmester
387525f9c3 Merge pull request #249 from Alexk2309/feature/subtitle-size-change
Added the ability to change subtitle size
2024-12-05 18:10:46 +01:00
Fredrik Burmester
cf182d8473 Merge branch 'master' into feature/subtitle-size-change 2024-12-05 18:10:38 +01:00
Fredrik Burmester
f0e3321a16 Merge pull request #248 from Alexk2309/feature/control-ui-change
Feature/control UI change
2024-12-05 18:09:38 +01:00
Fredrik Burmester
96c76e2b08 Merge pull request #247 from herrrta/feat/multiple-remux-downloads
Multiple Remux downloads
2024-12-05 18:09:27 +01:00
retardgerman
aaa07d93cf better bug_report.yml 2024-12-05 17:52:20 +01:00
Alex Kim
0716bba6ec Updated setting description 2024-12-06 03:47:58 +11:00
retardgerman
15476f3686 correct bug_report.yml 2024-12-05 17:44:15 +01:00
Alex Kim
97cf9185d3 Added the ability to change subtitle size 2024-12-06 03:40:48 +11:00
retardgerman
c11ad17ca5 fixed bug_report.yml 2024-12-05 17:34:20 +01:00
retardgerman
b0d563bc48 new bug_report.yml 2024-12-05 17:25:55 +01:00
retardgerman
909fc84ec0 Update bug_report.yml 2024-12-05 17:15:36 +01:00
retardgerman
0400597061 finally fixed bug_report.yml 2024-12-05 17:13:01 +01:00
retardgerman
b44a5fbbba fixed bug_report.yml 2024-12-05 17:09:13 +01:00
retardgerman
a5f6ba27b1 reworked bug submit handling 2024-12-05 17:08:32 +01:00
Alex Kim
ece1b8f2b9 Removed consoled log for changing brightness 2024-12-06 03:02:11 +11:00
Alex Kim
beb6702112 merge with master 2024-12-06 02:53:28 +11:00
Alex Kim
98c0ed4ad5 Removed pink background from slider 2024-12-06 02:50:16 +11:00
Alex Kim
b3f471bfa6 Added brightness slider 2024-12-06 01:17:24 +11:00
herrrta
1a10f0debf # Multiple Remux downloads
- Added stepper component
- Disabled more download settings based on download method
- refactored useRemuxHlsToMp4.ts to allow for multiple remux downloads
2024-12-05 01:27:55 -05:00
Fredrik Burmester
ac266c6956 Merge pull request #243 from herrrta/feat/show-overall-download-size
Show app usage
2024-12-03 16:23:24 +01:00
herrrta
b23a50914c Show app usage
- Added app usage to settings
- add more readable size formats
2024-12-03 09:56:07 -05:00
Fredrik Burmester
5c4a419d22 Merge branch 'pr/242' 2024-12-03 14:53:27 +01:00
Fredrik Burmester
3d034864f9 chore 2024-12-03 11:59:25 +01:00
Fredrik Burmester
ea183c426b fix: play new item when pressing play button 2024-12-03 11:59:22 +01:00
Fredrik Burmester
92be991cf7 fix: subtitles burn in for chromecast 2024-12-03 11:59:13 +01:00
herrrta
b73c29221a New delete options & storage visibility
- Added react-native-progress dependency
- Added bottom sheet to downloads page to handle actions for deleting items by type
- Added ability to long press to delete a single series
- Added ability to delete by season
- Refactored delete helpers in DownloadProvider.tsx
- Display storage usage inside downloads & settings page
- Fixed Delete all downloaded files from delting user data in mmkv
2024-12-02 22:37:59 -05:00
Alex Kim
880a739dd4 Reworked controls to have pause, and skip not in the slider anymore 2024-12-03 03:56:55 +11:00
Fredrik Burmester
69ffdc2ddf fix: try to fix #239 2024-12-02 12:04:24 +01:00
Fredrik Burmester
d686bd8c7b fix: tab bar icon not hiding 2024-12-02 11:44:52 +01:00
Fredrik Burmester
c8a60e735b Merge branch 'pr/238' 2024-12-02 11:35:22 +01:00
Fredrik Burmester
05f7574e60 fix: hide tab bar icon 2024-12-02 11:34:57 +01:00
Fredrik Burmester
11b880863c Revert "feat: cache item size"
This reverts commit aec172d8f5.
2024-12-02 11:34:49 +01:00
Fredrik Burmester
aec172d8f5 feat: cache item size 2024-12-02 11:13:05 +01:00
herrrta
7b52528d72 # Persist DownloadedItem size when downloading or when reading file for the first time 2024-12-01 18:07:34 -05:00
Fredrik Burmester
5fd1d9080e chore 2024-12-01 23:08:21 +01:00
Fredrik Burmester
5cc0f381fa chore 2024-12-01 22:26:40 +01:00
Fredrik Burmester
0f547deb39 Merge branch 'pr/210' 2024-12-01 21:18:20 +01:00
Fredrik Burmester
5aeb80348a chore 2024-12-01 21:16:53 +01:00
Fredrik Burmester
1dfc0ac762 Merge branch 'pr/233' 2024-12-01 21:01:59 +01:00
Fredrik Burmester
2b8aee442a Merge branch 'pr/232' 2024-12-01 21:01:54 +01:00
Fredrik Burmester
3e45adfeb5 Merge branch 'pr/231' 2024-12-01 21:01:45 +01:00
herrrta
b41363d347 # Allow option for viewing custom menu links
- Added new 'Other' setting to toggle new tab visibility
- Added new Tab to show custom links
- Added icon asset for list
2024-12-01 14:26:49 -05:00
herrrta
2d5a27c015 # Add Button to download whole series/Season
- Refactored DownloadItem.tsx to be compatible with multiple items
- Updated queueActions.enqueue signature to be compatible with array of jobs
- Added download button beside season dropdown to download entire season
- Added download button to series page to download entire series
2024-12-01 14:23:38 -05:00
herrrta
b5c6403e2d # Add download size to offline media downloads
- Added getDownloadSize helper function to display media size
 in MB or GB when appropriate
2024-12-01 14:23:29 -05:00
herrrta
7eb7d17fa9 # New downloads page for downloaded TV-Series
- Renamed downloads.tsx to index.tsx
- Added new downloads/series.tsx page
- Downloading now saves series primary image
- Downloads index page now shows series primary image with downloaded episode counter
- Updated EpisodeCard.tsx to display more information
- Moved season dropdown from SeasonPicker.tsx into its own component SeasonDropdown.tsx
- Updated navigation in DownloadItem.tsx to direct to series page when a downloaded episode is clicked
2024-12-01 14:23:12 -05:00
Mateusz Kukieła
91b4e403e6 feat: add MacOS fullscreen support 2024-10-29 11:26:15 +01:00
60 changed files with 3246 additions and 2414 deletions

View File

@@ -1,26 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: '❌ bug'
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]

59
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Bug report
description: Create a report to help us improve
title: '[Bug]: '
labels:
- ['❌ bug']
projects:
- ['fredrikburmester/5']
assignees:
- fredrikburmester
body:
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Reproduction steps
description: "How do you trigger this bug? Please walk us through it step by step."
placeholder: |
1.
2.
3.
...
validations:
required: true
- type: textarea
id: device
attributes:
label: Which device and operating system are you using?
description: e.g. iPhone 15, iOS 18.1.1
validations:
required: true
- type: dropdown
id: version
attributes:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.22.0
- 0.21.0
- older
validations:
required: true
- type: textarea
id: screenshots
attributes:
label:
If applicable, please add screenshots to help explain your problem.
You can drag and drop images here or paste them directly into the comment box.

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.21.0", "version": "0.22.0",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -23,7 +23,10 @@
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.", "NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": { "NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true "NSAllowsArbitraryLoads": true
} },
"UISupportsTrueScreenSizeOnMac": true,
"UIFileSharingEnabled": true,
"LSSupportsOpeningDocumentsInPlace": true
}, },
"config": { "config": {
"usesNonExemptEncryption": false "usesNonExemptEncryption": false
@@ -33,14 +36,15 @@
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 46, "versionCode": 47,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png" "foregroundImage": "./assets/images/adaptive_icon.png"
}, },
"package": "com.fredrikburmester.streamyfin", "package": "com.fredrikburmester.streamyfin",
"permissions": [ "permissions": [
"android.permission.FOREGROUND_SERVICE", "android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS"
] ]
}, },
"plugins": [ "plugins": [
@@ -70,7 +74,8 @@
"expo-build-properties", "expo-build-properties",
{ {
"ios": { "ios": {
"deploymentTarget": "15.6" "deploymentTarget": "15.6",
"useFrameworks": "static"
}, },
"android": { "android": {
"android": { "android": {

View File

@@ -0,0 +1,20 @@
import {Stack} from "expo-router";
import { Platform } from "react-native";
export default function CustomMenuLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Custom Links",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,73 @@
import {FlatList, TouchableOpacity, View} from "react-native";
import {useSafeAreaInsets} from "react-native-safe-area-context";
import React, {useCallback, useEffect, useState} from "react";
import {useAtom} from "jotai/index";
import {apiAtom} from "@/providers/JellyfinProvider";
import {ListItem} from "@/components/ListItem";
import * as WebBrowser from 'expo-web-browser';
import Ionicons from '@expo/vector-icons/Ionicons';
import {Text} from "@/components/common/Text";
export interface MenuLink {
name: string,
url: string,
icon: string
}
export default function menuLinks() {
const [api] = useAtom(apiAtom);
const insets = useSafeAreaInsets()
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([])
const getMenuLinks = useCallback(async () => {
try {
const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json")
const config = response?.data;
if (!config && !config.hasOwnProperty("menuLinks")) {
console.error("Menu links not found");
return;
}
setMenuLinks(config?.menuLinks as MenuLink[])
} catch (error) {
console.error("Failed to retrieve config:", error);
}
},
[api]
)
useEffect(() => { getMenuLinks() }, []);
return (
<FlatList
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingTop: 10,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
data={menuLinks}
renderItem={({item}) => (
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url) }>
<ListItem
title={item.name}
iconAfter={<Ionicons name="link" size={24} color="white"/>}
/>
</TouchableOpacity>
)
}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}/>
)}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No links</Text>
</View>
}
/>
);
}

View File

@@ -1,14 +1,11 @@
import { Chromecast } from "@/components/Chromecast"; import { Chromecast } from "@/components/Chromecast";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useDownload } from "@/providers/DownloadProvider";
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
export default function IndexLayout() { export default function IndexLayout() {
const router = useRouter(); const router = useRouter();
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
@@ -35,11 +32,17 @@ export default function IndexLayout() {
}} }}
/> />
<Stack.Screen <Stack.Screen
name="downloads" name="downloads/index"
options={{ options={{
title: "Downloads", title: "Downloads",
}} }}
/> />
<Stack.Screen
name="downloads/[seriesId]"
options={{
title: "TV-Series",
}}
/>
<Stack.Screen <Stack.Screen
name="settings" name="settings"
options={{ options={{

View File

@@ -1,154 +0,0 @@
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles } = useDownload();
const router = useRouter();
const [settings] = useSettings();
const movies = useMemo(() => {
try {
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]);
const groupedBySeries = useMemo(() => {
try {
const episodes = downloadedFiles?.filter(
(f) => f.item.Type === "Episode"
);
const series: { [key: string]: DownloadedItem[] } = {};
episodes?.forEach((e) => {
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
series[e.item.SeriesName!].push(e);
});
return Object.values(series);
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]);
const insets = useSafeAreaInsets();
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
)}
<ActiveDownloads />
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item} />
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries?.map((items, index) => (
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
))}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
</View>
)}
</View>
</ScrollView>
);
}
function migration_20241124() {
const router = useRouter();
const { deleteAllFiles } = useDownload();
Alert.alert(
"New app version requires re-download",
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
[
{
text: "Back",
onPress: () => router.back(),
},
{
text: "Delete",
style: "destructive",
onPress: async () => await deleteAllFiles(),
},
]
);
}

View File

@@ -0,0 +1,132 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
export default function page() {
const navigation = useNavigation();
const local = useLocalSearchParams();
const { seriesId, episodeSeasonIndex } = local as {
seriesId: string;
episodeSeasonIndex: number | string | undefined;
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{}
);
const { downloadedFiles, deleteItems } = useDownload();
const series = useMemo(() => {
try {
return (
downloadedFiles
?.filter((f) => f.item.SeriesId == seriesId)
?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
) || []
);
} catch {
return [];
}
}, [downloadedFiles]);
const seasonIndex =
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
episodeSeasonIndex ||
"";
const groupBySeason = useMemo<BaseItemDto[]>(() => {
const seasons: Record<string, BaseItemDto[]> = {};
series?.forEach((episode) => {
if (!seasons[episode.item.ParentIndexNumber!]) {
seasons[episode.item.ParentIndexNumber!] = [];
}
seasons[episode.item.ParentIndexNumber!].push(episode.item);
});
return (
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
[]
);
}, [series, seasonIndex]);
const initialSeasonIndex = useMemo(
() =>
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber,
[groupBySeason]
);
useEffect(() => {
if (series.length > 0) {
navigation.setOptions({
title: series[0].item.SeriesName,
});
} else {
storage.delete(seriesId);
router.back();
}
}, [series]);
const deleteSeries = useCallback(() => {
Alert.alert(
"Delete season",
"Are you sure you want to delete the entire season?",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
onPress: () => deleteItems(groupBySeason),
style: "destructive",
},
]
);
}, [groupBySeason]);
return (
<View className="flex-1">
{series.length > 0 && (
<View className="flex flex-row items-center justify-start my-2 px-4">
<SeasonDropdown
item={series[0].item}
seasons={series.map((s) => s.item)}
state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
}));
}}
/>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
</View>
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name="trash" size={20} color="white" />
</TouchableOpacity>
</View>
</View>
)}
<ScrollView key={seasonIndex} className="px-4">
{groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} />
))}
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,231 @@
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import {useNavigation, useRouter} from "expo-router";
import { useAtom } from "jotai";
import React, {useEffect, useMemo, useRef} from "react";
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
import {toast} from "sonner-native";
import {writeToLog} from "@/utils/log";
export default function page() {
const navigation = useNavigation();
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
const router = useRouter();
const [settings] = useSettings();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const movies = useMemo(() => {
try {
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]);
const groupedBySeries = useMemo(() => {
try {
const episodes = downloadedFiles?.filter(
(f) => f.item.Type === "Episode"
);
const series: { [key: string]: DownloadedItem[] } = {};
episodes?.forEach((e) => {
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
series[e.item.SeriesName!].push(e);
});
return Object.values(series);
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]);
const insets = useSafeAreaInsets();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={bottomSheetModalRef.current?.present}
>
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
</TouchableOpacity>
)
})
}, [downloadedFiles]);
const deleteMovies = () => deleteFileByType("Movie")
.then(() => toast.success("Deleted all movies successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all movies");
});
const deleteShows = () => deleteFileByType("Episode")
.then(() => toast.success("Deleted all TV-Series successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all TV-Series");
});
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
return (
<>
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
key={index}
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name="close" size={24} color="red"/>
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
)}
<ActiveDownloads/>
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item}/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">TV-Series</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{groupedBySeries?.map((items) => (
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
</View>
)}
</View>
</ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetView>
<View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</>
);
}
function migration_20241124() {
const router = useRouter();
const { deleteAllFiles } = useDownload();
Alert.alert(
"New app version requires re-download",
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
[
{
text: "Back",
onPress: () => router.back(),
},
{
text: "Delete",
style: "destructive",
onPress: async () => await deleteAllFiles(),
},
]
);
}

View File

@@ -188,7 +188,7 @@ export default function index() {
( (
await getUserLibraryApi(api).getLatestMedia({ await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id, userId: user?.Id,
limit: 50, limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"], fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1, imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"], enableImageTypes: ["Primary", "Backdrop", "Thumb"],

View File

@@ -2,32 +2,41 @@ import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem"; import { ListItem } from "@/components/ListItem";
import { SettingToggles } from "@/components/settings/SettingToggles"; import { SettingToggles } from "@/components/settings/SettingToggles";
import { useDownload } from "@/providers/DownloadProvider"; import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs, readFromLog } from "@/utils/log"; import {clearLogs, useLog} from "@/utils/log";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { Alert, ScrollView, View } from "react-native"; import {Alert, ScrollView, View} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import * as Progress from 'react-native-progress';
import * as FileSystem from "expo-file-system";
export default function settings() { export default function settings() {
const { logout } = useJellyfin(); const { logout } = useJellyfin();
const { deleteAllFiles } = useDownload(); const { deleteAllFiles, appSizeUsage } = useDownload();
const { logs } = useLog();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { data: logs } = useQuery({
queryKey: ["logs"],
queryFn: async () => readFromLog(),
refetchInterval: 1000,
});
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const {data: size , isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage],
queryFn: async () => {
const app = await appSizeUsage
const remaining = await FileSystem.getFreeDiskStorageAsync()
const total = await FileSystem.getTotalDiskCapacityAsync()
return {app, remaining, total, used: (total - remaining) / total}
}
})
const openQuickConnectAuthCodeInput = () => { const openQuickConnectAuthCodeInput = () => {
Alert.prompt( Alert.prompt(
"Quick connect", "Quick connect",
@@ -57,6 +66,27 @@ export default function settings() {
); );
}; };
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
} catch (e) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Error
);
toast.error("Error deleting files");
}
}
const onClearLogsClicked = async () => {
clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
};
return ( return (
<ScrollView <ScrollView
contentContainerStyle={{ contentContainerStyle={{
@@ -81,6 +111,9 @@ export default function settings() {
<ListItem title="Server" subTitle={api?.basePath} /> <ListItem title="Server" subTitle={api?.basePath} />
<ListItem title="Token" subTitle={api?.accessToken} /> <ListItem title="Token" subTitle={api?.accessToken} />
</View> </View>
<Button className="my-2.5" color="black" onPress={logout}>
Log out
</Button>
</View> </View>
<View> <View>
@@ -92,42 +125,36 @@ export default function settings() {
<SettingToggles /> <SettingToggles />
<View> <View className="flex flex-col space-y-2">
<Text className="font-bold text-lg mb-2">Account and storage</Text> <Text className="font-bold text-lg mb-2">Storage</Text>
<View className="flex flex-col space-y-2"> <View className="mb-4 space-y-2">
<Button color="black" onPress={logout}> {size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
Log out <Progress.Bar
</Button> className="bg-gray-100/10"
<Button indeterminate={appSizeLoading}
color="red" color="#9333ea"
onPress={async () => { width={null}
try { height={10}
await deleteAllFiles(); borderRadius={6}
Haptics.notificationAsync( borderWidth={0}
Haptics.NotificationFeedbackType.Success progress={size?.used}
); />
} catch (e) { {size && (
Haptics.notificationAsync( <Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
Haptics.NotificationFeedbackType.Error )}
);
toast.error("Error deleting files");
}
}}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={async () => {
await clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
}}
>
Delete all logs
</Button>
</View> </View>
<Button
color="red"
onPress={onDeleteClicked}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={onClearLogsClicked}
>
Delete all logs
</Button>
</View> </View>
<View> <View>
<Text className="font-bold text-lg mb-2">Logs</Text> <Text className="font-bold text-lg mb-2">Logs</Text>

View File

@@ -8,13 +8,17 @@ import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router"; import {useLocalSearchParams, useNavigation} from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React from "react"; import React, {useEffect} from "react";
import { useEffect, useMemo } from "react"; import { useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import {DownloadItems} from "@/components/DownloadItem";
import {MaterialCommunityIcons} from "@expo/vector-icons";
import {getTvShowsApi} from "@jellyfin/sdk/lib/utils/api";
const page: React.FC = () => { const page: React.FC = () => {
const navigation = useNavigation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as { const { id: seriesId, seasonIndex } = params as {
id: string; id: string;
@@ -56,7 +60,43 @@ const page: React.FC = () => {
[item] [item]
); );
if (!item || !backdropUrl) return null; const {data: allEpisodes, isLoading} = useQuery({
queryKey: ["AllEpisodes", item?.Id],
queryFn: async () => {
const res = await getTvShowsApi(api!).getEpisodes({
seriesId: item?.Id!,
userId: user?.Id!,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
});
return res?.data.Items || []
},
enabled: !!api && !!user?.Id && !!item?.Id
});
useEffect(() => {
navigation.setOptions({
headerRight: () => (
(!isLoading && allEpisodes && allEpisodes.length > 0) && (
<View className="flex flex-row items-center space-x-2">
<DownloadItems
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<MaterialCommunityIcons name="folder-download" size={24} color="white"/>
)}
DownloadedIconComponent={() => (
<MaterialCommunityIcons name="folder-check" size={26} color="#9333ea"/>
)}
/>
</View>
)
)
})
}, [allEpisodes, isLoading]);
if (!item || !backdropUrl)
return null;
return ( return (
<ParallaxScrollView <ParallaxScrollView

View File

@@ -6,7 +6,7 @@ import { withLayoutContext } from "expo-router";
import { import {
createNativeBottomTabNavigator, createNativeBottomTabNavigator,
NativeBottomTabNavigationEventMap, NativeBottomTabNavigationEventMap,
} from "react-native-bottom-tabs/react-navigation"; } from "@bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator(); const { Navigator } = createNativeBottomTabNavigator();
@@ -18,6 +18,7 @@ import type {
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { useSettings } from "@/utils/atoms/settings";
export const NativeTabs = withLayoutContext< export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions, BottomTabNavigationOptions,
@@ -27,6 +28,7 @@ export const NativeTabs = withLayoutContext<
>(Navigator); >(Navigator);
export default function TabLayout() { export default function TabLayout() {
const [settings] = useSettings();
return ( return (
<> <>
<SystemBars hidden={false} style="light" /> <SystemBars hidden={false} style="light" />
@@ -71,6 +73,18 @@ export default function TabLayout() {
: () => ({ sfSymbol: "rectangle.stack" }), : () => ({ sfSymbol: "rectangle.stack" }),
}} }}
/> />
<NativeTabs.Screen
name="(custom-links)"
options={{
title: "Custom Links",
// @ts-expect-error
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon:
Platform.OS == "android"
? () => require("@/assets/icons/list.png")
: () => ({ sfSymbol: "list.dash" }),
}}
/>
</NativeTabs> </NativeTabs>
</> </>
); );

View File

@@ -30,9 +30,23 @@ import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { useFocusEffect, useGlobalSearchParams } from "expo-router"; import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react"; import React, {
import { Alert, BackHandler, View } from "react-native"; useCallback,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import {
Alert,
BackHandler,
View,
AppState,
AppStateStatus,
} from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<VlcPlayerViewRef>(null);
@@ -73,7 +87,7 @@ export default function page() {
bitrateValue: string; bitrateValue: string;
offline: string; offline: string;
}>(); }>();
const [settings] = useSettings();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined; const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
@@ -166,69 +180,58 @@ export default function page() {
staleTime: 0, staleTime: 0,
}); });
const togglePlay = useCallback( const togglePlay = useCallback(async () => {
async (ms: number) => { if (!api) return;
if (!api) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
if (!offline && stream) { if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({ await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!, itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined, audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
positionTicks: msToTicks(ms), positionTicks: msToTicks(progress.value),
isPaused: true, isPaused: true,
playMethod: stream.url?.includes("m3u8") playMethod: stream.url?.includes("m3u8")
? "Transcode" ? "Transcode"
: "DirectStream", : "DirectStream",
playSessionId: stream.sessionId, playSessionId: stream.sessionId,
}); });
}
console.log("Actually marked as paused");
} 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(ms),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
} }
},
[
isPlaying,
api,
item,
stream,
videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
offline,
]
);
const play = useCallback(() => { console.log("Actually marked as paused");
videoRef.current?.play(); } else {
reportPlaybackStart(); videoRef.current?.play();
}, [videoRef]); if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
const pause = useCallback(() => { itemId: item?.Id!,
videoRef.current?.pause(); audioStreamIndex: audioIndex ? audioIndex : undefined,
}, [videoRef]); subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
}
}, [
isPlaying,
api,
item,
stream,
videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
offline,
progress.value,
]);
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return; if (offline) return;
@@ -284,6 +287,8 @@ export default function page() {
if (!item?.Id || !stream) return; if (!item?.Id || !stream) return;
console.log("onProgress ~", currentTimeInTicks, isPlaying);
await getPlaystateApi(api!).onPlaybackProgress({ await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id, itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined, audioStreamIndex: audioIndex ? audioIndex : undefined,
@@ -303,8 +308,7 @@ export default function page() {
useWebSocket({ useWebSocket({
isPlaying: isPlaying, isPlaying: isPlaying,
pauseVideo: pause, togglePlay: togglePlay,
playVideo: play,
stopPlayback: stop, stopPlayback: stop,
offline, offline,
}); });
@@ -338,29 +342,49 @@ export default function page() {
: 0; : 0;
}, [item]); }, [item]);
const backAction = () => {
videoRef.current?.stop();
return false;
};
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
const onBackPress = () => {
return backAction();
};
BackHandler.addEventListener("hardwareBackPress", onBackPress);
return async () => { return async () => {
videoRef.current?.stop(); stop();
BackHandler.removeEventListener("hardwareBackPress", onBackPress); console.log("Unmounted");
}; };
}, []) }, [])
); );
const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.match(/inactive|background/) && nextAppState === "active") {
console.log("App has come to the foreground!");
// Handle app coming to the foreground
} else if (nextAppState.match(/inactive|background/)) {
console.log("App has gone to the background!");
// Handle app going to the background
if (videoRef.current && videoRef.current.pause) {
videoRef.current.pause();
}
}
setAppState(nextAppState);
};
// Use AppState.addEventListener and return a cleanup function
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => {
// Cleanup the event listener when the component is unmounted
subscription.remove();
};
}, [appState]);
// Preselection of audio and subtitle tracks. // Preselection of audio and subtitle tracks.
let initOptions = ["--sub-text-scale=60"]; if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
let externalTrack = { name: "", DeliveryUrl: "" }; let externalTrack = { name: "", DeliveryUrl: "" };
const allSubs = const allSubs =
@@ -455,21 +479,6 @@ export default function page() {
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
/> />
<View
style={{
position: "absolute",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
opacity: isBuffering ? 1 : 0,
width: "100%",
height: "100%",
}}
pointerEvents="none"
>
<Loader />
</View>
</View> </View>
{videoRef.current && ( {videoRef.current && (
<Controls <Controls

View File

@@ -169,51 +169,44 @@ const Player = () => {
const poster = usePoster(item, api); const poster = usePoster(item, api);
const videoSource = useVideoSource(item, api, poster, stream?.url); const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback( const togglePlay = useCallback(async () => {
async (ticks: number) => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); if (isPlaying) {
if (isPlaying) { videoRef.current?.pause();
videoRef.current?.pause(); await getPlaystateApi(api!).onPlaybackProgress({
await getPlaystateApi(api!).onPlaybackProgress({ itemId: item?.Id!,
itemId: item?.Id!, audioStreamIndex: audioIndex ? audioIndex : undefined,
audioStreamIndex: audioIndex ? audioIndex : undefined, subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, mediaSourceId: mediaSourceId,
mediaSourceId: mediaSourceId, positionTicks: Math.floor(progress.value),
positionTicks: Math.floor(ticks), isPaused: true,
isPaused: true, playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playMethod: stream?.url.includes("m3u8") playSessionId: stream?.sessionId,
? "Transcode" });
: "DirectStream", } else {
playSessionId: stream?.sessionId, videoRef.current?.resume();
}); await getPlaystateApi(api!).onPlaybackProgress({
} else { itemId: item?.Id!,
videoRef.current?.resume(); audioStreamIndex: audioIndex ? audioIndex : undefined,
await getPlaystateApi(api!).onPlaybackProgress({ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
itemId: item?.Id!, mediaSourceId: mediaSourceId,
audioStreamIndex: audioIndex ? audioIndex : undefined, positionTicks: Math.floor(progress.value),
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, isPaused: false,
mediaSourceId: mediaSourceId, playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
positionTicks: Math.floor(ticks), playSessionId: stream?.sessionId,
isPaused: false, });
playMethod: stream?.url.includes("m3u8") }
? "Transcode" }, [
: "DirectStream", isPlaying,
playSessionId: stream?.sessionId, api,
}); item,
} videoRef,
}, settings,
[ stream,
isPlaying, audioIndex,
api, subtitleIndex,
item, mediaSourceId,
videoRef, ]);
settings,
stream,
audioIndex,
subtitleIndex,
mediaSourceId,
]
);
const play = useCallback(() => { const play = useCallback(() => {
videoRef.current?.resume(); videoRef.current?.resume();
@@ -307,9 +300,9 @@ const Player = () => {
useWebSocket({ useWebSocket({
isPlaying: isPlaying, isPlaying: isPlaying,
pauseVideo: pause, togglePlay: togglePlay,
playVideo: play,
stopPlayback: stop, stopPlayback: stop,
offline: false,
}); });
const [selectedTextTrack, setSelectedTextTrack] = useState< const [selectedTextTrack, setSelectedTextTrack] = useState<
@@ -369,23 +362,10 @@ const Player = () => {
})); }));
}; };
const backAction = () => {
videoRef.current?.pause();
return false;
};
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
const onBackPress = () => {
return backAction();
};
BackHandler.addEventListener("hardwareBackPress", onBackPress);
play();
return async () => { return async () => {
videoRef.current?.pause(); stop();
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
}; };
}, []) }, [])
); );
@@ -467,20 +447,6 @@ const Player = () => {
selectedTextTrack={selectedTextTrack} selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack} selectedAudioTrack={selectedAudioTrack}
/> />
<View
style={{
position: "absolute",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
opacity: isBuffering ? 1 : 0,
width: "100%",
height: "100%",
}}
pointerEvents="none"
>
<Loader />
</View>
</> </>
) : ( ) : (
<Text>No video source...</Text> <Text>No video source...</Text>

View File

@@ -6,10 +6,11 @@ import {
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { orientationAtom } from "@/utils/atoms/orientation"; import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings"; import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks"; import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { writeToLog } from "@/utils/log"; import { LogProvider, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server"; import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
@@ -310,50 +311,54 @@ function Layout() {
<JobQueueProvider> <JobQueueProvider>
<JellyfinProvider> <JellyfinProvider>
<PlaySettingsProvider> <PlaySettingsProvider>
<DownloadProvider> <LogProvider>
<BottomSheetModalProvider> <WebSocketProvider>
<SystemBars style="light" hidden={false} /> <DownloadProvider>
<ThemeProvider value={DarkTheme}> <BottomSheetModalProvider>
<Stack initialRouteName="/home"> <SystemBars style="light" hidden={false} />
<Stack.Screen <ThemeProvider value={DarkTheme}>
name="(auth)/(tabs)" <Stack initialRouteName="/home">
options={{ <Stack.Screen
headerShown: false, name="(auth)/(tabs)"
title: "", options={{
header: () => null, headerShown: false,
}} title: "",
/> header: () => null,
<Stack.Screen }}
name="(auth)/player" />
options={{ <Stack.Screen
headerShown: false, name="(auth)/player"
title: "", options={{
header: () => null, headerShown: false,
}} title: "",
/> header: () => null,
<Stack.Screen }}
name="login" />
options={{ headerShown: false, title: "Login" }} <Stack.Screen
/> name="login"
<Stack.Screen name="+not-found" /> options={{ headerShown: false, title: "Login" }}
</Stack> />
<Toaster <Stack.Screen name="+not-found" />
duration={4000} </Stack>
toastOptions={{ <Toaster
style: { duration={4000}
backgroundColor: "#262626", toastOptions={{
borderColor: "#363639", style: {
borderWidth: 1, backgroundColor: "#262626",
}, borderColor: "#363639",
titleStyle: { borderWidth: 1,
color: "white", },
}, titleStyle: {
}} color: "white",
closeButton },
/> }}
</ThemeProvider> closeButton
</BottomSheetModalProvider> />
</DownloadProvider> </ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider> </PlaySettingsProvider>
</JellyfinProvider> </JellyfinProvider>
</JobQueueProvider> </JobQueueProvider>

BIN
assets/icons/list.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,27 +1,30 @@
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom, useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator"; import { WatchedIndicator } from "./WatchedIndicator";
import React from "react"; import React from "react";
import { Ionicons } from "@expo/vector-icons";
type ContinueWatchingPosterProps = { type ContinueWatchingPosterProps = {
item: BaseItemDto; item: BaseItemDto;
useEpisodePoster?: boolean; useEpisodePoster?: boolean;
size?: "small" | "normal"; size?: "small" | "normal";
showPlayButton?: boolean;
}; };
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item, item,
useEpisodePoster = false, useEpisodePoster = false,
size = "normal", size = "normal",
showPlayButton = false,
}) => { }) => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
/** /**
* Get horrizontal poster for movie and episode, with failover to primary. * Get horizontal poster for movie and episode, with failover to primary.
*/ */
const url = useMemo(() => { const url = useMemo(() => {
if (!api) return; if (!api) return;
@@ -73,16 +76,23 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
${size === "small" ? "w-32" : "w-44"} ${size === "small" ? "w-32" : "w-44"}
`} `}
> >
<Image <View className="w-full h-full flex items-center justify-center">
key={item.Id} <Image
id={item.Id} key={item.Id}
source={{ id={item.Id}
uri: url, source={{
}} uri: url,
cachePolicy={"memory-disk"} }}
contentFit="cover" cachePolicy={"memory-disk"}
className="w-full h-full" contentFit="cover"
/> className="w-full h-full"
/>
{showPlayButton && (
<View className="absolute inset-0 flex items-center justify-center">
<Ionicons name="play-circle" size={40} color="white" />
</View>
)}
</View>
{!progress && <WatchedIndicator item={item} />} {!progress && <WatchedIndicator item={item} />}
{progress > 0 && ( {progress > 0 && (
<> <>

View File

@@ -6,7 +6,6 @@ import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server"; import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
import native from "@/utils/profiles/native";
import download from "@/utils/profiles/download"; import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { import {
@@ -19,9 +18,9 @@ import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { router, useFocusEffect } from "expo-router"; import { Href, router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react"; import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View, ViewProps } from "react-native"; import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
@@ -34,15 +33,22 @@ import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
interface DownloadProps extends ViewProps { interface DownloadProps extends ViewProps {
item: BaseItemDto; items: BaseItemDto[];
MissingDownloadIconComponent: () => React.ReactElement;
DownloadedIconComponent: () => React.ReactElement;
} }
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => { export const DownloadItems: React.FC<DownloadProps> = ({
items,
MissingDownloadIconComponent,
DownloadedIconComponent,
...props
}) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const { processes, startBackgroundDownload } = useDownload(); const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4(); const { startRemuxing } = useRemuxHlsToMp4();
const [selectedMediaSource, setSelectedMediaSource] = useState< const [selectedMediaSource, setSelectedMediaSource] = useState<
@@ -56,23 +62,14 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
value: undefined, value: undefined,
}); });
useFocusEffect( const userCanDownload = useMemo(
useCallback(() => { () => user?.Policy?.EnableContentDownloading,
if (!settings) return; [user]
const { bitrate, mediaSource, audioIndex, subtitleIndex } = );
getDefaultPlaySettings(item, settings); const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === "optimized",
// 4. Set states [settings]
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [item, settings])
); );
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);
/** /**
* Bottom sheet * Bottom sheet
@@ -89,69 +86,169 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
bottomSheetModalRef.current?.dismiss(); bottomSheetModalRef.current?.dismiss();
}, []); }, []);
/** // region computed
* Start download const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
*/ const pendingItems = useMemo(
const initiateDownload = useCallback(async () => { () =>
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) { items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
throw new Error( [items, downloadedFiles]
"DownloadItem ~ initiateDownload: No api or user or item" );
); const isDownloaded = useMemo(() => {
} if (!downloadedFiles) return false;
return pendingItems.length == 0;
}, [downloadedFiles, pendingItems]);
const res = await getStreamUrl({ const itemsProcesses = useMemo(
api, () => processes?.filter((p) => itemIds.includes(p.item.Id)),
item, [processes, itemIds]
startTimeTicks: 0, );
userId: user?.Id,
audioStreamIndex: selectedAudioStream,
maxStreamingBitrate: maxBitrate.value,
mediaSourceId: selectedMediaSource.Id,
subtitleStreamIndex: selectedSubtitleStream,
deviceProfile: download,
});
if (!res) { const progress = useMemo(() => {
Alert.alert( if (itemIds.length == 1)
"Something went wrong", return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
"Could not get stream url from Jellyfin" return (
); ((itemIds.length -
return; queue.filter((q) => itemIds.includes(q.item.Id)).length) /
} itemIds.length) *
100
);
}, [queue, itemsProcesses, itemIds]);
const { mediaSource, url } = res; const itemsQueued = useMemo(() => {
return (
pendingItems.length > 0 &&
pendingItems.every((p) => queue.some((q) => p.Id == q.item.Id))
);
}, [queue, pendingItems]);
// endregion computed
if (!url || !mediaSource) throw new Error("No url"); // region helper functions
const navigateToDownloads = () => router.push("/downloads");
saveDownloadItemInfoToDiskTmp(item, mediaSource, url); const onDownloadedPress = () => {
const firstItem = items?.[0];
router.push(
firstItem.Type !== "Episode"
? "/downloads"
: ({
pathname: `/downloads/${firstItem.SeriesId}`,
params: {
episodeSeasonIndex: firstItem.ParentIndexNumber,
},
} as Href)
);
};
if (settings?.downloadMethod === "optimized") { const acceptDownloadOptions = useCallback(() => {
return await startBackgroundDownload(url, item, mediaSource); if (userCanDownload === true) {
if (pendingItems.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
if (usingOptimizedServer) initiateDownload(...pendingItems);
else {
queueActions.enqueue(
queue,
setQueue,
...pendingItems.map((item) => ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
}))
);
}
} else { } else {
return await startRemuxing(item, url, mediaSource); toast.error("You are not allowed to download files.");
} }
}, [ }, [
api, queue,
item, setQueue,
startBackgroundDownload, pendingItems,
user?.Id, usingOptimizedServer,
userCanDownload,
// Need to be reference at the time async lambda is created for initiateDownload
maxBitrate,
selectedMediaSource, selectedMediaSource,
selectedAudioStream, selectedAudioStream,
selectedSubtitleStream, selectedSubtitleStream,
maxBitrate,
settings?.downloadMethod,
]); ]);
/** /**
* Check if item is downloaded * Start download
*/ */
const { downloadedFiles } = useDownload(); const initiateDownload = useCallback(
async (...items: BaseItemDto[]) => {
if (
!api ||
!user?.Id ||
items.some((p) => !p.Id) ||
(pendingItems.length === 1 && !selectedMediaSource?.Id)
) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
);
}
let mediaSource = selectedMediaSource;
let audioIndex: number | undefined = selectedAudioStream;
let subtitleIndex: number | undefined = selectedSubtitleStream;
const isDownloaded = useMemo(() => { for (const item of items) {
if (!downloadedFiles) return false; if (pendingItems.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
item,
settings!
));
}
return downloadedFiles.some((file) => file.item.Id === item.Id); const res = await getStreamUrl({
}, [downloadedFiles, item.Id]); api,
item,
startTimeTicks: 0,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: maxBitrate.value,
mediaSourceId: mediaSource?.Id,
subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
});
if (!res) {
Alert.alert(
"Something went wrong",
"Could not get stream url from Jellyfin"
);
continue;
}
const { mediaSource: source, url } = res;
if (!url || !source) throw new Error("No url");
saveDownloadItemInfoToDiskTmp(item, source, url);
if (usingOptimizedServer) {
await startBackgroundDownload(url, item, source);
} else {
await startRemuxing(item, url);
}
}
},
[
api,
user?.Id,
pendingItems,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
settings,
maxBitrate,
usingOptimizedServer,
startBackgroundDownload,
startRemuxing,
]
);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
@@ -163,31 +260,38 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
), ),
[] []
); );
// endregion helper functions
const process = useMemo(() => { // Allow to select & set settings for single download
if (!processes) return null; useFocusEffect(
useCallback(() => {
if (!settings) return;
if (pendingItems.length !== 1) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(items[0], settings);
return processes.find((process) => process?.item?.Id === item.Id); // 4. Set states
}, [processes, item.Id]); setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, pendingItems, settings])
);
return ( return (
<View <View
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center" className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center"
{...props} {...props}
> >
{process && process?.item.Id === item.Id ? ( {processes && itemsProcesses.length > 0 ? (
<TouchableOpacity <TouchableOpacity onPress={navigateToDownloads}>
onPress={() => { {progress === 0 ? (
router.push("/downloads");
}}
>
{process.progress === 0 ? (
<Loader /> <Loader />
) : ( ) : (
<View className="-rotate-45"> <View className="-rotate-45">
<ProgressCircle <ProgressCircle
size={24} size={24}
fill={process.progress} fill={progress}
width={4} width={4}
tintColor="#9334E9" tintColor="#9334E9"
backgroundColor="#bdc3c7" backgroundColor="#bdc3c7"
@@ -195,25 +299,17 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
) : queue.some((i) => i.id === item.Id) ? ( ) : itemsQueued ? (
<TouchableOpacity <TouchableOpacity onPress={navigateToDownloads}>
onPress={() => {
router.push("/downloads");
}}
>
<Ionicons name="hourglass" size={24} color="white" /> <Ionicons name="hourglass" size={24} color="white" />
</TouchableOpacity> </TouchableOpacity>
) : isDownloaded ? ( ) : isDownloaded ? (
<TouchableOpacity <TouchableOpacity onPress={onDownloadedPress}>
onPress={() => { {DownloadedIconComponent()}
router.push("/downloads");
}}
>
<Ionicons name="cloud-download" size={26} color="#9333ea" />
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<TouchableOpacity onPress={handlePresentModalPress}> <TouchableOpacity onPress={handlePresentModalPress}>
<Ionicons name="cloud-download-outline" size={24} color="white" /> {MissingDownloadIconComponent()}
</TouchableOpacity> </TouchableOpacity>
)} )}
<BottomSheetModal <BottomSheetModal
@@ -236,62 +332,46 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
<View className="flex flex-col space-y-2 w-full items-start"> <View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector <BitrateSelector
inverted inverted
onChange={(val) => setMaxBitrate(val)} onChange={setMaxBitrate}
selected={maxBitrate} selected={maxBitrate}
/> />
<MediaSourceSelector {pendingItems.length === 1 && (
item={item} <>
onChange={setSelectedMediaSource} <MediaSourceSelector
selected={selectedMediaSource} item={items[0]}
/> onChange={setSelectedMediaSource}
{selectedMediaSource && ( selected={selectedMediaSource}
<View className="flex flex-col space-y-2">
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/> />
<SubtitleTrackSelector {selectedMediaSource && (
source={selectedMediaSource} <View className="flex flex-col space-y-2">
onChange={setSelectedSubtitleStream} <AudioTrackSelector
selected={selectedSubtitleStream} source={selectedMediaSource}
/> onChange={setSelectedAudioStream}
</View> selected={selectedAudioStream}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
)}
</>
)} )}
</View> </View>
<Button <Button
className="mt-auto" className="mt-auto"
onPress={() => { onPress={acceptDownloadOptions}
if (userCanDownload === true) {
if (!item.Id) {
throw new Error("No item id");
}
closeModal();
if (settings?.downloadMethod === "remux") {
queueActions.enqueue(queue, setQueue, {
id: item.Id,
execute: async () => {
await initiateDownload();
},
item,
});
} else {
initiateDownload();
}
} else {
toast.error("You are not allowed to download files.");
}
}}
color="purple" color="purple"
> >
Download Download
</Button> </Button>
<View className="opacity-70 text-center w-full flex items-center"> <View className="opacity-70 text-center w-full flex items-center">
{settings?.downloadMethod === "optimized" ? ( <Text className="text-xs">
<Text className="text-xs">Using optimized server</Text> {usingOptimizedServer
) : ( ? "Using optimized server"
<Text className="text-xs">Using default method</Text> : "Using default method"}
)} </Text>
</View> </View>
</View> </View>
</BottomSheetView> </BottomSheetView>
@@ -299,3 +379,19 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
</View> </View>
); );
}; };
export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({
item,
}) => {
return (
<DownloadItems
items={[item]}
MissingDownloadIconComponent={() => (
<Ionicons name="cloud-download-outline" size={24} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name="cloud-download" size={26} color="#9333ea" />
)}
/>
);
};

View File

@@ -1,6 +1,6 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector"; import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector"; import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadItem } from "@/components/DownloadItem"; import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlayButton } from "@/components/PlayButton"; import { PlayButton } from "@/components/PlayButton";
@@ -32,6 +32,16 @@ import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader"; import { ItemHeader } from "./ItemHeader";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import {
brightness,
ColorMatrix,
colorTone,
concatColorMatrices,
contrast,
saturate,
sepia,
tint,
} from "react-native-color-matrix-image-filters";
export type SelectedOptions = { export type SelectedOptions = {
bitrate: Bitrate; bitrate: Bitrate;
@@ -49,7 +59,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
useImageColors({ item }); useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true); const [loadingLogo, setLoadingLogo] = useState(false);
const [headerHeight, setHeaderHeight] = useState(350); const [headerHeight, setHeaderHeight] = useState(350);
const [selectedOptions, setSelectedOptions] = useState< const [selectedOptions, setSelectedOptions] = useState<
@@ -87,7 +97,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<Chromecast background="blur" width={22} height={22} /> <Chromecast background="blur" width={22} height={22} />
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
<DownloadItem item={item} /> <DownloadSingleItem item={item} />
<PlayedStatus item={item} /> <PlayedStatus item={item} />
</View> </View>
)} )}
@@ -139,18 +149,45 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
logo={ logo={
<> <>
{logoUrl ? ( {logoUrl ? (
<Image <ColorMatrix
source={{ matrix={[
uri: logoUrl, 1,
}} 0,
0,
0,
0, // Red channel remains unchanged
0,
1,
0,
0,
0, // Green channel remains unchanged
0,
0,
1,
0,
0, // Blue channel remains unchanged
1,
1,
1,
1,
-1, // Make black (R=0, G=0, B=0) transparent
]}
style={{ style={{
height: 130, height: 130,
width: "100%", width: "100%",
resizeMode: "contain",
}} }}
onLoad={() => setLoadingLogo(false)} >
onError={() => setLoadingLogo(false)} <Image
/> source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
</ColorMatrix>
) : null} ) : null}
</> </>
} }
@@ -228,6 +265,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<OverviewText text={item.Overview} className="px-4 my-4" /> <OverviewText text={item.Overview} className="px-4 my-4" />
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<> <>
{item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<CastAndCrew item={item} className="mb-4" loading={loading} /> <CastAndCrew item={item} className="mb-4" loading={loading} />
{item.People && item.People.length > 0 && ( {item.People && item.People.length > 0 && (
@@ -243,10 +284,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View> </View>
)} )}
{item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<SimilarItems itemId={item.Id} /> <SimilarItems itemId={item.Id} />
</> </>
)} )}

View File

@@ -31,6 +31,7 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Button } from "./Button"; import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent"; import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item: BaseItemDto;
@@ -111,18 +112,11 @@ export const PlayButton: React.FC<Props> = ({
if (state && state !== PlayServicesState.SUCCESS) if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state); CastContext.showPlayServicesErrorDialog(state);
else { else {
// If we're opening a currently playing item, don't restart the media.
// Instead just open controls.
if (isOpeningCurrentlyPlayingMedia) {
CastContext.showExpandedControls();
return;
}
// Get a new URL with the Chromecast device profile: // Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({ const data = await getStreamUrl({
api, api,
item, item,
deviceProfile: ios, deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!, startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id, userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex, audioStreamIndex: selectedOptions.audioIndex,

View File

@@ -40,7 +40,7 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
<Text className="text-lg font-bold mb-2">Active downloads</Text> <Text className="text-lg font-bold mb-2">Active downloads</Text>
<View className="space-y-2"> <View className="space-y-2">
{processes?.map((p) => ( {processes?.map((p) => (
<DownloadCard key={p.id} process={p} /> <DownloadCard key={p.item.Id} process={p} />
))} ))}
</View> </View>
</View> </View>
@@ -77,7 +77,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
await queryClient.refetchQueries({ queryKey: ["jobs"] }); await queryClient.refetchQueries({ queryKey: ["jobs"] });
} }
} else { } else {
FFmpegKit.cancel(); FFmpegKit.cancel(Number(id));
setProcesses((prev) => prev.filter((p) => p.id !== id)); setProcesses((prev) => prev.filter((p) => p.id !== id));
} }
}, },

View File

@@ -0,0 +1,47 @@
import { Text } from "@/components/common/Text";
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect, useMemo, useState } from "react";
import { TextProps } from "react-native";
interface DownloadSizeProps extends TextProps {
items: BaseItemDto[];
}
export const DownloadSize: React.FC<DownloadSizeProps> = ({
items,
...props
}) => {
const { downloadedFiles, getDownloadedItemSize } = useDownload();
const [size, setSize] = useState<string | undefined>();
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
useEffect(() => {
if (!downloadedFiles) return;
let s = 0;
for (const item of items) {
if (!item.Id) continue;
const size = getDownloadedItemSize(item.Id);
if (size) {
s += size;
}
}
setSize(bytesToReadable(s));
}, [itemIds]);
const sizeText = useMemo(() => {
if (!size) return "...";
return size;
}, [size]);
return (
<>
<Text className="text-xs text-neutral-500" {...props}>
{sizeText}
</Text>
</>
);
};

View File

@@ -1,37 +1,35 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import React, { useCallback, useMemo, useRef } from "react"; import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import { import {
ActionSheetProvider, ActionSheetProvider,
useActionSheet, useActionSheet,
} from "@expo/react-native-action-sheet"; } from "@expo/react-native-action-sheet";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { Text } from "../common/Text";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { ItemCardText } from "../ItemCardText";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/common/Text";
import { runtimeTicksToSeconds } from "@/utils/time";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
interface EpisodeCardProps { interface EpisodeCardProps extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
} }
/** export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
* EpisodeCard component displays an episode with action sheet options.
* @param {EpisodeCardProps} props - The component props.
* @returns {React.ReactElement} The rendered EpisodeCard component.
*/
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useDownload(); const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener(); const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const base64Image = useMemo(() => { const base64Image = useMemo(() => {
return storage.getString(item.Id!); return storage.getString(item.Id!);
}, []); }, [item]);
const handleOpenFile = useCallback(() => { const handleOpenFile = useCallback(() => {
openFile(item); openFile(item);
@@ -76,32 +74,29 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
<TouchableOpacity <TouchableOpacity
onPress={handleOpenFile} onPress={handleOpenFile}
onLongPress={showActionSheet} onLongPress={showActionSheet}
className="flex flex-col w-44 mr-2" key={item.Id}
className="flex flex-col mb-4"
> >
{base64Image ? ( <View className="flex flex-row items-start mb-2">
<View className="w-44 aspect-video rounded-lg overflow-hidden"> <View className="mr-2">
<Image <ContinueWatchingPoster size="small" item={item} useEpisodePoster />
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
</View> </View>
) : ( <View className="shrink">
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center"> <Text numberOfLines={2} className="">
<Ionicons {item.Name}
name="image-outline" </Text>
size={24} <Text numberOfLines={1} className="text-xs text-neutral-500">
color="gray" {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
className="self-center mt-16" </Text>
/> <Text className="text-xs text-neutral-500">
{runtimeTicksToSeconds(item.RunTimeTicks)}
</Text>
</View> </View>
)} </View>
<ItemCardText item={item} />
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
{item.Overview}
</Text>
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -7,6 +7,7 @@ import * as Haptics from "expo-haptics";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
@@ -97,6 +98,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
</View> </View>
)} )}
<ItemCardText item={item} /> <ItemCardText item={item} />
<DownloadSize items={[item]} />
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -1,55 +1,82 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { ScrollView, View } from "react-native"; import {TouchableOpacity, View} from "react-native";
import { EpisodeCard } from "./EpisodeCard";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { useMemo } from "react"; import React, {useCallback, useMemo} from "react";
import { SeasonPicker } from "../series/SeasonPicker"; import {storage} from "@/utils/mmkv";
import {Image} from "expo-image";
import {Ionicons} from "@expo/vector-icons";
import {router} from "expo-router";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import {useDownload} from "@/providers/DownloadProvider";
import {useActionSheet} from "@expo/react-native-action-sheet";
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => { export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
const groupBySeason = useMemo(() => { const { deleteItems } = useDownload();
const seasons: Record<string, BaseItemDto[]> = {}; const { showActionSheetWithOptions } = useActionSheet();
items.forEach((item) => { const base64Image = useMemo(() => {
if (!seasons[item.SeasonName!]) { return storage.getString(items[0].SeriesId!);
seasons[item.SeasonName!] = []; }, []);
const deleteSeries = useCallback(
async () => deleteItems(items),
[items]
);
const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"];
const destructiveButtonIndex = 0;
showActionSheetWithOptions({
options,
destructiveButtonIndex,
},
(selectedIndex) => {
if (selectedIndex == destructiveButtonIndex) {
deleteSeries();
}
} }
seasons[item.SeasonName!].push(item);
});
return Object.values(seasons).sort(
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
); );
}, [items]); }, [showActionSheetWithOptions, deleteSeries]);
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => {
return a.IndexNumber! > b.IndexNumber! ? 1 : -1;
};
return ( return (
<View> <TouchableOpacity
<View className="flex flex-row items-center justify-between px-4"> onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
<Text className="text-lg font-bold shrink">{items[0].SeriesName}</Text> onLongPress={showActionSheet}
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center"> >
<Text className="text-xs font-bold">{items.length}</Text> {base64Image ? (
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
<View
className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1">
<Text className="text-xs font-bold">{items.length}</Text>
</View>
</View> </View>
</View> ) : (
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
<Ionicons
name="image-outline"
size={24}
color="gray"
className="self-center mt-16"
/>
</View>
)}
<Text className="opacity-50 mb-2 px-4">TV-Series</Text> <View className="w-28 mt-2 flex flex-col">
{groupBySeason.map((seasonItems, seasonIndex) => ( <Text numberOfLines={2} className="">{items[0].SeriesName}</Text>
<View key={seasonIndex}> <Text className="text-xs opacity-50">{items[0].ProductionYear}</Text>
<Text className="mb-2 font-semibold px-4"> <DownloadSize items={items} />
{seasonItems[0].SeasonName} </View>
</Text> </TouchableOpacity>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{seasonItems.sort(sortByIndex)?.map((item, index) => (
<EpisodeCard item={item} key={index} />
))}
</View>
</ScrollView>
</View>
))}
</View>
); );
}; };

View File

@@ -0,0 +1,44 @@
import {TouchableOpacity, View} from "react-native";
import {Text} from "@/components/common/Text";
interface StepperProps {
value: number,
step: number,
min: number,
max: number,
onUpdate: (value: number) => void,
appendValue?: string,
}
export const Stepper: React.FC<StepperProps> = ({
value,
step,
min,
max,
onUpdate,
appendValue
}) => {
return (
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() => onUpdate(Math.max(min, value - step))}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text
className={
"w-auto h-8 bg-neutral-800 py-2 px-1 flex items-center justify-center" + (appendValue ? "first-letter:px-2" : "")
}
>
{value}{appendValue}
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() => onUpdate(Math.min(max, value + step))}
>
<Text>+</Text>
</TouchableOpacity>
</View>
)
}

View File

@@ -0,0 +1,121 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
type Props = {
item: BaseItemDto;
seasons: BaseItemDto[];
initialSeasonIndex?: number;
state: SeasonIndexState;
onSelect: (season: BaseItemDto) => void;
};
type SeasonKeys = {
id: keyof BaseItemDto;
title: keyof BaseItemDto;
index: keyof BaseItemDto;
};
export type SeasonIndexState = {
[seriesId: string]: number | null | undefined;
};
export const SeasonDropdown: React.FC<Props> = ({
item,
seasons,
initialSeasonIndex,
state,
onSelect,
}) => {
const keys = useMemo<SeasonKeys>(
() =>
item.Type === "Episode"
? {
id: "ParentId",
title: "SeasonName",
index: "ParentIndexNumber",
}
: {
id: "Id",
title: "Name",
index: "IndexNumber",
},
[item]
);
const seasonIndex = useMemo(
() => state[(item[keys.id] as string) ?? ""],
[state]
);
useEffect(() => {
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
let initialIndex: number | undefined;
if (initialSeasonIndex !== undefined) {
// Use the provided initialSeasonIndex if it exists in the seasons
const seasonExists = seasons.some(
(season: any) => season[keys.index] === initialSeasonIndex
);
if (seasonExists) {
initialIndex = initialSeasonIndex;
}
}
if (initialIndex === undefined) {
// Fall back to the previous logic if initialIndex is not set
const season1 = seasons.find((season: any) => season[keys.index] === 1);
const season0 = seasons.find((season: any) => season[keys.index] === 0);
const firstSeason = season1 || season0 || seasons[0];
onSelect(firstSeason);
}
if (initialIndex !== undefined) {
const initialSeason = seasons.find(
(season: any) => season[keys.index] === initialIndex
);
if (initialSeason) onSelect(initialSeason!);
else throw Error("Initial index could not be found!");
}
}
}, [seasons, seasonIndex, item[keys.id], initialSeasonIndex]);
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
Number(a[keys.index]) - Number(b[keys.index]);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row">
<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>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
{seasons?.sort(sortByIndex).map((season: any) => (
<DropdownMenu.Item
key={season[keys.title]}
onSelect={() => onSelect(season)}
>
<DropdownMenu.ItemTitle>
{season[keys.title]}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

View File

@@ -2,30 +2,27 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToSeconds } from "@/utils/time"; import { runtimeTicksToSeconds } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { TouchableOpacity, View } from "react-native"; import { View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { DownloadItem } from "../DownloadItem"; import { DownloadItems, DownloadSingleItem } from "../DownloadItem";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Image } from "expo-image";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
type Props = { type Props = {
item: BaseItemDto; item: BaseItemDto;
initialSeasonIndex?: number; initialSeasonIndex?: number;
}; };
type SeasonIndexState = {
[seriesId: string]: number;
};
export const seasonIndexAtom = atom<SeasonIndexState>({}); export const seasonIndexAtom = atom<SeasonIndexState>({});
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => { export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
@@ -35,8 +32,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
const seasonIndex = seasonIndexState[item.Id ?? ""]; const seasonIndex = seasonIndexState[item.Id ?? ""];
const router = useRouter();
const { data: seasons } = useQuery({ const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id], queryKey: ["seasons", item.Id],
queryFn: async () => { queryFn: async () => {
@@ -61,37 +56,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
enabled: !!api && !!user?.Id && !!item.Id, enabled: !!api && !!user?.Id && !!item.Id,
}); });
useEffect(() => {
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
let initialIndex: number | undefined;
if (initialSeasonIndex !== undefined) {
// Use the provided initialSeasonIndex if it exists in the seasons
const seasonExists = seasons.some(
(season: any) => season.IndexNumber === initialSeasonIndex
);
if (seasonExists) {
initialIndex = initialSeasonIndex;
}
}
if (initialIndex === undefined) {
// Fall back to the previous logic if initialIndex is not set
const season1 = seasons.find((season: any) => season.IndexNumber === 1);
const season0 = seasons.find((season: any) => season.IndexNumber === 0);
const firstSeason = season1 || season0 || seasons[0];
initialIndex = firstSeason.IndexNumber;
}
if (initialIndex !== undefined) {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: initialIndex,
}));
}
}
}, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]);
const selectedSeasonId: string | null = useMemo( const selectedSeasonId: string | null = useMemo(
() => () =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id, seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
@@ -148,39 +112,37 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
minHeight: 144 * nrOfEpisodes, minHeight: 144 * nrOfEpisodes,
}} }}
> >
<DropdownMenu.Root> <View className="flex flex-row justify-start items-center px-4">
<DropdownMenu.Trigger> <SeasonDropdown
<View className="flex flex-row px-4"> item={item}
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"> seasons={seasons}
<Text>Season {seasonIndex}</Text> state={seasonIndexState}
</TouchableOpacity> onSelect={(season) => {
</View> setSeasonIndexState((prev) => ({
</DropdownMenu.Trigger> ...prev,
<DropdownMenu.Content [item.Id ?? ""]: season.IndexNumber,
loop={true} }));
side="bottom" }}
align="start" />
alignOffset={0} <DownloadItems
avoidCollisions={true} className="ml-2"
collisionPadding={8} items={episodes || []}
sideOffset={8} MissingDownloadIconComponent={() => (
> <MaterialCommunityIcons
<DropdownMenu.Label>Seasons</DropdownMenu.Label> name="download-multiple"
{seasons?.map((season: any) => ( size={20}
<DropdownMenu.Item color="white"
key={season.Name} />
onSelect={() => { )}
setSeasonIndexState((prev) => ({ DownloadedIconComponent={() => (
...prev, <MaterialCommunityIcons
[item.Id ?? ""]: season.IndexNumber, name="check-all"
})); size={20}
}} color="#9333ea"
> />
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle> )}
</DropdownMenu.Item> />
))} </View>
</DropdownMenu.Content>
</DropdownMenu.Root>
<View className="px-4 flex flex-col my-4"> <View className="px-4 flex flex-col my-4">
{isFetching ? ( {isFetching ? (
<View <View
@@ -218,7 +180,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
</Text> </Text>
</View> </View>
<View className="self-start ml-auto -mt-0.5"> <View className="self-start ml-auto -mt-0.5">
<DownloadItem item={e} /> <DownloadSingleItem item={e} />
</View> </View>
</View> </View>

View File

@@ -3,6 +3,7 @@ import { TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { LANGUAGES } from "@/constants/Languages"; import { LANGUAGES } from "@/constants/Languages";
import { TextInput } from "react-native-gesture-handler";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -122,6 +123,45 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Root> </DropdownMenu.Root>
</View> </View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Subtitle Size</Text>
<Text className="text-xs opacity-50">
Choose a default subtitle size for direct play (only works for
some subtitle formats).
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
subtitleSize: Math.max(0, settings.subtitleSize - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{settings.subtitleSize}
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
subtitleSize: Math.min(120, settings.subtitleSize + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
<View <View
className={` className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4 flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4

View File

@@ -4,7 +4,7 @@ import {
getOrSetDeviceId, getOrSetDeviceId,
userAtom, userAtom,
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import {ScreenOrientationEnum, Settings, useSettings} from "@/utils/atoms/settings";
import { import {
BACKGROUND_FETCH_TASK, BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync, registerBackgroundFetchAsync,
@@ -17,7 +17,7 @@ import * as BackgroundFetch from "expo-background-fetch";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager"; import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import { import {
Linking, Linking,
Switch, Switch,
@@ -32,6 +32,7 @@ import { Input } from "../common/Input";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { MediaToggles } from "./MediaToggles"; import { MediaToggles } from "./MediaToggles";
import {Stepper} from "@/components/inputs/Stepper";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -403,6 +404,26 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</View> </View>
)} )}
</View> </View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Show Custom Menu Links</Text>
<Text className="text-xs opacity-50">
Show custom menu links defined inside your Jellyfin web config.json file
</Text>
<TouchableOpacity
onPress={() =>
Linking.openURL("https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links")
}
>
<Text className="text-xs text-purple-600">More info</Text>
</TouchableOpacity>
</View>
<Switch
value={settings.showCustomMenuLinks}
onValueChange={(value) => updateSettings({ showCustomMenuLinks: value })}
/>
</View>
</View> </View>
</View> </View>
@@ -463,7 +484,44 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
</View> </View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4"> <View
pointerEvents={
settings.downloadMethod === "remux" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "remux"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Remux max download</Text>
<Text className="text-xs opacity-50 shrink">
This is the total media you want to be able to download at the same time.
</Text>
</View>
<Stepper
value={settings.remuxConcurrentLimit}
step={1}
min={1}
max={4}
onUpdate={(value) => updateSettings({remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"]})}
/>
</View>
<View
pointerEvents={
settings.downloadMethod === "optimized" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "optimized"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink"> <View className="flex flex-col shrink">
<Text className="font-semibold">Auto download</Text> <Text className="font-semibold">Auto download</Text>
<Text className="text-xs opacity-50 shrink"> <Text className="text-xs opacity-50 shrink">

View File

@@ -0,0 +1,68 @@
import React, { useEffect } from "react";
import { View, StyleSheet } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { Slider } from "react-native-awesome-slider";
import * as Brightness from "expo-brightness";
import { Ionicons } from "@expo/vector-icons";
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
const BrightnessSlider = () => {
const brightness = useSharedValue(50);
const min = useSharedValue(0);
const max = useSharedValue(100);
useEffect(() => {
const fetchInitialBrightness = async () => {
const initialBrightness = await Brightness.getBrightnessAsync();
console.log("initialBrightness", initialBrightness);
brightness.value = initialBrightness * 100;
};
fetchInitialBrightness();
}, []);
const handleValueChange = async (value: number) => {
brightness.value = value;
await Brightness.setBrightnessAsync(value / 100);
};
return (
<View style={styles.sliderContainer}>
<Slider
progress={brightness}
minimumValue={min}
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}}
/>
<Ionicons
name="sunny"
size={20}
color="#FDFDFD"
style={{
marginLeft: 8,
}}
/>
</View>
);
};
const styles = StyleSheet.create({
sliderContainer: {
width: 150,
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
});
export default BrightnessSlider;

View File

@@ -50,6 +50,14 @@ import { VideoProvider } from "./contexts/VideoContext";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import DropdownViewDirect from "./dropdown/DropdownViewDirect"; import DropdownViewDirect from "./dropdown/DropdownViewDirect";
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding"; import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import BrightnessSlider from "./BrightnessSlider";
import SkipButton from "./SkipButton";
import { debounce } from "lodash";
import { EpisodeList } from "./EpisodeList";
import { BlurView } from "expo-blur";
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
interface Props { interface Props {
item: BaseItemDto; item: BaseItemDto;
@@ -63,7 +71,7 @@ interface Props {
ignoreSafeAreas?: boolean; ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>; setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
enableTrickplay?: boolean; enableTrickplay?: boolean;
togglePlay: (ticks: number) => void; togglePlay: () => void;
setShowControls: (shown: boolean) => void; setShowControls: (shown: boolean) => void;
offline?: boolean; offline?: boolean;
isVideoLoaded?: boolean; isVideoLoaded?: boolean;
@@ -110,12 +118,15 @@ export const Controls: React.FC<Props> = ({
const [settings] = useSettings(); const [settings] = useSettings();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const { previousItem, nextItem } = useAdjacentItems({ item }); const { previousItem, nextItem } = useAdjacentItems({ item });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( const {
item, trickPlayUrl,
!offline && enableTrickplay calculateTrickplayUrl,
); trickplayInfo,
prefetchAllTrickplayImages,
} = useTrickplay(item, !offline && enableTrickplay);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0); const [remainingTime, setRemainingTime] = useState(0);
@@ -238,12 +249,28 @@ export const Controls: React.FC<Props> = ({
} }
}, [item, isVlc]); }, [item, isVlc]);
useEffect(() => {
prefetchAllTrickplayImages();
}, []);
const toggleControls = () => setShowControls(!showControls); const toggleControls = () => setShowControls(!showControls);
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
setIsSliding(true);
wasPlayingRef.current = isPlaying;
lastProgressRef.current = progress.value;
pause();
isSeeking.value = true;
}, [showControls, isPlaying]);
const [isSliding, setIsSliding] = useState(false);
const handleSliderComplete = useCallback( const handleSliderComplete = useCallback(
async (value: number) => { async (value: number) => {
isSeeking.value = false; isSeeking.value = false;
progress.value = value; progress.value = value;
setIsSliding(false);
await seek( await seek(
Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))) Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
@@ -254,27 +281,20 @@ export const Controls: React.FC<Props> = ({
); );
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
const handleSliderChange = useCallback(
debounce((value: number) => {
const progressInTicks = msToTicks(value);
console.log("Progress in ticks", progressInTicks);
calculateTrickplayUrl(progressInTicks);
const handleSliderChange = (value: number) => { const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const progressInTicks = isVlc ? msToTicks(value) : value; const hours = Math.floor(progressInSeconds / 3600);
calculateTrickplayUrl(progressInTicks); const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks)); setTime({ hours, minutes, seconds });
const hours = Math.floor(progressInSeconds / 3600); }, 10),
const minutes = Math.floor((progressInSeconds % 3600) / 60); []
const seconds = progressInSeconds % 60; );
setTime({ hours, minutes, seconds });
};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
wasPlayingRef.current = isPlaying;
lastProgressRef.current = progress.value;
pause();
isSeeking.value = true;
}, [showControls, isPlaying]);
const handleSkipBackward = useCallback(async () => { const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) return; if (!settings?.rewindSkipTime) return;
@@ -318,282 +338,408 @@ export const Controls: React.FC<Props> = ({
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}, []); }, []);
const memoizedRenderBubble = useCallback(() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
console.log("time, ", time);
return (
<View
style={{
position: "absolute",
left: -57,
bottom: 15,
paddingTop: 30,
paddingBottom: 5,
width: tileWidth * 1.5,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: tileWidth,
height: tileHeight,
alignSelf: "center",
transform: [{ scale: 1.4 }],
borderRadius: 5,
}}
className="bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
resizeMode: "cover",
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
<Text
style={{
marginTop: 30,
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
</Text>
</View>
);
}, [trickPlayUrl, trickplayInfo, time]);
const [EpisodeView, setEpisodeView] = useState(false);
const switchOnEpisodeMode = () => {
setEpisodeView(true);
if (isPlaying) togglePlay(progress.value);
};
const gotoEpisode = async (itemId: string) => {
const item = await getItemById(api, itemId);
console.log("Item", item);
if (!settings || !item) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(item, settings);
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrate.toString(),
}).toString();
if (!bitrate.value) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
};
return ( return (
<ControlProvider <ControlProvider
item={item} item={item}
mediaSource={mediaSource} mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
> >
<SafeAreaView {EpisodeView ? (
style={{ <EpisodeList item={item} close={() => setEpisodeView(false)} />
flex: 1, ) : (
position: "absolute", <>
top: insets.top, <VideoProvider
left: insets.left, getAudioTracks={getAudioTracks}
right: insets.right, getSubtitleTracks={getSubtitleTracks}
bottom: insets.bottom, setAudioTrack={setAudioTrack}
}} setSubtitleTrack={setSubtitleTrack}
> setSubtitleURL={setSubtitleURL}
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
{!mediaSource?.TranscodingUrl ? (
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
</VideoProvider>
<View
style={[
{
position: "absolute",
bottom: 97,
},
]}
className={`z-10 p-4
${showSkipButton ? "opacity-100" : "opacity-0"}
`}
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
> >
<Text className="text-white">Skip Intro</Text> {!mediaSource?.TranscodingUrl ? (
</TouchableOpacity> <DropdownViewDirect showControls={showControls} />
</View> ) : (
<DropdownViewTranscoding showControls={showControls} />
)}
</VideoProvider>
<View <Pressable
style={{ onPressIn={() => {
position: "absolute", toggleControls();
bottom: 94,
height: 70,
}}
pointerEvents={showSkipCreditButton ? "auto" : "none"}
className={`z-10 p-4 ${
showSkipCreditButton ? "opacity-100" : "opacity-0"
}`}
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
<Pressable
onPressIn={() => {
toggleControls();
}}
style={{
position: "absolute",
width: Dimensions.get("window").width,
height: Dimensions.get("window").height,
}}
></Pressable>
<View
style={[
{
position: "absolute",
top: 0,
right: 0,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
>
{Platform.OS !== "ios" && (
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={async () => {
if (stop) await stop();
router.back();
}} }}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" style={{
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute", position: "absolute",
right: 0, width: Dimensions.get("window").width,
left: 0, height: Dimensions.get("window").height,
bottom: 0, }}
opacity: showControls ? 1 : 0, ></Pressable>
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-col p-4`}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
)}
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
)}
{item?.Type === "Audio" && (
<Text className="text-xs opacity-50">{item?.Album}</Text>
)}
</View>
<View <View
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800`} style={[
{
position: "absolute",
top: 0,
right: 0,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
> >
<View className="flex flex-row items-center space-x-4"> {item?.Type === "Episode" && (
<TouchableOpacity <TouchableOpacity
style={{ onPress={() => {
opacity: !previousItem ? 0.5 : 1, switchOnEpisodeMode();
}} }}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="list" size={24} color="white" />
</TouchableOpacity>
)}
{previousItem && (
<TouchableOpacity
onPress={goToPreviousItem} onPress={goToPreviousItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
> >
<Ionicons name="play-skip-back" size={24} color="white" /> <Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}> )}
{nextItem && (
<TouchableOpacity
onPress={goToNextItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
)}
{mediaSource?.TranscodingUrl && (
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={async () => {
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={{
position: "absolute",
top: "50%", // Center vertically
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
transform: [{ translateY: -22.5 }], // Adjust for the button's height (half of 45)
paddingHorizontal: "28%", // Add some padding to the left and right
opacity: showControls ? 1 : 0,
}}
pointerEvents={showControls ? "box-none" : "none"}
>
<View
style={{
position: "absolute",
alignItems: "center",
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
bottom: 30,
}}
>
<BrightnessSlider />
</View>
<TouchableOpacity onPress={handleSkipBackward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons <Ionicons
name="refresh-outline" name="refresh-outline"
size={26} size={50}
color="white" color="white"
style={{ style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }], transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}} }}
/> />
</TouchableOpacity> <Text
<TouchableOpacity style={{
onPress={() => { position: "absolute",
togglePlay(progress.value); color: "white",
}} fontSize: 16,
> fontWeight: "bold",
bottom: 10,
}}
>
{settings?.rewindSkipTime}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay();
}}
>
{!isBuffering ? (
<Ionicons <Ionicons
name={isPlaying ? "pause" : "play"} name={isPlaying ? "pause" : "play"}
size={30} size={50}
color="white" color="white"
/> />
</TouchableOpacity> ) : (
<TouchableOpacity onPress={handleSkipForward}> <Loader size={"large"} />
<Ionicons name="refresh-outline" size={26} color="white" /> )}
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={goToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150; <TouchableOpacity onPress={handleSkipForward}>
const tileHeight = 150 / trickplayInfo.aspectRatio!; <View
return ( style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name="refresh-outline" size={50} color="white" />
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.forwardSkipTime}
</Text>
</View>
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
right: 0,
left: 0,
bottom: 0,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "box-none" : "none"}
className={`flex flex-col p-4`}
>
<View
className="shrink flex flex-col justify-center h-full mb-2"
style={{
flexDirection: "row",
justifyContent: "space-between",
}}
>
<View
style={{
flexDirection: "column",
alignSelf: "flex-end", // Shrink height based on content
}}
>
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
)}
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">
{item?.ProductionYear}
</Text>
)}
{item?.Type === "Audio" && (
<Text className="text-xs opacity-50">{item?.Album}</Text>
)}
</View>
<View
style={{
flexDirection: "column",
alignSelf: "flex-end",
marginRight: insets.right,
}}
>
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText="Skip Intro"
/>
<SkipButton
showButton={showSkipCreditButton}
onPress={skipCredit}
buttonText="Skip Credits"
/>
</View>
</View>
<View
className={`flex flex-col-reverse py-4 pb-1 px-4 rounded-lg items-center bg-neutral-800`}
>
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#666",
heartbeatColor: "#999",
}}
renderThumb={() => (
<View <View
style={{ style={{
position: "absolute", width: 18,
bottom: 0, height: 18,
left: 0, left: -2,
width: tileWidth, borderRadius: 10,
height: tileHeight, backgroundColor: "#fff",
marginLeft: -tileWidth / 4, justifyContent: "center",
marginTop: -tileHeight / 4 - 60, alignItems: "center",
zIndex: 10,
}} }}
className=" bg-neutral-800 overflow-hidden" />
> )}
<Image cache={cacheProgress}
cachePolicy={"memory-disk"} onSlidingStart={handleSliderStart}
style={{ onSlidingComplete={handleSliderComplete}
width: 150 * trickplayInfo?.data.TileWidth!, onValueChange={handleSliderChange}
height: containerStyle={{
(150 / trickplayInfo.aspectRatio!) * borderRadius: 100,
trickplayInfo?.data.TileHeight!, }}
transform: [ renderBubble={() => isSliding && memoizedRenderBubble()}
{ translateX: -x * tileWidth }, sliderHeight={10}
{ translateY: -y * tileHeight }, thumbWidth={0}
], progress={progress}
}} minimumValue={min}
source={{ uri: url }} maximumValue={max}
contentFit="cover" />
/> <View className="flex flex-row items-center justify-between mt-0.5">
<Text <Text className="text-[12px] text-neutral-400">
style={{ {formatTimeString(currentTime, isVlc ? "ms" : "s")}
position: "absolute", </Text>
bottom: 5, <Text className="text-[12px] text-neutral-400">
left: 5, -{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
color: "white", </Text>
backgroundColor: "rgba(0, 0, 0, 0.5)", </View>
padding: 5,
borderRadius: 5,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${
time.seconds < 10 ? `0${time.seconds}` : time.seconds
}`}
</Text>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
</Text>
</View> </View>
</View> </View>
</View> </View>
</View> </>
</SafeAreaView> )}
</ControlProvider> </ControlProvider>
); );
}; };

View File

@@ -0,0 +1,289 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToSeconds } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState, useRef } from "react";
import { View, TouchableOpacity } from "react-native";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { Loader } from "@/components/Loader";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Text } from "@/components/common/Text";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
HorizontalScroll,
HorizontalScrollRef,
} from "@/components/common/HorrizontalScroll";
import { router, useLocalSearchParams } from "expo-router";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
import { useSettings } from "@/utils/atoms/settings";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { Item } from "zeego/dropdown-menu";
type Props = {
item: BaseItemDto;
close: () => void;
};
export const seasonIndexAtom = atom<SeasonIndexState>({});
export const EpisodeList: React.FC<Props> = ({ item, close }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); // Get safe area insets
const [settings] = useSettings();
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
const scrollToIndex = (index: number) => {
scrollViewRef.current?.scrollToIndex(index, 100);
};
// Set the initial season index
useEffect(() => {
if (item.SeriesId) {
setSeasonIndexState((prev) => ({
...prev,
[item.SeriesId ?? ""]: item.ParentIndexNumber ?? 0,
}));
}
}, []);
const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
const [seriesItem, setSeriesItem] = useState<BaseItemDto | null>(null);
// This effect fetches the series item data/
useEffect(() => {
if (item.SeriesId) {
getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then(
(res) => {
setSeriesItem(res);
}
);
}
}, [item.SeriesId]);
const { data: seasons } = useQuery({
queryKey: ["seasons", item.SeriesId],
queryFn: async () => {
if (!api || !user?.Id || !item.SeriesId) return [];
const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.SeriesId}/Seasons`,
{
params: {
userId: user?.Id,
itemId: item.SeriesId,
Fields:
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount",
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
return response.data.Items;
},
enabled: !!api && !!user?.Id && !!item.SeasonId,
});
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
[seasons, seasonIndex]
);
const { data: episodes, isFetching } = useQuery({
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.SeriesId || "",
userId: user.Id,
seasonId: selectedSeasonId || undefined,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
});
return res.data.Items;
},
enabled: !!api && !!user?.Id && !!selectedSeasonId,
});
useEffect(() => {
if (item?.Type === "Episode" && item.Id) {
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
if (index !== undefined && index !== -1) {
setTimeout(() => {
scrollToIndex(index);
}, 400);
}
}
}, [episodes, item]);
const queryClient = useQueryClient();
useEffect(() => {
for (let e of episodes || []) {
queryClient.prefetchQuery({
queryKey: ["item", e.Id],
queryFn: async () => {
if (!e.Id) return;
const res = await getUserItemData({
api,
userId: user?.Id,
itemId: e.Id,
});
return res;
},
staleTime: 60 * 5 * 1000,
});
}
}, [episodes]);
// Scroll to the current item when episodes are fetched
useEffect(() => {
if (episodes && scrollViewRef.current) {
const currentItemIndex = episodes.findIndex((e) => e.Id === item.Id);
if (currentItemIndex !== -1) {
scrollViewRef.current.scrollToIndex(currentItemIndex, 16); // Adjust the scroll position based on item width
}
}
}, [episodes, item.Id]);
const { audioIndex, subtitleIndex, bitrateValue } = useLocalSearchParams<{
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const gotoEpisode = async (itemId: string) => {
const item = await getItemById(api, itemId);
if (!settings || !item) return;
const { mediaSource } = getDefaultPlaySettings(item, settings);
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue,
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
};
if (!episodes) {
return <Loader />;
}
return (
<View
style={{
position: "absolute",
backgroundColor: "black",
height: "100%",
width: "100%",
}}
>
<>
<View
style={{
justifyContent: "space-between",
}}
className={`flex flex-row items-center space-x-2 z-10 p-4`}
>
{seriesItem && (
<SeasonDropdown
item={seriesItem}
seasons={seasons}
state={seasonIndexState}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[item.SeriesId ?? ""]: season.IndexNumber,
}));
}}
/>
)}
<TouchableOpacity
onPress={async () => {
close();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<HorizontalScroll
ref={scrollViewRef}
data={episodes}
extraData={item}
renderItem={(_item, idx) => (
<View
key={_item.Id}
style={{}}
className={`flex flex-col w-44 ${
item.Id !== _item.Id ? "opacity-75" : ""
}`}
>
<TouchableOpacity
onPress={() => {
gotoEpisode(_item.Id);
}}
>
<ContinueWatchingPoster
item={_item}
useEpisodePoster
showPlayButton={_item.Id !== item.Id}
/>
</TouchableOpacity>
<View className="shrink">
<Text
numberOfLines={2}
style={{
lineHeight: 18, // Adjust this value based on your text size
height: 36, // lineHeight * 2 for consistent two-line space
}}
>
{_item.Name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-475">
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
</Text>
<Text className="text-xs text-neutral-500">
{runtimeTicksToSeconds(_item.RunTimeTicks)}
</Text>
</View>
<View className="self-start mt-2">
<DownloadSingleItem item={_item} />
</View>
<Text
numberOfLines={5}
className="text-xs text-neutral-500 shrink"
>
{_item.Overview}
</Text>
</View>
)}
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
estimatedItemSize={200}
showsHorizontalScrollIndicator={false}
/>
</>
</View>
);
};

View File

@@ -0,0 +1,39 @@
import React from "react";
import { View, TouchableOpacity, Text, StyleSheet } from "react-native";
interface SkipButtonProps {
onPress: () => void;
showButton: boolean;
buttonText: string;
}
const SkipButton: React.FC<SkipButtonProps> = ({
onPress,
showButton,
buttonText,
}) => {
return (
<View style={{ display: showButton ? "flex" : "none" }}>
<TouchableOpacity onPress={onPress} style={styles.button}>
<Text style={styles.text}>{buttonText}</Text>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: "rgba(0, 0, 0, 0.75)",
borderRadius: 5,
paddingHorizontal: 10,
paddingVertical: 15,
borderWidth: 2,
borderColor: "#5A5454",
},
text: {
color: "white",
fontWeight: "bold",
},
});
export default SkipButton;

View File

@@ -22,13 +22,13 @@
} }
}, },
"production": { "production": {
"channel": "0.21.0", "channel": "0.22.0",
"android": { "android": {
"image": "latest" "image": "latest"
} }
}, },
"production-apk": { "production-apk": {
"channel": "0.21.0", "channel": "0.22.0",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"

View File

@@ -1,8 +1,8 @@
import index from "@/app/(auth)/(tabs)/(home)";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
interface AdjacentEpisodesProps { interface AdjacentEpisodesProps {
@@ -12,81 +12,53 @@ interface AdjacentEpisodesProps {
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => { export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { data: previousItem } = useQuery({ const { data: adjacentItems } = useQuery({
queryKey: ["previousItem", item?.Id, item?.ParentId, item?.IndexNumber], queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
queryFn: async (): Promise<BaseItemDto | null> => { queryFn: async (): Promise<BaseItemDto[] | null> => {
const parentId = item?.AlbumId || item?.ParentId; if (!api || !item || !item.SeriesId) {
const indexNumber = item?.IndexNumber;
if (
!api ||
!parentId ||
indexNumber === undefined ||
indexNumber === null ||
indexNumber - 1 < 1
) {
return null; return null;
} }
const newIndexNumber = indexNumber - 2; const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.SeriesId,
const res = await getItemsApi(api).getItems({ adjacentTo: item.Id,
parentId: parentId!, limit: 3,
startIndex: newIndexNumber,
limit: 1,
sortBy: ["IndexNumber"],
includeItemTypes: ["Episode", "Audio"],
fields: ["MediaSources", "MediaStreams", "ParentId"], fields: ["MediaSources", "MediaStreams", "ParentId"],
}); });
if (res.data.Items?.[0]?.IndexNumber !== indexNumber - 1) { return res.data.Items || null;
throw new Error("Previous item is not correct");
}
return res.data.Items?.[0] || null;
}, },
enabled: item?.Type === "Episode" || item?.Type === "Audio", enabled:
!!api &&
!!item?.Id &&
!!item?.SeriesId &&
(item?.Type === "Episode" || item?.Type === "Audio"),
staleTime: 0, staleTime: 0,
}); });
const { data: nextItem } = useQuery({ const previousItem = useMemo(() => {
queryKey: ["nextItem", item?.Id, item?.ParentId, item?.IndexNumber], if (!adjacentItems || adjacentItems.length <= 1) {
queryFn: async (): Promise<BaseItemDto | null> => { return null;
const parentId = item?.AlbumId || item?.ParentId; }
const indexNumber = item?.IndexNumber;
if ( if (adjacentItems.length === 2) {
!api || return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
!parentId || }
indexNumber === undefined ||
indexNumber === null
) {
console.log("No next item", {
itemId: item?.Id,
parentId: parentId,
indexNumber: indexNumber,
});
return null;
}
const res = await getItemsApi(api).getItems({ return adjacentItems[0];
parentId: parentId!, }, [adjacentItems, item]);
startIndex: indexNumber,
sortBy: ["IndexNumber"],
limit: 1,
includeItemTypes: ["Episode", "Audio"],
fields: ["MediaSources", "MediaStreams", "ParentId"],
});
if (res.data.Items?.[0]?.IndexNumber !== indexNumber + 1) { const nextItem = useMemo(() => {
throw new Error("Previous item is not correct"); if (!adjacentItems || adjacentItems.length <= 1) {
} return null;
}
return res.data.Items?.[0] || null; if (adjacentItems.length === 2) {
}, return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
enabled: item?.Type === "Episode" || item?.Type === "Audio", }
staleTime: 0,
}); return adjacentItems[2];
}, [adjacentItems, item]);
return { previousItem, nextItem }; return { previousItem, nextItem };
}; };

View File

@@ -17,16 +17,20 @@ const useDefaultPlaySettings = (
// 2. Get default or preferred audio // 2. Get default or preferred audio
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex; const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
const preferedAudioIndex = mediaSource?.MediaStreams?.find( const preferedAudioIndex = mediaSource?.MediaStreams?.find(
(x) => x.Language === settings?.defaultAudioLanguage (x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage
)?.Index; )?.Index;
const firstAudioIndex = mediaSource?.MediaStreams?.find( const firstAudioIndex = mediaSource?.MediaStreams?.find(
(x) => x.Type === "Audio" (x) => x.Type === "Audio"
)?.Index; )?.Index;
// 3. Get default or preferred subtitle // 3. Get default or preferred subtitle
const preferedSubtitleIndex = mediaSource?.MediaStreams?.find( const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
(x) => x.Language === settings?.defaultSubtitleLanguage?.value (x) =>
x.Type === "Subtitle" &&
x.Language === settings?.defaultSubtitleLanguage?.value
)?.Index; )?.Index;
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find( const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
(stream) => stream.Type === "Subtitle" && stream.IsDefault (stream) => stream.Type === "Subtitle" && stream.IsDefault
)?.Index; )?.Index;
@@ -38,7 +42,9 @@ const useDefaultPlaySettings = (
defaultAudioIndex: defaultAudioIndex:
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined, preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
defaultSubtitleIndex: defaultSubtitleIndex:
preferedSubtitleIndex || defaultSubtitleIndex || undefined, preferedSubtitleIndex !== undefined
? preferedSubtitleIndex
: defaultSubtitleIndex || undefined,
defaultMediaSource: mediaSource || undefined, defaultMediaSource: mediaSource || undefined,
defaultBitrate: bitrate || undefined, defaultBitrate: bitrate || undefined,
}; };

View File

@@ -1,7 +1,7 @@
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage"; import { getItemImage } from "@/utils/getItemImage";
import { writeToLog } from "@/utils/log"; import { writeErrorLog, writeInfoLog, writeToLog } from "@/utils/log";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
@@ -9,11 +9,34 @@ import {
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native"; import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback } from "react"; import { useCallback } from "react";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import useImageStorage from "./useImageStorage"; import useImageStorage from "./useImageStorage";
import useDownloadHelper from "@/utils/download";
import { Api } from "@jellyfin/sdk";
import { useSettings } from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server";
const createFFmpegCommand = (url: string, output: string) => [
"-y", // overwrite output files without asking
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
// region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
"-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
"-multiple_requests 1", // http
"-tcp_nodelay 1", // http
// endregion ffmpeg protocol commands
"-fflags +genpts", // format flags
`-i ${url}`, // infile
"-map 0:v -map 0:a", // select all streams for video & audio
"-c copy", // streamcopy, preventing transcoding
"-bufsize 25M", // amount of data processed before calculating current bitrate
"-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output
output,
];
/** /**
* Custom hook for remuxing HLS to MP4 using FFmpeg. * Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -24,26 +47,125 @@ import useImageStorage from "./useImageStorage";
*/ */
export const useRemuxHlsToMp4 = () => { export const useRemuxHlsToMp4 = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();
const { saveDownloadedItemInfo, setProcesses } = useDownload();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const [settings] = useSettings();
const { saveImage } = useImageStorage(); const { saveImage } = useImageStorage();
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveDownloadedItemInfo, setProcesses, processes } = useDownload();
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
await saveSeriesPrimaryImage(item);
const itemImage = getItemImage({
item,
api,
variant: "Primary",
quality: 90,
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
};
const completeCallback = useCallback(
async (session: FFmpegSession, item: BaseItemDto) => {
try {
let endTime;
const returnCode = await session.getReturnCode();
const startTime = new Date();
if (returnCode.isValueSuccess()) {
endTime = new Date();
const stat = await session.getLastReceivedStatistics();
await queryClient.invalidateQueries({
queryKey: ["downloadedItems"],
});
saveDownloadedItemInfo(item, stat.getSize());
writeInfoLog(
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${
item.Name
},
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s`.replace(/^ +/g, "")
);
toast.success("Download completed");
} else if (returnCode.isValueError()) {
endTime = new Date();
const allLogs = session.getAllLogsAsString();
writeErrorLog(
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s. All logs: ${allLogs}`.replace(/^ +/g, "")
);
} else if (returnCode.isValueCancel()) {
endTime = new Date();
writeInfoLog(
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name},
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s`.replace(/^ +/g, "")
);
}
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
});
} catch (e) {
const error = e as Error;
writeErrorLog(
`useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name},
Error: ${error.message}, Stack: ${error.stack}`.replace(/^ +/g, "")
);
}
},
[processes, setProcesses]
);
const statisticsCallback = useCallback(
(statistics: Statistics, item: BaseItemDto) => {
const videoLength =
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
const percentage =
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
if (!item.Id) throw new Error("Item is undefined");
setProcesses((prev) => {
return prev.map((process) => {
if (process.itemId === item.Id) {
return {
...process,
id: statistics.getSessionId().toString(),
progress: percentage,
speed: Math.max(speed, 0),
};
}
return process;
});
});
},
[setProcesses, completeCallback]
);
const startRemuxing = useCallback( const startRemuxing = useCallback(
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => { async (item: BaseItemDto, url: string) => {
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`; const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
if (!api) throw new Error("API is not defined"); if (!api) throw new Error("API is not defined");
if (!item.Id) throw new Error("Item must have an Id"); if (!item.Id) throw new Error("Item must have an Id");
const itemImage = getItemImage({ // First lets save any important assets we want to present to the user offline
item, await onSaveAssets(api, item);
api,
variant: "Primary",
quality: 90,
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
toast.success(`Download started for ${item.Name}`, { toast.success(`Download started for ${item.Name}`, {
action: { action: {
@@ -55,128 +177,34 @@ export const useRemuxHlsToMp4 = () => {
}, },
}); });
const command = `-y -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -map 0:v -map 0:a -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
);
try { try {
setProcesses((prev) => [ const job: JobStatus = {
...prev, id: "",
{ deviceId: "",
id: "", inputUrl: url,
deviceId: "", item: item,
inputUrl: "", itemId: item.Id!,
item: item, outputPath: output,
itemId: item.Id!, progress: 0,
outputPath: "", status: "downloading",
progress: 0, timestamp: new Date(),
status: "downloading", };
timestamp: new Date(),
},
]);
FFmpegKitConfig.enableStatisticsCallback((statistics) => { writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
const videoLength = setProcesses((prev) => [...prev, job]);
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
const percentage = await FFmpegKit.executeAsync(
totalFrames > 0 createFFmpegCommand(url, output).join(" "),
? Math.floor((processedFrames / totalFrames) * 100) (session) => completeCallback(session, item),
: 0; undefined,
(s) => statisticsCallback(s, item)
if (!item.Id) throw new Error("Item is undefined"); );
setProcesses((prev) => {
return prev.map((process) => {
if (process.itemId === item.Id) {
return {
...process,
progress: percentage,
speed: Math.max(speed, 0),
};
}
return process;
});
});
});
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
await new Promise<void>((resolve, reject) => {
FFmpegKit.executeAsync(command, async (session) => {
try {
const returnCode = await session.getReturnCode();
const startTime = new Date();
let endTime;
if (returnCode.isValueSuccess()) {
endTime = new Date();
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${
item.Name
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s`
);
if (!item) throw new Error("Item is undefined");
await saveDownloadedItemInfo(item);
toast.success("Download completed");
await queryClient.invalidateQueries({
queryKey: ["downloadedItems"],
});
resolve();
} else if (returnCode.isValueError()) {
endTime = new Date();
const allLogs = session.getAllLogsAsString();
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${
item.Name
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s. All logs: ${allLogs}`
);
reject(new Error("Remuxing failed"));
} else if (returnCode.isValueCancel()) {
endTime = new Date();
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${
item.Name
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s`
);
resolve();
}
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
});
} catch (e) {
const error = e as Error;
const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name}, ${errorLog}`
);
reject(error);
}
});
});
} catch (e) { } catch (e) {
const error = e as Error; const error = e as Error;
const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
console.error("Failed to remux:", error); console.error("Failed to remux:", error);
writeToLog( writeErrorLog(
"ERROR", `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, ${errorLog}` Error: ${error.message}, Stack: ${error.stack}`
); );
setProcesses((prev) => { setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id); return prev.filter((process) => process.itemId !== item.Id);
@@ -184,7 +212,7 @@ export const useRemuxHlsToMp4 = () => {
throw error; // Re-throw the error to propagate it to the caller throw error; // Re-throw the error to propagate it to the caller
} }
}, },
[] [settings, processes, setProcesses, completeCallback, statisticsCallback]
); );
const cancelRemuxing = useCallback(() => { const cancelRemuxing = useCallback(() => {

View File

@@ -1,8 +1,7 @@
// hooks/useTrickplay.ts
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { ticksToMs } from "@/utils/time"; import { ticksToMs } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
@@ -111,9 +110,45 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
[trickplayInfo, item, api, enabled] [trickplayInfo, item, api, enabled]
); );
const prefetchAllTrickplayImages = useCallback(() => {
if (!api || !enabled || !trickplayInfo || !item.Id || !item.RunTimeTicks) {
return;
}
const { data, resolution } = trickplayInfo;
const { Interval, TileWidth, TileHeight, Width, Height } = data;
if (
!Interval ||
!TileWidth ||
!TileHeight ||
!resolution ||
!Width ||
!Height
) {
throw new Error("Invalid trickplay data");
}
// Calculate tiles per sheet
const tilesPerRow = TileWidth;
const tilesPerColumn = TileHeight;
const tilesPerSheet = tilesPerRow * tilesPerColumn;
const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
const totalIndexes = Math.ceil(totalTiles / tilesPerSheet);
// Prefetch all trickplay images
for (let index = 0; index < totalIndexes; index++) {
const url = `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`;
Image.prefetch(url);
}
}, [trickplayInfo, item, api, enabled]);
return { return {
trickPlayUrl: enabled ? trickPlayUrl : null, trickPlayUrl: enabled ? trickPlayUrl : null,
calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null, calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null,
prefetchAllTrickplayImages: enabled
? prefetchAllTrickplayImages
: () => null,
trickplayInfo: enabled ? trickplayInfo : null, trickplayInfo: enabled ? trickplayInfo : null,
}; };
}; };

View File

@@ -1,91 +1,27 @@
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { Alert } from "react-native"; import { Alert } from "react-native";
import { Router, useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { Api } from "@jellyfin/sdk"; import { useWebSocketContext } from "@/providers/WebSocketProvider";
import { useAtomValue } from "jotai";
import {
apiAtom,
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
interface UseWebSocketProps { interface UseWebSocketProps {
isPlaying: boolean; isPlaying: boolean;
pauseVideo: () => void; togglePlay: () => void;
playVideo: () => void;
stopPlayback: () => void; stopPlayback: () => void;
offline?: boolean; offline: boolean;
} }
export const useWebSocket = ({ export const useWebSocket = ({
isPlaying, isPlaying,
pauseVideo, togglePlay,
playVideo,
stopPlayback, stopPlayback,
offline = false, offline,
}: UseWebSocketProps) => { }: UseWebSocketProps) => {
const router = useRouter(); const router = useRouter();
const user = useAtomValue(userAtom); const { ws } = useWebSocketContext();
const api = useAtomValue(apiAtom);
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const { data: deviceId } = useQuery({
queryKey: ["deviceId"],
queryFn: async () => {
return await getOrSetDeviceId();
},
staleTime: Infinity,
});
useEffect(() => { useEffect(() => {
if (offline || !deviceId || !api?.accessToken) return; if (!ws) return;
if (offline) return;
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api?.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api?.accessToken
}&deviceId=${deviceId}`;
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
newWebSocket.onopen = () => {
setIsConnected(true);
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
}
}, 30000);
};
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
newWebSocket.onclose = (e) => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
};
setWs(newWebSocket);
return () => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
newWebSocket.close();
};
}, [api, deviceId, user, offline]);
useEffect(() => {
if (offline || !ws) return;
ws.onmessage = (e) => { ws.onmessage = (e) => {
const json = JSON.parse(e.data); const json = JSON.parse(e.data);
@@ -95,8 +31,7 @@ export const useWebSocket = ({
if (command === "PlayPause") { if (command === "PlayPause") {
console.log("Command ~ PlayPause"); console.log("Command ~ PlayPause");
if (isPlaying) pauseVideo(); togglePlay();
else playVideo();
} else if (command === "Stop") { } else if (command === "Stop") {
console.log("Command ~ Stop"); console.log("Command ~ Stop");
stopPlayback(); stopPlayback();
@@ -108,7 +43,9 @@ export const useWebSocket = ({
Alert.alert("Message from server: " + title, body); Alert.alert("Message from server: " + title, body);
} }
}; };
}, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router, offline]);
return { isConnected }; return () => {
ws.onmessage = null;
};
}, [ws, stopPlayback, togglePlay, isPlaying, router]);
}; };

View File

@@ -1,6 +1,8 @@
package expo.modules.vlcplayer package expo.modules.vlcplayer
import android.content.Context import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
@@ -32,6 +34,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var startPosition: Int? = 0 private var startPosition: Int? = 0
private var isMediaReady: Boolean = false private var isMediaReady: Boolean = false
private var externalTrack: Map<String, String>? = null private var externalTrack: Map<String, String>? = null
var hasSource: Boolean = false
private val handler = Handler(Looper.getMainLooper())
private val updateInterval = 1000L // 1 second
private val updateProgressRunnable = object : Runnable {
override fun run() {
updateVideoProgress()
handler.postDelayed(this, updateInterval)
}
}
init { init {
setupView() setupView()
@@ -48,6 +60,11 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
fun setSource(source: Map<String, Any>) { fun setSource(source: Map<String, Any>) {
if (hasSource) {
mediaPlayer?.attachViews(videoLayout, null, false, false)
play()
return
}
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap() val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
val autoplay = source["autoplay"] as? Boolean ?: false val autoplay = source["autoplay"] as? Boolean ?: false
val isNetwork = source["isNetwork"] as? Boolean ?: false val isNetwork = source["isNetwork"] as? Boolean ?: false
@@ -87,6 +104,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// Log.d("VlcPlayerView", "Debug: Subtitle track index is less than -1, not setting") // Log.d("VlcPlayerView", "Debug: Subtitle track index is less than -1, not setting")
// } // }
hasSource = true
if (autoplay) { if (autoplay) {
Log.d("VlcPlayerView", "Playing...") Log.d("VlcPlayerView", "Playing...")
@@ -97,15 +115,18 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
fun play() { fun play() {
mediaPlayer?.play() mediaPlayer?.play()
isPaused = false isPaused = false
handler.post(updateProgressRunnable) // Start updating progress
} }
fun pause() { fun pause() {
mediaPlayer?.pause() mediaPlayer?.pause()
isPaused = true isPaused = true
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
} }
fun stop() { fun stop() {
mediaPlayer?.stop() mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
} }
fun seekTo(time: Int) { fun seekTo(time: Int) {
@@ -170,6 +191,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
println("onDetachedFromWindow") println("onDetachedFromWindow")
super.onDetachedFromWindow() super.onDetachedFromWindow()
mediaPlayer?.stop() mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
media?.release() media?.release()
mediaPlayer?.release() mediaPlayer?.release()
@@ -187,7 +209,9 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
MediaPlayer.Event.Buffering, MediaPlayer.Event.Buffering,
MediaPlayer.Event.EndReached, MediaPlayer.Event.EndReached,
MediaPlayer.Event.EncounteredError -> updatePlayerState(event) MediaPlayer.Event.EncounteredError -> updatePlayerState(event)
MediaPlayer.Event.TimeChanged -> updateVideoProgress() MediaPlayer.Event.TimeChanged -> {
// Do nothing here, as we are updating progress every 1 second
}
} }
} }
@@ -241,7 +265,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val currentTimeMs = player.time.toInt() val currentTimeMs = player.time.toInt()
val durationMs = player.media?.duration?.toInt() ?: 0 val durationMs = player.media?.duration?.toInt() ?: 0
if (currentTimeMs >= 0 && currentTimeMs < durationMs) { if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
// Set subtitle URL if available // Set subtitle URL if available
if (player.isPlaying && !isMediaReady) { if (player.isPlaying && !isMediaReady) {

View File

@@ -5,7 +5,7 @@ import UIKit
class VlcPlayerView: ExpoView { class VlcPlayerView: ExpoView {
private var mediaPlayer: VLCMediaPlayer? private var mediaPlayer: VLCMediaPlayer?
private var videoView: UIView? private var videoView: UIView?
private var progressUpdateInterval: TimeInterval = 0.5 private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
private var isPaused: Bool = false private var isPaused: Bool = false
private var currentGeometryCString: [CChar]? private var currentGeometryCString: [CChar]?
private var lastReportedState: VLCMediaPlayerState? private var lastReportedState: VLCMediaPlayerState?
@@ -14,13 +14,17 @@ class VlcPlayerView: ExpoView {
private var startPosition: Int32 = 0 private var startPosition: Int32 = 0
private var isMediaReady: Bool = false private var isMediaReady: Bool = false
private var externalTrack: [String: String]? private var externalTrack: [String: String]?
private var progressTimer: DispatchSourceTimer?
private var isStopping: Bool = false // Define isStopping here
private var lastProgressCall = Date().timeIntervalSince1970
var hasSource = false
// MARK: - Initialization // MARK: - Initialization
required init(appContext: AppContext? = nil) { required init(appContext: AppContext? = nil) {
super.init(appContext: appContext) super.init(appContext: appContext)
setupView() setupView()
// setupNotifications() setupNotifications()
} }
// MARK: - Setup // MARK: - Setup
@@ -48,82 +52,71 @@ class VlcPlayerView: ExpoView {
self, selector: #selector(applicationWillResignActive), self, selector: #selector(applicationWillResignActive),
name: UIApplication.willResignActiveNotification, object: nil) name: UIApplication.willResignActiveNotification, object: nil)
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, selector: #selector(applicationWillEnterForeground), self, selector: #selector(applicationDidBecomeActive),
name: UIApplication.willEnterForegroundNotification, object: nil) name: UIApplication.didBecomeActiveNotification, object: nil)
} }
// MARK: - Public Methods // MARK: - Public Methods
@objc func play() { @objc func play() {
DispatchQueue.main.async { [weak self] in self.mediaPlayer?.play()
guard let self = self else { return } self.isPaused = false
self.mediaPlayer?.play() print("Play")
self.isPaused = false
print("Play")
}
} }
@objc func pause() { @objc func pause() {
DispatchQueue.main.async { [weak self] in self.mediaPlayer?.pause()
guard let self = self else { return } self.isPaused = true
self.mediaPlayer?.pause()
self.isPaused = true
}
} }
@objc func seekTo(_ time: Int32) { @objc func seekTo(_ time: Int32) {
DispatchQueue.main.async { [weak self] in guard let player = self.mediaPlayer else { return }
guard let self = self, let player = self.mediaPlayer else { return }
let wasPlaying = player.isPlaying
if wasPlaying {
self.pause()
}
if let duration = player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime)
let wasPlaying = player.isPlaying
if wasPlaying { if wasPlaying {
player.pause() self.play()
}
if let duration = player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime)
// Wait for a short moment to ensure the seek has been processed
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if wasPlaying {
player.play()
}
self.updatePlayerState()
}
} else {
print("Error: Unable to retrieve video duration")
} }
self.updatePlayerState()
} else {
print("Error: Unable to retrieve video duration")
} }
} }
@objc func setSource(_ source: [String: Any]) { @objc func setSource(_ source: [String: Any]) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if self.hasSource {
return
}
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:] let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
self.externalTrack = source["externalTrack"] as? [String: String] self.externalTrack = source["externalTrack"] as? [String: String]
var initOptions = source["initOptions"] as? [Any] ?? [] var initOptions = source["initOptions"] as? [Any] ?? []
startPosition = source["startPosition"] as? Int32 ?? 0 self.startPosition = source["startPosition"] as? Int32 ?? 0
initOptions.append("--start-time=\(startPosition)") initOptions.append("--start-time=\(self.startPosition)")
let uri = source["uri"] as? String guard let uri = source["uri"] as? String, !uri.isEmpty else {
let autoplay = source["autoplay"] as? Bool ?? false
let isNetwork = source["isNetwork"] as? Bool ?? false
guard let uri = uri, !uri.isEmpty else {
print("Error: Invalid or empty URI") print("Error: Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"]) self.onVideoError?(["error": "Invalid or empty URI"])
return return
} }
let autoplay = source["autoplay"] as? Bool ?? false
let isNetwork = source["isNetwork"] as? Bool ?? false
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()]) self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
self.mediaPlayer = VLCMediaPlayer(options: initOptions) self.mediaPlayer = VLCMediaPlayer(options: initOptions)
self.mediaPlayer?.delegate = self self.mediaPlayer?.delegate = self
self.mediaPlayer?.drawable = self.videoView self.mediaPlayer?.drawable = self.videoView
self.mediaPlayer?.scaleFactor = 0 self.mediaPlayer?.scaleFactor = 0
@@ -134,14 +127,8 @@ class VlcPlayerView: ExpoView {
media = VLCMedia(url: URL(string: uri)!) media = VLCMedia(url: URL(string: uri)!)
} else { } else {
print("Loading local file: \(uri)") print("Loading local file: \(uri)")
if uri.starts(with: "file://") { if uri.starts(with: "file://"), let url = URL(string: uri) {
if let url = URL(string: uri) { media = VLCMedia(url: url)
media = VLCMedia(url: url)
} else {
print("Error: Invalid local file URL")
self.onVideoError?(["error": "Invalid local file URL"])
return
}
} else { } else {
media = VLCMedia(path: uri) media = VLCMedia(path: uri)
} }
@@ -150,18 +137,8 @@ class VlcPlayerView: ExpoView {
print("Debug: Media options: \(mediaOptions)") print("Debug: Media options: \(mediaOptions)")
media.addOptions(mediaOptions) media.addOptions(mediaOptions)
// Apply subtitle options
let subtitleTrackIndex = source["subtitleTrackIndex"] as? Int ?? -1
print("Debug: Subtitle track index from source: \(subtitleTrackIndex)")
if subtitleTrackIndex >= -1 {
self.setSubtitleTrack(subtitleTrackIndex)
print("Debug: Set subtitle track to index: \(subtitleTrackIndex)")
} else {
print("Debug: Subtitle track index is less than -1, not setting")
}
self.mediaPlayer?.media = media self.mediaPlayer?.media = media
self.hasSource = true
if autoplay { if autoplay {
print("Playing...") print("Playing...")
@@ -170,33 +147,8 @@ class VlcPlayerView: ExpoView {
} }
} }
// TODO
// @objc func setMuted(_ muted: Bool) {
// DispatchQueue.main.async {
// self.mediaPlayer?.audio?.isMuted = muted
// }
// }
// TODO
// @objc func setVolume(_ volume: Int) {
// DispatchQueue.main.async {
// self.mediaPlayer?.audio?.volume = Int32(volume)
// }
// }
// TODO
// @objc func setVideoAspectRatio(_ ratio: String) {
// DispatchQueue.main.async {
// ratio.withCString { cString in
// self.mediaPlayer?.videoAspectRatio = UnsafeMutablePointer(mutating: cString)
// }
// }
// }
@objc func setAudioTrack(_ trackIndex: Int) { @objc func setAudioTrack(_ trackIndex: Int) {
DispatchQueue.main.async { self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
}
} }
@objc func getAudioTracks() -> [[String: Any]]? { @objc func getAudioTracks() -> [[String: Any]]? {
@@ -211,73 +163,27 @@ class VlcPlayerView: ExpoView {
} }
} }
// @objc func getAudioTracks(
// _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
// ) {
// DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// guard let self = self, let mediaPlayer = self.mediaPlayer else {
// DispatchQueue.main.async {
// reject("ERROR", "Media player not available", nil)
// }
// return
// }
// guard let trackNames = mediaPlayer.audioTrackNames,
// let trackIndexes = mediaPlayer.audioTrackIndexes
// else {
// DispatchQueue.main.async {
// reject("ERROR", "No audio tracks available", nil)
// }
// return
// }
// let tracks = zip(trackNames, trackIndexes).map { name, index in
// return ["name": name, "index": index]
// }
// DispatchQueue.main.async {
// resolve(tracks)
// }
// }
// }
@objc func setSubtitleTrack(_ trackIndex: Int) { @objc func setSubtitleTrack(_ trackIndex: Int) {
print("Debug: Attempting to set subtitle track to index: \(trackIndex)") print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
DispatchQueue.main.async { self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
if trackIndex == -1 { print(
print("Debug: Disabling subtitles") "Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
// Disable subtitles )
self.mediaPlayer?.currentVideoSubTitleIndex = -1
} else {
print("Debug: Setting subtitle track to index: \(trackIndex)")
// Set the subtitle track
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
}
// Print the result
if let currentIndex = self.mediaPlayer?.currentVideoSubTitleIndex {
print("Debug: Current subtitle track index after setting: \(currentIndex)")
} else {
print("Debug: Unable to retrieve current subtitle track index")
}
}
} }
@objc func setSubtitleURL(_ subtitleURL: String, name: String) { @objc func setSubtitleURL(_ subtitleURL: String, name: String) {
DispatchQueue.main.async { [weak self] in guard let url = URL(string: subtitleURL) else {
guard let self = self, let url = URL(string: subtitleURL) else { print("Error: Invalid subtitle URL")
print("Error: Invalid subtitle URL") return
return }
}
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true) let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
if let result = result { if let result = result {
let internalName = "Track \(self.customSubtitles.count + 1)" let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)") print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name)) self.customSubtitles.append((internalName: internalName, originalName: name))
} else { } else {
print("Failed to add subtitle") print("Failed to add subtitle")
}
} }
} }
@@ -330,135 +236,6 @@ class VlcPlayerView: ExpoView {
print("Track not found for name: \(trackName)") print("Track not found for name: \(trackName)")
} }
// @objc func getSubtitleTracks(
// _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
// ) {
// DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// guard let self = self, let mediaPlayer = self.mediaPlayer else {
// DispatchQueue.main.async {
// reject("ERROR", "Media player not available", nil)
// }
// return
// }
// let count = mediaPlayer.numberOfSubtitlesTracks
// guard count > 0 else {
// DispatchQueue.main.async {
// reject("ERROR", "No subtitle tracks available", nil)
// }
// return
// }
// var tracks: [[String: Any]] = [["name": "Disabled", "index": -1]]
// if let names = mediaPlayer.videoSubTitlesNames as? [String],
// let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
// {
// for (index, name) in zip(indexes, names) {
// tracks.append(["name": name, "index": index.intValue])
// }
// }
// DispatchQueue.main.async {
// resolve(tracks)
// }
// }
// }
// TODO
// @objc func setSubtitleDelay(_ delay: Int) {
// DispatchQueue.main.async {
// self.mediaPlayer?.currentVideoSubTitleDelay = NSInteger(delay)
// }
// }
// TODO
// @objc func setAudioDelay(_ delay: Int) {
// DispatchQueue.main.async {
// self.mediaPlayer?.currentAudioPlaybackDelay = NSInteger(delay)
// }
// }
// TODO
// @objc func takeSnapshot(_ path: String, width: Int, height: Int) {
// DispatchQueue.main.async { [weak self] in
// guard let self = self else { return }
// self.mediaPlayer?.saveVideoSnapshot(
// at: path, withWidth: Int32(width), andHeight: Int32(height))
// }
// }
// TODO
// @objc func setVideoCropGeometry(_ geometry: String?) {
// DispatchQueue.main.async {
// if let geometry = geometry, !geometry.isEmpty {
// self.currentGeometryCString = geometry.cString(using: .utf8)
// self.currentGeometryCString?.withUnsafeMutableBufferPointer { buffer in
// self.mediaPlayer?.videoCropGeometry = buffer.baseAddress
// }
// } else {
// self.currentGeometryCString = nil
// self.mediaPlayer?.videoCropGeometry = nil
// }
// }
// }
// TODO
// @objc func getVideoCropGeometry() -> String? {
// guard let cString = mediaPlayer?.videoCropGeometry else {
// return nil
// }
// return String(cString: cString)
// }
// TODO
// @objc func setRate(_ rate: Float) {
// DispatchQueue.main.async {
// self.mediaPlayer?.rate = rate
// }
// }
// TODO
// @objc func nextChapter() {
// DispatchQueue.main.async {
// self.mediaPlayer?.nextChapter()
// }
// }
// TODO
// @objc func previousChapter() {
// DispatchQueue.main.async {
// self.mediaPlayer?.previousChapter()
// }
// }
// TODO
// @objc func getChapters() -> [[String: Any]]? {
// guard let currentTitleIndex = mediaPlayer?.currentTitleIndex,
// let chapters = mediaPlayer?.chapterDescriptions(ofTitle: currentTitleIndex)
// as? [[String: Any]]
// else {
// return nil
// }
// return chapters.compactMap { chapter in
// guard let name = chapter[VLCChapterDescriptionName] as? String,
// let timeOffset = chapter[VLCChapterDescriptionTimeOffset] as? NSNumber,
// let duration = chapter[VLCChapterDescriptionDuration] as? NSNumber
// else {
// return nil
// }
// return [
// "name": name,
// "timeOffset": timeOffset.doubleValue,
// "duration": duration.doubleValue,
// ]
// }
// }
private var isStopping: Bool = false
@objc func stop(completion: (() -> Void)? = nil) { @objc func stop(completion: (() -> Void)? = nil) {
guard !isStopping else { guard !isStopping else {
completion?() completion?()
@@ -479,15 +256,11 @@ class VlcPlayerView: ExpoView {
// MARK: - Private Methods // MARK: - Private Methods
@objc private func applicationWillResignActive() { @objc private func applicationWillResignActive() {
if !isPaused {
pause()
}
} }
@objc private func applicationWillEnterForeground() { @objc private func applicationDidBecomeActive() {
if !isPaused {
play()
}
} }
private func performStop(completion: (() -> Void)? = nil) { private func performStop(completion: (() -> Void)? = nil) {
@@ -509,42 +282,31 @@ class VlcPlayerView: ExpoView {
completion?() completion?()
} }
private func getSubtitleOptions() -> [String: Any] { private func updateVideoProgress() {
return [ guard let player = self.mediaPlayer else { return }
// Text scaling (100 is default, increase for larger text)
"sub-text-scale": "105",
// Text color (RRGGBB format, 16777215 is white) let currentTimeMs = player.time.intValue
"freetype-color": "16777215", let durationMs = player.media?.length.intValue ?? 0
// Outline thickness (reduced from 2 to 1 for less border) print("Debug: Current time: \(currentTimeMs)")
"freetype-outline-thickness": "1", if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady {
// Outline color (RRGGBB format, 0 is black) self.isMediaReady = true
"freetype-outline-color": "0", // Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
// Text opacity (0-255, 255 is fully opaque) if let name = externalTrack["name"], !name.isEmpty {
"freetype-opacity": "255", let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
self.setSubtitleURL(deliveryUrl, name: name)
// Shadow opacity (increased from 128 to 180 for more shadow) }
"freetype-shadow-opacity": "180", }
}
// Shadow offset (increased from 2 to 3 for more pronounced shadow) self.onVideoProgress?([
"freetype-shadow-offset": "3", "currentTime": currentTimeMs,
"duration": durationMs,
// Text alignment (0: center, 1: left, 2: right) ])
"sub-text-alignment": "0", }
// Vertical margin (from bottom of the screen, in pixels)
"sub-margin-bottom": "50",
// Background opacity (0-255, 0 for no background)
"freetype-background-opacity": "64",
// Background color (RRGGBB format)
"freetype-background-color": "0",
]
} }
// MARK: - Expo Events // MARK: - Expo Events
@objc var onPlaybackStateChanged: RCTDirectEventBlock? @objc var onPlaybackStateChanged: RCTDirectEventBlock?
@@ -562,116 +324,64 @@ class VlcPlayerView: ExpoView {
} }
extension VlcPlayerView: VLCMediaPlayerDelegate { extension VlcPlayerView: VLCMediaPlayerDelegate {
func mediaPlayerStateChanged(_ aNotification: Notification) { func mediaPlayerTimeChanged(_ aNotification: Notification) {
DispatchQueue.main.async { [weak self] in // self?.updateVideoProgress()
guard let self = self else { return } let timeNow = Date().timeIntervalSince1970
self.updatePlayerState() if timeNow - lastProgressCall >= 1 {
lastProgressCall = timeNow
updateVideoProgress()
} }
} }
func mediaPlayerStateChanged(_ aNotification: Notification) {
self.updatePlayerState()
}
private func updatePlayerState() { private func updatePlayerState() {
DispatchQueue.main.async { [weak self] in guard let player = self.mediaPlayer else { return }
guard let self = self, let player = self.mediaPlayer else { return } let currentState = player.state
let currentState = player.state
var stateInfo: [String: Any] = [ var stateInfo: [String: Any] = [
"target": self.reactTag ?? NSNull(), "target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue, "currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0, "duration": player.media?.length.intValue ?? 0,
"error": false, "error": false,
] ]
if player.isPlaying { if player.isPlaying {
stateInfo["isPlaying"] = true stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing" stateInfo["state"] = "Playing"
} else { } else {
stateInfo["isPlaying"] = false stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused" 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)
}
} }
}
// func seekToStartTime() { if player.state == VLCMediaPlayerState.buffering {
// DispatchQueue.main.async { [weak self] in stateInfo["isBuffering"] = true
// guard let self = self, let player = self.mediaPlayer else { return } stateInfo["state"] = "Buffering"
} else if player.state == VLCMediaPlayerState.error {
// if let startPosition = self.startPosition, startPosition > 0 { print("player.state ~ error")
// print("Debug: Seeking to start position: \(startPosition)") stateInfo["state"] = "Error"
// player.time = VLCTime(int: Int32(startPosition)) self.onVideoLoadEnd?(stateInfo)
} else if player.state == VLCMediaPlayerState.opening {
// // Ensure the player continues playing after seeking print("player.state ~ opening")
// if !player.isPlaying { stateInfo["state"] = "Opening"
// player.play()
// }
// }
// }
// }
func mediaPlayerTimeChanged(_ aNotification: Notification) {
DispatchQueue.main.async { [weak self] in
self?.updateVideoProgress()
} }
}
private func updateVideoProgress() { if self.lastReportedState != currentState
DispatchQueue.main.async { || self.lastReportedIsPlaying != player.isPlaying
guard let player = self.mediaPlayer else { return } {
self.lastReportedState = currentState
let currentTimeMs = player.time.intValue self.lastReportedIsPlaying = player.isPlaying
let durationMs = player.media?.length.intValue ?? 0 self.onVideoStateChange?(stateInfo)
if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady {
self.isMediaReady = true
// Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
if let name = externalTrack["name"] as? String, !name.isEmpty {
let deliveryUrl = externalTrack["DeliveryUrl"] as? String ?? ""
self.setSubtitleURL(deliveryUrl, name: name)
}
}
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
} }
} }
} }
extension VlcPlayerView: VLCMediaDelegate { extension VlcPlayerView: VLCMediaDelegate {
// func mediaMetaDataDidChange(_ aMedia: VLCMedia) { // Implement VLCMediaDelegate methods if needed
// // Implement if needed
// }
// func mediaDidFinishParsing(_ aMedia: VLCMedia) {
// DispatchQueue.main.async {
// let duration = aMedia.length.intValue
// self.onVideoStateChange?(["type": "MediaParsed", "duration": duration])
// }
// }
} }
extension VLCMediaPlayerState { extension VLCMediaPlayerState {

View File

@@ -16,6 +16,7 @@
"preset": "jest-expo" "preset": "jest-expo"
}, },
"dependencies": { "dependencies": {
"@bottom-tabs/react-navigation": "^0.7.1",
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0", "@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0", "@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.4", "@expo/vector-icons": "^14.0.4",
@@ -39,6 +40,7 @@
"expo-asset": "~10.0.10", "expo-asset": "~10.0.10",
"expo-background-fetch": "~12.0.1", "expo-background-fetch": "~12.0.1",
"expo-blur": "~13.0.2", "expo-blur": "~13.0.2",
"expo-brightness": "~12.0.1",
"expo-build-properties": "~0.12.5", "expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2", "expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.29", "expo-dev-client": "~4.0.29",
@@ -69,8 +71,9 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-native": "0.74.5", "react-native": "0.74.5",
"react-native-awesome-slider": "^2.5.6", "react-native-awesome-slider": "^2.5.6",
"react-native-bottom-tabs": "^0.4.0", "react-native-bottom-tabs": "^0.7.3",
"react-native-circular-progress": "^1.4.1", "react-native-circular-progress": "^1.4.1",
"react-native-color-matrix-image-filters": "^7.0.1",
"react-native-compressor": "^1.9.0", "react-native-compressor": "^1.9.0",
"react-native-device-info": "^14.0.1", "react-native-device-info": "^14.0.1",
"react-native-edge-to-edge": "^1.1.1", "react-native-edge-to-edge": "^1.1.1",
@@ -82,6 +85,7 @@
"react-native-ios-utilities": "^4.5.1", "react-native-ios-utilities": "^4.5.1",
"react-native-mmkv": "^2.12.2", "react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.3.0", "react-native-pager-view": "6.3.0",
"react-native-progress": "^5.0.1",
"react-native-reanimated": "~3.10.1", "react-native-reanimated": "~3.10.1",
"react-native-reanimated-carousel": "4.0.0-canary.15", "react-native-reanimated-carousel": "4.0.0-canary.15",
"react-native-safe-area-context": "4.10.5", "react-native-safe-area-context": "4.10.5",

View File

@@ -1,6 +1,6 @@
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device"; import { getOrSetDeviceId } from "@/utils/device";
import { writeToLog } from "@/utils/log"; import { useLog, writeToLog } from "@/utils/log";
import { import {
cancelAllJobs, cancelAllJobs,
cancelJobById, cancelJobById,
@@ -30,7 +30,7 @@ import {
import axios from "axios"; import axios from "axios";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import React, { import React, {
createContext, createContext,
useCallback, useCallback,
@@ -46,12 +46,17 @@ import * as Notifications from "expo-notifications";
import { getItemImage } from "@/utils/getItemImage"; import { getItemImage } from "@/utils/getItemImage";
import useImageStorage from "@/hooks/useImageStorage"; import useImageStorage from "@/hooks/useImageStorage";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import useDownloadHelper from "@/utils/download";
import { FileInfo } from "expo-file-system";
import * as Haptics from "expo-haptics";
export type DownloadedItem = { export type DownloadedItem = {
item: Partial<BaseItemDto>; item: Partial<BaseItemDto>;
mediaSource: MediaSourceInfo; mediaSource: MediaSourceInfo;
}; };
export const processesAtom = atom<JobStatus[]>([]);
function onAppStateChange(status: AppStateStatus) { function onAppStateChange(status: AppStateStatus) {
focusManager.setFocused(status === "active"); focusManager.setFocused(status === "active");
} }
@@ -65,10 +70,12 @@ function useDownloadProvider() {
const [settings] = useSettings(); const [settings] = useSettings();
const router = useRouter(); const router = useRouter();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const { logs } = useLog();
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage(); const { saveImage } = useImageStorage();
const [processes, setProcesses] = useState<JobStatus[]>([]); const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const authHeader = useMemo(() => { const authHeader = useMemo(() => {
return api?.accessToken; return api?.accessToken;
@@ -258,8 +265,11 @@ function useDownloadProvider() {
) )
); );
}) })
.done(async () => { .done(async (doneHandler) => {
await saveDownloadedItemInfo(process.item); await saveDownloadedItemInfo(
process.item,
doneHandler.bytesDownloaded
);
toast.success(`Download completed for ${process.item.Name}`, { toast.success(`Download completed for ${process.item.Name}`, {
duration: 3000, duration: 3000,
action: { action: {
@@ -311,6 +321,7 @@ function useDownloadProvider() {
const fileExtension = mediaSource.TranscodingContainer; const fileExtension = mediaSource.TranscodingContainer;
const deviceId = await getOrSetDeviceId(); const deviceId = await getOrSetDeviceId();
await saveSeriesPrimaryImage(item);
const itemImage = getItemImage({ const itemImage = getItemImage({
item, item,
api, api,
@@ -384,19 +395,25 @@ function useDownloadProvider() {
); );
const deleteAllFiles = async (): Promise<void> => { const deleteAllFiles = async (): Promise<void> => {
try { Promise.all([
await deleteLocalFiles(); deleteLocalFiles(),
removeDownloadedItemsFromStorage(); removeDownloadedItemsFromStorage(),
await cancelAllServerJobs(); cancelAllServerJobs(),
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
toast.success("All files, folders, and jobs deleted successfully"); ])
} catch (error) { .then(() =>
console.error("Failed to delete all files, folders, and jobs:", error); toast.success("All files, folders, and jobs deleted successfully")
toast.error("An error occurred while deleting files and jobs"); )
} .catch((reason) => {
console.error("Failed to delete all files, folders, and jobs:", reason);
toast.error("An error occurred while deleting files and jobs");
});
}; };
const deleteLocalFiles = async (): Promise<void> => { const forEveryDirectoryFile = async (
includeMMKV: boolean = true,
callback: (file: FileInfo) => void
) => {
const baseDirectory = FileSystem.documentDirectory; const baseDirectory = FileSystem.documentDirectory;
if (!baseDirectory) { if (!baseDirectory) {
throw new Error("Base directory not found"); throw new Error("Base directory not found");
@@ -404,25 +421,31 @@ function useDownloadProvider() {
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory); const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
for (const item of dirContents) { for (const item of dirContents) {
const itemPath = `${baseDirectory}${item}`; // Exclude mmkv directory.
const itemInfo = await FileSystem.getInfoAsync(itemPath); // Deleting this deletes all user information as well. Logout should handle this.
if (item == "mmkv" && !includeMMKV) continue;
const itemInfo = await FileSystem.getInfoAsync(`${baseDirectory}${item}`);
if (itemInfo.exists) { if (itemInfo.exists) {
if (itemInfo.isDirectory) { callback(itemInfo);
await FileSystem.deleteAsync(itemPath, { idempotent: true });
} else {
await FileSystem.deleteAsync(itemPath, { idempotent: true });
}
} }
} }
}; };
const removeDownloadedItemsFromStorage = (): void => { const deleteLocalFiles = async (): Promise<void> => {
try { await forEveryDirectoryFile(false, (file) => {
storage.delete("downloadedItems"); console.warn("Deleting file", file.uri);
} catch (error) { FileSystem.deleteAsync(file.uri, { idempotent: true });
console.error("Failed to remove downloadedItems from storage:", error); });
throw error; };
}
const removeDownloadedItemsFromStorage = async () => {
// delete any saved images first
Promise.all([deleteFileByType("Movie"), deleteFileByType("Episode")])
.then(() => storage.delete("downloadedItems"))
.catch((reason) => {
console.error("Failed to remove downloadedItems from storage:", reason);
throw reason;
});
}; };
const cancelAllServerJobs = async (): Promise<void> => { const cancelAllServerJobs = async (): Promise<void> => {
@@ -430,7 +453,8 @@ function useDownloadProvider() {
throw new Error("No auth header available"); throw new Error("No auth header available");
} }
if (!settings?.optimizedVersionsServerUrl) { if (!settings?.optimizedVersionsServerUrl) {
throw new Error("No server URL configured"); console.error("No server URL configured");
return;
} }
const deviceId = await getOrSetDeviceId(); const deviceId = await getOrSetDeviceId();
@@ -490,6 +514,40 @@ function useDownloadProvider() {
} }
}; };
const deleteItems = async (items: BaseItemDto[]) => {
Promise.all(
items.map((i) => {
if (i.Id) return deleteFile(i.Id);
return;
})
).then(() =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
);
};
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
await Promise.all(
downloadedFiles
?.filter((file) => file.item.Type == type)
?.flatMap((file) => {
const promises = [];
if (type == "Episode" && file.item.SeriesId)
promises.push(deleteFile(file.item.SeriesId));
promises.push(deleteFile(file.item.Id!));
return promises;
}) || []
);
};
const appSizeUsage = useMemo(async () => {
const sizes: number[] = [];
await forEveryDirectoryFile(true, (file) => {
if (file.exists) sizes.push(file.size);
});
return sizes.reduce((sum, size) => sum + size, 0);
}, [logs, downloadedFiles]);
function getDownloadedItem(itemId: string): DownloadedItem | null { function getDownloadedItem(itemId: string): DownloadedItem | null {
try { try {
const downloadedItems = storage.getString("downloadedItems"); const downloadedItems = storage.getString("downloadedItems");
@@ -519,7 +577,7 @@ function useDownloadProvider() {
} }
} }
function saveDownloadedItemInfo(item: BaseItemDto) { function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {
try { try {
const downloadedItems = storage.getString("downloadedItems"); const downloadedItems = storage.getString("downloadedItems");
let items: DownloadedItem[] = downloadedItems let items: DownloadedItem[] = downloadedItems
@@ -546,6 +604,8 @@ function useDownloadProvider() {
deleteDownloadItemInfoFromDiskTmp(item.Id!); deleteDownloadItemInfoFromDiskTmp(item.Id!);
storage.set("downloadedItems", JSON.stringify(items)); storage.set("downloadedItems", JSON.stringify(items));
storage.set("downloadedItemSize-" + item.Id, size.toString());
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
refetch(); refetch();
} catch (error) { } catch (error) {
@@ -556,17 +616,26 @@ function useDownloadProvider() {
} }
} }
function getDownloadedItemSize(itemId: string): number {
const size = storage.getString("downloadedItemSize-" + itemId);
return size ? parseInt(size) : 0;
}
return { return {
processes, processes,
startBackgroundDownload, startBackgroundDownload,
downloadedFiles, downloadedFiles,
deleteAllFiles, deleteAllFiles,
deleteFile, deleteFile,
deleteItems,
saveDownloadedItemInfo, saveDownloadedItemInfo,
removeProcess, removeProcess,
setProcesses, setProcesses,
startDownload, startDownload,
getDownloadedItem, getDownloadedItem,
deleteFileByType,
appSizeUsage,
getDownloadedItemSize,
}; };
} }
@@ -587,3 +656,16 @@ export function useDownload() {
} }
return context; return context;
} }
export function bytesToReadable(bytes: number): string {
const gb = bytes / 1e9;
if (gb >= 1) return `${gb.toFixed(2)} GB`;
const mb = bytes / 1024 / 1024;
if (mb >= 1) return `${mb.toFixed(2)} MB`;
const kb = bytes / 1024;
if (kb >= 1) return `${kb.toFixed(2)} KB`;
return `${bytes.toFixed(2)} B`;
}

View File

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

View File

@@ -1,13 +1,10 @@
import { Bitrate } from "@/components/BitrateSelector"; import { Bitrate } from "@/components/BitrateSelector";
import { settingsAtom } from "@/utils/atoms/settings"; import { settingsAtom } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native"; import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
@@ -19,7 +16,6 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { apiAtom, userAtom } from "./JellyfinProvider"; import { apiAtom, userAtom } from "./JellyfinProvider";
import iosFmp4 from "@/utils/profiles/iosFmp4";
export type PlaybackType = { export type PlaybackType = {
item?: BaseItemDto | null; item?: BaseItemDto | null;
@@ -124,25 +120,25 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
[api, user, settings, playSettings] [api, user, settings, playSettings]
); );
useEffect(() => { // useEffect(() => {
const postCaps = async () => { // const postCaps = async () => {
if (!api) return; // if (!api) return;
await getSessionApi(api).postFullCapabilities({ // await getSessionApi(api).postFullCapabilities({
clientCapabilitiesDto: { // clientCapabilitiesDto: {
AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679", // AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679",
DeviceProfile: native as any, // DeviceProfile: native as any,
IconUrl: // IconUrl:
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png", // "https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
PlayableMediaTypes: ["Audio", "Video"], // PlayableMediaTypes: ["Audio", "Video"],
SupportedCommands: ["Play"], // SupportedCommands: ["Play"],
SupportsMediaControl: true, // SupportsMediaControl: true,
SupportsPersistentIdentifier: true, // SupportsPersistentIdentifier: true,
}, // },
}); // });
}; // };
postCaps(); // postCaps();
}, [settings, api]); // }, [settings, api]);
return ( return (
<PlaySettingsContext.Provider <PlaySettingsContext.Provider

View File

@@ -0,0 +1,144 @@
import React, {
createContext,
useContext,
useEffect,
useState,
ReactNode,
useMemo,
useCallback,
} from "react";
import { Alert, AppState, AppStateStatus } from "react-native";
import { useAtomValue } from "jotai";
import { useQuery } from "@tanstack/react-query";
import {
apiAtom,
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import native from "@/utils/profiles/native";
interface WebSocketProviderProps {
children: ReactNode;
}
interface WebSocketContextType {
ws: WebSocket | null;
isConnected: boolean;
}
const WebSocketContext = createContext<WebSocketContextType | null>(null);
export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const deviceId = useMemo(() => {
return getOrSetDeviceId();
}, []);
const connectWebSocket = useCallback(() => {
if (!deviceId || !api?.accessToken) return;
const protocol = api.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api.accessToken
}&deviceId=${deviceId}`;
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
newWebSocket.onopen = () => {
setIsConnected(true);
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
}
}, 30000);
};
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
newWebSocket.onclose = () => {
if (keepAliveInterval) clearInterval(keepAliveInterval);
setIsConnected(false);
};
setWs(newWebSocket);
return () => {
if (keepAliveInterval) clearInterval(keepAliveInterval);
newWebSocket.close();
};
}, [api, deviceId]);
useEffect(() => {
const cleanup = connectWebSocket();
return cleanup;
}, [connectWebSocket]);
useEffect(() => {
if (!deviceId || !api || !api?.accessToken) return;
const init = async () => {
await getSessionApi(api).postFullCapabilities({
clientCapabilitiesDto: {
AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679",
IconUrl:
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
PlayableMediaTypes: ["Audio", "Video"],
SupportedCommands: ["Play"],
SupportsMediaControl: true,
SupportsPersistentIdentifier: true,
},
});
};
init();
}, [api, deviceId]);
useEffect(() => {
const handleAppStateChange = (state: AppStateStatus) => {
if (state === "background" || state === "inactive") {
console.log("App moving to background, closing WebSocket...");
ws?.close();
} else if (state === "active") {
console.log("App coming to foreground, reconnecting WebSocket...");
connectWebSocket();
}
};
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => {
subscription.remove();
ws?.close();
};
}, [ws, connectWebSocket]);
return (
<WebSocketContext.Provider value={{ ws, isConnected }}>
{children}
</WebSocketContext.Provider>
);
};
export const useWebSocketContext = (): WebSocketContextType => {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error(
"useWebSocketContext must be used within a WebSocketProvider"
);
}
return context;
};

View File

@@ -1,6 +1,9 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
import {JobStatus} from "@/utils/optimize-server";
import {processesAtom} from "@/providers/DownloadProvider";
import {useSettings} from "@/utils/atoms/settings";
export interface Job { export interface Job {
id: string; id: string;
@@ -13,8 +16,8 @@ export const runningAtom = atom<boolean>(false);
export const queueAtom = atom<Job[]>([]); export const queueAtom = atom<Job[]>([]);
export const queueActions = { export const queueActions = {
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => { enqueue: (queue: Job[], setQueue: (update: Job[]) => void, ...job: Job[]) => {
const updatedQueue = [...queue, job]; const updatedQueue = [...queue, ...job];
console.info("Enqueueing job", job, updatedQueue); console.info("Enqueueing job", job, updatedQueue);
setQueue(updatedQueue); setQueue(updatedQueue);
}, },
@@ -24,14 +27,17 @@ export const queueActions = {
setProcessing: (processing: boolean) => void setProcessing: (processing: boolean) => void
) => { ) => {
const [job, ...rest] = queue; const [job, ...rest] = queue;
setQueue(rest);
console.info("Processing job", job); console.info("Processing job", job);
setProcessing(true); setProcessing(true);
// Excute the function assiociated with the job. // Allow job to execute so that it gets added as a processes first BEFORE updating new queue
await job.execute(); try {
await job.execute();
} finally {
setQueue(rest);
}
console.info("Job done", job); console.info("Job done", job);
@@ -49,11 +55,13 @@ export const queueActions = {
export const useJobProcessor = () => { export const useJobProcessor = () => {
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const [running, setRunning] = useAtom(runningAtom); const [running, setRunning] = useAtom(runningAtom);
const [processes] = useAtom<JobStatus[]>(processesAtom);
const [settings] = useSettings();
useEffect(() => { useEffect(() => {
if (queue.length > 0 && !running) { if (!running && queue.length > 0 && settings && processes.length < settings?.remuxConcurrentLimit) {
console.info("Processing queue", queue); console.info("Processing queue", queue);
queueActions.processJob(queue, setQueue, setRunning); queueActions.processJob(queue, setQueue, setRunning);
} }
}, [queue, running, setQueue, setRunning]); }, [processes, queue, running, setQueue, setRunning]);
}; };

View File

@@ -2,6 +2,7 @@ import { atom, useAtom } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "expo-screen-orientation";
import { storage } from "../mmkv"; import { storage } from "../mmkv";
import { Platform } from "react-native";
export type DownloadQuality = "original" | "high" | "low"; export type DownloadQuality = "original" | "high" | "low";
@@ -74,6 +75,9 @@ export type Settings = {
optimizedVersionsServerUrl?: string | null; optimizedVersionsServerUrl?: string | null;
downloadMethod: "optimized" | "remux"; downloadMethod: "optimized" | "remux";
autoDownload: boolean; autoDownload: boolean;
showCustomMenuLinks: boolean;
subtitleSize: number;
remuxConcurrentLimit: 1 | 2 | 3 | 4; // TODO: Maybe let people choose their own limit? 4 seems like a safe max?
}; };
const loadSettings = (): Settings => { const loadSettings = (): Settings => {
@@ -103,6 +107,9 @@ const loadSettings = (): Settings => {
optimizedVersionsServerUrl: null, optimizedVersionsServerUrl: null,
downloadMethod: "remux", downloadMethod: "remux",
autoDownload: false, autoDownload: false,
showCustomMenuLinks: false,
subtitleSize: Platform.OS === "ios" ? 60 : 100,
remuxConcurrentLimit: 1,
}; };
try { try {

33
utils/download.ts Normal file
View File

@@ -0,0 +1,33 @@
import useImageStorage from "@/hooks/useImageStorage";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { storage } from "@/utils/mmkv";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai";
const useDownloadHelper = () => {
const [api] = useAtom(apiAtom);
const { saveImage } = useImageStorage();
const saveSeriesPrimaryImage = async (item: BaseItemDto) => {
console.log(`Attempting to save primary image for item: ${item.Id}`);
if (
item.Type === "Episode" &&
item.SeriesId &&
!storage.getString(item.SeriesId)
) {
console.log(`Saving primary image for series: ${item.SeriesId}`);
await saveImage(
item.SeriesId,
getPrimaryImageUrlById({ api, id: item.SeriesId })
);
console.log(`Primary image saved for series: ${item.SeriesId}`);
} else {
console.log(`Skipping primary image save for item: ${item.Id}`);
}
};
return { saveSeriesPrimaryImage };
};
export default useDownloadHelper;

View File

@@ -10,8 +10,8 @@ interface PlaySettings {
item: BaseItemDto; item: BaseItemDto;
bitrate: (typeof BITRATES)[0]; bitrate: (typeof BITRATES)[0];
mediaSource?: MediaSourceInfo | null; mediaSource?: MediaSourceInfo | null;
audioIndex?: number | null; audioIndex?: number | undefined;
subtitleIndex?: number | null; subtitleIndex?: number | undefined;
} }
export function getDefaultPlaySettings( export function getDefaultPlaySettings(

View File

@@ -1,5 +1,7 @@
import { atomWithStorage, createJSONStorage } from "jotai/utils"; import { atomWithStorage, createJSONStorage } from "jotai/utils";
import { storage } from "./mmkv"; import { storage } from "./mmkv";
import {useQuery} from "@tanstack/react-query";
import React, {createContext, useContext} from "react";
type LogLevel = "INFO" | "WARN" | "ERROR"; type LogLevel = "INFO" | "WARN" | "ERROR";
@@ -17,6 +19,24 @@ const mmkvStorage = createJSONStorage(() => ({
})); }));
const logsAtom = atomWithStorage("logs", [], mmkvStorage); const logsAtom = atomWithStorage("logs", [], mmkvStorage);
const LogContext = createContext<ReturnType<typeof useLogProvider> | null>(null);
const DownloadContext = createContext<ReturnType<
typeof useLogProvider
> | null>(null);
function useLogProvider() {
const { data: logs } = useQuery({
queryKey: ["logs"],
queryFn: async () => readFromLog(),
refetchInterval: 1000,
});
return {
logs
}
}
export const writeToLog = (level: LogLevel, message: string, data?: any) => { export const writeToLog = (level: LogLevel, message: string, data?: any) => {
const newEntry: LogEntry = { const newEntry: LogEntry = {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -35,6 +55,9 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => {
storage.set("logs", JSON.stringify(recentLogs)); storage.set("logs", JSON.stringify(recentLogs));
}; };
export const writeInfoLog = (message: string, data?: any) => writeToLog("INFO", message, data);
export const writeErrorLog = (message: string, data?: any) => writeToLog("ERROR", message, data);
export const readFromLog = (): LogEntry[] => { export const readFromLog = (): LogEntry[] => {
const logs = storage.getString("logs"); const logs = storage.getString("logs");
return logs ? JSON.parse(logs) : []; return logs ? JSON.parse(logs) : [];
@@ -44,4 +67,22 @@ export const clearLogs = () => {
storage.delete("logs"); storage.delete("logs");
}; };
export function useLog() {
const context = useContext(LogContext);
if (context === null) {
throw new Error("useLog must be used within a LogProvider");
}
return context;
}
export function LogProvider({children}: { children: React.ReactNode }) {
const provider = useLogProvider();
return (
<LogContext.Provider value={provider}>
{children}
</LogContext.Provider>
)
}
export default logsAtom; export default logsAtom;

View File

@@ -1,18 +1,25 @@
import { import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
DeviceProfile
} from "@jellyfin/sdk/lib/generated-client/models";
export const chromecastProfile: DeviceProfile = { export const chromecastProfile: DeviceProfile = {
Name: "Chromecast Video Profile", Name: "Chromecast Video Profile",
Id: "chromecast-001", MaxStreamingBitrate: 8000000, // 8 Mbps
MaxStreamingBitrate: 4000000, // 4 Mbps MaxStaticBitrate: 8000000, // 8 Mbps
MaxStaticBitrate: 4000000, // 4 Mbps
MusicStreamingTranscodingBitrate: 384000, // 384 kbps MusicStreamingTranscodingBitrate: 384000, // 384 kbps
CodecProfiles: [
{
Type: "Video",
Codec: "h264",
},
{
Type: "Audio",
Codec: "aac,mp3,flac,opus,vorbis",
},
],
DirectPlayProfiles: [ DirectPlayProfiles: [
{ {
Container: "mp4,webm", Container: "mp4",
Type: "Video", Type: "Video",
VideoCodec: "h264,vp8,vp9", VideoCodec: "h264",
AudioCodec: "aac,mp3,opus,vorbis", AudioCodec: "aac,mp3,opus,vorbis",
}, },
{ {
@@ -34,89 +41,32 @@ export const chromecastProfile: DeviceProfile = {
], ],
TranscodingProfiles: [ TranscodingProfiles: [
{ {
Container: "ts",
Type: "Video", Type: "Video",
VideoCodec: "h264", Context: "Streaming",
AudioCodec: "aac,mp3",
Protocol: "hls", Protocol: "hls",
Context: "Streaming", Container: "ts",
MaxAudioChannels: "2", VideoCodec: "h264, hevc",
MinSegments: 2, AudioCodec: "aac,mp3,ac3",
BreakOnNonKeyFrames: true, CopyTimestamps: false,
EnableSubtitlesInManifest: true,
}, },
{ {
Container: "mp4", Type: "Audio",
Type: "Video", Context: "Streaming",
VideoCodec: "h264",
AudioCodec: "aac",
Protocol: "http", Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
{
Container: "mp3", Container: "mp3",
Type: "Audio",
AudioCodec: "mp3", AudioCodec: "mp3",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2", MaxAudioChannels: "2",
}, },
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
],
ContainerProfiles: [
{
Type: "Video",
Container: "mp4",
},
{
Type: "Video",
Container: "webm",
},
],
CodecProfiles: [
{
Type: "Video",
Codec: "h264",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoBitDepth",
Value: "8",
},
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "41",
},
],
},
{
Type: "Video",
Codec: "vp9",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoBitDepth",
Value: "10",
},
],
},
], ],
SubtitleProfiles: [ SubtitleProfiles: [
{ {
Format: "vtt", Format: "vtt",
Method: "Hls", Method: "Encode",
}, },
{ {
Format: "vtt", Format: "vtt",
Method: "External", Method: "Encode",
}, },
], ],
}; };

View File

@@ -1,180 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from '../../constants/MediaTypes';
import BaseProfile from './base';
/**
* Device profile for Expo Video player on iOS 10
*/
export default {
...BaseProfile,
Name: 'Expo iOS 10 Video Profile',
CodecProfiles: [
// iOS<13 only supports max h264 level 4.2 in ts containers
{
Codec: 'h264',
Conditions: [
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsAnamorphic',
Value: 'true'
},
{
Condition: 'EqualsAny',
IsRequired: false,
Property: 'VideoProfile',
Value: 'high|main|baseline|constrained baseline'
},
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsInterlaced',
Value: 'true'
},
{
Condition: 'LessThanEqual',
IsRequired: false,
Property: 'VideoLevel',
Value: '42'
}
],
Container: 'ts',
Type: MediaTypes.Video
},
...BaseProfile.CodecProfiles
],
DirectPlayProfiles: [
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mp4,m4v',
Type: MediaTypes.Video,
VideoCodec: 'h264,vc1'
},
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mov',
Type: MediaTypes.Video,
VideoCodec: 'h264'
},
{
Container: 'mp3',
Type: MediaTypes.Audio
},
{
Container: 'aac',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'm4a',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'm4b',
Type: MediaTypes.Audio
},
{
Container: 'alac',
Type: MediaTypes.Audio
},
{
AudioCodec: 'alac',
Container: 'm4a',
Type: MediaTypes.Audio
},
{
AudioCodec: 'alac',
Container: 'm4b',
Type: MediaTypes.Audio
},
{
Container: 'wav',
Type: MediaTypes.Audio
}
],
TranscodingProfiles: [
{
AudioCodec: 'aac',
BreakOnNonKeyFrames: true,
Container: 'aac',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac,mp3',
BreakOnNonKeyFrames: true,
Container: 'ts',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Video,
VideoCodec: 'h264'
},
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mp4',
Context: 'Static',
Protocol: 'http',
Type: MediaTypes.Video,
VideoCodec: 'h264'
}
]
};

View File

@@ -1,49 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import iOSProfile from './ios';
/**
* Device profile for Expo Video player on iOS 11-12
*/
export default {
...iOSProfile,
Name: 'Expo iOS 12 Video Profile',
CodecProfiles: [
// iOS<13 only supports max h264 level 4.2 in ts containers
{
Codec: 'h264',
Conditions: [
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsAnamorphic',
Value: 'true'
},
{
Condition: 'EqualsAny',
IsRequired: false,
Property: 'VideoProfile',
Value: 'high|main|baseline|constrained baseline'
},
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsInterlaced',
Value: 'true'
},
{
Condition: 'LessThanEqual',
IsRequired: false,
Property: 'VideoLevel',
Value: '42'
}
],
Container: 'ts',
Type: 'Video'
},
...iOSProfile.CodecProfiles
]
};

View File

@@ -1,35 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from '../../constants/MediaTypes';
import iOSProfile from './ios';
/**
* Device profile for Expo Video player on iOS 13+ with fMP4 support
*/
export default {
...iOSProfile,
Name: 'Expo iOS fMP4 Video Profile',
TranscodingProfiles: [
// Add all audio profiles from default profile
...iOSProfile.TranscodingProfiles.filter(profile => profile.Type === MediaTypes.Audio),
// Add fMP4 profile
{
AudioCodec: 'aac,mp3,flac,alac',
BreakOnNonKeyFrames: true,
Container: 'mp4',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Video,
VideoCodec: 'hevc,h264'
},
// Add all video profiles from default profile
...iOSProfile.TranscodingProfiles.filter(profile => profile.Type === MediaTypes.Video)
]
};

View File

@@ -10,8 +10,8 @@ import MediaTypes from "../../constants/MediaTypes";
*/ */
export default { export default {
Name: "1. Vlc Player", Name: "1. Vlc Player",
MaxStaticBitrate: 20_000_000, MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 20_000_000, MaxStreamingBitrate: 999_999_999,
CodecProfiles: [ CodecProfiles: [
{ {
Type: MediaTypes.Video, Type: MediaTypes.Video,

View File

@@ -1,259 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from "../../constants/MediaTypes";
/**
* Device profile for old phones (aka does not support HEVC)
*
* This file is a modified version of the original file.
*
* Link to original: https://github.com/jellyfin/jellyfin-expo/blob/e7b7e736a8602c94612917ef02de22f87c7c28f2/utils/profiles/ios.js#L4
*/
export default {
MaxStreamingBitrate: 3000000,
MaxStaticBitrate: 3000000,
MusicStreamingTranscodingBitrate: 256000,
DirectPlayProfiles: [
{
Container: "mp4,m4v",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
{
Container: "mkv",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
{
Container: "mov",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
{
Container: "mp3",
Type: "Audio",
},
{
Container: "aac",
Type: "Audio",
},
{
Container: "m4a",
AudioCodec: "aac",
Type: "Audio",
},
{
Container: "m4b",
AudioCodec: "aac",
Type: "Audio",
},
{
Container: "hls",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
],
TranscodingProfiles: [
{
Container: "mp4",
Type: "Audio",
AudioCodec: "aac",
Context: "Streaming",
Protocol: "hls",
MaxAudioChannels: "2",
MinSegments: "1",
BreakOnNonKeyFrames: true,
},
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Context: "Streaming",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Context: "Streaming",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Context: "Static",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Context: "Static",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "mp4",
Type: "Video",
AudioCodec: "aac,mp2",
VideoCodec: "h264",
Context: "Streaming",
Protocol: "hls",
MaxAudioChannels: "2",
MinSegments: "1",
BreakOnNonKeyFrames: true,
Conditions: [
{
Condition: "LessThanEqual",
Property: "Width",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "Height",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoFramerate",
Value: "60",
IsRequired: false,
},
],
},
{
Container: "ts",
Type: "Video",
AudioCodec: "aac,mp3,mp2",
VideoCodec: "h264",
Context: "Streaming",
Protocol: "hls",
MaxAudioChannels: "2",
MinSegments: "1",
BreakOnNonKeyFrames: true,
Conditions: [
{
Condition: "LessThanEqual",
Property: "Width",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "Height",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoFramerate",
Value: "60",
IsRequired: false,
},
],
},
],
ContainerProfiles: [],
CodecProfiles: [
{
Type: "VideoAudio",
Codec: "aac",
Conditions: [
{
Condition: "Equals",
Property: "IsSecondaryAudio",
Value: "false",
IsRequired: false,
},
],
},
{
Type: "VideoAudio",
Conditions: [
{
Condition: "Equals",
Property: "IsSecondaryAudio",
Value: "false",
IsRequired: false,
},
],
},
{
Type: "Video",
Codec: "h264",
Conditions: [
{
Condition: "NotEquals",
Property: "IsAnamorphic",
Value: "true",
IsRequired: false,
},
{
Condition: "EqualsAny",
Property: "VideoProfile",
Value: "high|main|baseline|constrained baseline",
IsRequired: false,
},
{
Condition: "EqualsAny",
Property: "VideoRangeType",
Value: "SDR",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "52",
IsRequired: false,
},
{
Condition: "NotEquals",
Property: "IsInterlaced",
Value: "true",
IsRequired: false,
},
],
},
{
Type: "Video",
Conditions: [
{
Condition: "LessThanEqual",
Property: "Width",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "Height",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoFramerate",
Value: "65",
IsRequired: false,
},
],
},
],
SubtitleProfiles: [
{
Method: "Encode",
},
],
};