Merge branch 'main' into selfsigned

This commit is contained in:
Violet Caulfield
2026-01-11 00:30:14 -06:00
committed by GitHub
83 changed files with 752 additions and 823 deletions
+1 -4
View File
@@ -16,7 +16,6 @@ Here's the best way to get started:
### Universal Dependencies
- [Ruby](https://www.ruby-lang.org/en/documentation/installation/) for Fastlane
- [NodeJS v22](https://nodejs.org/en/download) for React Native
- [Bun](https://bun.sh/) for managing dependencies
@@ -31,10 +30,8 @@ Here's the best way to get started:
##### Setup
- Clone this repository
- Run `bun init-ios:new-arch` to initialize the project
- Run `bun init-ios` to initialize the project
- This will install `npm` packages, install `bundler` and required gems, and install required CocoaPods with [React Native's New Architecture](https://reactnative.dev/blog/2024/10/23/the-new-architecture-is-here#what-is-the-new-architecture)
- In the `ios` directory, run `fastlane match development --readonly` to fetch the development signing certificates
- _You will need access to the "Jellify Signing" private repository_
##### Running
+2 -2
View File
@@ -91,8 +91,8 @@ android {
applicationId "com.cosmonautical.jellify"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 170
versionName "1.0.11"
versionCode 171
versionName "1.0.12"
resValue "string", "build_config_package", "com.jellify"
}
+7 -7
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "jellify",
@@ -30,6 +29,7 @@
"lodash": "^4.17.21",
"openai": "5.21.0",
"react": "19.2.0",
"react-freeze": "^1.0.4",
"react-native": "0.83.1",
"react-native-background-actions": "^4.0.1",
"react-native-blob-util": "^0.22.2",
@@ -42,10 +42,10 @@
"react-native-google-cast": "^4.9.1",
"react-native-haptic-feedback": "^2.3.3",
"react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "^4.1.0",
"react-native-mmkv": "^4.1.1",
"react-native-nitro-fetch": "^0.1.6",
"react-native-nitro-modules": "0.32.0-beta.0",
"react-native-nitro-ota": "0.9.0",
"react-native-nitro-modules": "^0.32.1",
"react-native-nitro-ota": "^0.10.0",
"react-native-pager-view": "8.0.0",
"react-native-reanimated": "4.1.6",
"react-native-safe-area-context": "5.6.2",
@@ -1924,13 +1924,13 @@
"react-native-linear-gradient": ["react-native-linear-gradient@2.8.3", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-KflAXZcEg54PXkLyflaSZQ3PJp4uC4whM7nT/Uot9m0e/qxFV3p6uor1983D1YOBJbJN7rrWdqIjq0T42jOJyA=="],
"react-native-mmkv": ["react-native-mmkv@4.1.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-ia76WnU6dkLZxFkSSflxqFgHT2pIaML763aucEu7nMglF41oEWTdTtBu0o8a1cxbhZOaONk6KF8RQp5fLvPitA=="],
"react-native-mmkv": ["react-native-mmkv@4.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="],
"react-native-nitro-fetch": ["react-native-nitro-fetch@0.1.6", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "^0.29.2", "react-native-worklets-core": "^1.6.0" }, "optionalPeers": ["react-native-worklets-core"] }, "sha512-DbE/vN5B67SJM8Q0myHOwSSc7ASqJPaKYXVsWdNGIPS+csr9gygCKILT4RQ+xZ92iJGKn4bfyq+rRsacRWBV9A=="],
"react-native-nitro-modules": ["react-native-nitro-modules@0.32.0-beta.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Yb3PaefFZXVLAj/2meruDX9IH6Q8P0eeKqLiTcwY8NG/bMb/cUqteYA9FZIhc8TUGmn/FiQu18hhCF0QYySOWA=="],
"react-native-nitro-modules": ["react-native-nitro-modules@0.32.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-V+Vy76e4fxRxgVGu5Uh3cBPvuFQW8fM1OUKk1mqEA/JawjhX+hxHtBhpfuvNjV0BnV/uXCIg8/eK+rTpB6tqFg=="],
"react-native-nitro-ota": ["react-native-nitro-ota@0.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "^0.31.10" } }, "sha512-hxSGMy612Iz+DEgJu1fLBFV9w2Z9s3hR7glnj/qDmzpF5+Bb2NxPGmr19hhhL6riKZx/+BO3e1mY4OG1cek65w=="],
"react-native-nitro-ota": ["react-native-nitro-ota@0.10.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "0.32.0" } }, "sha512-pxmdaeNdUdnYdD1M8BpbtQo4mZrtljWFg0gspuIohTJqi97JYIRq0b+SReN0sMMo0w912k4XXSGMr/IduGoMNg=="],
"react-native-pager-view": ["react-native-pager-view@8.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-oAwlWT1lhTkIs9HhODnjNNl/owxzn9DP1MbP+az6OTUdgbmzA16Up83sBH8NRKwrH8rNm7iuWnX1qMqiiWOLhg=="],
+2 -1
View File
@@ -6,9 +6,10 @@ import App from './App'
import { name as appName } from './app.json'
import { PlaybackService } from './src/player'
import TrackPlayer from 'react-native-track-player'
import { enableScreens } from 'react-native-screens'
import { enableFreeze, enableScreens } from 'react-native-screens'
enableScreens(true)
enableFreeze(true)
AppRegistry.registerComponent(appName, () => App)
+6 -6
View File
@@ -543,7 +543,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 278;
CURRENT_PROJECT_VERSION = 279;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_BITCODE = NO;
@@ -554,7 +554,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.11;
MARKETING_VERSION = 1.0.12;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
@@ -585,7 +585,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 278;
CURRENT_PROJECT_VERSION = 279;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -595,7 +595,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.11;
MARKETING_VERSION = 1.0.12;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
@@ -823,7 +823,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 278;
CURRENT_PROJECT_VERSION = 279;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -834,7 +834,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.11;
MARKETING_VERSION = 1.0.12;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
-5
View File
@@ -20,9 +20,6 @@ end
target 'Jellify' do
config = use_native_modules!
pod 'SDWebImage', :modular_headers => true
pod 'NitroOtaBundleManager', :path => '../node_modules/react-native-nitro-ota'
use_react_native!(
:path => config[:reactNativePath],
# An absolute path to your application root.
@@ -30,8 +27,6 @@ target 'Jellify' do
)
pod 'SDWebImage', :modular_headers => true
post_install do |installer|
# https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202
react_native_post_install(
+14 -52
View File
@@ -11,8 +11,7 @@ PODS:
- fmt (11.0.2)
- Gifu (3.5.1)
- glog (0.3.5)
- google-cast-sdk (4.8.3):
- Protobuf (~> 3.13)
- google-cast-sdk (4.8.4)
- hermes-engine (0.14.0):
- hermes-engine/Pre-built (= 0.14.0)
- hermes-engine/Pre-built (0.14.0)
@@ -47,7 +46,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- NitroMmkv (4.1.0):
- NitroMmkv (4.1.1):
- boost
- DoubleConversion
- fast_float
@@ -78,7 +77,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- NitroModules (0.32.0-beta.0):
- NitroModules (0.32.1):
- boost
- DoubleConversion
- fast_float
@@ -107,7 +106,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- NitroOta (0.9.0):
- NitroOta (0.10.0):
- boost
- DoubleConversion
- fast_float
@@ -115,6 +114,7 @@ PODS:
- glog
- hermes-engine
- NitroModules
- NitroOtaBundleManager (= 0.10.0)
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
@@ -138,36 +138,8 @@ PODS:
- SocketRocket
- SSZipArchive
- Yoga
- NitroOtaBundleManager (0.9.0):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- NitroOtaBundleManager (0.10.0)
- PromisesObjC (2.4.0)
- Protobuf (3.29.5)
- RCT-Folly (2024.11.18.00):
- boost
- DoubleConversion
@@ -3270,9 +3242,6 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- SDWebImage (5.21.2):
- SDWebImage/Core (= 5.21.2)
- SDWebImage/Core (5.21.2)
- Sentry/HybridSDK (8.57.3)
- SocketRocket (0.7.1)
- SSZipArchive (2.4.3)
@@ -3293,7 +3262,6 @@ DEPENDENCIES:
- NitroMmkv (from `../node_modules/react-native-mmkv`)
- NitroModules (from `../node_modules/react-native-nitro-modules`)
- NitroOta (from `../node_modules/react-native-nitro-ota`)
- NitroOtaBundleManager (from `../node_modules/react-native-nitro-ota`)
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
- RCTRequired (from `../node_modules/react-native/Libraries/Required`)
@@ -3387,7 +3355,6 @@ DEPENDENCIES:
- RNScreens (from `../node_modules/react-native-screens`)
- "RNSentry (from `../node_modules/@sentry/react-native`)"
- RNWorklets (from `../node_modules/react-native-worklets`)
- SDWebImage
- SocketRocket (~> 0.7.1)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
@@ -3398,9 +3365,8 @@ SPEC REPOS:
- Gifu
- google-cast-sdk
- MMKVCore
- NitroOtaBundleManager
- PromisesObjC
- Protobuf
- SDWebImage
- Sentry
- SocketRocket
- SSZipArchive
@@ -3433,8 +3399,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-nitro-modules"
NitroOta:
:path: "../node_modules/react-native-nitro-ota"
NitroOtaBundleManager:
:path: "../node_modules/react-native-nitro-ota"
RCT-Folly:
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
RCTDeprecation:
@@ -3633,16 +3597,15 @@ SPEC CHECKSUMS:
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
Gifu: 9f7e52357d41c0739709019eb80a71ad9aab1b6d
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
google-cast-sdk: 1fb6724e94cc5ff23b359176e0cf6360586bb97a
hermes-engine: 83ac7cadb2a3a158ae6d9e4192417c5232065e99
google-cast-sdk: 32f65af50d164e3c475e79ad123db3cc26fbcd37
hermes-engine: 6878b8fefe82b91b24caae48ac97164746244d09
MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df
NitroFetch: 660adfb47f84b28db664f97b50e5dc28506ab6c1
NitroMmkv: ce1df9b9f0e06dfbde2455d863047e0411fceb6e
NitroModules: 7dc2f90b6122b23143848972adc5ac9513537d43
NitroOta: d6a10da7e79dd58dace797d43b43e8e034c86eca
NitroOtaBundleManager: 59a8392078acfd0f28d31c1360f15f7eddcd8482
NitroMmkv: 8ed7ef6f41b91785fc580c975f68d6d675214767
NitroModules: bbd7f9f8913dc8af187349463e4368a1144d6e31
NitroOta: 92d4eb528566b6babf5e4a30adbda44bfa803a9b
NitroOtaBundleManager: 8fad871db2daf6b9ee6f04a100c79605cfa81e8d
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
Protobuf: 164aea2ae380c3951abdc3e195220c01d17400e0
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
RCTDeprecation: a41bbdd9af30bf2e5715796b313e44ec43eefff1
RCTRequired: 7be34aabb0b77c3cefe644528df0fa0afad4e4d0
@@ -3735,7 +3698,6 @@ SPEC CHECKSUMS:
RNScreens: ffbb0296608eb3560de641a711bbdb663ed1f6b4
RNSentry: fdb39d5f294e492aa2f08ad80e510310dc223772
RNWorklets: 69f7239afaf3a156f7f9549eeb0ae8d02adc095f
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a
Sentry: c643eb180df401dd8c734c5036ddd9dd9218daa6
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
@@ -3743,6 +3705,6 @@ SPEC CHECKSUMS:
SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d
Yoga: 5456bb010373068fc92221140921b09d126b116e
PODFILE CHECKSUM: 05d07b9cff134e4c27345bc2b588e090e4d3431c
PODFILE CHECKSUM: b9282cc589d492312a14687826c7e41a850a22cf
COCOAPODS: 1.16.2
+1 -1
View File
@@ -1,5 +1,5 @@
import TrackPlayer from 'react-native-track-player'
import { playLaterInQueue } from '../../src/providers/Player/functions/queue'
import { playLaterInQueue } from '../../src/hooks/player/functions/queue'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getApi } from '../../src/stores'
+1 -1
View File
@@ -1,5 +1,5 @@
import JellifyTrack from '../../src/types/JellifyTrack'
import calculateTrackVolume from '../../src/providers/Player/utils/normalization'
import calculateTrackVolume from '../../src/hooks/player/functions/normalization'
describe('Normalization Module', () => {
it('should calculate the volume for a track with a normalization gain of 6', () => {
+1 -1
View File
@@ -1,5 +1,5 @@
import 'react-native'
import { shuffleJellifyTracks } from '../../src/providers/Player/utils/shuffle'
import { shuffleJellifyTracks } from '../../src/hooks/player/functions/utils/shuffle'
import { QueuingType } from '../../src/enums/queuing-type'
import JellifyTrack from '../../src/types/JellifyTrack'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "jellify",
"version": "1.0.11",
"version": "1.0.12",
"private": true,
"scripts": {
"init-android": "bun i",
@@ -62,6 +62,7 @@
"lodash": "^4.17.21",
"openai": "5.21.0",
"react": "19.2.0",
"react-freeze": "^1.0.4",
"react-native": "0.83.1",
"react-native-background-actions": "^4.0.1",
"react-native-blob-util": "^0.22.2",
@@ -74,10 +75,10 @@
"react-native-google-cast": "^4.9.1",
"react-native-haptic-feedback": "^2.3.3",
"react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "^4.1.0",
"react-native-mmkv": "^4.1.1",
"react-native-nitro-fetch": "^0.1.6",
"react-native-nitro-modules": "0.32.0-beta.0",
"react-native-nitro-ota": "0.9.0",
"react-native-nitro-modules": "^0.32.1",
"react-native-nitro-ota": "^0.10.0",
"react-native-pager-view": "8.0.0",
"react-native-reanimated": "4.1.6",
"react-native-safe-area-context": "5.6.2",
+4 -4
View File
@@ -6,14 +6,14 @@ import { useUserPlaylists } from '../../queries/playlist'
const useHomeQueries = () => {
const { refetch: refetchUserPlaylists } = useUserPlaylists()
const { refetch: refetchRecentArtists } = useRecentArtists()
const { refetch: refetchRecentlyPlayed } = useRecentlyPlayedTracks()
const { refetch: refetchFrequentArtists } = useFrequentlyPlayedArtists()
const { refetch: refetchFrequentlyPlayed } = useFrequentlyPlayedTracks()
const { refetch: refetchRecentArtists } = useRecentArtists()
const { refetch: refetchFrequentArtists } = useFrequentlyPlayedArtists()
return useMutation({
mutationFn: async () => {
await Promise.allSettled([
@@ -1,22 +1,24 @@
import JellifyTrack from '../../../../types/JellifyTrack'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api'
import { AxiosResponse } from 'axios'
import { getApi } from '../../../../stores'
import { Api } from '@jellyfin/sdk'
export default async function reportPlaybackCompleted(
api: Api | undefined,
track: JellifyTrack,
): Promise<AxiosResponse<void, unknown>> {
const api = getApi()
): Promise<void> {
if (!api) return Promise.reject('API instance not set')
const { sessionId, item, mediaSourceInfo } = track
return await getPlaystateApi(api).reportPlaybackStopped({
playbackStopInfo: {
SessionId: sessionId,
ItemId: item.Id,
PositionTicks: mediaSourceInfo?.RunTimeTicks || item.RunTimeTicks,
},
})
try {
await getPlaystateApi(api).reportPlaybackStopped({
playbackStopInfo: {
SessionId: sessionId,
ItemId: item.Id,
PositionTicks: mediaSourceInfo?.RunTimeTicks || item.RunTimeTicks,
},
})
} catch (error) {
console.error('Unable to report playback stopped', error)
}
}
@@ -1,24 +1,26 @@
import { getApi } from '../../../../stores'
import JellifyTrack from '../../../../types/JellifyTrack'
import { convertSecondsToRunTimeTicks } from '../../../../utils/mapping/ticks-to-seconds'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api'
import { AxiosResponse } from 'axios'
import { Api } from '@jellyfin/sdk'
export default async function reportPlaybackProgress(
api: Api | undefined,
track: JellifyTrack,
position: number,
): Promise<AxiosResponse<void, unknown>> {
const api = getApi()
): Promise<void> {
if (!api) return Promise.reject('API instance not set')
const { sessionId, item } = track
return await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: {
SessionId: sessionId,
ItemId: item.Id,
PositionTicks: convertSecondsToRunTimeTicks(position),
},
})
try {
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: {
SessionId: sessionId,
ItemId: item.Id,
PositionTicks: convertSecondsToRunTimeTicks(position),
},
})
} catch (error) {
console.error('Unable to report playback progress', error)
}
}
@@ -1,19 +1,26 @@
import { Api } from '@jellyfin/sdk'
import JellifyTrack from '../../../../types/JellifyTrack'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api'
import { getApi } from '../../../../stores'
export default async function reportPlaybackStarted(track: JellifyTrack) {
const api = getApi()
import { convertSecondsToRunTimeTicks } from '../../../../utils/mapping/ticks-to-seconds'
import { Api } from '@jellyfin/sdk'
export default async function reportPlaybackStarted(
api: Api | undefined,
track: JellifyTrack,
position?: number | undefined,
) {
if (!api) return Promise.reject('API instance not set')
const { sessionId, item } = track
return await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: {
SessionId: sessionId,
ItemId: item.Id,
},
})
try {
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: {
SessionId: sessionId,
ItemId: item.Id,
PositionTicks: position ? convertSecondsToRunTimeTicks(position) : undefined,
},
})
} catch (error) {
console.error('Unable to report playback started', error)
}
}
@@ -1,21 +1,28 @@
import JellifyTrack from '../../../../types/JellifyTrack'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api'
import { AxiosResponse } from 'axios'
import { getApi } from '../../../../stores'
import { convertSecondsToRunTimeTicks } from '../../../../utils/mapping/ticks-to-seconds'
import { Api } from '@jellyfin/sdk/lib/api'
export default async function reportPlaybackStopped(
api: Api | undefined,
track: JellifyTrack,
): Promise<AxiosResponse<void, unknown>> {
const api = getApi()
lastPosition?: number | undefined,
): Promise<void> {
if (!api) return Promise.reject('API instance not set')
const { sessionId, item } = track
return await getPlaystateApi(api).reportPlaybackStopped({
playbackStopInfo: {
SessionId: sessionId,
ItemId: item.Id,
},
})
try {
await getPlaystateApi(api).reportPlaybackStopped({
playbackStopInfo: {
SessionId: sessionId,
ItemId: item.Id,
PositionTicks: lastPosition
? convertSecondsToRunTimeTicks(lastPosition)
: undefined,
},
})
} catch (error) {
console.error('Unable to report playback stopped', error)
}
}
-54
View File
@@ -1,54 +0,0 @@
import JellifyTrack from '../../../types/JellifyTrack'
import { useMutation } from '@tanstack/react-query'
import reportPlaybackCompleted from './functions/playback-completed'
import reportPlaybackStopped from './functions/playback-stopped'
import isPlaybackFinished from './utils'
import reportPlaybackProgress from './functions/playback-progress'
import reportPlaybackStarted from './functions/playback-started'
import { useApi } from '../../../stores'
interface PlaybackStartedMutation {
track: JellifyTrack
}
export const useReportPlaybackStarted = () =>
useMutation({
onMutate: () => {},
mutationFn: async ({ track }: PlaybackStartedMutation) => reportPlaybackStarted(track),
onError: (error) => console.error(`Reporting playback started failed`, error),
onSuccess: () => {},
})
interface PlaybackStoppedMutation {
track: JellifyTrack
lastPosition: number
duration: number
}
export const useReportPlaybackStopped = () =>
useMutation({
onMutate: ({ lastPosition, duration }) => {},
mutationFn: async ({ track, lastPosition, duration }: PlaybackStoppedMutation) => {
return isPlaybackFinished(lastPosition, duration)
? await reportPlaybackCompleted(track)
: await reportPlaybackStopped(track)
},
onError: (error, { lastPosition, duration }) =>
console.error(
`Reporting playback ${isPlaybackFinished(lastPosition, duration) ? 'completed' : 'stopped'} failed`,
error,
),
onSuccess: (_, { lastPosition, duration }) => {},
})
interface PlaybackProgressMutation {
track: JellifyTrack
position: number
}
export const useReportPlaybackProgress = () =>
useMutation({
onMutate: ({ position }) => {},
mutationFn: async ({ track, position }: PlaybackProgressMutation) =>
reportPlaybackProgress(track, position),
})
+3
View File
@@ -7,6 +7,9 @@ import { isUndefined } from 'lodash'
/**
* Adds a track to a Jellyfin playlist.
*
* @deprecated Let's just use the {@link addManyToPlaylist} mutation instead
* and not factor in for a singular track
*
* @param api The Jellyfin {@link Api} client
* @param user The signed in {@link JellifyUser}
* @param track The {@link BaseItemDto} to add
+4 -4
View File
@@ -14,7 +14,7 @@ import flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { fetchRecentlyAdded } from '../recents/utils'
import { queryClient } from '../../../constants/query-client'
import { getApi, useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import { getApi, getUser, useJellifyLibrary } from '../../../stores'
import useLibraryStore from '../../../stores/library'
import { fetchAlbumDiscs } from '../item'
import { Api } from '@jellyfin/sdk/lib/api'
@@ -24,8 +24,8 @@ const useAlbums: () => [
RefObject<Set<string>>,
UseInfiniteQueryResult<(string | number | BaseItemDto)[]>,
] = () => {
const api = useApi()
const [user] = useJellifyUser()
const api = getApi()
const user = getUser()
const [library] = useJellifyLibrary()
const isFavorites = useLibraryStore((state) => state.isFavorites)
@@ -65,7 +65,7 @@ const useAlbums: () => [
export default useAlbums
export const useRecentlyAddedAlbums = () => {
const api = useApi()
const api = getApi()
const [library] = useJellifyLibrary()
return useInfiniteQuery({
-2
View File
@@ -1,8 +1,6 @@
import { QueryKeys } from '../../../enums/query-keys'
import { useQuery } from '@tanstack/react-query'
import fetchStorageInUse from './utils/storage-in-use'
import { getAudioCache } from '../../mutations/download/offlineModeUtils'
import DownloadQueryKeys from './keys'
import { AUDIO_CACHE_QUERY } from './constants'
export const useStorageInUse = () =>
+10 -10
View File
@@ -1,14 +1,14 @@
import { UserPlaylistsQueryKey } from './keys'
import { PlaylistTracksQueryKey, PublicPlaylistsQueryKey, UserPlaylistsQueryKey } from './keys'
import { useInfiniteQuery } from '@tanstack/react-query'
import { fetchUserPlaylists, fetchPublicPlaylists, fetchPlaylistTracks } from './utils'
import { ApiLimits } from '../../../configs/query.config'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import { getApi, getUser, useJellifyLibrary } from '../../../stores'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { QueryKeys } from '../../../enums/query-keys'
export const useUserPlaylists = () => {
const api = useApi()
const [user] = useJellifyUser()
const api = getApi()
const user = getUser()
const [library] = useJellifyLibrary()
return useInfiniteQuery({
@@ -23,12 +23,12 @@ export const useUserPlaylists = () => {
})
}
export const usePlaylistTracks = (playlist: BaseItemDto) => {
const api = useApi()
export const usePlaylistTracks = (playlist: BaseItemDto, disabled?: boolean | undefined) => {
const api = getApi()
return useInfiniteQuery({
// Changed from QueryKeys.ItemTracks to avoid cache conflicts with old useQuery data
queryKey: [QueryKeys.ItemTracks, 'infinite', playlist.Id!],
queryKey: PlaylistTracksQueryKey(playlist),
queryFn: ({ pageParam }) => fetchPlaylistTracks(api, playlist.Id!, pageParam),
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
@@ -36,16 +36,16 @@ export const usePlaylistTracks = (playlist: BaseItemDto) => {
if (!lastPage) return undefined
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
enabled: Boolean(api && playlist.Id),
enabled: Boolean(api && playlist.Id && !disabled),
})
}
export const usePublicPlaylists = () => {
const api = useApi()
const api = getApi()
const [library] = useJellifyLibrary()
return useInfiniteQuery({
queryKey: [QueryKeys.PublicPlaylists, library?.playlistLibraryId],
queryKey: PublicPlaylistsQueryKey(library),
queryFn: ({ pageParam }) => fetchPublicPlaylists(api, library, pageParam),
select: (data) => data.pages.flatMap((page) => page),
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
+8
View File
@@ -1,4 +1,6 @@
import { QueryKeys } from '../../../enums/query-keys'
import { JellifyLibrary } from '@/src/types/JellifyLibrary'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
enum PlaylistQueryKeys {
UserPlaylists,
@@ -10,6 +12,12 @@ export const UserPlaylistsQueryKey = (library: JellifyLibrary | undefined) => [
library?.playlistLibraryId,
]
export const PlaylistTracksQueryKey = (playlist: BaseItemDto) => [
QueryKeys.ItemTracks,
'infinite',
playlist.Id!,
]
export const PublicPlaylistsQueryKey = (library: JellifyLibrary | undefined) => [
PlaylistQueryKeys.PublicPlaylists,
library?.playlistLibraryId,
+13 -7
View File
@@ -1,9 +1,9 @@
import { RecentlyPlayedArtistsQueryKey, RecentlyPlayedTracksQueryKey } from './keys'
import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'
import { InfiniteData, useInfiniteQuery, useQueries } from '@tanstack/react-query'
import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from './utils'
import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { isUndefined } from 'lodash'
import { useApi, useJellifyUser, useJellifyLibrary, getUser, getApi } from '../../../stores'
import { useJellifyLibrary, getUser, getApi } from '../../../stores'
import { ONE_MINUTE } from '../../../constants/query-client'
import { JellifyLibrary } from '@/src/types/JellifyLibrary'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
@@ -11,7 +11,6 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
const RECENTS_QUERY_CONFIG = {
maxPages: MaxPages.Home,
staleTime: ONE_MINUTE * 5,
refetchOnMount: false,
} as const
export const useRecentlyPlayedTracks = () => {
@@ -44,11 +43,15 @@ export const PlayItAgainQuery = (library: JellifyLibrary | undefined) => {
}
export const useRecentArtists = () => {
const api = useApi()
const [user] = useJellifyUser()
const api = getApi()
const user = getUser()
const [library] = useJellifyLibrary()
const { data: recentlyPlayedTracks } = useRecentlyPlayedTracks()
const {
data: recentlyPlayedTracks,
isPending: recentlyPlayedTracksPending,
isStale: recentlyPlayedTracksStale,
} = useRecentlyPlayedTracks()
return useInfiniteQuery({
queryKey: RecentlyPlayedArtistsQueryKey(user, library),
@@ -58,7 +61,10 @@ export const useRecentArtists = () => {
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
return lastPage.length > 0 ? lastPageParam + 1 : undefined
},
enabled: !isUndefined(recentlyPlayedTracks),
enabled:
!isUndefined(recentlyPlayedTracks) &&
!recentlyPlayedTracksPending &&
!recentlyPlayedTracksStale,
...RECENTS_QUERY_CONFIG,
})
}
+42 -43
View File
@@ -13,10 +13,9 @@ import { isUndefined } from 'lodash'
import { JellifyLibrary } from '../../../../types/JellifyLibrary'
import { JellifyUser } from '../../../../types/JellifyUser'
import { queryClient } from '../../../../constants/query-client'
import { InfiniteData } from '@tanstack/react-query'
import { fetchItems } from '../../item'
import { RecentlyPlayedTracksQueryKey } from '../keys'
import { RECENTLY_PLAYED_ALBUM_THRESHOLD } from '../../../../configs/home.config'
import { PlayItAgainQuery } from '..'
export async function fetchRecentlyAdded(
api: Api | undefined,
@@ -146,53 +145,53 @@ export function fetchRecentlyPlayedArtists(
if (isUndefined(library)) return reject('Library instance not set')
// Get the recently played tracks from the query client
const recentlyPlayedTracks = queryClient.getQueryData<InfiniteData<BaseItemDto[]>>(
RecentlyPlayedTracksQueryKey(user, library),
)
if (!recentlyPlayedTracks) {
return resolve([])
}
queryClient
.ensureInfiniteQueryData(PlayItAgainQuery(library))
.then((recentlyPlayedTracks) => {
if (!recentlyPlayedTracks) {
return resolve([])
}
// Get the artists from the recently played tracks
const artists = recentlyPlayedTracks.pages[page]
// Get the artists from the recently played tracks
const artists = recentlyPlayedTracks.pages[page]
// Map artist from the recently played tracks
.map((track) => (track.ArtistItems ? track.ArtistItems[0] : undefined))
// Map artist from the recently played tracks
.map((track) => (track.ArtistItems ? track.ArtistItems[0] : undefined))
// Filter out undefined artists
.filter((artist) => artist !== undefined)
// Filter out undefined artists
.filter((artist) => artist !== undefined)
// Filter out duplicate artists
.filter(
(artist, index, artists) =>
artists.findIndex((duplicateArtist) => duplicateArtist.Id === artist.Id) ===
index,
)
// Filter out duplicate artists
.filter(
(artist, index, artists) =>
artists.findIndex(
(duplicateArtist) => duplicateArtist.Id === artist.Id,
) === index,
)
fetchItems(
api,
user,
library,
[BaseItemKind.MusicArtist],
page,
undefined,
undefined,
undefined,
undefined,
artists.map((artist) => artist.Id!),
)
.then((artistPages) => {
resolve(
artistPages.data.sort((a, b) => {
const aIndex = artists.findIndex((artist) => artist.Id === a.Id)
const bIndex = artists.findIndex((artist) => artist.Id === b.Id)
return aIndex - bIndex
}),
fetchItems(
api,
user,
library,
[BaseItemKind.MusicArtist],
page,
undefined,
undefined,
undefined,
undefined,
artists.map((artist) => artist.Id!),
)
.then((artistPages) => {
resolve(
artistPages.data.sort((a, b) => {
const aIndex = artists.findIndex((artist) => artist.Id === a.Id)
const bIndex = artists.findIndex((artist) => artist.Id === b.Id)
return aIndex - bIndex
}),
)
})
.catch(reject)
})
.catch((error) => {
console.error(error)
return reject(error)
})
.catch(reject)
})
}
+3 -3
View File
@@ -14,7 +14,7 @@ import { useAllDownloadedTracks } from '../download'
import { queryClient } from '../../../constants/query-client'
import UserDataQueryKey from '../user-data/keys'
import { JellifyUser } from '@/src/types/JellifyUser'
import { useApi, useJellifyUser, useJellifyLibrary } from '../../../stores'
import { useJellifyLibrary, getApi, getUser } from '../../../stores'
import useLibraryStore from '../../../stores/library'
const useTracks: (
@@ -28,8 +28,8 @@ const useTracks: (
sortOrder,
isFavoritesParam,
) => {
const api = useApi()
const [user] = useJellifyUser()
const api = getApi()
const user = getUser()
const [library] = useJellifyLibrary()
const {
isFavorites: isLibraryFavorites,
+95 -91
View File
@@ -1,18 +1,7 @@
import { useMutation } from '@tanstack/react-query'
import { UseInfiniteQueryResult, useMutation, InfiniteData } from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { addManyToPlaylist, addToPlaylist } from '../../api/mutations/playlists'
import { useState } from 'react'
import {
YStack,
XStack,
Spacer,
YGroup,
Separator,
ListItem,
getTokens,
Spinner,
View,
} from 'tamagui'
import { addManyToPlaylist } from '../../api/mutations/playlists'
import { YStack, XStack, Spacer, Spinner, View } from 'tamagui'
import Icon from '../Global/components/icon'
import { AddToPlaylistMutation } from './types'
import { Text } from '../Global/helpers/text'
@@ -24,14 +13,16 @@ import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { usePlaylistTracks, useUserPlaylists } from '../../api/queries/playlist'
import { getApi, getUser } from '../../stores'
import Animated, { Easing, FadeIn, FadeOut } from 'react-native-reanimated'
import { FlashList, ViewToken } from '@shopify/flash-list'
import { useState } from 'react'
import { queryClient } from '../../constants/query-client'
import { PlaylistTracksQueryKey } from '../../api/queries/playlist/keys'
export default function AddToPlaylist({
track,
tracks,
source,
}: {
track?: BaseItemDto
tracks?: BaseItemDto[]
tracks: BaseItemDto[]
source?: BaseItemDto
}): React.JSX.Element {
const {
@@ -40,23 +31,34 @@ export default function AddToPlaylist({
isSuccess: playlistsFetchSuccess,
} = useUserPlaylists()
const [visiblePlaylistIds, setVisiblePlaylistIds] = useState<string[]>([])
const onViewableItemsChanged = ({
viewableItems,
}: {
viewableItems: ViewToken<BaseItemDto>[]
}) => {
const visibleIds = viewableItems.map(({ item }) => item.Id!)
setVisiblePlaylistIds(visibleIds)
}
return (
<View flex={1}>
{(source ?? track) && (
{(source ?? tracks[0]) && (
<XStack gap={'$2'} margin={'$4'}>
<ItemImage item={source ?? track!} width={'$12'} height={'$12'} />
<ItemImage item={source ?? tracks[0]} width={'$12'} height={'$12'} />
<YStack gap={'$2'}>
<TextTicker {...TextTickerConfig}>
<Text bold fontSize={'$6'}>
{getItemName(source ?? track!)}
{getItemName(source ?? tracks[0])}
</Text>
</TextTicker>
{(source ?? track)?.ArtistItems && (
{(source ?? tracks[0])?.ArtistItems && (
<TextTicker {...TextTickerConfig}>
<Text bold>
{`${(source ?? track)!.ArtistItems?.map((artist) => getItemName(artist)).join(', ')}`}
{`${(source ?? tracks[0])!.ArtistItems?.map((artist) => getItemName(artist)).join(', ')}`}
</Text>
</TextTicker>
)}
@@ -65,15 +67,19 @@ export default function AddToPlaylist({
)}
{!playlistsFetchPending && playlistsFetchSuccess && (
<YGroup separator={<Separator />} scrollable flex={1}>
{playlists?.map((playlist) => (
<FlashList
data={playlists}
renderItem={({ item: playlist }) => (
<AddToPlaylistRow
key={playlist.Id}
playlist={playlist}
tracks={tracks ? tracks : track ? [track] : []}
tracks={tracks ? tracks : tracks[0] ? [tracks[0]] : []}
visible={visiblePlaylistIds.includes(playlist.Id!)}
/>
))}
</YGroup>
)}
keyExtractor={(item) => item.Id!}
onViewableItemsChanged={onViewableItemsChanged}
/>
)}
</View>
)
@@ -82,96 +88,94 @@ export default function AddToPlaylist({
function AddToPlaylistRow({
playlist,
tracks,
visible,
}: {
playlist: BaseItemDto
tracks: BaseItemDto[]
visible: boolean
}): React.JSX.Element {
const trigger = useHapticFeedback()
const {
data: playlistTracks,
isPending: fetchingPlaylistTracks,
refetch: refetchPlaylistTracks,
} = usePlaylistTracks(playlist)
const { data: playlistTracks, isPending: fetchingPlaylistTracks } = usePlaylistTracks(
playlist,
!visible,
)
const useAddToPlaylist = useMutation({
mutationFn: ({
track,
playlist,
tracks,
}: AddToPlaylistMutation & { tracks?: BaseItemDto[] }) => {
mutationFn: ({ playlist, tracks }: AddToPlaylistMutation) => {
trigger('impactLight')
const api = getApi()
const user = getUser()
if (tracks && tracks.length > 0) {
return addManyToPlaylist(api, user, tracks, playlist)
}
return addToPlaylist(api, user, track!, playlist)
return addManyToPlaylist(api, user, tracks, playlist)
},
onSuccess: (data, { playlist }) => {
onSuccess: (_, { tracks }) => {
trigger('notificationSuccess')
setIsInPlaylist(true)
queryClient.setQueryData(
PlaylistTracksQueryKey(playlist),
(prev: InfiniteData<BaseItemDto[]> | undefined) => {
if (!prev) return prev
refetchPlaylistTracks()
return {
...prev,
pages: prev.pages.map((page: BaseItemDto[], idx: number) =>
idx === prev.pages.length - 1 ? [...page, ...tracks] : page,
),
}
},
)
},
onError: () => {
onError: (error) => {
console.error(error)
trigger('notificationError')
},
})
const [isInPlaylist, setIsInPlaylist] = useState<boolean>(
const isInPlaylist =
tracks.filter((track) =>
playlistTracks?.map((playlistTrack) => playlistTrack.Id).includes(track.Id),
).length > 0,
)
).length > 0
return (
<YGroup.Item key={playlist.Id!}>
<ListItem
animation={'quick'}
disabled={isInPlaylist}
hoverTheme
opacity={isInPlaylist ? 0.5 : 1}
pressStyle={{ opacity: 0.6 }}
onPress={() => {
if (!isInPlaylist) {
useAddToPlaylist.mutate({
track: undefined,
tracks,
playlist,
})
}
}}
<XStack
animation={'quick'}
disabled={isInPlaylist}
alignItems='center'
gap={'$2'}
margin={'$2'}
opacity={isInPlaylist ? 0.5 : 1}
pressStyle={{ opacity: 0.6 }}
onPress={() => {
if (!isInPlaylist) {
useAddToPlaylist.mutate({
tracks,
playlist,
})
}
}}
>
<ItemImage item={playlist} height={'$11'} width={'$11'} />
<YStack alignItems='flex-start' flexGrow={1}>
<Text bold>{playlist.Name ?? 'Untitled Playlist'}</Text>
<Text color={'$neutral'}>{`${playlistTracks?.length ?? 0} tracks`}</Text>
</YStack>
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
>
<XStack alignItems='center' gap={'$2'}>
<ItemImage item={playlist} height={'$11'} width={'$11'} />
<YStack alignItems='flex-start' flexGrow={1}>
<Text bold>{playlist.Name ?? 'Untitled Playlist'}</Text>
<Text color={getTokens().color.amethyst.val}>{`${
playlistTracks?.length ?? 0
} tracks`}</Text>
</YStack>
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
>
{isInPlaylist ? (
<Icon flex={1} name='check-circle-outline' color={'$success'} />
) : fetchingPlaylistTracks ? (
<Spinner color={'$primary'} />
) : (
<Spacer flex={1} />
)}
</Animated.View>
</XStack>
</ListItem>
</YGroup.Item>
{isInPlaylist ? (
<Icon flex={1} name='check-circle-outline' color={'$success'} />
) : fetchingPlaylistTracks ? (
<Spinner color={'$primary'} />
) : (
<Spacer flex={1} />
)}
</Animated.View>
</XStack>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
export interface AddToPlaylistMutation {
track?: BaseItemDto
tracks: BaseItemDto[]
playlist: BaseItemDto
}
+80 -71
View File
@@ -13,8 +13,15 @@ import navigationRef from '../../../navigation'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import ItemRow from '../Global/components/item-row'
import formatArtistNames from '../../utils/formatting/artist-names'
import { Freeze } from 'react-freeze'
export default function AlbumTrackListFooter({ album }: { album: BaseItemDto }): React.JSX.Element {
export default function AlbumTrackListFooter({
album,
freeze,
}: {
album: BaseItemDto
freeze: boolean
}): React.JSX.Element {
const navigation =
useNavigation<
NativeStackNavigationProp<
@@ -25,77 +32,79 @@ export default function AlbumTrackListFooter({ album }: { album: BaseItemDto }):
const { data: suggestions, isPending: isLoadingSuggestions } = useSimilarItems(album)
return (
<YStack gap={'$3'} marginVertical={'$2'} flex={1}>
{album.ArtistItems && album.ArtistItems.length > 1 && (
<YStack>
<Text marginHorizontal={'$2'} fontWeight='bold'>
Featuring
</Text>
<Freeze freeze={freeze}>
<YStack gap={'$3'} marginVertical={'$2'} flex={1}>
{album.ArtistItems && album.ArtistItems.length > 1 && (
<YStack>
<Text marginHorizontal={'$2'} fontWeight='bold'>
Featuring
</Text>
<FlashList
data={album.ArtistItems}
renderItem={({ item: artist }) => (
<ItemRow
circular
item={artist}
onPress={() => {
navigation.navigate('Artist', {
artist,
})
}}
onLongPress={() => {
navigationRef.navigate('Context', { item: artist })
}}
/>
)}
/>
</YStack>
)}
<FlashList
data={album.ArtistItems}
renderItem={({ item: artist }) => (
<ItemRow
circular
item={artist}
onPress={() => {
navigation.navigate('Artist', {
artist,
})
}}
onLongPress={() => {
navigationRef.navigate('Context', { item: artist })
}}
/>
)}
/>
</YStack>
)}
{suggestions && suggestions.length > 0 && (
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
layout={LinearTransition.springify()}
style={{ flex: 1 }}
>
<Text marginHorizontal={'$2'} fontWeight='bold'>
Similar Albums
</Text>
<HorizontalCardList
data={suggestions}
renderItem={({ item: album }) => (
<ItemCard
size={'$8'}
item={album}
squared
caption={album.Name ?? 'Unknown Album'}
subCaption={formatArtistNames(album.Artists ?? [])}
onPress={() => {
navigation.push('Album', {
album,
})
}}
onLongPress={() => {
navigationRef.navigate('Context', { item: album })
}}
captionAlign='left'
/>
)}
ListEmptyComponent={
<YStack alignContent='center'>
{isLoadingSuggestions ? (
<Spinner alignSelf='center' color={'$primary'} />
) : (
<Text justifyContent='center' textAlign='center'>
No similar albums found
</Text>
)}
</YStack>
}
/>
</Animated.View>
)}
</YStack>
{suggestions && suggestions.length > 0 && (
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
layout={LinearTransition.springify()}
style={{ flex: 1 }}
>
<Text marginHorizontal={'$2'} fontWeight='bold'>
Similar Albums
</Text>
<HorizontalCardList
data={suggestions}
renderItem={({ item: album }) => (
<ItemCard
size={'$8'}
item={album}
squared
caption={album.Name ?? 'Unknown Album'}
subCaption={formatArtistNames(album.Artists ?? [])}
onPress={() => {
navigation.push('Album', {
album,
})
}}
onLongPress={() => {
navigationRef.navigate('Context', { item: album })
}}
captionAlign='left'
/>
)}
ListEmptyComponent={
<YStack alignContent='center'>
{isLoadingSuggestions ? (
<Spinner alignSelf='center' color={'$primary'} />
) : (
<Text justifyContent='center' textAlign='center'>
No similar albums found
</Text>
)}
</YStack>
}
/>
</Animated.View>
)}
</YStack>
</Freeze>
)
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { QueuingType } from '../../enums/queuing-type'
import { useLoadNewQueue } from '../../providers/Player/hooks/callbacks'
import { useLoadNewQueue } from '../../hooks/player/callbacks'
import { BaseStackParamList } from '../../screens/types'
import { useApi } from '../../stores'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
+21 -18
View File
@@ -1,6 +1,6 @@
import { YStack, XStack, Separator, Spinner } from 'tamagui'
import { YStack, XStack, Separator, Spinner, useTheme } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { SectionList } from 'react-native'
import { RefreshControl, SectionList } from 'react-native'
import Track from '../Global/components/Track'
import FavoriteButton from '../Global/components/favorite-button'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
@@ -10,7 +10,7 @@ import Icon from '../Global/components/icon'
import { useNavigation } from '@react-navigation/native'
import { BaseStackParamList } from '../../screens/types'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import { useApi } from '../../stores'
import { getApi } from '../../stores'
import { QueryKeys } from '../../enums/query-keys'
import { fetchAlbumDiscs } from '../../api/queries/item'
import { useQuery } from '@tanstack/react-query'
@@ -18,13 +18,7 @@ import useAddToPendingDownloads, { useIsDownloading } from '../../stores/network
import { useIsDownloaded } from '../../api/queries/download'
import AlbumTrackListFooter from './footer'
import AlbumTrackListHeader from './header'
import Animated, {
Easing,
FadeIn,
FadeOut,
FadeOutDown,
LinearTransition,
} from 'react-native-reanimated'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import { useStorageContext } from '../../providers/Storage'
/**
@@ -38,9 +32,15 @@ import { useStorageContext } from '../../providers/Storage'
export function Album({ album }: { album: BaseItemDto }): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
const api = useApi()
const api = getApi()
const { data: discs, isPending } = useQuery({
const theme = useTheme()
const {
data: discs,
isPending,
refetch,
} = useQuery({
queryKey: [QueryKeys.ItemTracks, album.Id],
queryFn: () => fetchAlbumDiscs(api, album),
})
@@ -144,17 +144,20 @@ export function Album({ album }: { album: BaseItemDto }): React.JSX.Element {
queue={album}
/>
)}
ListFooterComponent={() => <AlbumTrackListFooter album={album} />}
ListFooterComponent={() => <AlbumTrackListFooter album={album} freeze={isPending} />}
ListEmptyComponent={() => (
<YStack flex={1} alignContent='center' margin={'$4'}>
{isPending ? (
<Spinner color={'$primary'} />
) : (
<Text color={'$borderColor'}>No album tracks</Text>
)}
{isPending ? null : <Text color={'$borderColor'}>No album tracks</Text>}
</YStack>
)}
onScrollBeginDrag={closeAllSwipeableRows}
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
tintColor={theme.primary.val}
/>
}
/>
)
}
+2
View File
@@ -14,6 +14,7 @@ import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import useLibraryStore from '../../stores/library'
import { RefreshControl } from 'react-native'
import MAX_ITEMS_IN_RECYCLE_POOL from '../../configs/library.config'
interface AlbumsProps {
albumsInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
@@ -147,6 +148,7 @@ export default function Albums({
}}
onScrollBeginDrag={closeAllSwipeableRows}
removeClippedSubviews
maxItemsInRecyclePool={MAX_ITEMS_IN_RECYCLE_POOL}
/>
{showAlphabeticalSelector && albumPageParams && (
+48 -27
View File
@@ -2,45 +2,50 @@ import React from 'react'
import { useArtistContext } from '../../providers/Artist'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '@/src/screens/types'
import { DefaultSectionT, SectionList, SectionListData } from 'react-native'
import { DefaultSectionT, RefreshControl, SectionList, SectionListData } from 'react-native'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import ItemRow from '../Global/components/item-row'
import ArtistHeader from './header'
import { Text } from '../Global/helpers/text'
import SimilarArtists from './similar'
import { Spinner, useTheme, YStack } from 'tamagui'
export default function ArtistOverviewTab({
navigation,
}: {
navigation: NativeStackNavigationProp<BaseStackParamList>
}): React.JSX.Element {
const { featuredOn, artist, albums } = useArtistContext()
const { featuredOn, albums, fetchingAlbums, refresh } = useArtistContext()
const sections: SectionListData<BaseItemDto>[] = [
{
title: 'Albums',
data: albums?.filter(({ ChildCount }) => (ChildCount ?? 0) > 6) ?? [],
},
{
title: 'EPs',
data:
albums?.filter(
({ ChildCount }) => (ChildCount ?? 0) <= 6 && (ChildCount ?? 0) >= 3,
) ?? [],
},
{
title: 'Singles',
data: albums?.filter(({ ChildCount }) => (ChildCount ?? 0) === 1) ?? [],
},
{
title: '',
data: albums?.filter(({ ChildCount }) => typeof ChildCount !== 'number') ?? [],
},
{
title: 'Featured On',
data: featuredOn ?? [],
},
]
const theme = useTheme()
const sections: SectionListData<BaseItemDto>[] = albums
? [
{
title: 'Albums',
data: albums.filter(({ ChildCount }) => (ChildCount ?? 0) > 6) ?? [],
},
{
title: 'EPs',
data:
albums.filter(
({ ChildCount }) => (ChildCount ?? 0) <= 6 && (ChildCount ?? 0) >= 3,
) ?? [],
},
{
title: 'Singles',
data: albums.filter(({ ChildCount }) => (ChildCount ?? 0) === 1) ?? [],
},
{
title: '',
data: albums.filter(({ ChildCount }) => typeof ChildCount !== 'number') ?? [],
},
{
title: 'Featured On',
data: featuredOn ?? [],
},
]
: []
const renderSectionHeader = ({
section,
@@ -60,7 +65,23 @@ export default function ArtistOverviewTab({
ListHeaderComponent={ArtistHeader}
renderSectionHeader={renderSectionHeader}
renderItem={({ item }) => <ItemRow item={item} navigation={navigation} />}
refreshControl={
<RefreshControl
refreshing={fetchingAlbums}
onRefresh={refresh}
tintColor={theme.primary.val}
/>
}
ListFooterComponent={SimilarArtists}
ListEmptyComponent={
<YStack justifyContent='center' alignContent='center'>
{fetchingAlbums ? (
<Spinner color={'$primary'} flex={1} />
) : (
<Text color={'$neutral'}>No albums</Text>
)}
</YStack>
}
/>
)
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '@/src/screens/types'
import IconButton from '../Global/helpers/icon-button'
import { fetchAlbumDiscs } from '../../api/queries/item'
import { useLoadNewQueue } from '../../providers/Player/hooks/callbacks'
import { useLoadNewQueue } from '../../hooks/player/callbacks'
import { QueuingType } from '../../enums/queuing-type'
import { getApi } from '../../stores'
import Icon from '../Global/components/icon'
+35 -30
View File
@@ -7,41 +7,46 @@ import { ActivityIndicator } from 'react-native'
import { YStack } from 'tamagui'
import { FlashList } from '@shopify/flash-list'
import ItemRow from '../Global/components/item-row'
import React from 'react'
import { Freeze } from 'react-freeze'
export default function SimilarArtists(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
const { artist, similarArtists, fetchingSimilarArtists } = useArtistContext()
const { artist, similarArtists, fetchingSimilarArtists, fetchingAlbums, albums } =
useArtistContext()
return (
<YStack flex={1}>
<Text
margin={'$2'}
fontSize={'$6'}
bold
>{`Similar to ${artist.Name ?? 'Unknown Artist'}`}</Text>
<Freeze freeze={fetchingAlbums && !albums}>
<YStack flex={1}>
<Text
margin={'$2'}
fontSize={'$6'}
bold
>{`Similar to ${artist.Name ?? 'Unknown Artist'}`}</Text>
<FlashList
data={similarArtists}
renderItem={({ item: artist }) => (
<ItemRow
item={artist}
onPress={() => {
navigation.push('Artist', {
artist,
})
}}
/>
)}
ListEmptyComponent={
fetchingSimilarArtists ? (
<ActivityIndicator />
) : (
<Text justify={'center'} textAlign='center'>
No similar artists
</Text>
)
}
/>
</YStack>
<FlashList
data={similarArtists}
renderItem={({ item: artist }) => (
<ItemRow
item={artist}
onPress={() => {
navigation.push('Artist', {
artist,
})
}}
/>
)}
ListEmptyComponent={
fetchingSimilarArtists ? (
<ActivityIndicator />
) : (
<Text justify={'center'} textAlign='center'>
No similar artists
</Text>
)
}
/>
</YStack>
</Freeze>
)
}
+2
View File
@@ -14,6 +14,7 @@ import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import useLibraryStore from '../../stores/library'
import { RefreshControl } from 'react-native'
import MAX_ITEMS_IN_RECYCLE_POOL from '../../configs/library.config'
export interface ArtistsProps {
artistsInfiniteQuery: UseInfiniteQueryResult<
@@ -162,6 +163,7 @@ export default function Artists({
}}
onScrollBeginDrag={closeAllSwipeableRows}
removeClippedSubviews
maxItemsInRecyclePool={MAX_ITEMS_IN_RECYCLE_POOL}
/>
{showAlphabeticalSelector && artistPageParams && (
+1 -1
View File
@@ -1,7 +1,7 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { CarPlay, ListTemplate } from 'react-native-carplay'
import CarPlayNowPlaying from './NowPlaying'
import { loadQueue } from '../../providers/Player/functions/queue'
import { loadQueue } from '../../hooks/player/functions/queue'
import formatArtistNames from '../../utils/formatting/artist-names'
const AlbumTemplate = (
+3
View File
@@ -45,6 +45,9 @@ const CarPlayHome = new ListTemplate({
switch (index) {
case 0: {
// Recent Artists
await queryClient.ensureInfiniteQueryData(PlayItAgainQuery(library))
const artists = queryClient.getQueryData<InfiniteData<BaseItemDto[], unknown>>(
RecentlyPlayedArtistsQueryKey(user, library),
) ?? { pages: [], pageParams: [] }
+1 -1
View File
@@ -9,7 +9,7 @@ import { AlbumDiscsQuery } from '../../api/queries/album'
import { getApi } from '../../stores'
import AlbumTemplate from './Album'
import { AlbumDiscsQueryKey } from '../../api/queries/album/keys'
import { loadQueue } from '../../providers/Player/functions/queue'
import { loadQueue } from '../../hooks/player/functions/queue'
const TracksTemplate = (items: BaseItemDto[], queuingRef: Queue) =>
new ListTemplate({
+4 -8
View File
@@ -13,7 +13,7 @@ import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../enums/query-keys'
import { fetchAlbumDiscs, fetchItem } from '../../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { AddToQueueMutation } from '../../providers/Player/interfaces'
import { AddToQueueMutation } from '../../hooks/player/interfaces'
import { QueuingType } from '../../enums/queuing-type'
import { useEffect } from 'react'
import navigationRef from '../../../navigation'
@@ -23,7 +23,7 @@ import ItemImage from '../Global/components/image'
import { StackActions } from '@react-navigation/native'
import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../Player/component.config'
import { useAddToQueue } from '../../providers/Player/hooks/callbacks'
import { useAddToQueue } from '../../hooks/player/callbacks'
import { useIsDownloaded } from '../../api/queries/download'
import { useDeleteDownloads } from '../../api/mutations/download'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
@@ -119,8 +119,7 @@ export default function ItemContext({
{renderAddToPlaylistRow && (
<AddToPlaylistRow
track={isTrack ? item : undefined}
tracks={isAlbum && discs ? discs.flatMap((d) => d.data) : undefined}
tracks={isAlbum && discs ? discs.flatMap((d) => d.data) : [item]}
source={isAlbum ? item : undefined}
/>
)}
@@ -148,12 +147,10 @@ export default function ItemContext({
}
function AddToPlaylistRow({
track,
tracks,
source,
}: {
track?: BaseItemDto
tracks?: BaseItemDto[]
tracks: BaseItemDto[]
source?: BaseItemDto
}): React.JSX.Element {
return (
@@ -167,7 +164,6 @@ function AddToPlaylistRow({
navigationRef.goBack()
navigationRef.dispatch(
StackActions.push('AddToPlaylist', {
track,
tracks,
source,
}),
@@ -9,7 +9,7 @@ import { useNetworkStatus } from '../../../../stores/network'
import navigationRef from '../../../../../navigation'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '../../../../screens/types'
import { useAddToQueue, useLoadNewQueue } from '../../../../providers/Player/hooks/callbacks'
import { useAddToQueue, useLoadNewQueue } from '../../../../hooks/player/callbacks'
import { useDownloadedTrack } from '../../../../api/queries/download'
import SwipeableRow from '../SwipeableRow'
import { useSwipeSettingsStore } from '../../../../stores/settings/swipe'
@@ -158,7 +158,7 @@ export default function Track({
},
addToPlaylist: () => {
console.info('Running add to playlist swipe handler')
navigationRef.dispatch(StackActions.push('AddToPlaylist', { track }))
navigationRef.dispatch(StackActions.push('AddToPlaylist', { tracks: [track] }))
},
}
@@ -9,7 +9,7 @@ import FavoriteIcon from './favorite-icon'
import navigationRef from '../../../../navigation'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '../../../screens/types'
import { useAddToQueue, useLoadNewQueue } from '../../../providers/Player/hooks/callbacks'
import { useAddToQueue, useLoadNewQueue } from '../../../hooks/player/callbacks'
import useItemContext from '../../../hooks/use-item-context'
import { RouteProp, useRoute } from '@react-navigation/native'
import React from 'react'
@@ -132,7 +132,7 @@ function ItemRow({
queuingType: QueuingType.DirectlyQueued,
}),
toggleFavorite: () => (isFavorite ? removeFavorite({ item }) : addFavorite({ item })),
addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }),
addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { tracks: [item] }),
})
const swipeConfig = isAudio
@@ -4,7 +4,7 @@ import HorizontalCardList from '../../../components/Global/components/horizontal
import ItemCard from '../../../components/Global/components/item-card'
import { QueuingType } from '../../../enums/queuing-type'
import Icon from '../../Global/components/icon'
import { useLoadNewQueue } from '../../../providers/Player/hooks/callbacks'
import { useLoadNewQueue } from '../../../hooks/player/callbacks'
import { useDisplayContext } from '../../../providers/Display/display-provider'
import HomeStackParamList from '../../../screens/Home/types'
import { useNavigation } from '@react-navigation/native'
@@ -6,7 +6,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { QueuingType } from '../../../enums/queuing-type'
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
import Icon from '../../Global/components/icon'
import { useLoadNewQueue } from '../../../providers/Player/hooks/callbacks'
import { useLoadNewQueue } from '../../../hooks/player/callbacks'
import { useDisplayContext } from '../../../providers/Display/display-provider'
import { useNavigation } from '@react-navigation/native'
import HomeStackParamList from '../../../screens/Home/types'
+2 -2
View File
@@ -2,8 +2,8 @@ import { State } from 'react-native-track-player'
import { Circle, Spinner, View } from 'tamagui'
import IconButton from '../../../components/Global/helpers/icon-button'
import { isUndefined } from 'lodash'
import { useTogglePlayback } from '../../../providers/Player/hooks/callbacks'
import { usePlaybackState } from '../../../providers/Player/hooks/queries'
import { useTogglePlayback } from '../../../hooks/player/callbacks'
import { usePlaybackState } from '../../../hooks/player/queries'
import React from 'react'
import Icon from '../../Global/components/icon'
@@ -8,7 +8,7 @@ import {
useSkip,
useToggleRepeatMode,
useToggleShuffle,
} from '../../../providers/Player/hooks/callbacks'
} from '../../../hooks/player/callbacks'
import { useRepeatModeStoreValue, useShuffle } from '../../../stores/player/queue'
export default function Controls(): React.JSX.Element {
+2 -2
View File
@@ -3,8 +3,8 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { Text, useWindowDimensions, View, YStack, ZStack, useTheme, XStack, Spacer } from 'tamagui'
import BlurredBackground from './blurred-background'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useProgress } from '../../../providers/Player/hooks/queries'
import { useSeekTo } from '../../../providers/Player/hooks/callbacks'
import { useProgress } from '../../../hooks/player/queries'
import { useSeekTo } from '../../../hooks/player/callbacks'
import { UPDATE_INTERVAL } from '../../../configs/player.config'
import React, { useEffect, useMemo, useRef, useCallback } from 'react'
import Animated, {
@@ -25,8 +25,6 @@ export default function QualityBadge({
return bitrate && container ? (
<Square
enterStyle={{ opacity: 1 }}
exitStyle={{ opacity: 0 }}
animation={'bouncy'}
justifyContent='center'
backgroundColor={'$primary'}
@@ -3,11 +3,11 @@ import { HorizontalSlider } from '../../../components/Global/helpers/slider'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { Spacer, XStack, YStack } from 'tamagui'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { useSeekTo } from '../../../providers/Player/hooks/callbacks'
import { useSeekTo } from '../../../hooks/player/callbacks'
import { RunTimeSeconds } from '../../../components/Global/helpers/time-codes'
import { UPDATE_INTERVAL } from '../../../configs/player.config'
import { ProgressMultiplier } from '../component.config'
import { useProgress } from '../../../providers/Player/hooks/queries'
import { useProgress } from '../../../hooks/player/queries'
import QualityBadge from './quality-badge'
import { useDisplayAudioQualityBadge } from '../../../stores/settings/player'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
@@ -15,7 +15,7 @@ import { Gesture } from 'react-native-gesture-handler'
import { useSharedValue, withDelay, withSpring } from 'react-native-reanimated'
import type { SharedValue } from 'react-native-reanimated'
import { runOnJS } from 'react-native-worklets'
import { usePrevious, useSkip } from '../../../providers/Player/hooks/callbacks'
import { usePrevious, useSkip } from '../../../hooks/player/callbacks'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
import { useCurrentTrack } from '../../../stores/player/queue'
import { useApi } from '../../../stores'
+1 -1
View File
@@ -18,7 +18,7 @@ import Animated, {
} from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { runOnJS } from 'react-native-worklets'
import { usePrevious, useSkip } from '../../providers/Player/hooks/callbacks'
import { usePrevious, useSkip } from '../../hooks/player/callbacks'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import Icon from '../Global/components/icon'
import { useCurrentTrack } from '../../stores/player/queue'
+11 -12
View File
@@ -7,7 +7,7 @@ import { PlayPauseIcon } from './components/buttons'
import { TextTickerConfig } from './component.config'
import { UPDATE_INTERVAL } from '../../configs/player.config'
import { Progress as TrackPlayerProgress } from 'react-native-track-player'
import { useProgress } from '../../providers/Player/hooks/queries'
import { useProgress } from '../../hooks/player/queries'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, {
@@ -23,7 +23,7 @@ import { runOnJS } from 'react-native-worklets'
import { RootStackParamList } from '../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import ItemImage from '../Global/components/image'
import { usePrevious, useSkip } from '../../providers/Player/hooks/callbacks'
import { usePrevious, useSkip } from '../../hooks/player/callbacks'
import { useCurrentTrack } from '../../stores/player/queue'
export default function Miniplayer(): React.JSX.Element {
@@ -83,18 +83,17 @@ export default function Miniplayer(): React.JSX.Element {
<Animated.View
collapsable={false}
testID='miniplayer-test-id'
entering={FadeInDown.easing(Easing.in(Easing.ease))}
exiting={FadeOutDown.easing(Easing.out(Easing.ease))}
entering={FadeInDown.springify()}
exiting={FadeOutDown.springify()}
>
<YStack>
<YStack
pressStyle={pressStyle}
animation={'quick'}
onPress={openPlayer}
backgroundColor='$background'
>
<MiniPlayerProgress />
<XStack
alignItems='center'
pressStyle={pressStyle}
animation={'quick'}
onPress={openPlayer}
padding={'$2'}
>
<XStack alignItems='center' padding={'$2'}>
<YStack justify='center' alignItems='center'>
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
+1 -1
View File
@@ -10,7 +10,7 @@ import {
useRemoveUpcomingTracks,
useReorderQueue,
useSkip,
} from '../../providers/Player/hooks/callbacks'
} from '../../hooks/player/callbacks'
import { usePlayerQueueStore, useQueueRef } from '../../stores/player/queue'
import Sortable from 'react-native-sortables'
import { OrderChangeParams, RenderItemInfo } from 'react-native-sortables/dist/typescript/types'
@@ -7,7 +7,7 @@ import { useNetworkStatus } from '../../../stores/network'
import { QueuingType } from '../../../enums/queuing-type'
import { useNavigation } from '@react-navigation/native'
import LibraryStackParamList from '@/src/screens/Library/types'
import { useLoadNewQueue } from '../../../providers/Player/hooks/callbacks'
import { useLoadNewQueue } from '../../../hooks/player/callbacks'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import ItemImage from '../../Global/components/image'
import { useApi } from '../../../stores'
+18 -7
View File
@@ -11,8 +11,7 @@ import { RenderItemInfo } from 'react-native-sortables/dist/typescript/types'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import PlaylistTracklistHeader from './components/header'
import navigationRef from '../../../navigation'
import { useLoadNewQueue } from '../../providers/Player/hooks/callbacks'
import { useNetworkStatus } from '../../stores/network'
import { useLoadNewQueue } from '../../hooks/player/callbacks'
import { QueuingType } from '../../enums/queuing-type'
import { useApi } from '../../stores'
import useStreamingDeviceProfile from '../../stores/device-profile'
@@ -20,13 +19,11 @@ import { useEffect, useLayoutEffect, useState } from 'react'
import { updatePlaylist } from '../../../src/api/mutations/playlists'
import { usePlaylistTracks } from '../../../src/api/queries/playlist'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { useMutation } from '@tanstack/react-query'
import { InfiniteData, useMutation } from '@tanstack/react-query'
import Animated, {
Easing,
FadeIn,
FadeInUp,
FadeOut,
FadeOutDown,
LinearTransition,
SlideInLeft,
SlideOutRight,
@@ -37,6 +34,8 @@ import { RefreshControl } from 'react-native'
import { useIsDownloaded } from '../../api/queries/download'
import useAddToPendingDownloads, { useIsDownloading } from '../../stores/network/downloads'
import { useStorageContext } from '../../providers/Storage'
import { queryClient } from '../../constants/query-client'
import { PlaylistTracksQueryKey } from '../../api/queries/playlist/keys'
export default function Playlist({
playlist,
@@ -85,11 +84,23 @@ export default function Playlist({
tracks.map((track) => track.Id!),
)
},
onSuccess: () => {
onSuccess: (_, { playlist, tracks }) => {
trigger('notificationSuccess')
// Refresh playlist component data
refetch()
queryClient.setQueryData<InfiniteData<BaseItemDto[]>>(
PlaylistTracksQueryKey(playlist),
(prev) => {
if (!prev) return prev
return {
...prev,
pages: prev.pages.map((page: BaseItemDto[]) =>
page.filter((track) => tracks.some((t) => t.Id === track.Id)),
),
}
},
)
},
onError: () => {
trigger('notificationError')
+26 -18
View File
@@ -14,6 +14,7 @@ import { closeAllSwipeableRows } from '../Global/components/swipeable-row-regist
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { RefreshControl } from 'react-native'
import ItemRow from '../Global/components/item-row'
import MAX_ITEMS_IN_RECYCLE_POOL from '../../configs/library.config'
interface TracksProps {
tracksInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
@@ -68,24 +69,30 @@ export default function Tracks({
}: {
index: number
item: string | number | BaseItemDto
}) =>
typeof track === 'string' ? (
<FlashListStickyHeader text={track.toUpperCase()} />
) : typeof track === 'number' ? null : typeof track === 'object' ? (
track.Type === BaseItemKind.Audio ? (
<Track
navigation={navigation}
showArtwork
index={0}
track={track}
testID={`track-item-${index}`}
tracklist={tracks.slice(tracks.indexOf(track), tracks.indexOf(track) + 50)}
queue={queue}
/>
) : (
<ItemRow navigation={navigation} item={track} />
)
) : null
}) => {
switch (typeof track) {
case 'string':
return <FlashListStickyHeader text={track.toUpperCase()} />
case 'object':
return track.Type === BaseItemKind.Audio ? (
<Track
navigation={navigation}
showArtwork
index={0}
track={track}
testID={`track-item-${index}`}
tracklist={tracks.slice(tracks.indexOf(track), tracks.indexOf(track) + 50)}
queue={queue}
/>
) : (
<ItemRow navigation={navigation} item={track} />
)
case 'number':
default:
return null
}
}
const ItemSeparatorComponent = ({
leadingItem,
@@ -171,6 +178,7 @@ export default function Tracks({
</YStack>
}
removeClippedSubviews
maxItemsInRecyclePool={MAX_ITEMS_IN_RECYCLE_POOL}
/>
{showAlphabeticalSelector && trackPageParams && (
+7
View File
@@ -0,0 +1,7 @@
/**
* The maximum number of items that will be in
* a library list's recycle pool
*/
const MAX_ITEMS_IN_RECYCLE_POOL = 40
export default MAX_ITEMS_IN_RECYCLE_POOL
+7
View File
@@ -1,4 +1,5 @@
import { Platform } from 'react-native'
import { Event } from 'react-native-track-player'
/**
* Interval in milliseconds for progress updates from the track player
@@ -28,3 +29,9 @@ export const BUFFERS =
backBuffer: 5, // 5 seconds back buffer
}
: {}
export const PLAYER_EVENTS: Event[] = [
Event.PlaybackActiveTrackChanged,
Event.PlaybackProgressUpdated,
Event.PlaybackState,
]
@@ -1,16 +1,16 @@
import TrackPlayer, { RepeatMode, State } from 'react-native-track-player'
import { loadQueue, playLaterInQueue, playNextInQueue } from '../functions/queue'
import { previous, skip } from '../functions/controls'
import { AddToQueueMutation, QueueMutation, QueueOrderMutation } from '../interfaces'
import { QueuingType } from '../../../enums/queuing-type'
import { loadQueue, playLaterInQueue, playNextInQueue } from './functions/queue'
import { previous, skip } from './functions/controls'
import { AddToQueueMutation, QueueMutation, QueueOrderMutation } from './interfaces'
import { QueuingType } from '../../enums/queuing-type'
import Toast from 'react-native-toast-message'
import { handleDeshuffle, handleShuffle } from '../functions/shuffle'
import { handleDeshuffle, handleShuffle } from './functions/shuffle'
import JellifyTrack from '@/src/types/JellifyTrack'
import calculateTrackVolume from '../utils/normalization'
import usePlayerEngineStore, { PlayerEngine } from '../../../stores/player/engine'
import calculateTrackVolume from './functions/normalization'
import usePlayerEngineStore, { PlayerEngine } from '../../stores/player/engine'
import { useRemoteMediaClient } from 'react-native-google-cast'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
import { usePlayerQueueStore } from '../../../stores/player/queue'
import useHapticFeedback from '../use-haptic-feedback'
import { usePlayerQueueStore } from '../../stores/player/queue'
/**
* A mutation to handle toggling the playback state
@@ -151,7 +151,6 @@ export const useLoadNewQueue = () => {
const trigger = useHapticFeedback()
return async (variables: QueueMutation) => {
trigger('impactLight')
await TrackPlayer.pause()
const { finalStartIndex, tracks } = await loadQueue({ ...variables })
}
}
+48
View File
@@ -0,0 +1,48 @@
import { isUndefined } from 'lodash'
import { SKIP_TO_PREVIOUS_THRESHOLD } from '../../../configs/player.config'
import TrackPlayer, { State } from 'react-native-track-player'
/**
* A function that will skip to the previous track if
* we are still at the beginning of the track.
*
* This behavior is configured via {@link SKIP_TO_PREVIOUS_THRESHOLD},
* which determines how many seconds until we will instead skip to the
* beginning of the track for convenience.
*
* Stops buffering the current track for performance.
*
* Starts playback at the end of the operation.
*/
export async function previous(): Promise<void> {
const { position } = await TrackPlayer.getProgress()
if (Math.floor(position) < SKIP_TO_PREVIOUS_THRESHOLD) {
await TrackPlayer.stop() // Stop buffering the current track
await TrackPlayer.skipToPrevious()
} else await TrackPlayer.seekTo(0)
const { state } = await TrackPlayer.getPlaybackState()
if (state !== State.Playing) await TrackPlayer.play()
}
/**
* A function that will skip to the next track or the specified
* track index.
*
* Stops buffering the current track for performance.
*
* Starts playback at the end of the operation.
*
* @param index The track index to skip to, to skip multiple tracks
*/
export async function skip(index: number | undefined): Promise<void> {
await TrackPlayer.stop() // Stop buffering the current track
if (!isUndefined(index)) await TrackPlayer.skip(index)
else await TrackPlayer.skipToNext()
const { state } = await TrackPlayer.getPlaybackState()
if (state !== State.Playing) await TrackPlayer.play()
}
+12
View File
@@ -0,0 +1,12 @@
import TrackPlayer from 'react-native-track-player'
import JellifyTrack from '../../../types/JellifyTrack'
import { queryClient } from '../../../constants/query-client'
import { usePlayerQueueStore } from '../../../stores/player/queue'
export function handleActiveTrackChanged(
activeTrack: JellifyTrack | undefined,
activeIndex: number | undefined,
): void {
usePlayerQueueStore.getState().setCurrentTrack(activeTrack as JellifyTrack)
usePlayerQueueStore.getState().setCurrentIndex(activeIndex)
}
@@ -1,12 +1,11 @@
import { mapDtoToTrack } from '../../../utils/mapping/item-to-track'
import { networkStatusTypes } from '../../../components/Network/internetConnectionWatcher'
import { filterTracksOnNetworkStatus } from '../utils/queue'
import { filterTracksOnNetworkStatus } from './utils/queue'
import { AddToQueueMutation, QueueMutation } from '../interfaces'
import { QueuingType } from '../../../enums/queuing-type'
import { shuffleJellifyTracks } from '../utils/shuffle'
import { shuffleJellifyTracks } from './utils/shuffle'
import TrackPlayer from 'react-native-track-player'
import JellifyTrack from '../../../types/JellifyTrack'
import { getCurrentTrack } from '.'
import { usePlayerQueueStore } from '../../../stores/player/queue'
import { getAudioCache } from '../../../api/mutations/download/offlineModeUtils'
import { isUndefined } from 'lodash'
@@ -25,6 +24,8 @@ export async function loadQueue({
shuffled = false,
startPlayback,
}: QueueMutation): Promise<LoadQueueResult> {
await TrackPlayer.stop()
const deviceProfile = useStreamingDeviceProfileStore.getState().deviceProfile!
const networkStatus = useNetworkStore.getState().networkStatus ?? networkStatusTypes.ONLINE
@@ -61,8 +62,6 @@ export async function loadQueue({
// The start index for the shuffled queue is always 0 (starting track is first)
const finalStartIndex = availableAudioItems.findIndex((item) => item.Id === startingTrack.Id)
await TrackPlayer.stop()
/**
* Keep the requested track as the currently playing track so there
* isn't any flickering in the miniplayer
@@ -120,13 +119,17 @@ export const playNextInQueue = async ({ tracks }: AddToQueueMutation) => {
.getState()
.unShuffledQueue.slice(
0,
usePlayerQueueStore.getState().unShuffledQueue.indexOf(getCurrentTrack()!) + 1,
usePlayerQueueStore
.getState()
.unShuffledQueue.indexOf(currentQueue[currentIndex!]) + 1,
),
...tracksToPlayNext,
...usePlayerQueueStore
.getState()
.unShuffledQueue.slice(
usePlayerQueueStore.getState().unShuffledQueue.indexOf(getCurrentTrack()!) + 1,
usePlayerQueueStore
.getState()
.unShuffledQueue.indexOf(currentQueue[currentIndex!]) + 1,
),
])
}
@@ -1,6 +1,6 @@
import JellifyTrack from '../../../types/JellifyTrack'
import Toast from 'react-native-toast-message'
import { shuffleJellifyTracks } from '../utils/shuffle'
import { shuffleJellifyTracks } from './utils/shuffle'
import TrackPlayer from 'react-native-track-player'
import { cloneDeep, isUndefined } from 'lodash'
import { usePlayerQueueStore } from '../../../stores/player/queue'
@@ -1,9 +1,9 @@
import _, { isNull, isUndefined } from 'lodash'
import JellifyTrack from '../../../types/JellifyTrack'
import JellifyTrack from '../../../../types/JellifyTrack'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { JellifyDownload } from '../../../types/JellifyDownload'
import { networkStatusTypes } from '../../../components/Network/internetConnectionWatcher'
import { QueuingType } from '../../../enums/queuing-type'
import { JellifyDownload } from '../../../../types/JellifyDownload'
import { networkStatusTypes } from '../../../../components/Network/internetConnectionWatcher'
import { QueuingType } from '../../../../enums/queuing-type'
export function buildNewQueue(
existingQueue: JellifyTrack[],
@@ -1,5 +1,5 @@
import { QueuingType } from '../../../enums/queuing-type'
import JellifyTrack from '../../../types/JellifyTrack'
import { QueuingType } from '../../../../enums/queuing-type'
import JellifyTrack from '../../../../types/JellifyTrack'
import { fetchManuallyQueuedTracks } from './queue'
export function shuffleJellifyTracks(tracks: JellifyTrack[]): {
@@ -1,9 +1,6 @@
import { QueuingType } from '../../enums/queuing-type'
import { BaseItemDto, DeviceProfile } from '@jellyfin/sdk/lib/generated-client/models'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { Queue } from '../../player/types/queue-item'
import { Api } from '@jellyfin/sdk'
import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher'
import { JellifyDownload } from '@/src/types/JellifyDownload'
/**
* A mutation to handle loading a new queue.
@@ -4,8 +4,8 @@ import {
useProgress as useProgressRNTP,
usePlaybackState as usePlaybackStateRNTP,
} from 'react-native-track-player'
import usePlayerEngineStore from '../../../stores/player/engine'
import { PlayerEngine } from '../../../stores/player/engine'
import usePlayerEngineStore from '../../stores/player/engine'
import { PlayerEngine } from '../../stores/player/engine'
import { MediaPlayerState, useRemoteMediaClient, useStreamPosition } from 'react-native-google-cast'
import { useEffect, useState } from 'react'
+10 -8
View File
@@ -8,23 +8,25 @@ import { fetchAlbumDiscs, fetchItem } from '../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import fetchUserData from '../api/queries/user-data/utils'
import { useRef } from 'react'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../stores/device-profile'
import UserDataQueryKey from '../api/queries/user-data/keys'
import MediaInfoQueryKey from '../api/queries/media/keys'
import useJellifyStore, { getApi } from '../stores'
import { getApi, getUser } from '../stores'
import {
useDownloadingDeviceProfileStore,
useStreamingDeviceProfileStore,
} from '../stores/device-profile'
export default function useItemContext(): (item: BaseItemDto) => void {
const api = getApi()
const user = useJellifyStore.getState().user
const streamingDeviceProfile = useStreamingDeviceProfile()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const user = getUser()
const prefetchedContext = useRef<Set<string>>(new Set())
return (item: BaseItemDto) => {
const effectSig = `${item.Id}-${item.Type}`
const streamingDeviceProfile = useStreamingDeviceProfileStore.getState().deviceProfile
const downloadingDeviceProfile = useDownloadingDeviceProfileStore.getState().deviceProfile
const effectSig = `${item.Id}-${item.Type}-${streamingDeviceProfile.Id}-${downloadingDeviceProfile.Id}`
// If we've already warmed the cache for this item, return
if (prefetchedContext.current.has(effectSig)) return
+4 -1
View File
@@ -1,10 +1,13 @@
import useAppActive from './use-app-active'
import { useCurrentTrack } from '../stores/player/queue'
import { useIsPlayerFocused } from '../stores/player/display'
export default function useIsMiniPlayerActive(): boolean {
const isAppActive = useAppActive()
const nowPlaying = useCurrentTrack()
return !!nowPlaying && isAppActive
const isPlayerFocused = useIsPlayerFocused()
return !!nowPlaying && isAppActive && !isPlayerFocused
}
+3 -10
View File
@@ -1,6 +1,6 @@
import TrackPlayer, { Event } from 'react-native-track-player'
import { SKIP_TO_PREVIOUS_THRESHOLD } from '../configs/player.config'
import { CarPlay } from 'react-native-carplay'
import { previous, skip } from '../hooks/player/functions/controls'
/**
* Jellify Playback Service.
@@ -16,16 +16,9 @@ export async function PlaybackService() {
await TrackPlayer.pause()
})
TrackPlayer.addEventListener(Event.RemoteNext, async () => {
await TrackPlayer.skipToNext()
})
TrackPlayer.addEventListener(Event.RemoteNext, async () => skip(undefined))
TrackPlayer.addEventListener(Event.RemotePrevious, async () => {
const progress = await TrackPlayer.getProgress()
if (progress.position < SKIP_TO_PREVIOUS_THRESHOLD) await TrackPlayer.skipToPrevious()
else await TrackPlayer.seekTo(0)
})
TrackPlayer.addEventListener(Event.RemotePrevious, previous)
TrackPlayer.addEventListener(Event.RemoteSeek, async (event) => {
await TrackPlayer.seekTo(event.position)
+6 -12
View File
@@ -2,11 +2,10 @@ import fetchSimilarArtists from '../../api/queries/suggestions/utils/similar'
import { QueryKeys } from '../../enums/query-keys'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { useQuery } from '@tanstack/react-query'
import { createContext, ReactNode, useContext } from 'react'
import { SharedValue, useSharedValue } from 'react-native-reanimated'
import { createContext, ReactNode, use } from 'react'
import { isUndefined } from 'lodash'
import { useArtistAlbums, useArtistFeaturedOn } from '../../api/queries/artist'
import { useApi, useJellifyUser, useJellifyLibrary } from '../../stores'
import { useJellifyLibrary, getApi, getUser } from '../../stores'
interface ArtistContext {
fetchingAlbums: boolean
@@ -17,7 +16,6 @@ interface ArtistContext {
featuredOn: BaseItemDto[] | undefined
similarArtists: BaseItemDto[] | undefined
artist: BaseItemDto
scroll: SharedValue<number>
}
const ArtistContext = createContext<ArtistContext>({
@@ -29,7 +27,6 @@ const ArtistContext = createContext<ArtistContext>({
featuredOn: [],
similarArtists: [],
refresh: () => {},
scroll: { value: 0 } as SharedValue<number>,
})
export const ArtistProvider = ({
@@ -39,8 +36,8 @@ export const ArtistProvider = ({
artist: BaseItemDto
children: ReactNode
}) => {
const api = useApi()
const [user] = useJellifyUser()
const api = getApi()
const user = getUser()
const [library] = useJellifyLibrary()
const {
@@ -71,8 +68,6 @@ export const ArtistProvider = ({
refetchSimilar()
}
const scroll = useSharedValue(0)
const value = {
artist,
albums,
@@ -82,10 +77,9 @@ export const ArtistProvider = ({
fetchingFeaturedOn,
fetchingSimilarArtists,
refresh,
scroll,
}
return <ArtistContext.Provider value={value}>{children}</ArtistContext.Provider>
return <ArtistContext value={value}>{children}</ArtistContext>
}
export const useArtistContext = () => useContext(ArtistContext)
export const useArtistContext = () => use(ArtistContext)
-44
View File
@@ -1,44 +0,0 @@
import JellifyTrack from '../../../types/JellifyTrack'
import {
ACTIVE_INDEX_QUERY_KEY,
NOW_PLAYING_QUERY_KEY,
PLAY_QUEUE_QUERY_KEY,
REPEAT_MODE_QUERY_KEY,
} from './query-keys'
import TrackPlayer, { Track } from 'react-native-track-player'
const PLAYER_QUERY_OPTIONS = {
enabled: true,
retry: false,
staleTime: Infinity,
gcTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
networkMode: 'always',
} as const
export const QUEUE_QUERY = {
queryKey: PLAY_QUEUE_QUERY_KEY,
queryFn: TrackPlayer.getQueue,
select: (data: Track[]) => data as JellifyTrack[],
...PLAYER_QUERY_OPTIONS,
}
export const CURRENT_INDEX_QUERY = {
queryKey: ACTIVE_INDEX_QUERY_KEY,
queryFn: TrackPlayer.getActiveTrackIndex,
...PLAYER_QUERY_OPTIONS,
}
export const NOW_PLAYING_QUERY = {
queryKey: NOW_PLAYING_QUERY_KEY,
queryFn: TrackPlayer.getActiveTrack,
select: (data: Track | undefined) => data as JellifyTrack | undefined,
...PLAYER_QUERY_OPTIONS,
}
export const REPEAT_MODE_QUERY = {
queryKey: REPEAT_MODE_QUERY_KEY,
queryFn: TrackPlayer.getRepeatMode,
...PLAYER_QUERY_OPTIONS,
}
@@ -1,15 +0,0 @@
import PlayerQueryKeys from '../enums/queue-keys'
export const ACTIVE_INDEX_QUERY_KEY = [PlayerQueryKeys.ActiveIndex]
export const NOW_PLAYING_QUERY_KEY = [PlayerQueryKeys.NowPlaying]
export const PLAY_QUEUE_QUERY_KEY = [PlayerQueryKeys.PlayQueue]
export const QUEUE_REF_QUERY_KEY = [PlayerQueryKeys.PlayQueueRef]
export const REPEAT_MODE_QUERY_KEY = [PlayerQueryKeys.RepeatMode]
export const UNSHUFFLED_QUEUE_QUERY_KEY = [PlayerQueryKeys.UnshuffledQueue]
export const SHUFFLED_QUERY_KEY = [PlayerQueryKeys.Shuffled]
-19
View File
@@ -1,19 +0,0 @@
/**
* A prefix used for the player query keys
*
* This is used as to avoid collisions with other keys
*/
const QUERY_KEY_PREFEX = 'PLAYER'
enum PlayerQueryKeys {
PlayQueue = QUERY_KEY_PREFEX + 'PLAY_QUEUE',
NowPlaying = QUERY_KEY_PREFEX + 'NOW_PLAYING',
ActiveIndex = QUERY_KEY_PREFEX + 'ACTIVE_INDEX',
PlaybackState = QUERY_KEY_PREFEX + 'PLAYBACK_STATE',
PlayQueueRef = QUERY_KEY_PREFEX + 'PlayQueueRef',
UnshuffledQueue = QUERY_KEY_PREFEX + 'UNSHUFFLED_QUEUE',
RepeatMode = QUERY_KEY_PREFEX + 'REPEAT_MODE',
Shuffled = QUERY_KEY_PREFEX + 'SHUFFLED',
}
export default PlayerQueryKeys
@@ -1,18 +0,0 @@
import { isUndefined } from 'lodash'
import { SKIP_TO_PREVIOUS_THRESHOLD } from '../../../configs/player.config'
import TrackPlayer, { State } from 'react-native-track-player'
export async function previous(): Promise<void> {
const { position } = await TrackPlayer.getProgress()
if (Math.floor(position) < SKIP_TO_PREVIOUS_THRESHOLD) TrackPlayer.skipToPrevious()
else await TrackPlayer.seekTo(0)
}
export async function skip(index: number | undefined): Promise<void> {
if (!isUndefined(index)) await TrackPlayer.skip(index)
else await TrackPlayer.skipToNext()
const { state } = await TrackPlayer.getPlaybackState()
if (state !== State.Playing) await TrackPlayer.play()
}
-41
View File
@@ -1,41 +0,0 @@
import TrackPlayer from 'react-native-track-player'
import JellifyTrack from '../../../types/JellifyTrack'
import { queryClient } from '../../../constants/query-client'
import {
ACTIVE_INDEX_QUERY_KEY,
NOW_PLAYING_QUERY_KEY,
PLAY_QUEUE_QUERY_KEY,
} from '../constants/query-keys'
import { usePlayerQueueStore } from '../../../stores/player/queue'
export function getActiveIndex(): number | undefined {
return queryClient.getQueryData(ACTIVE_INDEX_QUERY_KEY) as number | undefined
}
export function setActiveIndex(index: number): void {
queryClient.setQueryData(ACTIVE_INDEX_QUERY_KEY, index)
}
export function getCurrentTrack(): JellifyTrack | undefined {
return queryClient.getQueryData(NOW_PLAYING_QUERY_KEY)
}
export function getPlayQueue(): JellifyTrack[] | undefined {
return queryClient.getQueryData(PLAY_QUEUE_QUERY_KEY) as JellifyTrack[] | undefined
}
export function setPlayQueue(tracks: JellifyTrack[]): void {
queryClient.setQueryData(PLAY_QUEUE_QUERY_KEY, tracks)
}
export async function handleActiveTrackChanged(): Promise<void> {
const [queue, activeTrack, activeIndex] = await Promise.all([
TrackPlayer.getQueue(),
TrackPlayer.getActiveTrack(),
TrackPlayer.getActiveTrackIndex(),
])
usePlayerQueueStore.getState().setQueue(queue as JellifyTrack[])
usePlayerQueueStore.getState().setCurrentTrack(activeTrack as JellifyTrack)
usePlayerQueueStore.getState().setCurrentIndex(activeIndex)
}
+37 -43
View File
@@ -1,26 +1,23 @@
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
import TrackPlayer, { Event, State, useTrackPlayerEvents } from 'react-native-track-player'
import { createContext, useEffect, useState } from 'react'
import { handleActiveTrackChanged } from './functions'
import { handleActiveTrackChanged } from '../../hooks/player/functions'
import JellifyTrack from '../../types/JellifyTrack'
import { useAutoDownload } from '../../stores/settings/usage'
import reportPlaybackStopped from '../../api/mutations/playback/functions/playback-stopped'
import reportPlaybackCompleted from '../../api/mutations/playback/functions/playback-completed'
import isPlaybackFinished from '../../api/mutations/playback/utils'
import reportPlaybackStarted from '../../api/mutations/playback/functions/playback-started'
import calculateTrackVolume from './utils/normalization'
import calculateTrackVolume from '../../hooks/player/functions/normalization'
import saveAudioItem from '../../api/mutations/download/utils'
import { useDownloadingDeviceProfile } from '../../stores/device-profile'
import Initialize from './functions/initialization'
import Initialize from './utils/initialization'
import { useEnableAudioNormalization } from '../../stores/settings/player'
import { usePlayerQueueStore } from '../../stores/player/queue'
import usePostFullCapabilities from '../../api/mutations/session'
const PLAYER_EVENTS: Event[] = [
Event.PlaybackActiveTrackChanged,
Event.PlaybackProgressUpdated,
Event.PlaybackState,
]
import reportPlaybackProgress from '../../api/mutations/playback/functions/playback-progress'
import { PLAYER_EVENTS } from '../../configs/player.config'
import { getApi } from '../../stores'
interface PlayerContext {}
@@ -39,42 +36,44 @@ export const PlayerProvider: () => React.JSX.Element = () => {
usePerformanceMonitor('PlayerProvider', 3)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const eventHandler = async (event: any) => {
useTrackPlayerEvents(PLAYER_EVENTS, async (event) => {
const api = getApi()
switch (event.type) {
case Event.PlaybackActiveTrackChanged: {
// When we load a new queue, our index is updated before RNTP
// Because of this, we only need to respond to this event
// if the index from the event differs from what we have stored
if (event.track && enableAudioNormalization) {
const volume = calculateTrackVolume(event.track)
await TrackPlayer.setVolume(volume)
} else if (event.track) {
try {
await reportPlaybackStarted(event.track)
} catch (error) {
console.error('Unable to report playback started for track', error)
if (event.track) {
handleActiveTrackChanged(event.track as JellifyTrack, event.index)
reportPlaybackStarted(api, event.track as JellifyTrack, 0)
if (enableAudioNormalization) {
const volume = calculateTrackVolume(event.track as JellifyTrack)
await TrackPlayer.setVolume(volume)
}
}
await handleActiveTrackChanged()
if (event.lastTrack) {
try {
if (isPlaybackFinished(event.lastPosition, event.lastTrack.duration ?? 1))
await reportPlaybackCompleted(event.lastTrack as JellifyTrack)
else await reportPlaybackStopped(event.lastTrack as JellifyTrack)
} catch (error) {
console.error('Unable to report playback stopped for lastTrack', error)
}
if (event.lastTrack && event.lastPosition) {
if (isPlaybackFinished(event.lastPosition, event.lastTrack.duration ?? 1))
reportPlaybackCompleted(api, event.lastTrack as JellifyTrack)
else
reportPlaybackStopped(
api,
event.lastTrack as JellifyTrack,
event.lastPosition,
)
}
break
}
case Event.PlaybackProgressUpdated: {
const currentTrack = usePlayerQueueStore.getState().currentTrack
if (event.position && currentTrack)
reportPlaybackProgress(api, currentTrack, event.position)
if (event.position / event.duration > 0.3 && autoDownload && currentTrack) {
await saveAudioItem(currentTrack.item, downloadingDeviceProfile, true)
await saveAudioItem(currentTrack.item, downloadingDeviceProfile, true).then(
(value) => console.log('Track downloaded'),
)
}
break
@@ -82,20 +81,19 @@ export const PlayerProvider: () => React.JSX.Element = () => {
case Event.PlaybackState: {
const currentTrack = usePlayerQueueStore.getState().currentTrack
switch (event.state) {
case State.Playing:
if (currentTrack) await reportPlaybackStarted(currentTrack)
if (currentTrack) reportPlaybackStarted(api, currentTrack)
break
default:
if (currentTrack) await reportPlaybackStopped(currentTrack)
if (currentTrack) reportPlaybackStopped(api, currentTrack)
break
}
break
}
}
}
useTrackPlayerEvents(PLAYER_EVENTS, eventHandler)
})
useEffect(() => {
if (!initialized) {
@@ -104,9 +102,5 @@ export const PlayerProvider: () => React.JSX.Element = () => {
}
}, [])
return (
<PlayerContext.Provider value={{}}>
<></>
</PlayerContext.Provider>
)
return <PlayerContext value={{}} />
}
@@ -1,16 +1,12 @@
import { isUndefined } from 'lodash'
import { getActiveIndex, getCurrentTrack, getPlayQueue } from '.'
import TrackPlayer, { RepeatMode } from 'react-native-track-player'
import { usePlayerQueueStore } from '../../../stores/player/queue'
import { queryClient } from '../../../constants/query-client'
import { REPEAT_MODE_QUERY_KEY } from '../constants/query-keys'
import { createMMKV } from 'react-native-mmkv'
export default async function Initialize() {
const {
queue: persistedQueue,
currentIndex: persistedIndex,
currentTrack: persistedTrack,
repeatMode,
} = usePlayerQueueStore.getState()
@@ -19,9 +15,8 @@ export default async function Initialize() {
const savedPosition = progressStorage.getNumber('player-key') ?? 0
console.log('savedPosition before reset', savedPosition)
const storedPlayQueue = persistedQueue.length > 0 ? persistedQueue : getPlayQueue()
const storedIndex = persistedIndex ?? getActiveIndex()
const storedTrack = persistedTrack ?? getCurrentTrack()
const storedPlayQueue = persistedQueue.length > 0 ? persistedQueue : undefined
const storedIndex = persistedIndex
if (
Array.isArray(storedPlayQueue) &&
@@ -40,7 +35,6 @@ export default async function Initialize() {
const restoredRepeatMode = repeatMode ?? RepeatMode.Off
await TrackPlayer.setRepeatMode(restoredRepeatMode)
queryClient.setQueryData(REPEAT_MODE_QUERY_KEY, restoredRepeatMode)
// Restore saved playback position after queue is loaded
if (savedPosition > 0) {
+1 -7
View File
@@ -2,11 +2,5 @@ import AddToPlaylist from '../../components/AddToPlaylist/index'
import { AddToPlaylistProps } from '../types'
export default function AddToPlaylistSheet({ route }: AddToPlaylistProps): React.JSX.Element {
return (
<AddToPlaylist
track={route.params.track}
tracks={route.params.tracks}
source={route.params.source}
/>
)
return <AddToPlaylist tracks={route.params.tracks} source={route.params.source} />
}
+8 -1
View File
@@ -1,14 +1,21 @@
import React from 'react'
import React, { useEffect } from 'react'
import PlayerScreen from '../../components/Player'
import Queue from '../../components/Player/queue'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import MultipleArtistsSheet from '../Context/multiple-artists'
import { PlayerParamList } from './types'
import Lyrics from '../../components/Player/components/lyrics'
import usePlayerDisplayStore from '../../stores/player/display'
const PlayerStack = createNativeStackNavigator<PlayerParamList>()
export default function Player(): React.JSX.Element {
useEffect(() => {
usePlayerDisplayStore.getState().setIsPlayerFocused(true)
return () => usePlayerDisplayStore.getState().setIsPlayerFocused(false)
}, [])
return (
<PlayerStack.Navigator initialRouteName='PlayerScreen'>
<PlayerStack.Screen
+1 -1
View File
@@ -3,7 +3,7 @@ import { SignOutModalProps } from './types'
import { H5, Text } from '../../components/Global/helpers/text'
import Button from '../../components/Global/helpers/button'
import Icon from '../../components/Global/components/icon'
import { useResetQueue } from '../../providers/Player/hooks/callbacks'
import { useResetQueue } from '../../hooks/player/callbacks'
import { useClearAllDownloads } from '../../api/mutations/download'
import { useJellifyServer } from '../../stores'
import { useNavigation } from '@react-navigation/native'
+1 -2
View File
@@ -61,8 +61,7 @@ export type RootStackParamList = {
}
AddToPlaylist: {
track?: BaseItemDto
tracks?: BaseItemDto[]
tracks: BaseItemDto[]
source?: BaseItemDto
}
+18
View File
@@ -0,0 +1,18 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
type PlayerDisplayStore = {
isPlayerFocused: boolean
setIsPlayerFocused: (isPlayerFocused: boolean) => void
}
const usePlayerDisplayStore = create<PlayerDisplayStore>()(
devtools((set) => ({
isPlayerFocused: false,
setIsPlayerFocused: (isPlayerFocused) => set({ isPlayerFocused }),
})),
)
export const useIsPlayerFocused = () => usePlayerDisplayStore((state) => state.isPlayerFocused)
export default usePlayerDisplayStore