Merge main into Playlist-improvements, keeping unmemoized version

This commit is contained in:
skalthoff
2025-12-03 18:50:11 -08:00
68 changed files with 1893 additions and 1738 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
github: [anultravioletaurora, riteshshukla04, felinusfish, skalthoff] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] github: [anultravioletaurora, riteshshukla04, felinusfish, skalthoff] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: anultravioletaurora # Replace with a single Patreon username patreon: anultravioletaurora # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username ko_fi: jellify # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username liberapay: # Replace with a single Liberapay username
+40 -40
View File
@@ -1,5 +1,5 @@
import './gesture-handler' import './gesture-handler'
import React, { useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import 'react-native-url-polyfill/auto' import 'react-native-url-polyfill/auto'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client' import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import Jellify from './src/components/jellify' import Jellify from './src/components/jellify'
@@ -24,7 +24,7 @@ import ErrorBoundary from './src/components/ErrorBoundary'
import OTAUpdateScreen from './src/components/OtaUpdates' import OTAUpdateScreen from './src/components/OtaUpdates'
import { usePerformanceMonitor } from './src/hooks/use-performance-monitor' import { usePerformanceMonitor } from './src/hooks/use-performance-monitor'
import navigationRef from './navigation' import navigationRef from './navigation'
import { PROGRESS_UPDATE_EVENT_INTERVAL } from './src/player/config' import { BUFFERS, PROGRESS_UPDATE_EVENT_INTERVAL } from './src/player/config'
import { useThemeSetting } from './src/stores/settings/app' import { useThemeSetting } from './src/stores/settings/app'
LogBox.ignoreAllLogs() LogBox.ignoreAllLogs()
@@ -34,47 +34,47 @@ export default function App(): React.JSX.Element {
const performanceMetrics = usePerformanceMonitor('App', 3) const performanceMetrics = usePerformanceMonitor('App', 3)
const [playerIsReady, setPlayerIsReady] = useState<boolean>(false) const [playerIsReady, setPlayerIsReady] = useState<boolean>(false)
const playerInitializedRef = useRef<boolean>(false)
/** useEffect(() => {
* Enhanced Android buffer settings for gapless playback // Guard against double initialization (React StrictMode, hot reload)
* if (playerInitializedRef.current) return
* @see playerInitializedRef.current = true
*/
const buffers =
Platform.OS === 'android'
? {
maxCacheSize: 50 * 1024, // 50MB cache
maxBuffer: 30, // 30 seconds buffer
playBuffer: 2.5, // 2.5 seconds play buffer
backBuffer: 5, // 5 seconds back buffer
}
: {}
TrackPlayer.setupPlayer({ TrackPlayer.setupPlayer({
autoHandleInterruptions: true, autoHandleInterruptions: true,
iosCategory: IOSCategory.Playback, iosCategory: IOSCategory.Playback,
iosCategoryOptions: [IOSCategoryOptions.AllowAirPlay, IOSCategoryOptions.AllowBluetooth], iosCategoryOptions: [
androidAudioContentType: AndroidAudioContentType.Music, IOSCategoryOptions.AllowAirPlay,
minBuffer: 30, // 30 seconds minimum buffer IOSCategoryOptions.AllowBluetooth,
...buffers, ],
}) androidAudioContentType: AndroidAudioContentType.Music,
.then(() => minBuffer: 30, // 30 seconds minimum buffer
TrackPlayer.updateOptions({ ...BUFFERS,
capabilities: CAPABILITIES,
notificationCapabilities: CAPABILITIES,
// Reduced interval for smoother progress tracking and earlier prefetch detection
progressUpdateEventInterval: PROGRESS_UPDATE_EVENT_INTERVAL,
// Stop playback and remove notification when app is killed to prevent battery drain
android: {
appKilledPlaybackBehavior:
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
},
}),
)
.finally(() => {
setPlayerIsReady(true)
requestStoragePermission()
}) })
.then(() =>
TrackPlayer.updateOptions({
capabilities: CAPABILITIES,
notificationCapabilities: CAPABILITIES,
// Reduced interval for smoother progress tracking and earlier prefetch detection
progressUpdateEventInterval: PROGRESS_UPDATE_EVENT_INTERVAL,
// Stop playback and remove notification when app is killed to prevent battery drain
android: {
appKilledPlaybackBehavior:
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
},
}),
)
.catch((error) => {
// Player may already be initialized (e.g., after hot reload)
// This is expected and not a fatal error
console.log('[TrackPlayer] Setup caught:', error?.message ?? error)
})
.finally(() => {
setPlayerIsReady(true)
requestStoragePermission()
})
}, []) // Empty deps - only run once on mount
const [reloader, setReloader] = useState(0) const [reloader, setReloader] = useState(0)
+8 -2
View File
@@ -2,8 +2,8 @@
<img alt='Jellify logo' src='assets/transparent-banner.png' width="600" height="300" /> <img alt='Jellify logo' src='assets/transparent-banner.png' width="600" height="300" />
</p> </p>
[![Latest Version](https://img.shields.io/github/package-json/version/anultravioletaurora/jellify?label=Latest%20Version&color=indigo)](https://github.com/anultravioletaurora/Jellify/releases) [![Latest Version](https://img.shields.io/github/package-json/version/anultravioletaurora/jellify?label=Latest%20Version&color=indigo)](https://github.com/anultravioletaurora/Jellify/releases) [![iTunes App Store](https://img.shields.io/itunes/v/6736884612?logo=app-store&logoColor=white&label=Apple%20App%20Store&labelColor=%60&color=blue)](https://apps.apple.com/us/app/jellify/id6736884612) [![Google Play](https://img.shields.io/badge/Google%20Play-Download-red?logo=googleplay&logoColor=white)](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify)
[![publish-beta](https://github.com/anultravioletaurora/Jellify/actions/workflows/publish-beta.yml/badge.svg?branch=main)](https://github.com/anultravioletaurora/Jellify/actions/workflows/publish-beta.yml) [![Publish Over-the-Air Update](https://github.com/Jellify-Music/App/actions/workflows/publish-ota-update.yml/badge.svg)](https://github.com/Jellify-Music/App/actions/workflows/publish-ota-update.yml)
[![Sponsors](https://img.shields.io/github/sponsors/anultravioletaurora?label=Project%20Sponsors&color=magenta)](https://github.com/sponsors/anultravioletaurora) [![Patreon](https://img.shields.io/badge/Patreon-F96854?logo=patreon&logoColor=white)](https://patreon.com/anultravioletaurora?utm_medium=unknown&utm_source=join_link&utm_campaign=creatorshare_creator&utm_content=copyLink) [![Sponsors](https://img.shields.io/github/sponsors/anultravioletaurora?label=Project%20Sponsors&color=magenta)](https://github.com/sponsors/anultravioletaurora) [![Patreon](https://img.shields.io/badge/Patreon-F96854?logo=patreon&logoColor=white)](https://patreon.com/anultravioletaurora?utm_medium=unknown&utm_source=join_link&utm_campaign=creatorshare_creator&utm_content=copyLink)
@@ -65,6 +65,10 @@ These projects are **not** required to use _Jellify_, but are recommended by us
### Android ### Android
[![Google Play](https://img.shields.io/badge/Google%20Play-Download-red?logo=googleplay&logoColor=white)](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify)
#### Direct .APK Download
Head to [releases](https://github.com/Jellify-Music/App/releases) to download the required .APK directly. Head to [releases](https://github.com/Jellify-Music/App/releases) to download the required .APK directly.
Also there is [obtanium](https://github.com/ImranR98/Obtainium) to which you can add Jellify as a repo to use the above releases as a repository. Also there is [obtanium](https://github.com/ImranR98/Obtainium) to which you can add Jellify as a repo to use the above releases as a repository.
@@ -73,6 +77,8 @@ For Obtanium, click "Add App", put "https://github.com/Jellify-Music/App" as the
### iOS ### iOS
[![iTunes App Store](https://img.shields.io/itunes/v/6736884612?logo=app-store&logoColor=white&label=Apple%20App%20Store&labelColor=%60&color=blue)](https://apps.apple.com/us/app/jellify/id6736884612)
#### The TestFlight Way #### The TestFlight Way
Join the [TestFlight](https://testflight.apple.com/join/etVSc7ZQ) and install the latest version from there Join the [TestFlight](https://testflight.apple.com/join/etVSc7ZQ) and install the latest version from there
+2 -2
View File
@@ -91,8 +91,8 @@ android {
applicationId "com.cosmonautical.jellify" applicationId "com.cosmonautical.jellify"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 155 versionCode 157
versionName "0.21.3" versionName "0.22.1"
} }
signingConfigs { signingConfigs {
debug { debug {
+5 -1
View File
@@ -1,4 +1,8 @@
module.exports = { module.exports = {
presets: ['module:@react-native/babel-preset'], presets: ['module:@react-native/babel-preset'],
plugins: ['react-native-worklets/plugin', 'react-native-worklets-core/plugin'], plugins: [
'babel-plugin-react-compiler',
'react-native-worklets/plugin',
'react-native-worklets-core/plugin',
],
} }
+22 -20
View File
@@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "jellify", "name": "jellify",
@@ -11,19 +10,19 @@
"@react-native-community/netinfo": "^11.4.1", "@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2", "@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-vector-icons/material-design-icons": "12.4.0", "@react-native-vector-icons/material-design-icons": "12.4.0",
"@react-navigation/bottom-tabs": "7.8.6", "@react-navigation/bottom-tabs": "7.8.10",
"@react-navigation/material-top-tabs": "7.4.4", "@react-navigation/material-top-tabs": "7.4.7",
"@react-navigation/native": "7.1.21", "@react-navigation/native": "7.1.23",
"@react-navigation/native-stack": "7.8.0", "@react-navigation/native-stack": "7.8.4",
"@sentry/react-native": "7.6.0", "@sentry/react-native": "7.6.0",
"@shopify/flash-list": "2.2.0", "@shopify/flash-list": "2.2.0",
"@tamagui/config": "1.137.1", "@tamagui/config": "1.137.1",
"@tanstack/query-async-storage-persister": "5.89.0", "@tanstack/query-async-storage-persister": "5.89.0",
"@tanstack/react-query": "5.89.0", "@tanstack/react-query": "5.89.0",
"@tanstack/react-query-persist-client": "5.89.0", "@tanstack/react-query-persist-client": "5.89.0",
"@testing-library/react-native": "^13.2.3", "@testing-library/react-native": "13.3.3",
"@typedigital/telemetrydeck-react": "^0.4.1", "@typedigital/telemetrydeck-react": "^0.4.1",
"axios": "1.12.2", "axios": "1.13.2",
"bundle": "^2.1.0", "bundle": "^2.1.0",
"dlx": "^0.2.1", "dlx": "^0.2.1",
"invert-color": "^2.0.0", "invert-color": "^2.0.0",
@@ -45,13 +44,13 @@
"react-native-linear-gradient": "^2.8.3", "react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "3.3.3", "react-native-mmkv": "3.3.3",
"react-native-nitro-fetch": "^0.1.6", "react-native-nitro-fetch": "^0.1.6",
"react-native-nitro-modules": "^0.31.9", "react-native-nitro-modules": "0.31.10",
"react-native-nitro-ota": "^0.4.0", "react-native-nitro-ota": "0.7.2",
"react-native-pager-view": "^6.9.1", "react-native-pager-view": "^6.9.1",
"react-native-reanimated": "4.1.5", "react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2", "react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.18.0", "react-native-screens": "4.18.0",
"react-native-sortables": "^1.9.3", "react-native-sortables": "1.9.4",
"react-native-text-ticker": "^1.15.0", "react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3", "react-native-toast-message": "^2.3.3",
"react-native-track-player": "5.0.0-alpha0", "react-native-track-player": "5.0.0-alpha0",
@@ -83,6 +82,7 @@
"@types/react-native-vector-icons": "^6.4.18", "@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"babel-plugin-module-resolver": "^5.0.2", "babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.33.0", "eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
@@ -565,17 +565,17 @@
"@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.82.1", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-f5zpJg9gzh7JtCbsIwV+4kP3eI0QBuA93JGmwFRd4onQ3DnCjV2J5pYqdWtM95sjSKK1dyik59Gj01lLeKqs1Q=="], "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.82.1", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-f5zpJg9gzh7JtCbsIwV+4kP3eI0QBuA93JGmwFRd4onQ3DnCjV2J5pYqdWtM95sjSKK1dyik59Gj01lLeKqs1Q=="],
"@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.6", "", { "dependencies": { "@react-navigation/elements": "^2.8.3", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.21", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-0wGtU+I1rCUjvAqKtzD2dwQaTICFf5J233vkg20cLrx8LNQPAgSsbnsDSM6S315OOoVLCIL1dcrNv7ExLBlWfw=="], "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.10", "", { "dependencies": { "@react-navigation/elements": "^2.9.0", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.23", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-NxKjtlRwkGU3O3hPxpS+Aq7mVNfgtLzBe4xpGjQNphLzklRbxa6Me//m2eKzogpitZhLR2xZb1A49HrLuWe2ww=="],
"@react-navigation/core": ["@react-navigation/core@7.13.2", "", { "dependencies": { "@react-navigation/routers": "^7.5.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-A0pFeZlKp+FJob2lVr7otDt3M4rsSJrnAfXWoWR9JVeFtfEXsH/C0s7xtpDCMRUO58kzSBoTF1GYzoMC5DLD4g=="], "@react-navigation/core": ["@react-navigation/core@7.13.4", "", { "dependencies": { "@react-navigation/routers": "^7.5.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-JM9bkb7fr4P5YUOVEwoAZq3xPeSL9V6Nd1KKTyAwCgGUVhESbSRSy3Ri/PGu6ZcLb/t7/tM1NqP5tV1e1bAwUg=="],
"@react-navigation/elements": ["@react-navigation/elements@2.8.3", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.21", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-0c5nSDPP3bUFujgkSVqqMShaAup3XIxNe1KTK9LSmwKgWEneyo6OPIjIdiEwPlZvJZKi7ag5hDjacQLGwO0LGA=="], "@react-navigation/elements": ["@react-navigation/elements@2.9.0", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.23", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-423uE+q/esaiMbXVLckFOd9MbWG06/vCYOP2hwzEUj9ZwzUgSpsIPovcu78qa8UMuvKD8wkyouM01Wvav1y/KQ=="],
"@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.3", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.21", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-8OCT+tW4dlkEPhjmQWFEw867CKTL3och5N9TLt56lA+3pm55x1kljsVO6DF6BxF41iHrhIJIr09UrojVJDr5TQ=="], "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.7", "", { "dependencies": { "@react-navigation/elements": "^2.9.0", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.23", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-0fv+Ym9kOO7DLf8GRmkt9zNKPTbnYU62ATacv0zirNA+vBDT/fhlE67orUXsQa/nORXlUMvllCaKPf/oyD7UcQ=="],
"@react-navigation/native": ["@react-navigation/native@7.1.21", "", { "dependencies": { "@react-navigation/core": "^7.13.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-mhpAewdivBL01ibErr91FUW9bvKhfAF6Xv/yr6UOJtDhv0jU6iUASUcA3i3T8VJCOB/vxmoke7VDp8M+wBFs/Q=="], "@react-navigation/native": ["@react-navigation/native@7.1.23", "", { "dependencies": { "@react-navigation/core": "^7.13.4", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-V+drzVkoVA8VO83cJ59UYe7dfdnMFpGDAybp7d5O1ufxt321Z5tOtNDOzhMGzHUENqo9QWc4P/HuCUmz7KMy+A=="],
"@react-navigation/native-stack": ["@react-navigation/native-stack@7.8.0", "", { "dependencies": { "@react-navigation/elements": "^2.8.3", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.21", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-iRqQY+IYB610BJY/335/kdNDhXQ8L9nPUlIT+DSk88FA86+C+4/vek8wcKw8IrfwdorT4m+6TY0v7Qnrt+WLKQ=="], "@react-navigation/native-stack": ["@react-navigation/native-stack@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.9.0", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.23", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-7kpYHoZZ81SPtDDG9ttZtI4nXR8GbVsLL1KnT/7RiLkFdqHXlriGpVhG5BKJRS1CYXrGEn40NogYW2+OBplglg=="],
"@react-navigation/routers": ["@react-navigation/routers@7.5.2", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-kymreY5aeTz843E+iPAukrsOtc7nabAH6novtAPREmmGu77dQpfxPB2ZWpKb5nRErIRowp1kYRoN2Ckl+S6JYw=="], "@react-navigation/routers": ["@react-navigation/routers@7.5.2", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-kymreY5aeTz843E+iPAukrsOtc7nabAH6novtAPREmmGu77dQpfxPB2ZWpKb5nRErIRowp1kYRoN2Ckl+S6JYw=="],
@@ -1017,7 +1017,7 @@
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"axios": ["axios@1.12.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw=="], "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="],
"babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], "babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="],
@@ -1033,6 +1033,8 @@
"babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.5", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg=="], "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.5", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg=="],
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
"babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.32.0", "", { "dependencies": { "hermes-parser": "0.32.0" } }, "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg=="], "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.32.0", "", { "dependencies": { "hermes-parser": "0.32.0" } }, "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg=="],
"babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="], "babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="],
@@ -1927,9 +1929,9 @@
"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-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.31.9", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-w7NtHq4wP6LZgvDs7zbFU3B2uHpRx/bJlSTckw0By8NyEX39fURPGgHyi4a67q1O7I3iFJvbRNWUiiOBbNvHDg=="], "react-native-nitro-modules": ["react-native-nitro-modules@0.31.10", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ=="],
"react-native-nitro-ota": ["react-native-nitro-ota@0.4.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "^0.29.8" } }, "sha512-/JAoM2m3WsvnO7dC51bf5jCghxO78yrP3vHyq3/itK+MqiwU8HPk8bGbXLhE+/GYRPS8DbUHGrzptzO2KOoutQ=="], "react-native-nitro-ota": ["react-native-nitro-ota@0.7.2", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "^0.29.8" } }, "sha512-DUa2/QhFJBhSbzrTHGrc+qm1pSuJctccUcHlHZXjPV4fCEpi+4Y17QqI9U4D9MUnnP77afKEZJKFy+0NQeSAdA=="],
"react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="], "react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="],
@@ -1939,7 +1941,7 @@
"react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="], "react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="],
"react-native-sortables": ["react-native-sortables@1.9.3", "", { "optionalDependencies": { "react-native-haptic-feedback": ">=2.0.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-VLhW9+3AVEaJNwwQSgN+n/Qe+YRB0C0mNWTjHhyzcZ+YjY4BmJao4bZxl5lD6EsfqZ1Ij6B2ZdxjNlSkUXrvow=="], "react-native-sortables": ["react-native-sortables@1.9.4", "", { "optionalDependencies": { "react-native-haptic-feedback": ">=2.0.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-a6hxT+gl14HA5Sm8UiLXJqF8KMEQVa+mUJd75OnzoVsmrxUDtjAatlMdV0kI9qTQDT/ZSFLPRmdUhOR762IA4g=="],
"react-native-tab-view": ["react-native-tab-view@4.2.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-TUbh7Yr0tE/99t1pJQLbQ+4/Px67xkT7/r3AhfV+93Q3WoUira0Lx7yuKUP2C118doqxub8NCLERwcqsHr29nQ=="], "react-native-tab-view": ["react-native-tab-view@4.2.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-TUbh7Yr0tE/99t1pJQLbQ+4/Px67xkT7/r3AhfV+93Q3WoUira0Lx7yuKUP2C118doqxub8NCLERwcqsHr29nQ=="],
+6 -6
View File
@@ -543,7 +543,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 264; CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -554,7 +554,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.21.3; MARKETING_VERSION = 0.22.1;
NEW_SETTING = ""; NEW_SETTING = "";
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
@@ -585,7 +585,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 264; CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = WAH9CZ8BPG; DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -595,7 +595,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.21.3; MARKETING_VERSION = 0.22.1;
NEW_SETTING = ""; NEW_SETTING = "";
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
@@ -821,7 +821,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 264; CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = WAH9CZ8BPG; DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -832,7 +832,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.21.3; MARKETING_VERSION = 0.22.1;
NEW_SETTING = ""; NEW_SETTING = "";
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
+6 -6
View File
@@ -42,7 +42,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- SocketRocket - SocketRocket
- Yoga - Yoga
- NitroModules (0.31.9): - NitroModules (0.31.10):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -71,7 +71,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- SocketRocket - SocketRocket
- Yoga - Yoga
- NitroOta (0.4.0): - NitroOta (0.7.2):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -102,7 +102,7 @@ PODS:
- SocketRocket - SocketRocket
- SSZipArchive - SSZipArchive
- Yoga - Yoga
- NitroOtaBundleManager (0.4.0): - NitroOtaBundleManager (0.7.2):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -3449,9 +3449,9 @@ SPEC CHECKSUMS:
google-cast-sdk: 1fb6724e94cc5ff23b359176e0cf6360586bb97a google-cast-sdk: 1fb6724e94cc5ff23b359176e0cf6360586bb97a
hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5 hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5
NitroFetch: 660adfb47f84b28db664f97b50e5dc28506ab6c1 NitroFetch: 660adfb47f84b28db664f97b50e5dc28506ab6c1
NitroModules: 224bf833d249b0c7ce32831368f2887008579b13 NitroModules: 5bc319d441f4983894ea66b1d392c519536e6d23
NitroOta: b4f7cdbe660e8f07f80f5eb9f169d70f698ea284 NitroOta: 7755c4728f7348584cebb2d428480b1ed0cd2679
NitroOtaBundleManager: 5e7c0f8c3f76cc06f9fe07a63879fe35496c27c7 NitroOtaBundleManager: 482abb17f0ca629ad551da43f13e76e59dba9568
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
Protobuf: 164aea2ae380c3951abdc3e195220c01d17400e0 Protobuf: 164aea2ae380c3951abdc3e195220c01d17400e0
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
+11 -10
View File
@@ -1,6 +1,6 @@
{ {
"name": "jellify", "name": "jellify",
"version": "0.21.3", "version": "0.22.1",
"private": true, "private": true,
"scripts": { "scripts": {
"init-android": "bun i", "init-android": "bun i",
@@ -43,19 +43,19 @@
"@react-native-community/netinfo": "^11.4.1", "@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2", "@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-vector-icons/material-design-icons": "12.4.0", "@react-native-vector-icons/material-design-icons": "12.4.0",
"@react-navigation/bottom-tabs": "7.8.6", "@react-navigation/bottom-tabs": "7.8.10",
"@react-navigation/material-top-tabs": "7.4.4", "@react-navigation/material-top-tabs": "7.4.7",
"@react-navigation/native": "7.1.21", "@react-navigation/native": "7.1.23",
"@react-navigation/native-stack": "7.8.0", "@react-navigation/native-stack": "7.8.4",
"@sentry/react-native": "7.6.0", "@sentry/react-native": "7.6.0",
"@shopify/flash-list": "2.2.0", "@shopify/flash-list": "2.2.0",
"@tamagui/config": "1.137.1", "@tamagui/config": "1.137.1",
"@tanstack/query-async-storage-persister": "5.89.0", "@tanstack/query-async-storage-persister": "5.89.0",
"@tanstack/react-query": "5.89.0", "@tanstack/react-query": "5.89.0",
"@tanstack/react-query-persist-client": "5.89.0", "@tanstack/react-query-persist-client": "5.89.0",
"@testing-library/react-native": "^13.2.3", "@testing-library/react-native": "13.3.3",
"@typedigital/telemetrydeck-react": "^0.4.1", "@typedigital/telemetrydeck-react": "^0.4.1",
"axios": "1.12.2", "axios": "1.13.2",
"bundle": "^2.1.0", "bundle": "^2.1.0",
"dlx": "^0.2.1", "dlx": "^0.2.1",
"invert-color": "^2.0.0", "invert-color": "^2.0.0",
@@ -77,13 +77,13 @@
"react-native-linear-gradient": "^2.8.3", "react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "3.3.3", "react-native-mmkv": "3.3.3",
"react-native-nitro-fetch": "^0.1.6", "react-native-nitro-fetch": "^0.1.6",
"react-native-nitro-modules": "^0.31.9", "react-native-nitro-modules": "0.31.10",
"react-native-nitro-ota": "^0.4.0", "react-native-nitro-ota": "0.7.2",
"react-native-pager-view": "^6.9.1", "react-native-pager-view": "^6.9.1",
"react-native-reanimated": "4.1.5", "react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2", "react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.18.0", "react-native-screens": "4.18.0",
"react-native-sortables": "^1.9.3", "react-native-sortables": "1.9.4",
"react-native-text-ticker": "^1.15.0", "react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3", "react-native-toast-message": "^2.3.3",
"react-native-track-player": "5.0.0-alpha0", "react-native-track-player": "5.0.0-alpha0",
@@ -115,6 +115,7 @@
"@types/react-native-vector-icons": "^6.4.18", "@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"babel-plugin-module-resolver": "^5.0.2", "babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.33.0", "eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
+41 -11
View File
@@ -18,6 +18,27 @@ type DownloadedFileInfo = {
size: number size: number
} }
const getExtensionFromUrl = (url: string): string | null => {
const sanitized = url.split('?')[0]
const lastSegment = sanitized.split('/').pop() ?? ''
const match = lastSegment.match(/\.([a-zA-Z0-9]+)$/)
return match?.[1] ?? null
}
const normalizeExtension = (ext: string | undefined | null) => {
if (!ext) return null
const clean = ext.toLowerCase()
return clean === 'mpeg' ? 'mp3' : clean
}
const extensionFromContentType = (contentType: string | undefined): string | null => {
if (!contentType) return null
if (!contentType.includes('/')) return null
const [, subtypeRaw] = contentType.split('/')
const container = subtypeRaw.split(';')[0]
return normalizeExtension(container)
}
export type DeleteDownloadsResult = { export type DeleteDownloadsResult = {
deletedCount: number deletedCount: number
freedBytes: number freedBytes: number
@@ -29,23 +50,30 @@ export async function downloadJellyfinFile(
name: string, name: string,
songName: string, songName: string,
setDownloadProgress: JellifyDownloadProgressState, setDownloadProgress: JellifyDownloadProgressState,
preferredExtension?: string | null,
): Promise<DownloadedFileInfo> { ): Promise<DownloadedFileInfo> {
try { try {
// Fetch the file const urlExtension = normalizeExtension(getExtensionFromUrl(url))
const headRes = await axios.head(url) const hintedExtension = normalizeExtension(preferredExtension)
const contentType = headRes.headers['content-type']
// Step 2: Get extension from content-type let extension = urlExtension ?? hintedExtension ?? null
let extension = 'mp3' // default extension
if (contentType && contentType.includes('/')) { if (!extension) {
const parts = contentType.split('/') try {
const container = parts[1].split(';')[0] // handles "audio/m4a; charset=utf-8" const headRes = await axios.head(url)
if (container !== 'mpeg') { const headExtension = extensionFromContentType(headRes.headers['content-type'])
extension = container // don't use mpeg as an extension, use the default extension if (headExtension) extension = headExtension
} catch (error) {
console.warn(
'HEAD request failed when determining download type, using default',
error,
)
} }
} }
// Step 3: Build path if (!extension) extension = 'bin' // fallback without assuming a specific codec
// Build path
const fileName = `${name}.${extension}` const fileName = `${name}.${extension}`
const downloadDest = `${RNFS.DocumentDirectoryPath}/${fileName}` const downloadDest = `${RNFS.DocumentDirectoryPath}/${fileName}`
@@ -138,6 +166,7 @@ export const saveAudio = async (
track.item.Id as string, track.item.Id as string,
track.title as string, track.title as string,
setDownloadProgress, setDownloadProgress,
track.mediaSourceInfo?.Container,
) )
let downloadedArtworkFile: DownloadedFileInfo | undefined let downloadedArtworkFile: DownloadedFileInfo | undefined
if (track.artwork) { if (track.artwork) {
@@ -146,6 +175,7 @@ export const saveAudio = async (
track.item.Id as string, track.item.Id as string,
track.title as string, track.title as string,
setDownloadProgress, setDownloadProgress,
undefined,
) )
} }
track.url = downloadedTrackFile.uri track.url = downloadedTrackFile.uri
+23
View File
@@ -2,21 +2,44 @@ import { Api } from '@jellyfin/sdk'
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models' import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api' import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
// Default image size for list thumbnails (optimized for common row heights)
const DEFAULT_THUMBNAIL_SIZE = 200
export interface ImageUrlOptions {
/** Maximum width of the requested image */
maxWidth?: number
/** Maximum height of the requested image */
maxHeight?: number
/** Image quality (0-100) */
quality?: number
}
export function getItemImageUrl( export function getItemImageUrl(
api: Api | undefined, api: Api | undefined,
item: BaseItemDto, item: BaseItemDto,
type: ImageType, type: ImageType,
options?: ImageUrlOptions,
): string | undefined { ): string | undefined {
const { AlbumId, AlbumPrimaryImageTag, ImageTags, Id } = item const { AlbumId, AlbumPrimaryImageTag, ImageTags, Id } = item
if (!api) return undefined if (!api) return undefined
// Use provided dimensions or default thumbnail size for list performance
const imageParams = {
tag: undefined as string | undefined,
maxWidth: options?.maxWidth ?? DEFAULT_THUMBNAIL_SIZE,
maxHeight: options?.maxHeight ?? DEFAULT_THUMBNAIL_SIZE,
quality: options?.quality ?? 90,
}
return AlbumId return AlbumId
? getImageApi(api).getItemImageUrlById(AlbumId, type, { ? getImageApi(api).getItemImageUrlById(AlbumId, type, {
...imageParams,
tag: AlbumPrimaryImageTag ?? undefined, tag: AlbumPrimaryImageTag ?? undefined,
}) })
: Id : Id
? getImageApi(api).getItemImageUrlById(Id, type, { ? getImageApi(api).getItemImageUrlById(Id, type, {
...imageParams,
tag: ImageTags ? ImageTags[type] : undefined, tag: ImageTags ? ImageTags[type] : undefined,
}) })
: undefined : undefined
+2
View File
@@ -31,6 +31,7 @@ const useStreamedMediaInfo = (itemId: string | null | undefined) => {
return useQuery({ return useQuery({
queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }), queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }),
queryFn: () => fetchMediaInfo(api, deviceProfile, itemId), queryFn: () => fetchMediaInfo(api, deviceProfile, itemId),
enabled: Boolean(api && deviceProfile && itemId),
staleTime: ONE_DAY, // Only refetch when the user's device profile changes staleTime: ONE_DAY, // Only refetch when the user's device profile changes
gcTime: ONE_DAY, gcTime: ONE_DAY,
}) })
@@ -60,6 +61,7 @@ export const useDownloadedMediaInfo = (itemId: string | null | undefined) => {
return useQuery({ return useQuery({
queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }), queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }),
queryFn: () => fetchMediaInfo(api, deviceProfile, itemId), queryFn: () => fetchMediaInfo(api, deviceProfile, itemId),
enabled: Boolean(api && deviceProfile && itemId),
staleTime: ONE_DAY, // Only refetch when the user's device profile changes staleTime: ONE_DAY, // Only refetch when the user's device profile changes
gcTime: ONE_DAY, gcTime: ONE_DAY,
}) })
+31 -35
View File
@@ -9,23 +9,24 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import InstantMixButton from '../Global/components/instant-mix-button' import InstantMixButton from '../Global/components/instant-mix-button'
import ItemImage from '../Global/components/image' import ItemImage from '../Global/components/image'
import React, { useCallback, useMemo } from 'react' import React, { useCallback } from 'react'
import { useSafeAreaFrame } from 'react-native-safe-area-context' import { useSafeAreaFrame } from 'react-native-safe-area-context'
import Icon from '../Global/components/icon' import Icon from '../Global/components/icon'
import { mapDtoToTrack } from '../../utils/mappings'
import { useNetworkContext } from '../../providers/Network'
import { useNetworkStatus } from '../../stores/network' import { useNetworkStatus } from '../../stores/network'
import { useLoadNewQueue } from '../../providers/Player/hooks/mutations' import { useLoadNewQueue } from '../../providers/Player/hooks/mutations'
import { QueuingType } from '../../enums/queuing-type' import { QueuingType } from '../../enums/queuing-type'
import { useAlbumContext } from '../../providers/Album'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import HomeStackParamList from '../../screens/Home/types' import HomeStackParamList from '../../screens/Home/types'
import LibraryStackParamList from '../../screens/Library/types' import LibraryStackParamList from '../../screens/Library/types'
import DiscoverStackParamList from '../../screens/Discover/types' import DiscoverStackParamList from '../../screens/Discover/types'
import { BaseStackParamList } from '../../screens/types' import { BaseStackParamList } from '../../screens/types'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile' import useStreamingDeviceProfile from '../../stores/device-profile'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry' import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import { useApi } from '../../stores' import { useApi } from '../../stores'
import { QueryKeys } from '../../enums/query-keys'
import { fetchAlbumDiscs } from '../../api/queries/item'
import { useQuery } from '@tanstack/react-query'
import useAddToPendingDownloads, { usePendingDownloads } from '../../stores/network/downloads'
/** /**
* The screen for an Album's track list * The screen for an Album's track list
@@ -35,37 +36,30 @@ import { useApi } from '../../stores'
* *
* @returns A React component * @returns A React component
*/ */
export function Album(): React.JSX.Element { export function Album({ album }: { album: BaseItemDto }): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>() const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
const { album, discs, isPending } = useAlbumContext()
const api = useApi() const api = useApi()
const { addToDownloadQueue, pendingDownloads } = useNetworkContext()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const downloadAlbum = (item: BaseItemDto[]) => { const { data: discs, isPending } = useQuery({
if (!api) return queryKey: [QueryKeys.ItemTracks, album.Id],
const jellifyTracks = item.map((item) => mapDtoToTrack(api, item, downloadingDeviceProfile)) queryFn: () => fetchAlbumDiscs(api, album),
addToDownloadQueue(jellifyTracks) })
}
const sections = useMemo( const addToDownloadQueue = useAddToPendingDownloads()
() =>
(Array.isArray(discs) ? discs : []).map(({ title, data }) => ({ const pendingDownloads = usePendingDownloads()
title,
data: Array.isArray(data) ? data : [], const downloadAlbum = (item: BaseItemDto[]) => addToDownloadQueue(item)
})),
[discs], const sections = (Array.isArray(discs) ? discs : []).map(({ title, data }) => ({
) title,
data: Array.isArray(data) ? data : [],
}))
const hasMultipleSections = sections.length > 1 const hasMultipleSections = sections.length > 1
const albumTrackList = useMemo(() => discs?.flatMap((disc) => disc.data), [discs]) const albumTrackList = discs?.flatMap((disc) => disc.data)
const handleScrollBeginDrag = useCallback(() => {
closeAllSwipeableRows()
}, [])
return ( return (
<SectionList <SectionList
@@ -100,7 +94,7 @@ export function Album(): React.JSX.Element {
</XStack> </XStack>
) : null ) : null
}} }}
ListHeaderComponent={AlbumTrackListHeader} ListHeaderComponent={() => <AlbumTrackListHeader album={album} />}
renderItem={({ item: track, index }) => ( renderItem={({ item: track, index }) => (
<Track <Track
navigation={navigation} navigation={navigation}
@@ -110,13 +104,13 @@ export function Album(): React.JSX.Element {
queue={album} queue={album}
/> />
)} )}
ListFooterComponent={AlbumTrackListFooter} ListFooterComponent={() => <AlbumTrackListFooter album={album} />}
ListEmptyComponent={() => ( ListEmptyComponent={() => (
<YStack flex={1} alignContent='center'> <YStack flex={1} alignContent='center'>
{isPending ? <Spinner color={'$primary'} /> : <Text>No tracks found</Text>} {isPending ? <Spinner color={'$primary'} /> : <Text>No tracks found</Text>}
</YStack> </YStack>
)} )}
onScrollBeginDrag={handleScrollBeginDrag} onScrollBeginDrag={closeAllSwipeableRows}
/> />
) )
} }
@@ -128,7 +122,7 @@ export function Album(): React.JSX.Element {
* @param playAlbum The function to call to play the album * @param playAlbum The function to call to play the album
* @returns A React component * @returns A React component
*/ */
function AlbumTrackListHeader(): React.JSX.Element { function AlbumTrackListHeader({ album }: { album: BaseItemDto }): React.JSX.Element {
const api = useApi() const api = useApi()
const { width } = useSafeAreaFrame() const { width } = useSafeAreaFrame()
@@ -138,7 +132,10 @@ function AlbumTrackListHeader(): React.JSX.Element {
const loadNewQueue = useLoadNewQueue() const loadNewQueue = useLoadNewQueue()
const { album, discs } = useAlbumContext() const { data: discs, isPending } = useQuery({
queryKey: [QueryKeys.ItemTracks, album.Id],
queryFn: () => fetchAlbumDiscs(api, album),
})
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>() const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
@@ -243,8 +240,7 @@ function AlbumTrackListHeader(): React.JSX.Element {
) )
} }
function AlbumTrackListFooter(): React.JSX.Element { function AlbumTrackListFooter({ album }: { album: BaseItemDto }): React.JSX.Element {
const { album } = useAlbumContext()
const navigation = const navigation =
useNavigation< useNavigation<
NativeStackNavigationProp< NativeStackNavigationProp<
+40 -41
View File
@@ -1,6 +1,6 @@
import { ActivityIndicator, RefreshControl } from 'react-native' import { RefreshControl } from 'react-native'
import { Separator, useTheme, XStack, YStack } from 'tamagui' import { Separator, useTheme, XStack, YStack } from 'tamagui'
import React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react' import React, { RefObject, useEffect, useRef } from 'react'
import { Text } from '../Global/helpers/text' import { Text } from '../Global/helpers/text'
import { FlashList, FlashListRef } from '@shopify/flash-list' import { FlashList, FlashListRef } from '@shopify/flash-list'
import { UseInfiniteQueryResult } from '@tanstack/react-query' import { UseInfiniteQueryResult } from '@tanstack/react-query'
@@ -13,6 +13,7 @@ import AZScroller, { useAlphabetSelector } from '../Global/components/alphabetic
import { isString } from 'lodash' import { isString } from 'lodash'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header' import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { useLibrarySortAndFilterContext } from '../../providers/Library' import { useLibrarySortAndFilterContext } from '../../providers/Library'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
interface AlbumsProps { interface AlbumsProps {
albumsInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error> albumsInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
@@ -38,55 +39,52 @@ export default function Albums({
const pendingLetterRef = useRef<string | null>(null) const pendingLetterRef = useRef<string | null>(null)
// Memoize expensive stickyHeaderIndices calculation to prevent unnecessary re-computations // Memoize expensive stickyHeaderIndices calculation to prevent unnecessary re-computations
const stickyHeaderIndices = React.useMemo(() => { const stickyHeaderIndices =
if (!showAlphabeticalSelector || !albumsInfiniteQuery.data) return [] !showAlphabeticalSelector || !albumsInfiniteQuery.data
? []
return albumsInfiniteQuery.data : albumsInfiniteQuery.data
.map((album, index) => (typeof album === 'string' ? index : 0)) .map((album, index) => (typeof album === 'string' ? index : 0))
.filter((value, index, indices) => indices.indexOf(value) === index) .filter((value, index, indices) => indices.indexOf(value) === index)
}, [showAlphabeticalSelector, albumsInfiniteQuery.data])
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } = const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase())) useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
const refreshControl = useMemo( const refreshControl = (
() => ( <RefreshControl
<RefreshControl refreshing={albumsInfiniteQuery.isFetching && !isAlphabetSelectorPending}
refreshing={albumsInfiniteQuery.isFetching && !isAlphabetSelectorPending} onRefresh={albumsInfiniteQuery.refetch}
onRefresh={albumsInfiniteQuery.refetch} tintColor={theme.primary.val}
tintColor={theme.primary.val} />
/>
),
[albumsInfiniteQuery.isFetching, isAlphabetSelectorPending, albumsInfiniteQuery.refetch],
) )
const ItemSeparatorComponent = useCallback( const ItemSeparatorComponent = ({
({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) => leadingItem,
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : ( trailingItem,
<Separator /> }: {
), leadingItem: unknown
[], trailingItem: unknown
) }) =>
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : <Separator />
const keyExtractor = useCallback( const keyExtractor = (item: BaseItemDto | string | number) =>
(item: BaseItemDto | string | number) => typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!
typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!,
[],
)
const renderItem = useCallback( const renderItem = ({
({ index, item: album }: { index: number; item: BaseItemDto | string | number }) => index,
typeof album === 'string' ? ( item: album,
<FlashListStickyHeader text={album.toUpperCase()} /> }: {
) : typeof album === 'number' ? null : typeof album === 'object' ? ( index: number
<ItemRow item={album} navigation={navigation} /> item: BaseItemDto | string | number
) : null, }) =>
[navigation], typeof album === 'string' ? (
) <FlashListStickyHeader text={album.toUpperCase()} />
) : typeof album === 'number' ? null : typeof album === 'object' ? (
<ItemRow item={album} navigation={navigation} />
) : null
const onEndReached = useCallback(() => { const onEndReached = () => {
if (albumsInfiniteQuery.hasNextPage) albumsInfiniteQuery.fetchNextPage() if (albumsInfiniteQuery.hasNextPage) albumsInfiniteQuery.fetchNextPage()
}, [albumsInfiniteQuery.hasNextPage, albumsInfiniteQuery.fetchNextPage]) }
// Effect for handling the pending alphabet selector letter // Effect for handling the pending alphabet selector letter
useEffect(() => { useEffect(() => {
@@ -144,6 +142,7 @@ export default function Albums({
ItemSeparatorComponent={ItemSeparatorComponent} ItemSeparatorComponent={ItemSeparatorComponent}
refreshControl={refreshControl} refreshControl={refreshControl}
stickyHeaderIndices={stickyHeaderIndices} stickyHeaderIndices={stickyHeaderIndices}
onScrollBeginDrag={closeAllSwipeableRows}
removeClippedSubviews removeClippedSubviews
/> />
+4 -3
View File
@@ -77,11 +77,12 @@ export default function ArtistTabBar({
> >
<Icon <Icon
name={ name={
sortBy === ItemSortBy.DateCreated ? 'calendar' : 'sort-alphabetical' sortBy === ItemSortBy.DateCreated
? 'calendar'
: 'sort-alphabetical-ascending'
} }
color={'$borderColor'} color={'$borderColor'}
/> />{' '}
<Text color={'$borderColor'}> <Text color={'$borderColor'}>
{sortBy === ItemSortBy.DateCreated ? 'Date Added' : 'A-Z'} {sortBy === ItemSortBy.DateCreated ? 'Date Added' : 'A-Z'}
</Text> </Text>
+1
View File
@@ -76,6 +76,7 @@ export default function ArtistHeader(): React.JSX.Element {
height={'$20'} height={'$20'}
type={ImageType.Backdrop} type={ImageType.Backdrop}
cornered cornered
imageOptions={{ maxWidth: width * 2, maxHeight: 640 }}
/> />
<YStack alignItems='center' paddingHorizontal={'$3'}> <YStack alignItems='center' paddingHorizontal={'$3'}>
+36 -34
View File
@@ -1,5 +1,5 @@
import React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react' import React, { RefObject, useEffect, useRef } from 'react'
import { getTokenValue, Separator, useTheme, XStack, YStack } from 'tamagui' import { Separator, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../Global/helpers/text' import { Text } from '../Global/helpers/text'
import { RefreshControl } from 'react-native' import { RefreshControl } from 'react-native'
import ItemRow from '../Global/components/item-row' import ItemRow from '../Global/components/item-row'
@@ -13,6 +13,7 @@ import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import LibraryStackParamList from '../../screens/Library/types' import LibraryStackParamList from '../../screens/Library/types'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header' import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
export interface ArtistsProps { export interface ArtistsProps {
artistsInfiniteQuery: UseInfiniteQueryResult< artistsInfiniteQuery: UseInfiniteQueryResult<
@@ -49,41 +50,41 @@ export default function Artists({
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } = const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase())) useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
const stickyHeaderIndices = useMemo(() => { const stickyHeaderIndices =
if (!showAlphabeticalSelector || !artists) return [] !showAlphabeticalSelector || !artists
? []
: artists
.map((artist, index, artists) => (typeof artist === 'string' ? index : 0))
.filter((value, index, indices) => indices.indexOf(value) === index)
return artists const ItemSeparatorComponent = ({
.map((artist, index, artists) => (typeof artist === 'string' ? index : 0)) leadingItem,
.filter((value, index, indices) => indices.indexOf(value) === index) trailingItem,
}, [showAlphabeticalSelector, artists]) }: {
leadingItem: unknown
trailingItem: unknown
}) =>
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : <Separator />
const ItemSeparatorComponent = useCallback( const KeyExtractor = (item: BaseItemDto | string | number, index: number) =>
({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) => typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : (
<Separator />
),
[],
)
const KeyExtractor = useCallback( const renderItem = ({
(item: BaseItemDto | string | number, index: number) => index,
typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!, item: artist,
[], }: {
) index: number
item: BaseItemDto | number | string
const renderItem = useCallback( }) =>
({ index, item: artist }: { index: number; item: BaseItemDto | number | string }) => typeof artist === 'string' ? (
typeof artist === 'string' ? ( // Don't render the letter if we don't have any artists that start with it
// Don't render the letter if we don't have any artists that start with it // If the index is the last index, or the next index is not an object, then don't render the letter
// If the index is the last index, or the next index is not an object, then don't render the letter index - 1 === artists.length || typeof artists[index + 1] !== 'object' ? null : (
index - 1 === artists.length || typeof artists[index + 1] !== 'object' ? null : ( <FlashListStickyHeader text={artist.toUpperCase()} />
<FlashListStickyHeader text={artist.toUpperCase()} /> )
) ) : typeof artist === 'number' ? null : typeof artist === 'object' ? (
) : typeof artist === 'number' ? null : typeof artist === 'object' ? ( <ItemRow circular item={artist} navigation={navigation} />
<ItemRow circular item={artist} navigation={navigation} /> ) : null
) : null,
[navigation],
)
// Effect for handling the pending alphabet selector letter // Effect for handling the pending alphabet selector letter
useEffect(() => { useEffect(() => {
@@ -155,6 +156,7 @@ export default function Artists({
if (artistsInfiniteQuery.hasNextPage && !artistsInfiniteQuery.isFetching) if (artistsInfiniteQuery.hasNextPage && !artistsInfiniteQuery.isFetching)
artistsInfiniteQuery.fetchNextPage() artistsInfiniteQuery.fetchNextPage()
}} }}
onScrollBeginDrag={closeAllSwipeableRows}
removeClippedSubviews removeClippedSubviews
/> />
+22 -40
View File
@@ -3,7 +3,7 @@ import {
BaseItemKind, BaseItemKind,
MediaSourceInfo, MediaSourceInfo,
} from '@jellyfin/sdk/lib/generated-client/models' } from '@jellyfin/sdk/lib/generated-client/models'
import { ListItem, ScrollView, Spinner, View, YGroup } from 'tamagui' import { ListItem, Spinner, View, YGroup } from 'tamagui'
import { BaseStackParamList, RootStackParamList } from '../../screens/types' import { BaseStackParamList, RootStackParamList } from '../../screens/types'
import { Text } from '../Global/helpers/text' import { Text } from '../Global/helpers/text'
import FavoriteContextMenuRow from '../Global/components/favorite-context-menu-row' import FavoriteContextMenuRow from '../Global/components/favorite-context-menu-row'
@@ -15,7 +15,7 @@ import { fetchAlbumDiscs, fetchItem } from '../../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { AddToQueueMutation } from '../../providers/Player/interfaces' import { AddToQueueMutation } from '../../providers/Player/interfaces'
import { QueuingType } from '../../enums/queuing-type' import { QueuingType } from '../../enums/queuing-type'
import { useCallback, useEffect, useMemo } from 'react' import { useEffect } from 'react'
import navigationRef from '../../../navigation' import navigationRef from '../../../navigation'
import { goToAlbumFromContextSheet, goToArtistFromContextSheet } from './utils/navigation' import { goToAlbumFromContextSheet, goToArtistFromContextSheet } from './utils/navigation'
import { getItemName } from '../../utils/text' import { getItemName } from '../../utils/text'
@@ -25,14 +25,17 @@ import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../Player/component.config' import { TextTickerConfig } from '../Player/component.config'
import { useAddToQueue } from '../../providers/Player/hooks/mutations' import { useAddToQueue } from '../../providers/Player/hooks/mutations'
import { useNetworkStatus } from '../../stores/network' import { useNetworkStatus } from '../../stores/network'
import { useNetworkContext } from '../../providers/Network' import useStreamingDeviceProfile from '../../stores/device-profile'
import { mapDtoToTrack } from '../../utils/mappings'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile'
import { useIsDownloaded } from '../../api/queries/download' import { useIsDownloaded } from '../../api/queries/download'
import { useDeleteDownloads } from '../../api/mutations/download' import { useDeleteDownloads } from '../../api/mutations/download'
import useHapticFeedback from '../../hooks/use-haptic-feedback' import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { Platform } from 'react-native' import { Platform } from 'react-native'
import { useApi } from '../../stores' import { useApi } from '../../stores'
import useAddToPendingDownloads, {
useIsDownloading,
usePendingDownloads,
} from '../../stores/network/downloads'
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
type StackNavigation = Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'> type StackNavigation = Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
@@ -55,6 +58,8 @@ export default function ItemContext({
const trigger = useHapticFeedback() const trigger = useHapticFeedback()
const [networkStatus] = useNetworkStatus()
const isArtist = item.Type === BaseItemKind.MusicArtist const isArtist = item.Type === BaseItemKind.MusicArtist
const isAlbum = item.Type === BaseItemKind.MusicAlbum const isAlbum = item.Type === BaseItemKind.MusicAlbum
const isTrack = item.Type === BaseItemKind.Audio const isTrack = item.Type === BaseItemKind.Audio
@@ -98,12 +103,12 @@ export default function ItemContext({
: [] : []
: [] : []
const itemTracks = useMemo(() => { const itemTracks = (() => {
if (isTrack) return [item] if (isTrack) return [item]
else if (isAlbum && discs) return discs.flatMap((data) => data.data) else if (isAlbum && discs) return discs.flatMap((data) => data.data)
else if (isPlaylist && tracks) return tracks else if (isPlaylist && tracks) return tracks
else return [] else return []
}, [isTrack, isAlbum, discs, isPlaylist, tracks]) })()
useEffect(() => trigger('impactLight'), [item?.Id]) useEffect(() => trigger('impactLight'), [item?.Id])
@@ -242,35 +247,15 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
} }
function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element { function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element {
const api = useApi() const addToDownloadQueue = useAddToPendingDownloads()
const { addToDownloadQueue, pendingDownloads } = useNetworkContext()
const useRemoveDownload = useDeleteDownloads() const useRemoveDownload = useDeleteDownloads()
const deviceProfile = useDownloadingDeviceProfile()
const isDownloaded = useIsDownloaded(items.map(({ Id }) => Id)) const isDownloaded = useIsDownloaded(items.map(({ Id }) => Id))
const downloadItems = useCallback(() => { const removeDownloads = () => useRemoveDownload(items.map(({ Id }) => Id))
if (!api) return
const tracks = items.map((item) => mapDtoToTrack(api, item, deviceProfile)) const isPending = useIsDownloading(items)
addToDownloadQueue(tracks)
}, [addToDownloadQueue, items])
const removeDownloads = useCallback(
() => useRemoveDownload(items.map(({ Id }) => Id)),
[useRemoveDownload, items],
)
const isPending = useMemo(
() =>
items.filter(
(item) =>
pendingDownloads.filter((download) => download.item.Id === item.Id).length > 0,
).length > 0,
[items, pendingDownloads],
)
return isPending ? ( return isPending ? (
<ListItem <ListItem
@@ -293,7 +278,7 @@ function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element
backgroundColor={'transparent'} backgroundColor={'transparent'}
gap={'$2.5'} gap={'$2.5'}
justifyContent='flex-start' justifyContent='flex-start'
onPress={downloadItems} onPress={() => addToDownloadQueue(items)}
pressStyle={{ opacity: 0.5 }} pressStyle={{ opacity: 0.5 }}
> >
<Icon <Icon
@@ -326,10 +311,10 @@ interface MenuRowProps {
} }
function ViewAlbumMenuRow({ album: album, stackNavigation }: MenuRowProps): React.JSX.Element { function ViewAlbumMenuRow({ album: album, stackNavigation }: MenuRowProps): React.JSX.Element {
const goToAlbum = useCallback(() => { const goToAlbum = () => {
if (stackNavigation && album) stackNavigation.navigate('Album', { album }) if (stackNavigation && album) stackNavigation.navigate('Album', { album })
else goToAlbumFromContextSheet(album) else goToAlbumFromContextSheet(album)
}, [album, stackNavigation, navigationRef]) }
return ( return (
<ListItem <ListItem
@@ -380,13 +365,10 @@ function ViewArtistMenuRow({
enabled: !!artistId, enabled: !!artistId,
}) })
const goToArtist = useCallback( const goToArtist = (artist: BaseItemDto) => {
(artist: BaseItemDto) => { if (stackNavigation) stackNavigation.navigate('Artist', { artist })
if (stackNavigation) stackNavigation.navigate('Artist', { artist }) else goToArtistFromContextSheet(artist)
else goToArtistFromContextSheet(artist) }
},
[stackNavigation, navigationRef],
)
return artist ? ( return artist ? (
<ListItem <ListItem
@@ -1,4 +1,4 @@
import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react' import React, { RefObject, useEffect, useRef, useState } from 'react'
import { LayoutChangeEvent, Platform, View as RNView } from 'react-native' import { LayoutChangeEvent, Platform, View as RNView } from 'react-native'
import { getToken, Spinner, useTheme, View, YStack } from 'tamagui' import { getToken, Spinner, useTheme, View, YStack } from 'tamagui'
import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { Gesture, GestureDetector } from 'react-native-gesture-handler'
@@ -61,78 +61,70 @@ export default function AZScroller({
}) })
} }
const panGesture = useMemo( const panGesture = Gesture.Pan()
() => .runOnJS(true)
Gesture.Pan() .onBegin((e) => {
.runOnJS(true) const relativeY = e.absoluteY - alphabetSelectorTopY.current
.onBegin((e) => { setOverlayPositionY(relativeY - letterHeight.current * 1.5)
const relativeY = e.absoluteY - alphabetSelectorTopY.current const index = Math.floor(relativeY / letterHeight.current)
setOverlayPositionY(relativeY - letterHeight.current * 1.5) if (alphabet[index]) {
const index = Math.floor(relativeY / letterHeight.current) const letter = alphabet[index]
if (alphabet[index]) { selectedLetter.value = letter
const letter = alphabet[index] setOverlayLetter(letter)
selectedLetter.value = letter scheduleOnRN(showOverlay)
setOverlayLetter(letter) }
scheduleOnRN(showOverlay) })
} .onUpdate((e) => {
}) const relativeY = e.absoluteY - alphabetSelectorTopY.current
.onUpdate((e) => { setOverlayPositionY(relativeY - letterHeight.current * 1.5)
const relativeY = e.absoluteY - alphabetSelectorTopY.current const index = Math.floor(relativeY / letterHeight.current)
setOverlayPositionY(relativeY - letterHeight.current * 1.5) if (alphabet[index]) {
const index = Math.floor(relativeY / letterHeight.current) const letter = alphabet[index]
if (alphabet[index]) { selectedLetter.value = letter
const letter = alphabet[index] setOverlayLetter(letter)
selectedLetter.value = letter scheduleOnRN(showOverlay)
setOverlayLetter(letter) }
scheduleOnRN(showOverlay) })
} .onEnd(() => {
}) if (selectedLetter.value) {
.onEnd(() => { scheduleOnRN(async () => {
if (selectedLetter.value) { setOperationPending(true)
scheduleOnRN(async () => { onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
setOperationPending(true)
onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
scheduleOnRN(hideOverlay)
setOperationPending(false)
})
})
} else {
scheduleOnRN(hideOverlay) scheduleOnRN(hideOverlay)
} setOperationPending(false)
}), })
[onLetterSelect], })
) } else {
scheduleOnRN(hideOverlay)
}
})
const tapGesture = useMemo( const tapGesture = Gesture.Tap()
() => .runOnJS(true)
Gesture.Tap() .onBegin((e) => {
.runOnJS(true) const relativeY = e.absoluteY - alphabetSelectorTopY.current
.onBegin((e) => { setOverlayPositionY(relativeY - letterHeight.current * 1.5)
const relativeY = e.absoluteY - alphabetSelectorTopY.current const index = Math.floor(relativeY / letterHeight.current)
setOverlayPositionY(relativeY - letterHeight.current * 1.5) if (alphabet[index]) {
const index = Math.floor(relativeY / letterHeight.current) const letter = alphabet[index]
if (alphabet[index]) { selectedLetter.value = letter
const letter = alphabet[index] setOverlayLetter(letter)
selectedLetter.value = letter scheduleOnRN(showOverlay)
setOverlayLetter(letter) }
scheduleOnRN(showOverlay) })
} .onEnd(() => {
}) if (selectedLetter.value) {
.onEnd(() => { scheduleOnRN(async () => {
if (selectedLetter.value) { setOperationPending(true)
scheduleOnRN(async () => { onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
setOperationPending(true)
onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
scheduleOnRN(hideOverlay)
setOperationPending(false)
})
})
} else {
scheduleOnRN(hideOverlay) scheduleOnRN(hideOverlay)
} setOperationPending(false)
}), })
[onLetterSelect], })
) } else {
scheduleOnRN(hideOverlay)
}
})
const gesture = Gesture.Simultaneous(panGesture, tapGesture) const gesture = Gesture.Simultaneous(panGesture, tapGesture)
@@ -2,7 +2,9 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item
import { FlashList, FlashListProps } from '@shopify/flash-list' import { FlashList, FlashListProps } from '@shopify/flash-list'
import React from 'react' import React from 'react'
interface HorizontalCardListProps extends FlashListProps<BaseItemDto> {} type HorizontalCardListProps = Omit<FlashListProps<BaseItemDto>, 'estimatedItemSize'> & {
estimatedItemSize?: number
}
/** /**
* Displays a Horizontal FlatList of 20 ItemCards * Displays a Horizontal FlatList of 20 ItemCards
@@ -13,14 +15,17 @@ interface HorizontalCardListProps extends FlashListProps<BaseItemDto> {}
export default function HorizontalCardList({ export default function HorizontalCardList({
data, data,
renderItem, renderItem,
estimatedItemSize = 150,
...props ...props
}: HorizontalCardListProps): React.JSX.Element { }: HorizontalCardListProps): React.JSX.Element {
return ( return (
<FlashList <FlashList<BaseItemDto>
horizontal horizontal
data={data} data={data}
renderItem={renderItem} renderItem={renderItem}
removeClippedSubviews removeClippedSubviews
// @ts-expect-error - estimatedItemSize is required by FlashList but types are incorrect
estimatedItemSize={estimatedItemSize}
style={{ style={{
overflow: 'hidden', overflow: 'hidden',
}} }}
@@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import { import {
AnimationKeys,
ColorTokens, ColorTokens,
getToken, getToken,
getTokens, getTokens,
@@ -11,6 +12,7 @@ import {
YStack, YStack,
} from 'tamagui' } from 'tamagui'
import MaterialDesignIcon from '@react-native-vector-icons/material-design-icons' import MaterialDesignIcon from '@react-native-vector-icons/material-design-icons'
import { on } from 'events'
const smallSize = 28 const smallSize = 28
@@ -42,8 +44,14 @@ export default function Icon({
const theme = useTheme() const theme = useTheme()
const size = large ? largeSize : small ? smallSize : regularSize const size = large ? largeSize : small ? smallSize : regularSize
const animation = onPress || onPressIn ? 'quick' : undefined
const pressStyle = animation ? { opacity: 0.6 } : undefined
return ( return (
<YStack <YStack
animation={animation}
pressStyle={pressStyle}
alignContent='center' alignContent='center'
justifyContent='center' justifyContent='center'
onPress={onPress} onPress={onPress}
+12 -3
View File
@@ -6,7 +6,7 @@ import { ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { Blurhash } from 'react-native-blurhash' import { Blurhash } from 'react-native-blurhash'
import { getBlurhashFromDto } from '../../../utils/blurhash' import { getBlurhashFromDto } from '../../../utils/blurhash'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated' import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { getItemImageUrl } from '../../../api/queries/image/utils' import { getItemImageUrl, ImageUrlOptions } from '../../../api/queries/image/utils'
import { memo, useCallback, useMemo, useState } from 'react' import { memo, useCallback, useMemo, useState } from 'react'
import { useApi } from '../../../stores' import { useApi } from '../../../stores'
@@ -18,6 +18,8 @@ interface ItemImageProps {
width?: Token | number | string | undefined width?: Token | number | string | undefined
height?: Token | number | string | undefined height?: Token | number | string | undefined
testID?: string | undefined testID?: string | undefined
/** Image resolution options for requesting higher quality images */
imageOptions?: ImageUrlOptions
} }
const ItemImage = memo( const ItemImage = memo(
@@ -29,10 +31,14 @@ const ItemImage = memo(
width, width,
height, height,
testID, testID,
imageOptions,
}: ItemImageProps): React.JSX.Element { }: ItemImageProps): React.JSX.Element {
const api = useApi() const api = useApi()
const imageUrl = useMemo(() => getItemImageUrl(api, item, type), [api, item.Id, type]) const imageUrl = useMemo(
() => getItemImageUrl(api, item, type, imageOptions),
[api, item.Id, type, imageOptions],
)
return imageUrl ? ( return imageUrl ? (
<Image <Image
@@ -56,7 +62,10 @@ const ItemImage = memo(
prevProps.circular === nextProps.circular && prevProps.circular === nextProps.circular &&
prevProps.width === nextProps.width && prevProps.width === nextProps.width &&
prevProps.height === nextProps.height && prevProps.height === nextProps.height &&
prevProps.testID === nextProps.testID, prevProps.testID === nextProps.testID &&
prevProps.imageOptions?.maxWidth === nextProps.imageOptions?.maxWidth &&
prevProps.imageOptions?.maxHeight === nextProps.imageOptions?.maxHeight &&
prevProps.imageOptions?.quality === nextProps.imageOptions?.quality,
) )
interface ItemBlurhashProps { interface ItemBlurhashProps {
@@ -39,7 +39,7 @@ function ItemCardComponent({
useEffect(() => { useEffect(() => {
if (item.Type === 'Audio') warmContext(item) if (item.Type === 'Audio') warmContext(item)
}, [item.Id, warmContext]) }, [item.Id, item.Type, warmContext])
const hoverStyle = useMemo(() => (onPress ? { scale: 0.925 } : undefined), [onPress]) const hoverStyle = useMemo(() => (onPress ? { scale: 0.925 } : undefined), [onPress])
+235 -287
View File
@@ -14,7 +14,7 @@ import { useNetworkStatus } from '../../../stores/network'
import useStreamingDeviceProfile from '../../../stores/device-profile' import useStreamingDeviceProfile from '../../../stores/device-profile'
import useItemContext from '../../../hooks/use-item-context' import useItemContext from '../../../hooks/use-item-context'
import { RouteProp, useRoute } from '@react-navigation/native' import { RouteProp, useRoute } from '@react-navigation/native'
import React, { memo, useCallback, useMemo, useState } from 'react' import React from 'react'
import { LayoutChangeEvent } from 'react-native' import { LayoutChangeEvent } from 'react-native'
import Animated, { import Animated, {
SharedValue, SharedValue,
@@ -30,12 +30,14 @@ import { useIsFavorite } from '../../../api/queries/user-data'
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite' import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
import { useApi } from '../../../stores' import { useApi } from '../../../stores'
import { useHideRunTimesSetting } from '../../../stores/settings/app' import { useHideRunTimesSetting } from '../../../stores/settings/app'
import { Queue } from '../../../player/types/queue-item'
interface ItemRowProps { interface ItemRowProps {
item: BaseItemDto item: BaseItemDto
circular?: boolean circular?: boolean
onPress?: () => void onPress?: () => void
navigation?: Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'> navigation?: Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
queueName?: Queue
} }
/** /**
@@ -49,318 +51,264 @@ interface ItemRowProps {
* @param navigation - The navigation object. * @param navigation - The navigation object.
* @returns * @returns
*/ */
const ItemRow = memo( function ItemRow({
function ItemRow({ item, circular, navigation, onPress }: ItemRowProps): React.JSX.Element { item,
const artworkAreaWidth = useSharedValue(0) circular,
navigation,
onPress,
queueName,
}: ItemRowProps): React.JSX.Element {
const artworkAreaWidth = useSharedValue(0)
const api = useApi() const api = useApi()
const [networkStatus] = useNetworkStatus() const [networkStatus] = useNetworkStatus()
const deviceProfile = useStreamingDeviceProfile() const deviceProfile = useStreamingDeviceProfile()
const loadNewQueue = useLoadNewQueue() const loadNewQueue = useLoadNewQueue()
const addToQueue = useAddToQueue() const addToQueue = useAddToQueue()
const { mutate: addFavorite } = useAddFavorite() const { mutate: addFavorite } = useAddFavorite()
const { mutate: removeFavorite } = useRemoveFavorite() const { mutate: removeFavorite } = useRemoveFavorite()
const [hideRunTimes] = useHideRunTimesSetting() const [hideRunTimes] = useHideRunTimesSetting()
const warmContext = useItemContext() const warmContext = useItemContext()
const { data: isFavorite } = useIsFavorite(item) const { data: isFavorite } = useIsFavorite(item)
const onPressIn = useCallback(() => warmContext(item), [warmContext, item.Id]) const onPressIn = () => warmContext(item)
const onLongPress = useCallback( const onLongPress = () =>
() => navigationRef.navigate('Context', {
navigationRef.navigate('Context', { item,
item, navigation,
navigation, })
}),
[navigationRef, navigation, item.Id],
)
const onPressCallback = useCallback(async () => { const onPressCallback = async () => {
if (onPress) await onPress() if (onPress) await onPress()
else else
switch (item.Type) { switch (item.Type) {
case 'Audio': { case 'Audio': {
loadNewQueue({ loadNewQueue({
api,
networkStatus,
deviceProfile,
track: item,
tracklist: [item],
index: 0,
queue: 'Search',
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
break
}
case 'MusicArtist': {
navigation?.navigate('Artist', { artist: item })
break
}
case 'MusicAlbum': {
navigation?.navigate('Album', { album: item })
break
}
case 'Playlist': {
navigation?.navigate('Playlist', { playlist: item, canEdit: true })
break
}
default: {
break
}
}
}, [loadNewQueue, item.Id, navigation])
const renderRunTime = useMemo(
() => item.Type === BaseItemKind.Audio && !hideRunTimes,
[item.Type, hideRunTimes],
)
const isAudio = useMemo(() => item.Type === 'Audio', [item.Type])
const playlistTrackCount = useMemo(
() => (item.Type === 'Playlist' ? (item.SongCount ?? item.ChildCount ?? 0) : undefined),
[item.Type, item.SongCount, item.ChildCount],
)
const leftSettings = useSwipeSettingsStore((s) => s.left)
const rightSettings = useSwipeSettingsStore((s) => s.right)
const swipeHandlers = useCallback(
() => ({
addToQueue: async () =>
await addToQueue({
api, api,
deviceProfile,
networkStatus, networkStatus,
tracks: [item], deviceProfile,
queuingType: QueuingType.DirectlyQueued, track: item,
}), tracklist: [item],
toggleFavorite: () => index: 0,
isFavorite ? removeFavorite({ item }) : addFavorite({ item }), queue: queueName ?? 'Search',
addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }), queuingType: QueuingType.FromSelection,
}), startPlayback: true,
[ })
addToQueue, break
}
case 'MusicArtist': {
navigation?.navigate('Artist', { artist: item })
break
}
case 'MusicAlbum': {
navigation?.navigate('Album', { album: item })
break
}
case 'Playlist': {
navigation?.navigate('Playlist', { playlist: item, canEdit: true })
break
}
default: {
break
}
}
}
const renderRunTime = item.Type === BaseItemKind.Audio && !hideRunTimes
const isAudio = item.Type === 'Audio'
const playlistTrackCount =
item.Type === 'Playlist' ? (item.SongCount ?? item.ChildCount ?? 0) : undefined
const leftSettings = useSwipeSettingsStore((s) => s.left)
const rightSettings = useSwipeSettingsStore((s) => s.right)
const swipeHandlers = () => ({
addToQueue: async () =>
await addToQueue({
api, api,
deviceProfile, deviceProfile,
networkStatus, networkStatus,
item, tracks: [item],
addFavorite, queuingType: QueuingType.DirectlyQueued,
removeFavorite, }),
isFavorite, toggleFavorite: () => (isFavorite ? removeFavorite({ item }) : addFavorite({ item })),
], addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }),
) })
const swipeConfig = useMemo( const swipeConfig = isAudio
() => ? buildSwipeConfig({
isAudio left: leftSettings,
? buildSwipeConfig({ right: rightSettings,
left: leftSettings, handlers: swipeHandlers(),
right: rightSettings, })
handlers: swipeHandlers(), : {}
})
: {},
[isAudio, leftSettings, rightSettings, swipeHandlers],
)
const handleArtworkLayout = useCallback( const handleArtworkLayout = (event: LayoutChangeEvent) => {
(event: LayoutChangeEvent) => { const { width } = event.nativeEvent.layout
const { width } = event.nativeEvent.layout artworkAreaWidth.value = width
artworkAreaWidth.value = width }
},
[artworkAreaWidth],
)
const pressStyle = useMemo(() => ({ opacity: 0.5 }), []) const pressStyle = {
opacity: 0.5,
}
return ( return (
<SwipeableRow <SwipeableRow
disabled={!isAudio} disabled={!isAudio}
{...swipeConfig} {...swipeConfig}
onLongPress={onLongPress} onLongPress={onLongPress}
onPress={onPressCallback}
>
<XStack
alignContent='center'
width={'100%'}
testID={item.Id ? `item-row-${item.Id}` : undefined}
onPressIn={onPressIn}
onPress={onPressCallback} onPress={onPressCallback}
onLongPress={onLongPress}
animation={'quick'}
pressStyle={pressStyle}
paddingVertical={'$2'}
paddingRight={'$2'}
paddingLeft={'$1'}
backgroundColor={'$background'}
borderRadius={'$2'}
> >
<XStack <HideableArtwork item={item} circular={circular} onLayout={handleArtworkLayout} />
alignContent='center' <SlidingTextArea leftGapWidth={artworkAreaWidth}>
width={'100%'} <ItemRowDetails item={item} />
testID={item.Id ? `item-row-${item.Id}` : undefined} </SlidingTextArea>
onPressIn={onPressIn}
onPress={onPressCallback}
onLongPress={onLongPress}
animation={'quick'}
pressStyle={pressStyle}
paddingVertical={'$2'}
paddingRight={'$2'}
paddingLeft={'$1'}
backgroundColor={'$background'}
borderRadius={'$2'}
>
<HideableArtwork
item={item}
circular={circular}
onLayout={handleArtworkLayout}
/>
<SlidingTextArea leftGapWidth={artworkAreaWidth}>
<ItemRowDetails item={item} />
</SlidingTextArea>
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1}>
{renderRunTime ? (
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
) : item.Type === 'Playlist' ? (
<Text color={'$borderColor'}>
{`${playlistTrackCount ?? 0} ${playlistTrackCount === 1 ? 'Track' : 'Tracks'}`}
</Text>
) : null}
<FavoriteIcon item={item} />
{item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
<Icon name='dots-horizontal' onPress={onLongPress} />
) : null}
</XStack>
</XStack>
</SwipeableRow>
)
},
(prevProps, nextProps) => {
return (
prevProps.item.Id === nextProps.item.Id &&
prevProps.circular === nextProps.circular &&
!!prevProps.onPress === !!nextProps.onPress &&
prevProps.navigation === nextProps.navigation
)
},
)
const ItemRowDetails = memo(
function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
const route = useRoute<RouteProp<BaseStackParamList>>()
const shouldRenderArtistName =
item.Type === 'Audio' || (item.Type === 'MusicAlbum' && route.name !== 'Artist')
const shouldRenderProductionYear = item.Type === 'MusicAlbum' && route.name === 'Artist'
const shouldRenderGenres =
item.Type === 'Playlist' || item.Type === BaseItemKind.MusicArtist
return (
<YStack alignContent='center' justifyContent='center' flexGrow={1}>
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Name ?? ''}
</Text>
{shouldRenderArtistName && (
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.AlbumArtist ?? 'Untitled Artist'}
</Text>
)}
{shouldRenderProductionYear && (
<XStack gap='$2'>
<Text
color={'$borderColor'}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{item.ProductionYear?.toString() ?? 'Unknown Year'}
</Text>
<Text color={'$borderColor'}></Text>
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1}>
{renderRunTime ? (
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks> <RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
</XStack> ) : item.Type === 'Playlist' ? (
)} <Text color={'$borderColor'}>
{`${playlistTrackCount ?? 0} ${playlistTrackCount === 1 ? 'Track' : 'Tracks'}`}
</Text>
) : null}
<FavoriteIcon item={item} />
{shouldRenderGenres && item.Genres && ( {item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
<Icon name='dots-horizontal' onPress={onLongPress} />
) : null}
</XStack>
</XStack>
</SwipeableRow>
)
}
function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
const route = useRoute<RouteProp<BaseStackParamList>>()
const shouldRenderArtistName =
item.Type === 'Audio' || (item.Type === 'MusicAlbum' && route.name !== 'Artist')
const shouldRenderProductionYear = item.Type === 'MusicAlbum' && route.name === 'Artist'
const shouldRenderGenres = item.Type === 'Playlist' || item.Type === BaseItemKind.MusicArtist
return (
<YStack alignContent='center' justifyContent='center' flexGrow={1}>
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Name ?? ''}
</Text>
{shouldRenderArtistName && (
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.AlbumArtist ?? 'Untitled Artist'}
</Text>
)}
{shouldRenderProductionYear && (
<XStack gap='$2'>
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}> <Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Genres?.join(', ') ?? ''} {item.ProductionYear?.toString() ?? 'Unknown Year'}
</Text> </Text>
)}
</YStack> <Text color={'$borderColor'}></Text>
)
}, <RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
(prevProps, nextProps) => prevProps.item.Id === nextProps.item.Id, </XStack>
) )}
{shouldRenderGenres && item.Genres && (
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Genres?.join(', ') ?? ''}
</Text>
)}
</YStack>
)
}
// Artwork wrapper that fades out when the quick-action menu is open // Artwork wrapper that fades out when the quick-action menu is open
const HideableArtwork = memo( function HideableArtwork({
function HideableArtwork({ item,
item, circular,
circular, onLayout,
onLayout, }: {
}: { item: BaseItemDto
item: BaseItemDto circular?: boolean
circular?: boolean onLayout?: (event: LayoutChangeEvent) => void
onLayout?: (event: LayoutChangeEvent) => void }): React.JSX.Element {
}): React.JSX.Element { const { tx } = useSwipeableRowContext()
const { tx } = useSwipeableRowContext() // Hide artwork as soon as swiping starts (any non-zero tx)
// Hide artwork as soon as swiping starts (any non-zero tx) const style = useAnimatedStyle(() => ({
const style = useAnimatedStyle(() => ({ opacity: tx.value === 0 ? withTiming(1) : 0,
opacity: tx.value === 0 ? withTiming(1) : 0, }))
})) return (
return ( <Animated.View style={style} onLayout={onLayout}>
<Animated.View style={style} onLayout={onLayout}> <XStack marginHorizontal={'$3'} marginVertical={'auto'} alignItems='center'>
<XStack marginHorizontal={'$3'} marginVertical={'auto'} alignItems='center'> <ItemImage
<ItemImage item={item}
item={item} height={'$12'}
height={'$12'} width={'$12'}
width={'$12'} circular={item.Type === 'MusicArtist' || circular}
circular={item.Type === 'MusicArtist' || circular} />
/> </XStack>
</XStack> </Animated.View>
</Animated.View> )
) }
},
(prevProps, nextProps) =>
prevProps.item.Id === nextProps.item.Id &&
prevProps.circular === nextProps.circular &&
!!prevProps.onLayout === !!nextProps.onLayout,
)
const SlidingTextArea = memo( function SlidingTextArea({
function SlidingTextArea({ leftGapWidth,
leftGapWidth, children,
children, }: {
}: { leftGapWidth: SharedValue<number>
leftGapWidth: SharedValue<number> children: React.ReactNode
children: React.ReactNode }): React.JSX.Element {
}): React.JSX.Element { const { tx, rightWidth } = useSwipeableRowContext()
const { tx, rightWidth } = useSwipeableRowContext() const tokenValue = getToken('$2', 'space')
const tokenValue = getToken('$2', 'space') const spacingValue = typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`)
const spacingValue = const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8
typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`) const style = useAnimatedStyle(() => {
const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8 const t = tx.value
const style = useAnimatedStyle(() => { let offset = 0
const t = tx.value if (t > 0 && leftGapWidth.get() > 0) {
let offset = 0 offset = -Math.min(t, leftGapWidth.get())
if (t > 0 && leftGapWidth.get() > 0) { } else if (t < 0) {
offset = -Math.min(t, leftGapWidth.get()) const rightSpace = Math.max(0, rightWidth)
} else if (t < 0) { const compensate = Math.min(-t, rightSpace)
const rightSpace = Math.max(0, rightWidth) const progress = rightSpace > 0 ? compensate / rightSpace : 1
const compensate = Math.min(-t, rightSpace) offset = compensate * 0.7 + quickActionBuffer * progress
const progress = rightSpace > 0 ? compensate / rightSpace : 1 }
offset = compensate * 0.7 + quickActionBuffer * progress return { transform: [{ translateX: offset }] }
} })
return { transform: [{ translateX: offset }] } const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8
}) return (
const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8 <Animated.View style={[{ flex: 5, paddingRight: paddingRightValue }, style]}>
return ( {children}
<Animated.View style={[{ flex: 5, paddingRight: paddingRightValue }, style]}> </Animated.View>
{children} )
</Animated.View> }
)
},
(prevProps, nextProps) =>
prevProps.leftGapWidth === nextProps.leftGapWidth &&
prevProps.children?.valueOf() === nextProps.children?.valueOf(),
)
export default ItemRow export default ItemRow
@@ -1,9 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Spinner, ToggleGroup, XStack, YStack } from 'tamagui' import { Spinner, ToggleGroup, XStack, YStack } from 'tamagui'
import { H2, Text } from '../helpers/text' import { H2, Text } from '../helpers/text'
import Button from '../helpers/button' import Button from '../helpers/button'
import { SafeAreaView } from 'react-native-safe-area-context' import { SafeAreaView } from 'react-native-safe-area-context'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { BaseItemDto, CollectionType } from '@jellyfin/sdk/lib/generated-client/models'
import { QueryKeys } from '../../../enums/query-keys' import { QueryKeys } from '../../../enums/query-keys'
import { fetchUserViews } from '../../../api/queries/libraries' import { fetchUserViews } from '../../../api/queries/libraries'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
@@ -57,7 +57,7 @@ export default function LibrarySelector({
const [selectedLibraryId, setSelectedLibraryId] = useState<string | undefined>( const [selectedLibraryId, setSelectedLibraryId] = useState<string | undefined>(
library?.musicLibraryId, library?.musicLibraryId,
) )
const [playlistLibrary, setPlaylistLibrary] = useState<BaseItemDto | undefined>(undefined) const playlistLibrary = useRef<BaseItemDto | undefined>(undefined)
const handleLibrarySelection = () => { const handleLibrarySelection = () => {
if (!selectedLibraryId || !libraries) return if (!selectedLibraryId || !libraries) return
@@ -65,23 +65,24 @@ export default function LibrarySelector({
const selectedLibrary = libraries.find((lib) => lib.Id === selectedLibraryId) const selectedLibrary = libraries.find((lib) => lib.Id === selectedLibraryId)
if (selectedLibrary) { if (selectedLibrary) {
onLibrarySelected(selectedLibraryId, selectedLibrary, playlistLibrary) onLibrarySelected(selectedLibraryId, selectedLibrary, playlistLibrary.current)
} }
} }
const hasMultipleLibraries = musicLibraries.length > 1 const hasMultipleLibraries = musicLibraries.length > 1
useEffect(() => {
if (libraries) {
setMusicLibraries(libraries.filter((library) => library.CollectionType === 'music'))
}
}, [libraries, isPending])
useEffect(() => { useEffect(() => {
if (!isPending && isSuccess && libraries) { if (!isPending && isSuccess && libraries) {
setMusicLibraries(
libraries.filter((library) => library.CollectionType === CollectionType.Music),
)
// Find the playlist library // Find the playlist library
const foundPlaylistLibrary = libraries.find((lib) => lib.CollectionType === 'playlists') const foundPlaylistLibrary = libraries.find(
setPlaylistLibrary(foundPlaylistLibrary) (lib) => lib.CollectionType === CollectionType.Playlists,
)
if (foundPlaylistLibrary) playlistLibrary.current = foundPlaylistLibrary
} }
}, [isPending, isSuccess, libraries]) }, [isPending, isSuccess, libraries])
+196 -247
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useCallback, useState, memo } from 'react' import React, { useState } from 'react'
import { getToken, Theme, useTheme, XStack, YStack } from 'tamagui' import { getToken, Theme, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text' import { Text } from '../helpers/text'
import { RunTimeTicks } from '../helpers/time-codes' import { RunTimeTicks } from '../helpers/time-codes'
@@ -17,7 +17,6 @@ import ItemImage from './image'
import Animated, { useAnimatedStyle } from 'react-native-reanimated' import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import { useAddToQueue, useLoadNewQueue } from '../../../providers/Player/hooks/mutations' import { useAddToQueue, useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import useStreamingDeviceProfile from '../../../stores/device-profile' import useStreamingDeviceProfile from '../../../stores/device-profile'
import useStreamedMediaInfo from '../../../api/queries/media'
import { useDownloadedTrack } from '../../../api/queries/download' import { useDownloadedTrack } from '../../../api/queries/download'
import SwipeableRow from './SwipeableRow' import SwipeableRow from './SwipeableRow'
import { useSwipeSettingsStore } from '../../../stores/settings/swipe' import { useSwipeSettingsStore } from '../../../stores/settings/swipe'
@@ -29,6 +28,7 @@ import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favori
import { StackActions } from '@react-navigation/native' import { StackActions } from '@react-navigation/native'
import { useSwipeableRowContext } from './swipeable-row-context' import { useSwipeableRowContext } from './swipeable-row-context'
import { useHideRunTimesSetting } from '../../../stores/settings/app' import { useHideRunTimesSetting } from '../../../stores/settings/app'
import useStreamedMediaInfo from '../../../api/queries/media'
export interface TrackProps { export interface TrackProps {
track: BaseItemDto track: BaseItemDto
@@ -45,98 +45,76 @@ export interface TrackProps {
editing?: boolean | undefined editing?: boolean | undefined
} }
const Track = memo( export default function Track({
function Track({ track,
track, navigation,
navigation, tracklist,
tracklist, index,
index, queue,
queue, showArtwork,
showArtwork, onPress,
onPress, onLongPress,
onLongPress, testID,
testID, isNested,
isNested, invertedColors,
invertedColors, editing,
editing, }: TrackProps): React.JSX.Element {
}: TrackProps): React.JSX.Element { const theme = useTheme()
const theme = useTheme() const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
const api = useApi() const api = useApi()
const deviceProfile = useStreamingDeviceProfile() const deviceProfile = useStreamingDeviceProfile()
const [hideRunTimes] = useHideRunTimesSetting() const [hideRunTimes] = useHideRunTimesSetting()
const nowPlaying = useCurrentTrack() const nowPlaying = useCurrentTrack()
const playQueue = usePlayQueue() const playQueue = usePlayQueue()
const loadNewQueue = useLoadNewQueue() const loadNewQueue = useLoadNewQueue()
const addToQueue = useAddToQueue() const addToQueue = useAddToQueue()
const [networkStatus] = useNetworkStatus() const [networkStatus] = useNetworkStatus()
const { data: mediaInfo } = useStreamedMediaInfo(track.Id) const { data: mediaInfo } = useStreamedMediaInfo(track.Id)
const offlineAudio = useDownloadedTrack(track.Id) const offlineAudio = useDownloadedTrack(track.Id)
const { mutate: addFavorite } = useAddFavorite() const { mutate: addFavorite } = useAddFavorite()
const { mutate: removeFavorite } = useRemoveFavorite() const { mutate: removeFavorite } = useRemoveFavorite()
const { data: isFavoriteTrack } = useIsFavorite(track) const { data: isFavoriteTrack } = useIsFavorite(track)
const leftSettings = useSwipeSettingsStore((s) => s.left) const leftSettings = useSwipeSettingsStore((s) => s.left)
const rightSettings = useSwipeSettingsStore((s) => s.right) const rightSettings = useSwipeSettingsStore((s) => s.right)
// Memoize expensive computations // Memoize expensive computations
const isPlaying = useMemo( const isPlaying = nowPlaying?.item.Id === track.Id
() => nowPlaying?.item.Id === track.Id,
[nowPlaying?.item.Id, track.Id],
)
const isOffline = useMemo( const isOffline = networkStatus === networkStatusTypes.DISCONNECTED
() => networkStatus === networkStatusTypes.DISCONNECTED,
[networkStatus],
)
// Memoize tracklist for queue loading // Memoize tracklist for queue loading
const memoizedTracklist = useMemo( const memoizedTracklist = tracklist ?? playQueue?.map((track) => track.item) ?? []
() => tracklist ?? playQueue?.map((track) => track.item) ?? [],
[tracklist, playQueue],
)
// Memoize handlers to prevent recreation // Memoize handlers to prevent recreation
const handlePress = useCallback(async () => { const handlePress = async () => {
if (onPress) { if (onPress) {
await onPress() await onPress()
} else { } else {
loadNewQueue({ loadNewQueue({
api, api,
deviceProfile, deviceProfile,
networkStatus, networkStatus,
track, track,
index, index,
tracklist: memoizedTracklist, tracklist: memoizedTracklist,
queue, queue,
queuingType: QueuingType.FromSelection, queuingType: QueuingType.FromSelection,
startPlayback: true, startPlayback: true,
}) })
} }
}, [onPress, track, index, memoizedTracklist, queue, useLoadNewQueue]) }
const handleLongPress = useCallback(() => { const handleLongPress = () => {
if (onLongPress) { if (onLongPress) {
onLongPress() onLongPress()
} else { } else {
navigationRef.navigate('Context', {
item: track,
navigation,
streamingMediaSourceInfo: mediaInfo?.MediaSources
? mediaInfo!.MediaSources![0]
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}
}, [onLongPress, track, isNested, mediaInfo?.MediaSources, offlineAudio])
const handleIconPress = useCallback(() => {
navigationRef.navigate('Context', { navigationRef.navigate('Context', {
item: track, item: track,
navigation, navigation,
@@ -145,192 +123,165 @@ const Track = memo(
: undefined, : undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo, downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
}) })
}, [track, isNested, mediaInfo?.MediaSources, offlineAudio]) }
}
// Memoize text color to prevent recalculation const handleIconPress = () => {
const textColor = useMemo(() => { navigationRef.navigate('Context', {
if (isPlaying) return theme.primary.val item: track,
if (isOffline) return offlineAudio ? theme.color : theme.neutral.val navigation,
return theme.color streamingMediaSourceInfo: mediaInfo?.MediaSources
}, [isPlaying, isOffline, offlineAudio, theme.primary.val, theme.color, theme.neutral.val]) ? mediaInfo!.MediaSources![0]
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}
// Memoize artists text // Memoize text color to prevent recalculation
const artistsText = useMemo(() => track.Artists?.join(', ') ?? '', [track.Artists]) const textColor = isPlaying
? theme.primary.val
: isOffline
? offlineAudio
? theme.color
: theme.neutral.val
: theme.color
// Memoize track name // Memoize artists text
const trackName = useMemo(() => track.Name ?? 'Untitled Track', [track.Name]) const artistsText = track.Artists?.join(', ') ?? ''
// Memoize index number // Memoize track name
const indexNumber = useMemo(() => track.IndexNumber?.toString() ?? '', [track.IndexNumber]) const trackName = track.Name ?? 'Untitled Track'
// Memoize show artists condition // Memoize index number
const shouldShowArtists = useMemo( const indexNumber = track.IndexNumber?.toString() ?? ''
() => showArtwork || (track.Artists && track.Artists.length > 1),
[showArtwork, track.Artists],
)
const swipeHandlers = useMemo( // Memoize show artists condition
() => ({ const shouldShowArtists = showArtwork || (track.Artists && track.Artists.length > 1)
addToQueue: async () => {
console.info('Running add to queue swipe action') const swipeHandlers = {
await addToQueue({ addToQueue: async () => {
api, console.info('Running add to queue swipe action')
deviceProfile, await addToQueue({
networkStatus,
tracks: [track],
queuingType: QueuingType.DirectlyQueued,
})
},
toggleFavorite: () => {
console.info(
`Running ${isFavoriteTrack ? 'Remove' : 'Add'} favorite swipe action`,
)
if (isFavoriteTrack) removeFavorite({ item: track })
else addFavorite({ item: track })
},
addToPlaylist: () => {
console.info('Running add to playlist swipe handler')
navigationRef.dispatch(StackActions.push('AddToPlaylist', { track }))
},
}),
[
addToQueue,
api, api,
deviceProfile, deviceProfile,
networkStatus, networkStatus,
track, tracks: [track],
addFavorite, queuingType: QueuingType.DirectlyQueued,
removeFavorite, })
isFavoriteTrack, },
navigationRef, toggleFavorite: () => {
], console.info(`Running ${isFavoriteTrack ? 'Remove' : 'Add'} favorite swipe action`)
) if (isFavoriteTrack) removeFavorite({ item: track })
else addFavorite({ item: track })
},
addToPlaylist: () => {
console.info('Running add to playlist swipe handler')
navigationRef.dispatch(StackActions.push('AddToPlaylist', { track }))
},
}
const swipeConfig = useMemo( const swipeConfig = buildSwipeConfig({
() => left: leftSettings,
buildSwipeConfig({ right: rightSettings,
left: leftSettings, handlers: swipeHandlers,
right: rightSettings, })
handlers: swipeHandlers,
}),
[leftSettings, rightSettings, swipeHandlers],
)
const runtimeComponent = useMemo( const runtimeComponent = hideRunTimes ? (
() => <></>
hideRunTimes ? ( ) : (
<></> <RunTimeTicks
) : ( key={`${track.Id}-runtime`}
<RunTimeTicks props={{
key={`${track.Id}-runtime`} style: {
props={{ textAlign: 'right',
style: { minWidth: getToken('$10'),
textAlign: 'right', alignSelf: 'center',
minWidth: getToken('$10'), },
alignSelf: 'center', }}
}, >
}} {track.RunTimeTicks}
> </RunTimeTicks>
{track.RunTimeTicks} )
</RunTimeTicks>
),
[hideRunTimes, track.RunTimeTicks],
)
return ( return (
<Theme name={invertedColors ? 'inverted_purple' : undefined}> <Theme name={invertedColors ? 'inverted_purple' : undefined}>
<SwipeableRow <SwipeableRow
disabled={isNested === true} disabled={isNested === true}
{...swipeConfig} {...swipeConfig}
onLongPress={handleLongPress} onLongPress={handleLongPress}
onPress={handlePress} onPress={handlePress}
>
<XStack
alignContent='center'
alignItems='center'
flex={1}
testID={testID ?? undefined}
paddingVertical={'$2'}
justifyContent='flex-start'
paddingRight={'$2'}
animation={'quick'}
pressStyle={{ opacity: 0.5 }}
backgroundColor={'$background'}
> >
<XStack <XStack
alignContent='center' alignContent='center'
alignItems='center' justifyContent='center'
flex={1} marginHorizontal={showArtwork ? '$2' : '$1'}
testID={testID ?? undefined} onLayout={(e) => setArtworkAreaWidth(e.nativeEvent.layout.width)}
paddingVertical={'$2'}
justifyContent='flex-start'
paddingRight={'$2'}
animation={'quick'}
pressStyle={{ opacity: 0.5 }}
backgroundColor={'$background'}
> >
<XStack {showArtwork ? (
alignContent='center' <HideableArtwork>
justifyContent='center' <ItemImage item={track} width={'$12'} height={'$12'} />
marginHorizontal={showArtwork ? '$2' : '$1'} </HideableArtwork>
onLayout={(e) => setArtworkAreaWidth(e.nativeEvent.layout.width)} ) : (
> <Text
{showArtwork ? ( key={`${track.Id}-number`}
<HideableArtwork> color={textColor}
<ItemImage item={track} width={'$12'} height={'$12'} /> width={getToken('$12')}
</HideableArtwork> textAlign='center'
) : ( fontVariant={['tabular-nums']}
<Text >
key={`${track.Id}-number`} {indexNumber}
color={textColor} </Text>
width={getToken('$12')} )}
textAlign='center' </XStack>
fontVariant={['tabular-nums']}
>
{indexNumber}
</Text>
)}
</XStack>
<SlidingTextArea leftGapWidth={artworkAreaWidth} hasArtwork={!!showArtwork}> <SlidingTextArea leftGapWidth={artworkAreaWidth} hasArtwork={!!showArtwork}>
<YStack alignItems='flex-start' justifyContent='center' flex={6}> <YStack alignItems='flex-start' justifyContent='center' flex={1}>
<Text
key={`${track.Id}-name`}
bold
color={textColor}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{trackName}
</Text>
{shouldShowArtists && (
<Text <Text
key={`${track.Id}-name`} key={`${track.Id}-artists`}
bold
color={textColor}
lineBreakStrategyIOS='standard' lineBreakStrategyIOS='standard'
numberOfLines={1} numberOfLines={1}
color={'$borderColor'}
> >
{trackName} {artistsText}
</Text> </Text>
{shouldShowArtists && (
<Text
key={`${track.Id}-artists`}
lineBreakStrategyIOS='standard'
numberOfLines={1}
color={'$borderColor'}
>
{artistsText}
</Text>
)}
</YStack>
</SlidingTextArea>
<XStack justifyContent='flex-end' alignItems='center' flex={2} gap='$1'>
<DownloadedIcon item={track} />
<FavoriteIcon item={track} />
{runtimeComponent}
{!editing && (
<Icon name={'dots-horizontal'} onPress={handleIconPress} />
)} )}
</XStack> </YStack>
</SlidingTextArea>
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1} gap='$1'>
<DownloadedIcon item={track} />
<FavoriteIcon item={track} />
{runtimeComponent}
{!editing && <Icon name={'dots-horizontal'} onPress={handleIconPress} />}
</XStack> </XStack>
</SwipeableRow> </XStack>
</Theme> </SwipeableRow>
) </Theme>
}, )
(prevProps, nextProps) => }
prevProps.track.Id === nextProps.track.Id &&
prevProps.index === nextProps.index &&
prevProps.showArtwork === nextProps.showArtwork &&
prevProps.isNested === nextProps.isNested &&
prevProps.invertedColors === nextProps.invertedColors &&
prevProps.testID === nextProps.testID &&
prevProps.editing === nextProps.editing &&
prevProps.queue === nextProps.queue &&
prevProps.tracklist === nextProps.tracklist &&
!!prevProps.onPress === !!nextProps.onPress &&
!!prevProps.onLongPress === !!nextProps.onLongPress,
)
function HideableArtwork({ children }: { children: React.ReactNode }) { function HideableArtwork({ children }: { children: React.ReactNode }) {
const { tx } = useSwipeableRowContext() const { tx } = useSwipeableRowContext()
@@ -362,7 +313,5 @@ function SlidingTextArea({
} }
return { transform: [{ translateX: offset }] } return { transform: [{ translateX: offset }] }
}) })
return <Animated.View style={[{ flex: 5 }, style]}>{children}</Animated.View> return <Animated.View style={[{ flex: 1 }, style]}>{children}</Animated.View>
} }
export default Track
@@ -2,7 +2,7 @@ import HorizontalCardList from '../../../components/Global/components/horizontal
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { ItemCard } from '../../../components/Global/components/item-card' import { ItemCard } from '../../../components/Global/components/item-card'
import { H5, View, XStack } from 'tamagui' import { H5, XStack } from 'tamagui'
import Icon from '../../Global/components/icon' import Icon from '../../Global/components/icon'
import { useDisplayContext } from '../../../providers/Display/display-provider' import { useDisplayContext } from '../../../providers/Display/display-provider'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
@@ -11,6 +11,7 @@ import { RootStackParamList } from '../../../screens/types'
import { useFrequentlyPlayedArtists } from '../../../api/queries/frequents' import { useFrequentlyPlayedArtists } from '../../../api/queries/frequents'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { pickFirstGenre } from '../../../utils/genre-formatting' import { pickFirstGenre } from '../../../utils/genre-formatting'
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
export default function FrequentArtists(): React.JSX.Element { export default function FrequentArtists(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>() const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>()
@@ -42,14 +43,19 @@ export default function FrequentArtists(): React.JSX.Element {
[], [],
) )
return ( return frequentArtistsInfiniteQuery.data ? (
<View> <Animated.View
entering={FadeIn}
exiting={FadeOut}
layout={LinearTransition.springify()}
style={{
flex: 1,
}}
>
<XStack <XStack
alignItems='center' alignItems='center'
onPress={() => { onPress={() => {
navigation.navigate('MostPlayedArtists', { navigation.navigate('MostPlayedArtists')
artistsInfiniteQuery: frequentArtistsInfiniteQuery,
})
}} }}
> >
<H5 marginLeft={'$2'}>Most Played</H5> <H5 marginLeft={'$2'}>Most Played</H5>
@@ -57,9 +63,11 @@ export default function FrequentArtists(): React.JSX.Element {
</XStack> </XStack>
<HorizontalCardList <HorizontalCardList
data={frequentArtistsInfiniteQuery.data?.slice(0, horizontalItems) ?? []} data={frequentArtistsInfiniteQuery.data.slice(0, horizontalItems) ?? []}
renderItem={renderItem} renderItem={renderItem}
/> />
</View> </Animated.View>
) : (
<></>
) )
} }
@@ -1,5 +1,5 @@
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { H5, View, XStack } from 'tamagui' import { H5, XStack } from 'tamagui'
import HorizontalCardList from '../../../components/Global/components/horizontal-list' import HorizontalCardList from '../../../components/Global/components/horizontal-list'
import { ItemCard } from '../../../components/Global/components/item-card' import { ItemCard } from '../../../components/Global/components/item-card'
import { QueuingType } from '../../../enums/queuing-type' import { QueuingType } from '../../../enums/queuing-type'
@@ -13,6 +13,7 @@ import { useNetworkStatus } from '../../../stores/network'
import useStreamingDeviceProfile from '../../../stores/device-profile' import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useFrequentlyPlayedTracks } from '../../../api/queries/frequents' import { useFrequentlyPlayedTracks } from '../../../api/queries/frequents'
import { useApi } from '../../../stores' import { useApi } from '../../../stores'
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
export default function FrequentlyPlayedTracks(): React.JSX.Element { export default function FrequentlyPlayedTracks(): React.JSX.Element {
const api = useApi() const api = useApi()
@@ -30,14 +31,19 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
const loadNewQueue = useLoadNewQueue() const loadNewQueue = useLoadNewQueue()
const { horizontalItems } = useDisplayContext() const { horizontalItems } = useDisplayContext()
return ( return tracksInfiniteQuery.data ? (
<View> <Animated.View
entering={FadeIn}
exiting={FadeOut}
layout={LinearTransition.springify()}
style={{
flex: 1,
}}
>
<XStack <XStack
alignItems='center' alignItems='center'
onPress={() => { onPress={() => {
navigation.navigate('MostPlayedTracks', { navigation.navigate('MostPlayedTracks')
tracksInfiniteQuery,
})
}} }}
> >
<H5 marginLeft={'$2'}>On Repeat</H5> <H5 marginLeft={'$2'}>On Repeat</H5>
@@ -46,8 +52,8 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
<HorizontalCardList <HorizontalCardList
data={ data={
(tracksInfiniteQuery.data?.length ?? 0 > horizontalItems) tracksInfiniteQuery.data.length > horizontalItems
? tracksInfiniteQuery.data?.slice(0, horizontalItems) ? tracksInfiniteQuery.data.slice(0, horizontalItems)
: tracksInfiniteQuery.data : tracksInfiniteQuery.data
} }
renderItem={({ item: track, index }) => ( renderItem={({ item: track, index }) => (
@@ -81,6 +87,8 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
/> />
)} )}
/> />
</View> </Animated.View>
) : (
<></>
) )
} }
+16 -8
View File
@@ -11,6 +11,7 @@ import HomeStackParamList from '../../../screens/Home/types'
import { useRecentArtists } from '../../../api/queries/recents' import { useRecentArtists } from '../../../api/queries/recents'
import { pickFirstGenre } from '../../../utils/genre-formatting' import { pickFirstGenre } from '../../../utils/genre-formatting'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
export default function RecentArtists(): React.JSX.Element { export default function RecentArtists(): React.JSX.Element {
const recentArtistsInfiniteQuery = useRecentArtists() const recentArtistsInfiniteQuery = useRecentArtists()
@@ -22,10 +23,8 @@ export default function RecentArtists(): React.JSX.Element {
const { horizontalItems } = useDisplayContext() const { horizontalItems } = useDisplayContext()
const handleHeaderPress = useCallback(() => { const handleHeaderPress = useCallback(() => {
navigation.navigate('RecentArtists', { navigation.navigate('RecentArtists')
artistsInfiniteQuery: recentArtistsInfiniteQuery, }, [navigation])
})
}, [navigation, recentArtistsInfiniteQuery])
const renderItem = useCallback( const renderItem = useCallback(
({ item: recentArtist }: { item: BaseItemDto }) => ( ({ item: recentArtist }: { item: BaseItemDto }) => (
@@ -50,17 +49,26 @@ export default function RecentArtists(): React.JSX.Element {
[navigation, rootNavigation], [navigation, rootNavigation],
) )
return ( return recentArtistsInfiniteQuery.data ? (
<View> <Animated.View
entering={FadeIn}
exiting={FadeOut}
layout={LinearTransition.springify()}
style={{
flex: 1,
}}
>
<XStack alignItems='center' onPress={handleHeaderPress}> <XStack alignItems='center' onPress={handleHeaderPress}>
<H5 marginLeft={'$2'}>Recent Artists</H5> <H5 marginLeft={'$2'}>Recent Artists</H5>
<Icon name='arrow-right' /> <Icon name='arrow-right' />
</XStack> </XStack>
<HorizontalCardList <HorizontalCardList
data={recentArtistsInfiniteQuery.data?.slice(0, horizontalItems) ?? []} data={recentArtistsInfiniteQuery.data.slice(0, horizontalItems)}
renderItem={renderItem} renderItem={renderItem}
/> />
</View> </Animated.View>
) : (
<></>
) )
} }
+64 -60
View File
@@ -1,5 +1,5 @@
import React, { useMemo } from 'react' import React from 'react'
import { H5, View, XStack } from 'tamagui' import { H5, XStack } from 'tamagui'
import { ItemCard } from '../../Global/components/item-card' import { ItemCard } from '../../Global/components/item-card'
import { RootStackParamList } from '../../../screens/types' import { RootStackParamList } from '../../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
@@ -13,8 +13,8 @@ import HomeStackParamList from '../../../screens/Home/types'
import { useNetworkStatus } from '../../../stores/network' import { useNetworkStatus } from '../../../stores/network'
import useStreamingDeviceProfile from '../../../stores/device-profile' import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useRecentlyPlayedTracks } from '../../../api/queries/recents' import { useRecentlyPlayedTracks } from '../../../api/queries/recents'
import { useCurrentTrack } from '../../../stores/player/queue'
import { useApi } from '../../../stores' import { useApi } from '../../../stores'
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
export default function RecentlyPlayed(): React.JSX.Element { export default function RecentlyPlayed(): React.JSX.Element {
const api = useApi() const api = useApi()
@@ -23,8 +23,6 @@ export default function RecentlyPlayed(): React.JSX.Element {
const deviceProfile = useStreamingDeviceProfile() const deviceProfile = useStreamingDeviceProfile()
const nowPlaying = useCurrentTrack()
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>() const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>()
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>() const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
@@ -33,60 +31,66 @@ export default function RecentlyPlayed(): React.JSX.Element {
const tracksInfiniteQuery = useRecentlyPlayedTracks() const tracksInfiniteQuery = useRecentlyPlayedTracks()
const { horizontalItems } = useDisplayContext() const { horizontalItems } = useDisplayContext()
return useMemo(() => {
return (
<View>
<XStack
alignItems='center'
onPress={() => {
navigation.navigate('RecentTracks', {
tracksInfiniteQuery,
})
}}
>
<H5 marginLeft={'$2'}>Play it again</H5>
<Icon name='arrow-right' />
</XStack>
<HorizontalCardList return tracksInfiniteQuery.data ? (
data={ <Animated.View
(tracksInfiniteQuery.data?.length ?? 0 > horizontalItems) entering={FadeIn}
? tracksInfiniteQuery.data?.slice(0, horizontalItems) exiting={FadeOut}
: tracksInfiniteQuery.data layout={LinearTransition.springify()}
} style={{
renderItem={({ index, item: recentlyPlayedTrack }) => ( flex: 1,
<ItemCard }}
size={'$11'} >
caption={recentlyPlayedTrack.Name} <XStack
subCaption={`${recentlyPlayedTrack.Artists?.join(', ')}`} alignItems='center'
squared onPress={() => {
testId={`recently-played-${index}`} navigation.navigate('RecentTracks')
item={recentlyPlayedTrack} }}
onPress={() => { >
loadNewQueue({ <H5 marginLeft={'$2'}>Play it again</H5>
api, <Icon name='arrow-right' />
deviceProfile, </XStack>
networkStatus,
track: recentlyPlayedTrack, <HorizontalCardList
index: index, data={
tracklist: tracksInfiniteQuery.data ?? [recentlyPlayedTrack], (tracksInfiniteQuery.data.length ?? 0 > horizontalItems)
queue: 'Recently Played', ? tracksInfiniteQuery.data.slice(0, horizontalItems)
queuingType: QueuingType.FromSelection, : tracksInfiniteQuery.data
startPlayback: true, }
}) renderItem={({ index, item: recentlyPlayedTrack }) => (
}} <ItemCard
onLongPress={() => { size={'$11'}
rootNavigation.navigate('Context', { caption={recentlyPlayedTrack.Name}
item: recentlyPlayedTrack, subCaption={`${recentlyPlayedTrack.Artists?.join(', ')}`}
navigation, squared
}) testId={`recently-played-${index}`}
}} item={recentlyPlayedTrack}
marginHorizontal={'$1'} onPress={() => {
captionAlign='left' loadNewQueue({
/> api,
)} deviceProfile,
/> networkStatus,
</View> track: recentlyPlayedTrack,
) index: index,
}, [tracksInfiniteQuery.data, nowPlaying]) tracklist: tracksInfiniteQuery.data ?? [recentlyPlayedTrack],
queue: 'Recently Played',
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
}}
onLongPress={() => {
rootNavigation.navigate('Context', {
item: recentlyPlayedTrack,
navigation,
})
}}
marginHorizontal={'$1'}
captionAlign='left'
/>
)}
/>
</Animated.View>
) : (
<></>
)
} }
+8 -3
View File
@@ -7,6 +7,8 @@ import FrequentlyPlayedTracks from './helpers/frequent-tracks'
import { usePreventRemove } from '@react-navigation/native' import { usePreventRemove } from '@react-navigation/native'
import useHomeQueries from '../../api/mutations/home' import useHomeQueries from '../../api/mutations/home'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor' import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
import { useIsRestoring } from '@tanstack/react-query'
import { useRecentlyPlayedTracks } from '../../api/queries/recents'
const COMPONENT_NAME = 'Home' const COMPONENT_NAME = 'Home'
@@ -17,18 +19,21 @@ export function Home(): React.JSX.Element {
usePerformanceMonitor(COMPONENT_NAME, 5) usePerformanceMonitor(COMPONENT_NAME, 5)
const { isPending: refreshing, mutate: refresh } = useHomeQueries() const { isPending: refreshing, mutateAsync: refresh } = useHomeQueries()
const { isPending: loadingInitialData } = useRecentlyPlayedTracks()
const isRestoring = useIsRestoring()
return ( return (
<ScrollView <ScrollView
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{ contentContainerStyle={{
marginVertical: getToken('$4'), marginVertical: getToken('$4'),
marginHorizontal: getToken('$2'),
}} }}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing} refreshing={refreshing || isRestoring || loadingInitialData}
onRefresh={refresh} onRefresh={refresh}
tintColor={theme.primary.val} tintColor={theme.primary.val}
/> />
+1
View File
@@ -21,6 +21,7 @@ export default function LibraryScreen({
<LibraryTabsNavigator.Navigator <LibraryTabsNavigator.Navigator
tabBar={(props) => <LibraryTabBar {...props} />} tabBar={(props) => <LibraryTabBar {...props} />}
screenOptions={{ screenOptions={{
swipeEnabled: false, // Disable tab swiping to prevent conflicts with SwipeableRow gestures
tabBarIndicatorStyle: { tabBarIndicatorStyle: {
borderColor: theme.background.val, borderColor: theme.background.val,
borderBottomWidth: getTokenValue('$2'), borderBottomWidth: getTokenValue('$2'),
+29 -4
View File
@@ -4,7 +4,8 @@ import IconButton from '../../../components/Global/helpers/icon-button'
import { isUndefined } from 'lodash' import { isUndefined } from 'lodash'
import { useTogglePlayback } from '../../../providers/Player/hooks/mutations' import { useTogglePlayback } from '../../../providers/Player/hooks/mutations'
import { usePlaybackState } from '../../../providers/Player/hooks/queries' import { usePlaybackState } from '../../../providers/Player/hooks/queries'
import React, { useMemo } from 'react' import React from 'react'
import Icon from '../../Global/components/icon'
function PlayPauseButtonComponent({ function PlayPauseButtonComponent({
size, size,
@@ -17,9 +18,9 @@ function PlayPauseButtonComponent({
const state = usePlaybackState() const state = usePlaybackState()
const largeIcon = useMemo(() => isUndefined(size) || size >= 20, [size]) const largeIcon = isUndefined(size) || size >= 24
const button = useMemo(() => { const button = (() => {
switch (state) { switch (state) {
case State.Playing: { case State.Playing: {
return ( return (
@@ -56,7 +57,7 @@ function PlayPauseButtonComponent({
) )
} }
} }
}, [state, size, largeIcon, togglePlayback]) })()
return ( return (
<View justifyContent='center' alignItems='center' flex={flex}> <View justifyContent='center' alignItems='center' flex={flex}>
@@ -67,4 +68,28 @@ function PlayPauseButtonComponent({
const PlayPauseButton = React.memo(PlayPauseButtonComponent) const PlayPauseButton = React.memo(PlayPauseButtonComponent)
export function PlayPauseIcon(): React.JSX.Element {
const togglePlayback = useTogglePlayback()
const state = usePlaybackState()
const button = (() => {
switch (state) {
case State.Playing: {
return <Icon name='pause' color='$primary' onPress={togglePlayback} />
}
case State.Buffering:
case State.Loading: {
return <Spinner margin={10} color={'$primary'} />
}
default: {
return <Icon name='play' color='$primary' onPress={togglePlayback} />
}
}
})()
return button
}
export default PlayPauseButton export default PlayPauseButton
+13 -14
View File
@@ -1,6 +1,6 @@
import { XStack, YStack, Spacer, useTheme } from 'tamagui' import { XStack, YStack, Spacer, useTheme } from 'tamagui'
import { Text } from '../../Global/helpers/text' import { Text } from '../../Global/helpers/text'
import React, { useCallback, useMemo } from 'react' import React from 'react'
import ItemImage from '../../Global/components/image' import ItemImage from '../../Global/components/image'
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
@@ -20,16 +20,11 @@ export default function PlayerHeader(): React.JSX.Element {
const theme = useTheme() const theme = useTheme()
// If the Queue is a BaseItemDto, display the name of it const playingFrom = !queueRef
const playingFrom = useMemo( ? 'Untitled'
() => : typeof queueRef === 'object'
!queueRef ? (queueRef.Name ?? 'Untitled')
? 'Untitled' : queueRef
: typeof queueRef === 'object'
? (queueRef.Name ?? 'Untitled')
: queueRef,
[queueRef],
)
return ( return (
<YStack flexGrow={1} justifyContent='flex-start'> <YStack flexGrow={1} justifyContent='flex-start'>
@@ -75,10 +70,10 @@ function PlayerArtwork(): React.JSX.Element {
opacity: withTiming(nowPlaying ? 1 : 0), opacity: withTiming(nowPlaying ? 1 : 0),
})) }))
const handleLayout = useCallback((event: LayoutChangeEvent) => { const handleLayout = (event: LayoutChangeEvent) => {
artworkMaxHeight.set(event.nativeEvent.layout.height) artworkMaxHeight.set(event.nativeEvent.layout.height)
artworkMaxWidth.set(event.nativeEvent.layout.height) artworkMaxWidth.set(event.nativeEvent.layout.height)
}, []) }
return ( return (
<YStack <YStack
@@ -98,7 +93,11 @@ function PlayerArtwork(): React.JSX.Element {
...animatedStyle, ...animatedStyle,
}} }}
> >
<ItemImage item={nowPlaying!.item} testID='player-image-test-id' /> <ItemImage
item={nowPlaying!.item}
testID='player-image-test-id'
imageOptions={{ maxWidth: 800, maxHeight: 800 }}
/>
</Animated.View> </Animated.View>
)} )}
</YStack> </YStack>
+21 -7
View File
@@ -201,22 +201,36 @@ export default function Lyrics({
} }
}, [lyrics]) }, [lyrics])
const lyricStartTimes = useMemo(
() => parsedLyrics.map((line) => line.startTime),
[parsedLyrics],
)
// Track manually selected lyric for immediate feedback // Track manually selected lyric for immediate feedback
const manuallySelectedIndex = useSharedValue(-1) const manuallySelectedIndex = useSharedValue(-1)
const manualSelectTimeout = useRef<NodeJS.Timeout | null>(null) const manualSelectTimeout = useRef<NodeJS.Timeout | null>(null)
// Find current lyric line based on playback position // Find current lyric line based on playback position
const currentLyricIndex = useMemo(() => { const currentLyricIndex = useMemo(() => {
if (!position || parsedLyrics.length === 0) return -1 if (position === null || position === undefined || lyricStartTimes.length === 0) return -1
// Find the last lyric that has started // Binary search to find the last startTime <= position
for (let i = parsedLyrics.length - 1; i >= 0; i--) { let low = 0
if (position >= parsedLyrics[i].startTime) { let high = lyricStartTimes.length - 1
return i let found = -1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
if (position >= lyricStartTimes[mid]) {
found = mid
low = mid + 1
} else {
high = mid - 1
} }
} }
return -1
}, [position, parsedLyrics]) return found
}, [position, lyricStartTimes])
// Simple auto-scroll that keeps highlighted lyric in center // Simple auto-scroll that keeps highlighted lyric in center
const scrollToCurrentLyric = useCallback(() => { const scrollToCurrentLyric = useCallback(() => {
+51 -79
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react' import React, { useEffect, useState, useRef } from 'react'
import { HorizontalSlider } from '../../../components/Global/helpers/slider' import { HorizontalSlider } from '../../../components/Global/helpers/slider'
import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { Spacer, XStack, YStack } from 'tamagui' import { Spacer, XStack, YStack } from 'tamagui'
@@ -42,14 +42,9 @@ export default function Scrubber(): React.JSX.Element {
const [displayAudioQualityBadge] = useDisplayAudioQualityBadge() const [displayAudioQualityBadge] = useDisplayAudioQualityBadge()
// Memoize expensive calculations const maxDuration = Math.round(duration * ProgressMultiplier)
const maxDuration = useMemo(() => {
return Math.round(duration * ProgressMultiplier)
}, [duration])
const calculatedPosition = useMemo(() => { const calculatedPosition = Math.round(position! * ProgressMultiplier)
return Math.round(position! * ProgressMultiplier)
}, [position])
// Optimized position update logic with throttling // Optimized position update logic with throttling
useEffect(() => { useEffect(() => {
@@ -77,70 +72,57 @@ export default function Scrubber(): React.JSX.Element {
} }
}, [nowPlaying?.id]) }, [nowPlaying?.id])
// Optimized seek handler with debouncing const handleSeek = async (position: number) => {
const handleSeek = useCallback( const seekTime = Math.max(0, position / ProgressMultiplier)
async (position: number) => { lastSeekTimeRef.current = Date.now()
const seekTime = Math.max(0, position / ProgressMultiplier)
lastSeekTimeRef.current = Date.now()
try { try {
await seekTo(seekTime) await seekTo(seekTime)
} catch (error) { } catch (error) {
console.warn('handleSeek callback failed', error) console.warn('handleSeek callback failed', error)
isUserInteractingRef.current = false
setDisplayPosition(calculatedPosition)
} finally {
// Small delay to let the seek settle before allowing updates
setTimeout(() => {
isUserInteractingRef.current = false isUserInteractingRef.current = false
setDisplayPosition(calculatedPosition) }, 100)
} finally { }
// Small delay to let the seek settle before allowing updates }
setTimeout(() => {
isUserInteractingRef.current = false const currentSeconds = Math.max(0, Math.round(displayPosition / ProgressMultiplier))
}, 100)
} const totalSeconds = Math.round(duration)
const sliderProps = {
maxWidth: width / 1.1,
onSlideStart: (event: unknown, value: number) => {
isUserInteractingRef.current = true
trigger('impactLight')
// Immediately update position for responsive UI
const clampedValue = Math.max(0, Math.min(value, maxDuration))
setDisplayPosition(clampedValue)
}, },
[seekTo, setDisplayPosition], onSlideMove: (event: unknown, value: number) => {
) // Throttled haptic feedback for better performance
trigger('clockTick')
// Memoize time calculations to prevent unnecessary re-renders // Update position with proper clamping
const currentSeconds = useMemo(() => { const clampedValue = Math.max(0, Math.min(value, maxDuration))
return Math.max(0, Math.round(displayPosition / ProgressMultiplier)) setDisplayPosition(clampedValue)
}, [displayPosition]) },
onSlideEnd: async (event: unknown, value: number) => {
trigger('notificationSuccess')
const totalSeconds = useMemo(() => { // Clamp final value and update display
return Math.round(duration) const clampedValue = Math.max(0, Math.min(value, maxDuration))
}, [duration]) setDisplayPosition(clampedValue)
// Memoize slider props to prevent recreation // Perform the seek operation
const sliderProps = useMemo( await handleSeek(clampedValue)
() => ({ },
maxWidth: width / 1.1, }
onSlideStart: (event: unknown, value: number) => {
isUserInteractingRef.current = true
trigger('impactLight')
// Immediately update position for responsive UI
const clampedValue = Math.max(0, Math.min(value, maxDuration))
setDisplayPosition(clampedValue)
},
onSlideMove: (event: unknown, value: number) => {
// Throttled haptic feedback for better performance
trigger('clockTick')
// Update position with proper clamping
const clampedValue = Math.max(0, Math.min(value, maxDuration))
setDisplayPosition(clampedValue)
},
onSlideEnd: async (event: unknown, value: number) => {
trigger('notificationSuccess')
// Clamp final value and update display
const clampedValue = Math.max(0, Math.min(value, maxDuration))
setDisplayPosition(clampedValue)
// Perform the seek operation
await handleSeek(clampedValue)
},
}),
[maxDuration, handleSeek, calculatedPosition, width],
)
return ( return (
<GestureDetector gesture={scrubGesture}> <GestureDetector gesture={scrubGesture}>
@@ -157,16 +139,11 @@ export default function Scrubber(): React.JSX.Element {
/> />
<XStack alignItems='center' paddingTop={'$2'}> <XStack alignItems='center' paddingTop={'$2'}>
<YStack <YStack alignItems='flex-start' justifyContent='center' flex={1} height={'$2'}>
alignItems='flex-start'
justifyContent='center'
flexShrink={1}
height={'$2'}
>
<RunTimeSeconds alignment='left'>{currentSeconds}</RunTimeSeconds> <RunTimeSeconds alignment='left'>{currentSeconds}</RunTimeSeconds>
</YStack> </YStack>
<YStack alignItems='center' justifyContent='center' flexGrow={1} height={'$2'}> <YStack alignItems='center' justifyContent='center' flex={1} height={'$2'}>
{nowPlaying?.mediaSourceInfo && displayAudioQualityBadge ? ( {nowPlaying?.mediaSourceInfo && displayAudioQualityBadge ? (
<QualityBadge <QualityBadge
item={nowPlaying.item} item={nowPlaying.item}
@@ -178,12 +155,7 @@ export default function Scrubber(): React.JSX.Element {
)} )}
</YStack> </YStack>
<YStack <YStack alignItems='flex-end' justifyContent='center' flex={1} height={'$2'}>
alignItems='flex-end'
justifyContent='center'
flexShrink={1}
height={'$2'}
>
<RunTimeSeconds alignment='right'>{totalSeconds}</RunTimeSeconds> <RunTimeSeconds alignment='right'>{totalSeconds}</RunTimeSeconds>
</YStack> </YStack>
</XStack> </XStack>
+53 -87
View File
@@ -1,11 +1,10 @@
import React, { useMemo, useCallback } from 'react' import React from 'react'
import { getToken, Progress, XStack, YStack } from 'tamagui' import { Progress, XStack, YStack } from 'tamagui'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { Text } from '../Global/helpers/text' import { Text } from '../Global/helpers/text'
import TextTicker from 'react-native-text-ticker' import TextTicker from 'react-native-text-ticker'
import PlayPauseButton from './components/buttons' import { PlayPauseIcon } from './components/buttons'
import { TextTickerConfig } from './component.config' import { TextTickerConfig } from './component.config'
import { RunTimeSeconds } from '../Global/helpers/time-codes'
import { UPDATE_INTERVAL } from '../../player/config' import { UPDATE_INTERVAL } from '../../player/config'
import { Progress as TrackPlayerProgress } from 'react-native-track-player' import { Progress as TrackPlayerProgress } from 'react-native-track-player'
import { useProgress } from '../../providers/Player/hooks/queries' import { useProgress } from '../../providers/Player/hooks/queries'
@@ -23,7 +22,7 @@ import { runOnJS } from 'react-native-worklets'
import { RootStackParamList } from '../../screens/types' import { RootStackParamList } from '../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import ItemImage from '../Global/components/image' import ItemImage from '../Global/components/image'
import { usePrevious, useSkip } from '../../providers/Player/hooks/mutations' import { usePrevious, useSkip, useTogglePlayback } from '../../providers/Player/hooks/mutations'
import { useCurrentTrack } from '../../stores/player/queue' import { useCurrentTrack } from '../../stores/player/queue'
export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element { export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
@@ -36,53 +35,47 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
const translateX = useSharedValue(0) const translateX = useSharedValue(0)
const translateY = useSharedValue(0) const translateY = useSharedValue(0)
const handleSwipe = useCallback( const handleSwipe = (direction: string) => {
(direction: string) => { if (direction === 'Swiped Left') {
if (direction === 'Swiped Left') { // Inverted: Swipe left -> next
// Inverted: Swipe left -> next skip(undefined)
skip(undefined) } else if (direction === 'Swiped Right') {
} else if (direction === 'Swiped Right') { // Inverted: Swipe right -> previous
// Inverted: Swipe right -> previous previous()
previous() } else if (direction === 'Swiped Up') {
} else if (direction === 'Swiped Up') { // Navigate to the big player
// Navigate to the big player navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' })
navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' }) }
}
const gesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX
translateY.value = event.translationY
})
.onEnd((event) => {
const threshold = 100
if (event.translationX > threshold) {
runOnJS(handleSwipe)('Swiped Right')
translateX.value = withSpring(200)
} else if (event.translationX < -threshold) {
runOnJS(handleSwipe)('Swiped Left')
translateX.value = withSpring(-200)
} else if (event.translationY < -threshold) {
runOnJS(handleSwipe)('Swiped Up')
translateY.value = withSpring(-200)
} else {
translateX.value = withSpring(0)
translateY.value = withSpring(0)
} }
}, })
[skip, previous, navigation],
)
const gesture = useMemo( const openPlayer = () => navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' })
() =>
Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX
translateY.value = event.translationY
})
.onEnd((event) => {
const threshold = 100
if (event.translationX > threshold) { const pressStyle = {
runOnJS(handleSwipe)('Swiped Right') opacity: 0.6,
translateX.value = withSpring(200) }
} else if (event.translationX < -threshold) {
runOnJS(handleSwipe)('Swiped Left')
translateX.value = withSpring(-200)
} else if (event.translationY < -threshold) {
runOnJS(handleSwipe)('Swiped Up')
translateY.value = withSpring(-200)
} else {
translateX.value = withSpring(0)
translateY.value = withSpring(0)
}
}),
[translateX, translateY, handleSwipe],
)
const openPlayer = useCallback(
() => navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' }),
[navigation],
)
return ( return (
<GestureDetector gesture={gesture}> <GestureDetector gesture={gesture}>
@@ -95,12 +88,10 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
<MiniPlayerProgress /> <MiniPlayerProgress />
<XStack <XStack
alignItems='center' alignItems='center'
pressStyle={{ pressStyle={pressStyle}
opacity: 0.6,
}}
animation={'quick'} animation={'quick'}
onPress={openPlayer} onPress={openPlayer}
paddingBottom={'$1'} paddingVertical={'$2'}
> >
<YStack justify='center' alignItems='center' marginLeft={'$2'}> <YStack justify='center' alignItems='center' marginLeft={'$2'}>
<Animated.View <Animated.View
@@ -108,7 +99,12 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
exiting={FadeOut} exiting={FadeOut}
key={`${nowPlaying!.item.AlbumId}-album-image`} key={`${nowPlaying!.item.AlbumId}-album-image`}
> >
<ItemImage item={nowPlaying!.item} width={'$12'} height={'$12'} /> <ItemImage
item={nowPlaying!.item}
width={'$11'}
height={'$11'}
imageOptions={{ maxWidth: 200, maxHeight: 200 }}
/>
</Animated.View> </Animated.View>
</YStack> </YStack>
@@ -118,8 +114,6 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
marginLeft={'$2'} marginLeft={'$2'}
flex={6} flex={6}
> >
<MiniPlayerRuntime duration={nowPlaying!.duration} />
<Animated.View <Animated.View
entering={FadeIn} entering={FadeIn}
exiting={FadeOut} exiting={FadeOut}
@@ -148,7 +142,7 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
flex={2} flex={2}
marginRight={'$2'} marginRight={'$2'}
> >
<PlayPauseButton size={getToken('$12')} /> <PlayPauseIcon />
</XStack> </XStack>
</XStack> </XStack>
</YStack> </YStack>
@@ -157,43 +151,15 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
) )
}) })
function MiniPlayerRuntime({ duration }: { duration: number }): React.JSX.Element {
return (
<Animated.View entering={FadeIn} exiting={FadeOut} key='mini-player-runtime'>
<XStack gap={'$1'} justifyContent='flex-start' height={'$1'}>
<YStack justifyContent='center' marginRight={'$2'} paddingRight={'auto'}>
<MiniPlayerRuntimePosition />
</YStack>
<Text color={'$neutral'} textAlign='center'>
/
</Text>
<YStack justifyContent='center' marginLeft={'$2'}>
<RunTimeSeconds color={'$neutral'} alignment='right'>
{Math.max(0, Math.round(duration))}
</RunTimeSeconds>
</YStack>
</XStack>
</Animated.View>
)
}
function MiniPlayerRuntimePosition(): React.JSX.Element {
const { position } = useProgress(UPDATE_INTERVAL)
return <RunTimeSeconds alignment='left'>{Math.max(0, Math.round(position))}</RunTimeSeconds>
}
function MiniPlayerProgress(): React.JSX.Element { function MiniPlayerProgress(): React.JSX.Element {
const progress = useProgress(UPDATE_INTERVAL) const progress = useProgress(UPDATE_INTERVAL)
return ( return (
<Progress <Progress
size={'$0.75'} height={'$0.25'}
value={calculateProgressPercentage(progress)} value={calculateProgressPercentage(progress)}
backgroundColor={'$borderColor'} backgroundColor={'$borderColor'}
borderRadius={0} borderBottomEndRadius={'$2'}
> >
<Progress.Indicator borderColor={'$primary'} backgroundColor={'$primary'} /> <Progress.Indicator borderColor={'$primary'} backgroundColor={'$primary'} />
</Progress> </Progress>
+18 -50
View File
@@ -3,31 +3,33 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { H5, Spacer, XStack, YStack } from 'tamagui' import { H5, Spacer, XStack, YStack } from 'tamagui'
import InstantMixButton from '../../Global/components/instant-mix-button' import InstantMixButton from '../../Global/components/instant-mix-button'
import Icon from '../../Global/components/icon' import Icon from '../../Global/components/icon'
import { usePlaylistContext } from '../../../providers/Playlist' import { useNetworkStatus } from '../../../stores/network'
import { useNetworkStatus } from '../../../../src/stores/network'
import { useNetworkContext } from '../../../../src/providers/Network'
import { ActivityIndicator } from 'react-native' import { ActivityIndicator } from 'react-native'
import { mapDtoToTrack } from '../../../utils/mappings'
import { QueuingType } from '../../../enums/queuing-type' import { QueuingType } from '../../../enums/queuing-type'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import LibraryStackParamList from '@/src/screens/Library/types' import LibraryStackParamList from '@/src/screens/Library/types'
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations' import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import useStreamingDeviceProfile, { import useStreamingDeviceProfile from '../../../stores/device-profile'
useDownloadingDeviceProfile,
} from '../../../stores/device-profile'
import ItemImage from '../../Global/components/image' import ItemImage from '../../Global/components/image'
import { useApi } from '../../../stores' import { useApi } from '../../../stores'
import Input from '../../Global/helpers/input' import Input from '../../Global/helpers/input'
import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated' import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated'
import { Dispatch, SetStateAction } from 'react'
import useAddToPendingDownloads, { usePendingDownloads } from '../../../stores/network/downloads'
export default function PlaylistTracklistHeader({ export default function PlaylistTracklistHeader({
canEdit, playlist,
playlistTracks,
editing,
newName,
setNewName,
}: { }: {
canEdit?: boolean playlist: BaseItemDto
playlistTracks: BaseItemDto[] | undefined
editing: boolean
newName: string
setNewName: Dispatch<SetStateAction<string>>
}): React.JSX.Element { }): React.JSX.Element {
const { playlist, playlistTracks, editing, setEditing, newName, setNewName } =
usePlaylistContext()
return ( return (
<YStack justifyContent='center' alignItems='center' paddingTop={'$1'} marginBottom={'$2'}> <YStack justifyContent='center' alignItems='center' paddingTop={'$1'} marginBottom={'$2'}>
<YStack justifyContent='center' alignContent='center' padding={'$2'}> <YStack justifyContent='center' alignContent='center' padding={'$2'}>
@@ -68,10 +70,8 @@ export default function PlaylistTracklistHeader({
<Animated.View entering={FadeInDown} exiting={FadeOutDown}> <Animated.View entering={FadeInDown} exiting={FadeOutDown}>
<PlaylistHeaderControls <PlaylistHeaderControls
editing={editing} editing={editing}
setEditing={setEditing}
playlist={playlist} playlist={playlist}
playlistTracks={playlistTracks ?? []} playlistTracks={playlistTracks ?? []}
canEdit={canEdit}
/> />
</Animated.View> </Animated.View>
) : ( ) : (
@@ -82,21 +82,16 @@ export default function PlaylistTracklistHeader({
} }
function PlaylistHeaderControls({ function PlaylistHeaderControls({
editing,
setEditing,
playlist, playlist,
playlistTracks, playlistTracks,
canEdit,
}: { }: {
editing: boolean editing: boolean
setEditing: (editing: boolean) => void
playlist: BaseItemDto playlist: BaseItemDto
playlistTracks: BaseItemDto[] playlistTracks: BaseItemDto[]
canEdit: boolean | undefined
}): React.JSX.Element { }): React.JSX.Element {
const { addToDownloadQueue, pendingDownloads } = useNetworkContext() const addToDownloadQueue = useAddToPendingDownloads()
const pendingDownloads = usePendingDownloads()
const streamingDeviceProfile = useStreamingDeviceProfile() const streamingDeviceProfile = useStreamingDeviceProfile()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const loadNewQueue = useLoadNewQueue() const loadNewQueue = useLoadNewQueue()
const isDownloading = pendingDownloads.length != 0 const isDownloading = pendingDownloads.length != 0
const api = useApi() const api = useApi()
@@ -105,13 +100,7 @@ function PlaylistHeaderControls({
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>() const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
const downloadPlaylist = () => { const downloadPlaylist = () => addToDownloadQueue(playlistTracks)
if (!api) return
const jellifyTracks = playlistTracks.map((item) =>
mapDtoToTrack(api, item, downloadingDeviceProfile),
)
addToDownloadQueue(jellifyTracks)
}
const playPlaylist = (shuffled: boolean = false) => { const playPlaylist = (shuffled: boolean = false) => {
if (!playlistTracks || playlistTracks.length === 0) return if (!playlistTracks || playlistTracks.length === 0) return
@@ -133,18 +122,7 @@ function PlaylistHeaderControls({
return ( return (
<XStack justifyContent='center' marginVertical={'$1'} gap={'$2'} flexWrap='wrap'> <XStack justifyContent='center' marginVertical={'$1'} gap={'$2'} flexWrap='wrap'>
<YStack justifyContent='center' alignContent='center'> <YStack justifyContent='center' alignContent='center'>
{editing && canEdit ? ( <InstantMixButton item={playlist} navigation={navigation} />
<Icon
color={'$danger'}
name='delete-sweep-outline' // otherwise use "delete-circle"
onPress={() => {
navigation.push('DeletePlaylist', { playlist })
}}
small
/>
) : (
<InstantMixButton item={playlist} navigation={navigation} />
)}
</YStack> </YStack>
<YStack justifyContent='center' alignContent='center'> <YStack justifyContent='center' alignContent='center'>
@@ -155,16 +133,6 @@ function PlaylistHeaderControls({
<Icon name='shuffle' onPress={() => playPlaylist(true)} small /> <Icon name='shuffle' onPress={() => playPlaylist(true)} small />
</YStack> </YStack>
{canEdit && (
<YStack justifyContent='center' alignContent='center'>
<Icon
color={'$borderColor'}
name={editing ? 'content-save-outline' : 'pencil'}
onPress={() => setEditing(!editing)}
small
/>
</YStack>
)}
<YStack justifyContent='center' alignContent='center'> <YStack justifyContent='center' alignContent='center'>
{!isDownloading ? ( {!isDownloading ? (
<Icon <Icon
+30 -9
View File
@@ -1,3 +1,4 @@
import React, { useCallback } from 'react'
import { RefreshControl } from 'react-native-gesture-handler' import { RefreshControl } from 'react-native-gesture-handler'
import { Separator, useTheme } from 'tamagui' import { Separator, useTheme } from 'tamagui'
import { FlashList } from '@shopify/flash-list' import { FlashList } from '@shopify/flash-list'
@@ -7,6 +8,13 @@ import { FetchNextPageOptions } from '@tanstack/react-query'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { BaseStackParamList } from '@/src/screens/types' import { BaseStackParamList } from '@/src/screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
// Extracted as stable component to prevent recreation on each render
function ListSeparatorComponent(): React.JSX.Element {
return <Separator />
}
const ListSeparator = React.memo(ListSeparatorComponent)
export interface PlaylistsProps { export interface PlaylistsProps {
canEdit?: boolean | undefined canEdit?: boolean | undefined
@@ -30,10 +38,29 @@ export default function Playlists({
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>() const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
// Memoized key extractor to prevent recreation on each render
const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, [])
// Memoized render item to prevent recreation on each render
const renderItem = useCallback(
({ item: playlist }: { index: number; item: BaseItemDto }) => (
<ItemRow item={playlist} navigation={navigation} />
),
[navigation],
)
// Memoized end reached handler
const handleEndReached = useCallback(() => {
if (hasNextPage) {
fetchNextPage()
}
}, [hasNextPage, fetchNextPage])
return ( return (
<FlashList <FlashList
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior='automatic'
data={playlists} data={playlists}
keyExtractor={keyExtractor}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={isPending || isFetchingNextPage} refreshing={isPending || isFetchingNextPage}
@@ -41,15 +68,9 @@ export default function Playlists({
tintColor={theme.primary.val} tintColor={theme.primary.val}
/> />
} }
ItemSeparatorComponent={() => <Separator />} ItemSeparatorComponent={ListSeparator}
renderItem={({ index, item: playlist }) => ( renderItem={renderItem}
<ItemRow item={playlist} navigation={navigation} /> onEndReached={handleEndReached}
)}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
}
}}
removeClippedSubviews removeClippedSubviews
/> />
) )
+5 -5
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react' import React, { useState } from 'react'
import Input from '../Global/helpers/input' import Input from '../Global/helpers/input'
import ItemRow from '../Global/components/item-row' import ItemRow from '../Global/components/item-row'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
@@ -45,7 +45,7 @@ export default function Search({
queryFn: () => fetchSearchSuggestions(api, user, library?.musicLibraryId), queryFn: () => fetchSearchSuggestions(api, user, library?.musicLibraryId),
}) })
const search = useCallback(() => { const search = () => {
let timeout: ReturnType<typeof setTimeout> let timeout: ReturnType<typeof setTimeout>
return () => { return () => {
@@ -55,16 +55,16 @@ export default function Search({
refetchSuggestions() refetchSuggestions()
}, 1000) }, 1000)
} }
}, []) }
const handleSearchStringUpdate = (value: string | undefined) => { const handleSearchStringUpdate = (value: string | undefined) => {
setSearchString(value) setSearchString(value)
search() search()
} }
const handleScrollBeginDrag = useCallback(() => { const handleScrollBeginDrag = () => {
closeAllSwipeableRows() closeAllSwipeableRows()
}, []) }
return ( return (
<FlatList <FlatList
+2 -3
View File
@@ -1,4 +1,3 @@
import { useCallback } from 'react'
import ItemRow from '../Global/components/item-row' import ItemRow from '../Global/components/item-row'
import { Text } from '../Global/helpers/text' import { Text } from '../Global/helpers/text'
import { H3, Separator, YStack } from 'tamagui' import { H3, Separator, YStack } from 'tamagui'
@@ -17,9 +16,9 @@ export default function Suggestions({
suggestions: BaseItemDto[] | undefined suggestions: BaseItemDto[] | undefined
}): React.JSX.Element { }): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<SearchParamList>>() const navigation = useNavigation<NativeStackNavigationProp<SearchParamList>>()
const handleScrollBeginDrag = useCallback(() => { const handleScrollBeginDrag = () => {
closeAllSwipeableRows() closeAllSwipeableRows()
}, []) }
return ( return (
<FlashList <FlashList
+2 -2
View File
@@ -4,9 +4,9 @@ import RNFS from 'react-native-fs'
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated' import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
import { deleteAudioCache } from '../../api/mutations/download/offlineModeUtils' import { deleteAudioCache } from '../../api/mutations/download/offlineModeUtils'
import Icon from '../Global/components/icon' import Icon from '../Global/components/icon'
import { useNetworkContext } from '../../providers/Network'
import { getToken, View } from 'tamagui' import { getToken, View } from 'tamagui'
import { Text } from '../Global/helpers/text' import { Text } from '../Global/helpers/text'
import { useDownloadProgress } from '@/src/stores/network/downloads'
// 🔹 Single Download Item with animated progress bar // 🔹 Single Download Item with animated progress bar
function DownloadItem({ function DownloadItem({
@@ -43,7 +43,7 @@ export default function StorageBar(): React.JSX.Element {
const [used, setUsed] = useState(0) const [used, setUsed] = useState(0)
const [total, setTotal] = useState(1) const [total, setTotal] = useState(1)
const { activeDownloads: activeDownloadsArray } = useNetworkContext() const activeDownloadsArray = useDownloadProgress()
const usageShared = useSharedValue(0) const usageShared = useSharedValue(0)
const percentUsed = used / total const percentUsed = used / total
+37 -40
View File
@@ -1,4 +1,4 @@
import React, { RefObject, useMemo, useRef, useCallback, useEffect } from 'react' import React, { RefObject, useRef, useEffect } from 'react'
import Track from '../Global/components/track' import Track from '../Global/components/track'
import { Separator, useTheme, XStack, YStack } from 'tamagui' import { Separator, useTheme, XStack, YStack } from 'tamagui'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
@@ -35,29 +35,22 @@ export default function Tracks({
const pendingLetterRef = useRef<string | null>(null) const pendingLetterRef = useRef<string | null>(null)
const stickyHeaderIndicies = useMemo(() => { const stickyHeaderIndicies = (() => {
if (!showAlphabeticalSelector || !tracksInfiniteQuery.data) return [] if (!showAlphabeticalSelector || !tracksInfiniteQuery.data) return []
return tracksInfiniteQuery.data return tracksInfiniteQuery.data
.map((track, index) => (typeof track === 'string' ? index : 0)) .map((track, index) => (typeof track === 'string' ? index : 0))
.filter((value, index, indices) => indices.indexOf(value) === index) .filter((value, index, indices) => indices.indexOf(value) === index)
}, [showAlphabeticalSelector, tracksInfiniteQuery.data]) })()
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } = const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase())) useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
// Memoize the expensive tracks processing to prevent memory leaks const tracksToDisplay =
const tracksToDisplay = React.useMemo( tracksInfiniteQuery.data?.filter((track) => typeof track === 'object') ?? []
() => tracksInfiniteQuery.data?.filter((track) => typeof track === 'object') ?? [],
[tracksInfiniteQuery.data],
)
// Memoize key extraction for FlashList performance const keyExtractor = (item: string | number | BaseItemDto) =>
const keyExtractor = React.useCallback( typeof item === 'object' ? item.Id! : item.toString()
(item: string | number | BaseItemDto) =>
typeof item === 'object' ? item.Id! : item.toString(),
[],
)
/** /**
* Memoize render item to prevent recreation * Memoize render item to prevent recreation
@@ -66,31 +59,35 @@ export default function Tracks({
* it factors in the list headings, meaning pressing a track may not * it factors in the list headings, meaning pressing a track may not
* play that exact track, since the index was offset by the headings * play that exact track, since the index was offset by the headings
*/ */
const renderItem = useCallback( const renderItem = ({
({ item: track, index }: { index: number; item: string | number | BaseItemDto }) => item: track,
typeof track === 'string' ? ( index,
<FlashListStickyHeader text={track.toUpperCase()} /> }: {
) : typeof track === 'number' ? null : typeof track === 'object' ? ( index: number
<Track item: string | number | BaseItemDto
navigation={navigation} }) =>
showArtwork typeof track === 'string' ? (
index={0} <FlashListStickyHeader text={track.toUpperCase()} />
track={track} ) : typeof track === 'number' ? null : typeof track === 'object' ? (
testID={`track-item-${index}`} <Track
tracklist={tracksToDisplay.slice(index, index + 50)} navigation={navigation}
queue={queue} showArtwork
/> index={0}
) : null, track={track}
[tracksToDisplay, queue, navigation, queue], testID={`track-item-${index}`}
) tracklist={tracksToDisplay.slice(index, index + 50)}
queue={queue}
/>
) : null
const ItemSeparatorComponent = useCallback( const ItemSeparatorComponent = ({
({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) => leadingItem,
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : ( trailingItem,
<Separator /> }: {
), leadingItem: unknown
[], trailingItem: unknown
) }) =>
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : <Separator />
// Effect for handling the pending alphabet selector letter // Effect for handling the pending alphabet selector letter
useEffect(() => { useEffect(() => {
@@ -129,9 +126,9 @@ export default function Tracks({
} }
}, [pendingLetterRef.current, tracksInfiniteQuery.data]) }, [pendingLetterRef.current, tracksInfiniteQuery.data])
const handleScrollBeginDrag = useCallback(() => { const handleScrollBeginDrag = () => {
closeAllSwipeableRows() closeAllSwipeableRows()
}, []) }
return ( return (
<XStack flex={1}> <XStack flex={1}>
+9 -9
View File
@@ -2,7 +2,6 @@ import _ from 'lodash'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import Root from '../screens' import Root from '../screens'
import { PlayerProvider } from '../providers/Player' import { PlayerProvider } from '../providers/Player'
import { NetworkContextProvider } from '../providers/Network'
import { DisplayProvider } from '../providers/Display/display-provider' import { DisplayProvider } from '../providers/Display/display-provider'
import { import {
createTelemetryDeck, createTelemetryDeck,
@@ -20,6 +19,7 @@ import { StorageProvider } from '../providers/Storage'
import { useSelectPlayerEngine } from '../stores/player/engine' import { useSelectPlayerEngine } from '../stores/player/engine'
import { useSendMetricsSetting, useThemeSetting } from '../stores/settings/app' import { useSendMetricsSetting, useThemeSetting } from '../stores/settings/app'
import { GLITCHTIP_DSN } from '../configs/config' import { GLITCHTIP_DSN } from '../configs/config'
import useDownloadProcessor from '../hooks/use-download-processor'
/** /**
* The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider} * The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider}
* @returns The {@link Jellify} component * @returns The {@link Jellify} component
@@ -76,14 +76,14 @@ function App(): React.JSX.Element {
} }
}, [sendMetrics]) }, [sendMetrics])
useDownloadProcessor()
return ( return (
<NetworkContextProvider> <StorageProvider>
<StorageProvider> <CarPlayProvider />
<CarPlayProvider /> <PlayerProvider />
<PlayerProvider /> <Root />
<Root /> <Toast topOffset={getToken('$12')} config={JellifyToastConfig(theme)} />
<Toast topOffset={getToken('$12')} config={JellifyToastConfig(theme)} /> </StorageProvider>
</StorageProvider>
</NetworkContextProvider>
) )
} }
+1
View File
@@ -0,0 +1 @@
export const MAX_CONCURRENT_DOWNLOADS = 1
+74
View File
@@ -0,0 +1,74 @@
import { MMKV } from 'react-native-mmkv'
import { StateStorage } from 'zustand/middleware'
import { storage } from './storage'
// Import app version from package.json
const APP_VERSION = '0.21.3' // This should match package.json version
const STORAGE_VERSION_KEY = 'storage-schema-version'
/**
* Storage schema versions - increment when making breaking changes to persisted state
* This allows clearing specific stores when their schema changes
*/
export const STORAGE_SCHEMA_VERSIONS: Record<string, number> = {
'player-queue-storage': 2, // Bumped to v2 for slim persistence
}
/**
* Checks if a specific store needs to be cleared due to version bump
* and clears it if necessary
*/
export function migrateStorageIfNeeded(storeName: string, storage: MMKV): void {
const versionKey = `${STORAGE_VERSION_KEY}:${storeName}`
const storedVersion = storage.getNumber(versionKey)
const currentVersion = STORAGE_SCHEMA_VERSIONS[storeName] ?? 1
if (storedVersion !== currentVersion) {
// Clear the stale storage for this specific store
storage.delete(storeName)
// Update the version
storage.set(versionKey, currentVersion)
console.log(
`[Storage] Migrated ${storeName} from v${storedVersion ?? 0} to v${currentVersion}`,
)
}
}
/**
* Creates a versioned MMKV state storage that automatically clears stale data
* when the schema version changes. This is useful for stores that persist
* data that may become incompatible between app versions.
*
* @param storeName The unique name for this store (used as the MMKV key)
* @returns A StateStorage compatible object for Zustand's persist middleware
*/
export function createVersionedMmkvStorage(storeName: string): StateStorage {
// Run migration check on storage creation
migrateStorageIfNeeded(storeName, storage)
return {
getItem: (key: string) => {
const value = storage.getString(key)
return value === undefined ? null : value
},
setItem: (key: string, value: string) => {
storage.set(key, value)
},
removeItem: (key: string) => {
storage.delete(key)
},
}
}
/**
* Clears all versioned storage entries. Useful for debugging or forcing
* a complete cache reset.
*/
export function clearAllVersionedStorage(): void {
Object.keys(STORAGE_SCHEMA_VERSIONS).forEach((storeName) => {
storage.delete(storeName)
storage.delete(`${STORAGE_VERSION_KEY}:${storeName}`)
})
console.log('[Storage] Cleared all versioned storage')
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect } from 'react'
import {
useAddToCompletedDownloads,
useAddToCurrentDownloads,
useAddToFailedDownloads,
useDownloadsStore,
useRemoveFromCurrentDownloads,
useRemoveFromPendingDownloads,
} from '../stores/network/downloads'
import { MAX_CONCURRENT_DOWNLOADS } from '../configs/download.config'
import { useAllDownloadedTracks } from '../api/queries/download'
import { saveAudio } from '../api/mutations/download/offlineModeUtils'
const useDownloadProcessor = () => {
const { pendingDownloads, currentDownloads } = useDownloadsStore()
const { data: downloadedTracks } = useAllDownloadedTracks()
const addToCurrentDownloads = useAddToCurrentDownloads()
const removeFromCurrentDownloads = useRemoveFromCurrentDownloads()
const removeFromPendingDownloads = useRemoveFromPendingDownloads()
const addToCompletedDownloads = useAddToCompletedDownloads()
const addToFailedDownloads = useAddToFailedDownloads()
const { refetch: refetchDownloadedTracks } = useAllDownloadedTracks()
return useEffect(() => {
if (pendingDownloads.length > 0 && currentDownloads.length < MAX_CONCURRENT_DOWNLOADS) {
const availableSlots = MAX_CONCURRENT_DOWNLOADS - currentDownloads.length
const filesToStart = pendingDownloads.slice(0, availableSlots)
console.debug('Downloading from queue')
filesToStart.forEach((file) => {
addToCurrentDownloads(file)
removeFromPendingDownloads(file)
if (downloadedTracks?.some((t) => t.item.Id === file.item.Id)) {
removeFromCurrentDownloads(file)
addToCompletedDownloads(file)
return
}
saveAudio(file, () => {}, false).then((success) => {
removeFromCurrentDownloads(file)
if (success) {
addToCompletedDownloads(file)
} else {
addToFailedDownloads(file)
}
})
})
}
if (pendingDownloads.length === 0 && currentDownloads.length === 0) {
refetchDownloadedTracks()
}
}, [pendingDownloads.length, currentDownloads.length])
}
export default useDownloadProcessor
+9 -12
View File
@@ -7,7 +7,7 @@ import { fetchMediaInfo } from '../api/queries/media/utils'
import { fetchAlbumDiscs, fetchItem } from '../api/queries/item' import { fetchAlbumDiscs, fetchItem } from '../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import fetchUserData from '../api/queries/user-data/utils' import fetchUserData from '../api/queries/user-data/utils'
import { useCallback, useRef } from 'react' import { useRef } from 'react'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../stores/device-profile' import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../stores/device-profile'
import UserDataQueryKey from '../api/queries/user-data/keys' import UserDataQueryKey from '../api/queries/user-data/keys'
import MediaInfoQueryKey from '../api/queries/media/keys' import MediaInfoQueryKey from '../api/queries/media/keys'
@@ -23,20 +23,17 @@ export default function useItemContext(): (item: BaseItemDto) => void {
const prefetchedContext = useRef<Set<string>>(new Set()) const prefetchedContext = useRef<Set<string>>(new Set())
return useCallback( return (item: BaseItemDto) => {
(item: BaseItemDto) => { const effectSig = `${item.Id}-${item.Type}`
const effectSig = `${item.Id}-${item.Type}`
// If we've already warmed the cache for this item, return // If we've already warmed the cache for this item, return
if (prefetchedContext.current.has(effectSig)) return if (prefetchedContext.current.has(effectSig)) return
// Mark this item's context as warmed, preventing reruns // Mark this item's context as warmed, preventing reruns
prefetchedContext.current.add(effectSig) prefetchedContext.current.add(effectSig)
warmItemContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile) warmItemContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile)
}, }
[api, user, streamingDeviceProfile, downloadingDeviceProfile],
)
} }
function warmItemContext( function warmItemContext(
+10
View File
@@ -0,0 +1,10 @@
import useAppActive from './use-app-active'
import { useCurrentTrack } from '../stores/player/queue'
export default function useIsMiniPlayerActive(): boolean {
const isAppActive = useAppActive()
const nowPlaying = useCurrentTrack()
return !!nowPlaying && isAppActive
}
+11
View File
@@ -7,6 +7,14 @@ interface PerformanceMetrics {
totalRenderTime: number totalRenderTime: number
} }
// No-op metrics for production builds
const EMPTY_METRICS: PerformanceMetrics = {
renderCount: 0,
lastRenderTime: 0,
averageRenderTime: 0,
totalRenderTime: 0,
}
/** /**
* Hook to monitor component performance and detect excessive re-renders * Hook to monitor component performance and detect excessive re-renders
* @param componentName - Name of the component for logging * @param componentName - Name of the component for logging
@@ -17,6 +25,7 @@ export function usePerformanceMonitor(
componentName: string, componentName: string,
threshold: number = 10, threshold: number = 10,
): PerformanceMetrics { ): PerformanceMetrics {
// Skip all performance monitoring in production for zero overhead
const renderCount = useRef(0) const renderCount = useRef(0)
const renderTimes = useRef<number[]>([]) const renderTimes = useRef<number[]>([])
const lastRenderStart = useRef(Date.now()) const lastRenderStart = useRef(Date.now())
@@ -56,6 +65,8 @@ export function usePerformanceMonitor(
lastRenderStart.current = Date.now() lastRenderStart.current = Date.now()
}) })
if (!__DEV__) return EMPTY_METRICS
const averageRenderTime = const averageRenderTime =
renderTimes.current.length > 0 renderTimes.current.length > 0
? renderTimes.current.reduce((a, b) => a + b, 0) / renderTimes.current.length ? renderTimes.current.reduce((a, b) => a + b, 0) / renderTimes.current.length
+12
View File
@@ -1,3 +1,5 @@
import { Platform } from 'react-native'
/** /**
* Interval in milliseconds for progress updates from the track player * Interval in milliseconds for progress updates from the track player
* Lower value provides smoother scrubber movement but uses more resources * Lower value provides smoother scrubber movement but uses more resources
@@ -16,3 +18,13 @@ export const SKIP_TO_PREVIOUS_THRESHOLD: number = 4
* event will be emitted from the track player * event will be emitted from the track player
*/ */
export const PROGRESS_UPDATE_EVENT_INTERVAL: number = 30 export const PROGRESS_UPDATE_EVENT_INTERVAL: number = 30
export const BUFFERS =
Platform.OS === 'android'
? {
maxCacheSize: 50 * 1024, // 50MB cache
maxBuffer: 30, // 30 seconds buffer
playBuffer: 2.5, // 2.5 seconds play buffer
backBuffer: 5, // 5 seconds back buffer
}
: {}
+5 -10
View File
@@ -1,12 +1,7 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
export type Queue = /**
| BaseItemDto * Describes where playback was initiated from.
| 'Recently Played' * Allows known queue labels (e.g., "Recently Played") as well as dynamic strings like search terms.
| 'Search' */
| 'Favorite Tracks' export type Queue = BaseItemDto | string
| 'Downloaded Tracks'
| 'On Repeat'
| 'Instant Mix'
| 'Library'
| 'Artist Tracks'
-47
View File
@@ -1,47 +0,0 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { QueryKeys } from '../../enums/query-keys'
import { fetchAlbumDiscs } from '../../api/queries/item'
import { useQuery } from '@tanstack/react-query'
import { createContext, ReactNode, useContext } from 'react'
import { useApi } from '../../stores'
interface AlbumContext {
album: BaseItemDto
discs: { title: string; data: BaseItemDto[] }[] | undefined
isPending: boolean
}
function AlbumContextInitializer(album: BaseItemDto): AlbumContext {
const api = useApi()
const { data: discs, isPending } = useQuery({
queryKey: [QueryKeys.ItemTracks, album.Id],
queryFn: () => fetchAlbumDiscs(api, album),
})
return {
album,
discs,
isPending,
}
}
const AlbumContext = createContext<AlbumContext>({
album: {},
discs: undefined,
isPending: false,
})
export const AlbumProvider: ({
album,
children,
}: {
album: BaseItemDto
children: ReactNode
}) => React.JSX.Element = ({ album, children }) => {
const context = AlbumContextInitializer(album)
return <AlbumContext.Provider value={context}>{children}</AlbumContext.Provider>
}
export const useAlbumContext = () => useContext(AlbumContext)
+14 -27
View File
@@ -2,7 +2,7 @@ import fetchSimilar from '../../api/queries/similar'
import { QueryKeys } from '../../enums/query-keys' import { QueryKeys } from '../../enums/query-keys'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { createContext, ReactNode, useCallback, useContext, useMemo } from 'react' import { createContext, ReactNode, useContext } from 'react'
import { SharedValue, useSharedValue } from 'react-native-reanimated' import { SharedValue, useSharedValue } from 'react-native-reanimated'
import { isUndefined } from 'lodash' import { isUndefined } from 'lodash'
import { useArtistAlbums, useArtistFeaturedOn } from '../../api/queries/artist' import { useArtistAlbums, useArtistFeaturedOn } from '../../api/queries/artist'
@@ -65,38 +65,25 @@ export const ArtistProvider = ({
enabled: !isUndefined(artist.Id), enabled: !isUndefined(artist.Id),
}) })
const refresh = useCallback(() => { const refresh = () => {
refetchAlbums() refetchAlbums()
refetchFeaturedOn() refetchFeaturedOn()
refetchSimilar() refetchSimilar()
}, [refetchAlbums, refetchFeaturedOn, refetchSimilar]) }
const scroll = useSharedValue(0) const scroll = useSharedValue(0)
const value = useMemo( const value = {
() => ({ artist,
artist, albums,
albums, featuredOn,
featuredOn, similarArtists,
similarArtists, fetchingAlbums,
fetchingAlbums, fetchingFeaturedOn,
fetchingFeaturedOn, fetchingSimilarArtists,
fetchingSimilarArtists, refresh,
refresh, scroll,
scroll, }
}),
[
artist,
albums,
featuredOn,
similarArtists,
fetchingAlbums,
fetchingFeaturedOn,
fetchingSimilarArtists,
refresh,
scroll,
],
)
return <ArtistContext.Provider value={value}>{children}</ArtistContext.Provider> return <ArtistContext.Provider value={value}>{children}</ArtistContext.Provider>
} }
-115
View File
@@ -1,115 +0,0 @@
import React, { createContext, ReactNode, useContext, useEffect, useState, useMemo } from 'react'
import { JellifyDownloadProgress } from '../../types/JellifyDownload'
import { saveAudio } from '../../api/mutations/download/offlineModeUtils'
import JellifyTrack from '../../types/JellifyTrack'
import { useAllDownloadedTracks } from '../../api/queries/download'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
interface NetworkContext {
activeDownloads: JellifyDownloadProgress | undefined
pendingDownloads: JellifyTrack[]
downloadingDownloads: JellifyTrack[]
completedDownloads: JellifyTrack[]
failedDownloads: JellifyTrack[]
addToDownloadQueue: (items: JellifyTrack[]) => boolean
}
const MAX_CONCURRENT_DOWNLOADS = 1
const COMPONENT_NAME = 'NetworkProvider'
const NetworkContextInitializer = () => {
usePerformanceMonitor(COMPONENT_NAME, 5)
const [downloadProgress, setDownloadProgress] = useState<JellifyDownloadProgress>({})
// Mutiple Downloads
const [pending, setPending] = useState<JellifyTrack[]>([])
const [downloading, setDownloading] = useState<JellifyTrack[]>([])
const [completed, setCompleted] = useState<JellifyTrack[]>([])
const [failed, setFailed] = useState<JellifyTrack[]>([])
const { data: downloadedTracks, refetch: refetchDownloadedTracks } = useAllDownloadedTracks()
useEffect(() => {
if (pending.length > 0 && downloading.length < MAX_CONCURRENT_DOWNLOADS) {
const availableSlots = MAX_CONCURRENT_DOWNLOADS - downloading.length
const filesToStart = pending.slice(0, availableSlots)
filesToStart.forEach((file) => {
setDownloading((prev) => [...prev, file])
setPending((prev) => prev.filter((f) => f.item.Id !== file.item.Id))
if (downloadedTracks?.some((t) => t.item.Id === file.item.Id)) {
setDownloading((prev) => prev.filter((f) => f.item.Id !== file.item.Id))
setCompleted((prev) => [...prev, file])
return
}
saveAudio(file, setDownloadProgress, false).then((success) => {
setDownloading((prev) => prev.filter((f) => f.item.Id !== file.item.Id))
setDownloadProgress((prev) => {
const next = { ...prev }
delete next[file.url]
if (file.artwork) delete next[file.artwork]
return next
})
if (success) {
setCompleted((prev) => [...prev, file])
} else {
setFailed((prev) => [...prev, file])
}
})
})
}
if (pending.length === 0 && downloading.length === 0) {
refetchDownloadedTracks()
}
}, [pending, downloading])
const addToDownloadQueue = (items: JellifyTrack[]) => {
setPending((prev) => [...prev, ...items])
return true
}
return {
activeDownloads: downloadProgress,
downloadedTracks,
pendingDownloads: pending,
downloadingDownloads: downloading,
completedDownloads: completed,
failedDownloads: failed,
addToDownloadQueue,
}
}
const NetworkContext = createContext<NetworkContext>({
activeDownloads: {},
pendingDownloads: [],
downloadingDownloads: [],
completedDownloads: [],
failedDownloads: [],
addToDownloadQueue: () => true,
})
export const NetworkContextProvider: ({
children,
}: {
children: ReactNode
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
const context = NetworkContextInitializer()
// Memoize the context value to prevent unnecessary re-renders
const value = useMemo(
() => context,
[
context.downloadedTracks?.length,
context.pendingDownloads.length,
context.downloadingDownloads.length,
context.completedDownloads.length,
context.failedDownloads.length,
],
)
return <NetworkContext.Provider value={value}>{children}</NetworkContext.Provider>
}
export const useNetworkContext = () => useContext(NetworkContext)
+21 -4
View File
@@ -7,7 +7,7 @@ import {
import usePlayerEngineStore from '../../../stores/player/engine' import usePlayerEngineStore from '../../../stores/player/engine'
import { PlayerEngine } from '../../../stores/player/engine' import { PlayerEngine } from '../../../stores/player/engine'
import { MediaPlayerState, useRemoteMediaClient, useStreamPosition } from 'react-native-google-cast' import { MediaPlayerState, useRemoteMediaClient, useStreamPosition } from 'react-native-google-cast'
import { useMemo, useState } from 'react' import { useEffect, useState } from 'react'
export const useProgress = (UPDATE_INTERVAL: number): Progress => { export const useProgress = (UPDATE_INTERVAL: number): Progress => {
const { position, duration, buffered } = useProgressRNTP(UPDATE_INTERVAL) const { position, duration, buffered } = useProgressRNTP(UPDATE_INTERVAL)
@@ -58,16 +58,33 @@ export const usePlaybackState = (): State | undefined => {
const isCasting = playerEngineData === PlayerEngine.GOOGLE_CAST const isCasting = playerEngineData === PlayerEngine.GOOGLE_CAST
const [playbackState, setPlaybackState] = useState<State | undefined>(state) const [playbackState, setPlaybackState] = useState<State | undefined>(state)
useMemo(() => { useEffect(() => {
let unsubscribe: (() => void) | undefined
if (client && isCasting) { if (client && isCasting) {
client.onMediaStatusUpdated((status) => { const handler = (status: { playerState?: MediaPlayerState | null } | null) => {
if (status?.playerState) { if (status?.playerState) {
setPlaybackState(castToRNTPState(status.playerState)) setPlaybackState(castToRNTPState(status.playerState))
} }
}) }
const maybeUnsubscribe = client.onMediaStatusUpdated(handler)
// EmitterSubscription has a remove() method, wrap it as a function
if (
maybeUnsubscribe &&
typeof maybeUnsubscribe === 'object' &&
'remove' in maybeUnsubscribe
) {
const subscription = maybeUnsubscribe as { remove: () => void }
unsubscribe = () => subscription.remove()
}
} else { } else {
setPlaybackState(state) setPlaybackState(state)
} }
return () => {
if (unsubscribe) unsubscribe()
}
}, [client, isCasting, state]) }, [client, isCasting, state])
return playbackState return playbackState
+50 -58
View File
@@ -1,6 +1,6 @@
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor' import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
import TrackPlayer, { Event, State, useTrackPlayerEvents } from 'react-native-track-player' import TrackPlayer, { Event, State, useTrackPlayerEvents } from 'react-native-track-player'
import { createContext, useCallback, useEffect, useState } from 'react' import { createContext, useEffect, useState } from 'react'
import { handleActiveTrackChanged } from './functions' import { handleActiveTrackChanged } from './functions'
import JellifyTrack from '../../types/JellifyTrack' import JellifyTrack from '../../types/JellifyTrack'
import { useAutoDownload } from '../../stores/settings/usage' import { useAutoDownload } from '../../stores/settings/usage'
@@ -43,69 +43,61 @@ export const PlayerProvider: () => React.JSX.Element = () => {
usePerformanceMonitor('PlayerProvider', 3) usePerformanceMonitor('PlayerProvider', 3)
const eventHandler = useCallback( // eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any const eventHandler = async (event: any) => {
async (event: any) => { switch (event.type) {
switch (event.type) { case Event.PlaybackActiveTrackChanged: {
case Event.PlaybackActiveTrackChanged: { // When we load a new queue, our index is updated before RNTP
// When we load a new queue, our index is updated before RNTP // Because of this, we only need to respond to this event
// Because of this, we only need to respond to this event // if the index from the event differs from what we have stored
// if the index from the event differs from what we have stored if (event.track && enableAudioNormalization) {
if (event.track && enableAudioNormalization) { const volume = calculateTrackVolume(event.track)
const volume = calculateTrackVolume(event.track) await TrackPlayer.setVolume(volume)
await TrackPlayer.setVolume(volume) } else if (event.track) {
} else if (event.track) { try {
try { await reportPlaybackStarted(api, event.track)
await reportPlaybackStarted(api, event.track) } catch (error) {
} catch (error) { console.error('Unable to report playback started for track', error)
console.error('Unable to report playback started for track', error)
}
} }
await handleActiveTrackChanged()
if (event.lastTrack) {
try {
if (
isPlaybackFinished(
event.lastPosition,
event.lastTrack.duration ?? 1,
)
)
await reportPlaybackCompleted(api, event.lastTrack as JellifyTrack)
else await reportPlaybackStopped(api, event.lastTrack as JellifyTrack)
} catch (error) {
console.error('Unable to report playback stopped for lastTrack', error)
}
}
break
}
case Event.PlaybackProgressUpdated: {
const currentTrack = usePlayerQueueStore.getState().currentTrack
if (event.position / event.duration > 0.3 && autoDownload && currentTrack) {
await saveAudioItem(api, currentTrack.item, downloadingDeviceProfile, true)
}
break
} }
case Event.PlaybackState: { await handleActiveTrackChanged()
const currentTrack = usePlayerQueueStore.getState().currentTrack
switch (event.state) { if (event.lastTrack) {
case State.Playing: try {
if (currentTrack) await reportPlaybackStarted(api, currentTrack) if (isPlaybackFinished(event.lastPosition, event.lastTrack.duration ?? 1))
break await reportPlaybackCompleted(api, event.lastTrack as JellifyTrack)
default: else await reportPlaybackStopped(api, event.lastTrack as JellifyTrack)
if (currentTrack) await reportPlaybackStopped(api, currentTrack) } catch (error) {
break console.error('Unable to report playback stopped for lastTrack', error)
} }
break
} }
break
} }
}, case Event.PlaybackProgressUpdated: {
[api, autoDownload, enableAudioNormalization], const currentTrack = usePlayerQueueStore.getState().currentTrack
)
if (event.position / event.duration > 0.3 && autoDownload && currentTrack) {
await saveAudioItem(api, currentTrack.item, downloadingDeviceProfile, true)
}
break
}
case Event.PlaybackState: {
const currentTrack = usePlayerQueueStore.getState().currentTrack
switch (event.state) {
case State.Playing:
if (currentTrack) await reportPlaybackStarted(api, currentTrack)
break
default:
if (currentTrack) await reportPlaybackStopped(api, currentTrack)
break
}
break
}
}
}
useTrackPlayerEvents(PLAYER_EVENTS, eventHandler) useTrackPlayerEvents(PLAYER_EVENTS, eventHandler)
+49 -76
View File
@@ -1,18 +1,11 @@
import React, { import React, { PropsWithChildren, createContext, use, useContext, useState } from 'react'
PropsWithChildren,
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react'
import { useAllDownloadedTracks, useStorageInUse } from '../../api/queries/download' import { useAllDownloadedTracks, useStorageInUse } from '../../api/queries/download'
import { JellifyDownload, JellifyDownloadProgress } from '../../types/JellifyDownload' import { JellifyDownload, JellifyDownloadProgress } from '../../types/JellifyDownload'
import { import {
DeleteDownloadsResult, DeleteDownloadsResult,
deleteDownloadsByIds, deleteDownloadsByIds,
} from '../../api/mutations/download/offlineModeUtils' } from '../../api/mutations/download/offlineModeUtils'
import { useNetworkContext } from '../Network' import { useDownloadProgress } from '../../stores/network/downloads'
export type StorageSummary = { export type StorageSummary = {
totalSpace: number totalSpace: number
@@ -74,18 +67,15 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
refetch: refetchStorageInfo, refetch: refetchStorageInfo,
isFetching: isFetchingStorage, isFetching: isFetchingStorage,
} = useStorageInUse() } = useStorageInUse()
const { activeDownloads } = useNetworkContext() const activeDownloads = useDownloadProgress()
const [selection, setSelection] = useState<StorageSelectionState>({}) const [selection, setSelection] = useState<StorageSelectionState>({})
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [isManuallyRefreshing, setIsManuallyRefreshing] = useState(false) const [isManuallyRefreshing, setIsManuallyRefreshing] = useState(false)
const activeDownloadsCount = useMemo( const activeDownloadsCount = Object.keys(activeDownloads ?? {}).length
() => Object.keys(activeDownloads ?? {}).length,
[activeDownloads],
)
const summary = useMemo<StorageSummary | undefined>(() => { const summary: StorageSummary | undefined = (() => {
if (!downloads || !storageInfo) return undefined if (!downloads || !storageInfo) return undefined
const audioBytes = downloads.reduce( const audioBytes = downloads.reduce(
@@ -110,9 +100,9 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
artworkBytes, artworkBytes,
audioBytes, audioBytes,
} }
}, [downloads, storageInfo]) })()
const suggestions = useMemo<CleanupSuggestion[]>(() => { const suggestions: CleanupSuggestion[] = (() => {
if (!downloads || downloads.length === 0) return [] if (!downloads || downloads.length === 0) return []
const now = Date.now() const now = Date.now()
@@ -168,92 +158,75 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
}) })
return list return list
}, [downloads]) })()
const toggleSelection = useCallback((itemId: string) => { const toggleSelection = (itemId: string) => {
setSelection((prev) => ({ setSelection((prev) => ({
...prev, ...prev,
[itemId]: !prev[itemId], [itemId]: !prev[itemId],
})) }))
}, []) }
const clearSelection = useCallback(() => setSelection({}), []) const clearSelection = () => setSelection({})
const deleteDownloads = useCallback( const deleteDownloads = async (
async (itemIds: string[]): Promise<DeleteDownloadsResult | undefined> => { itemIds: string[],
if (!itemIds.length) return undefined ): Promise<DeleteDownloadsResult | undefined> => {
setIsDeleting(true) if (!itemIds.length) return undefined
try { setIsDeleting(true)
const result = await deleteDownloadsByIds(itemIds) try {
await Promise.all([refetchDownloads(), refetchStorageInfo()]) const result = await deleteDownloadsByIds(itemIds)
setSelection((prev) => { await Promise.all([refetchDownloads(), refetchStorageInfo()])
const updated = { ...prev } setSelection((prev) => {
itemIds.forEach((id) => delete updated[id]) const updated = { ...prev }
return updated itemIds.forEach((id) => delete updated[id])
}) return updated
return result })
} finally { return result
setIsDeleting(false) } finally {
} setIsDeleting(false)
}, }
[refetchDownloads, refetchStorageInfo], }
)
const deleteSelection = useCallback(async () => { const deleteSelection = async () => {
const idsToDelete = Object.entries(selection) const idsToDelete = Object.entries(selection)
.filter(([, isSelected]) => isSelected) .filter(([, isSelected]) => isSelected)
.map(([id]) => id) .map(([id]) => id)
return deleteDownloads(idsToDelete) return deleteDownloads(idsToDelete)
}, [selection, deleteDownloads]) }
const refresh = useCallback(async () => { const refresh = async () => {
setIsManuallyRefreshing(true) setIsManuallyRefreshing(true)
try { try {
await Promise.all([refetchDownloads(), refetchStorageInfo()]) await Promise.all([refetchDownloads(), refetchStorageInfo()])
} finally { } finally {
setIsManuallyRefreshing(false) setIsManuallyRefreshing(false)
} }
}, [refetchDownloads, refetchStorageInfo]) }
const refreshing = isFetchingDownloads || isFetchingStorage || isManuallyRefreshing const refreshing = isFetchingDownloads || isFetchingStorage || isManuallyRefreshing
const value = useMemo<StorageContextValue>( const value: StorageContextValue = {
() => ({ downloads,
downloads, summary,
summary, suggestions,
suggestions, selection,
selection, toggleSelection,
toggleSelection, clearSelection,
clearSelection, deleteSelection,
deleteSelection, deleteDownloads,
deleteDownloads, isDeleting,
isDeleting, refresh,
refresh, refreshing,
refreshing, activeDownloadsCount,
activeDownloadsCount, activeDownloads,
activeDownloads, }
}),
[
downloads,
summary,
suggestions,
selection,
toggleSelection,
clearSelection,
deleteSelection,
deleteDownloads,
isDeleting,
refresh,
refreshing,
activeDownloadsCount,
],
)
return <StorageContext.Provider value={value}>{children}</StorageContext.Provider> return <StorageContext.Provider value={value}>{children}</StorageContext.Provider>
} }
export const useStorageContext = () => { export const useStorageContext = () => {
const context = useContext(StorageContext) const context = use(StorageContext)
if (!context) throw new Error('StorageContext must be used within a StorageProvider') if (!context) throw new Error('StorageContext must be used within a StorageProvider')
return context return context
} }
+1 -6
View File
@@ -1,13 +1,8 @@
import { Album } from '../../components/Album' import { Album } from '../../components/Album'
import { AlbumProps } from '../types' import { AlbumProps } from '../types'
import { AlbumProvider } from '../../providers/Album'
export default function AlbumScreen({ route, navigation }: AlbumProps): React.JSX.Element { export default function AlbumScreen({ route, navigation }: AlbumProps): React.JSX.Element {
const { album } = route.params const { album } = route.params
return ( return <Album album={album} />
<AlbumProvider album={album}>
<Album />
</AlbumProvider>
)
} }
+4 -15
View File
@@ -1,24 +1,13 @@
import { BaseStackParamList } from '../types' import { BaseStackParamList } from '../types'
import { NativeStackScreenProps } from '@react-navigation/native-stack' import { NativeStackScreenProps } from '@react-navigation/native-stack'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { UseInfiniteQueryResult } from '@tanstack/react-query'
import { NavigatorScreenParams } from '@react-navigation/native'
type HomeStackParamList = BaseStackParamList & { type HomeStackParamList = BaseStackParamList & {
HomeScreen: undefined HomeScreen: undefined
RecentArtists: { RecentArtists: undefined
artistsInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error> MostPlayedArtists: undefined
} RecentTracks: undefined
MostPlayedArtists: { MostPlayedTracks: undefined
artistsInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error>
}
RecentTracks: {
tracksInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error>
}
MostPlayedTracks: {
tracksInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error>
}
} }
export default HomeStackParamList export default HomeStackParamList
+6 -9
View File
@@ -1,9 +1,8 @@
import { BaseStackParamList, RootStackParamList } from '../types' import { BaseStackParamList } from '../types'
import { RouteProp } from '@react-navigation/native' import { RouteProp } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import React from 'react' import React from 'react'
import Playlist from '../../components/Playlist/index' import Playlist from '../../components/Playlist/index'
import { PlaylistProvider } from '../../providers/Playlist'
export function PlaylistScreen({ export function PlaylistScreen({
route, route,
@@ -13,12 +12,10 @@ export function PlaylistScreen({
navigation: NativeStackNavigationProp<BaseStackParamList> navigation: NativeStackNavigationProp<BaseStackParamList>
}): React.JSX.Element { }): React.JSX.Element {
return ( return (
<PlaylistProvider playlist={route.params.playlist}> <Playlist
<Playlist playlist={route.params.playlist}
playlist={route.params.playlist} navigation={navigation}
navigation={navigation} canEdit={route.params.canEdit}
canEdit={route.params.canEdit} />
/>
</PlaylistProvider>
) )
} }
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react' import React, { useState } from 'react'
import { FlashList, ListRenderItem } from '@shopify/flash-list' import { FlashList, ListRenderItem } from '@shopify/flash-list'
import { useFocusEffect, useNavigation } from '@react-navigation/native' import { useFocusEffect, useNavigation } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -47,62 +47,44 @@ export default function StorageManagementScreen(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<SettingsStackParamList>>() const navigation = useNavigation<NativeStackNavigationProp<SettingsStackParamList>>()
const showDeletionToast = useDeletionToast() const showDeletionToast = useDeletionToast()
useFocusEffect( const sortedDownloads = !downloads
useCallback(() => { ? []
void refresh() : [...downloads].sort(
}, [refresh]), (a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime(),
) )
const sortedDownloads = useMemo(() => { const selectedIds = Object.entries(selection)
if (!downloads) return [] .filter(([, isSelected]) => isSelected)
return [...downloads].sort( .map(([id]) => id)
(a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime(),
)
}, [downloads])
const selectedIds = useMemo( const selectedBytes =
() => !selectedIds.length || !downloads
Object.entries(selection) ? 0
.filter(([, isSelected]) => isSelected) : downloads.reduce((total, download) => {
.map(([id]) => id), return new Set(selectedIds).has(download.item.Id as string)
[selection], ? total + getDownloadSize(download)
) : total
}, 0)
const selectedBytes = useMemo(() => { const handleApplySuggestion = async (suggestion: CleanupSuggestion) => {
if (!selectedIds.length || !downloads) return 0 if (!suggestion.itemIds.length) return
const selectedSet = new Set(selectedIds) setApplyingSuggestionId(suggestion.id)
return downloads.reduce((total, download) => { try {
return selectedSet.has(download.item.Id as string) const result = await deleteDownloads(suggestion.itemIds)
? total + getDownloadSize(download)
: total
}, 0)
}, [downloads, selectedIds])
const handleApplySuggestion = useCallback(
async (suggestion: CleanupSuggestion) => {
if (!suggestion.itemIds.length) return
setApplyingSuggestionId(suggestion.id)
try {
const result = await deleteDownloads(suggestion.itemIds)
if (result?.deletedCount)
showDeletionToast(`Removed ${result.deletedCount} downloads`, result.freedBytes)
} finally {
setApplyingSuggestionId(null)
}
},
[deleteDownloads, showDeletionToast],
)
const handleDeleteSingle = useCallback(
async (download: JellifyDownload) => {
const result = await deleteDownloads([download.item.Id as string])
if (result?.deletedCount) if (result?.deletedCount)
showDeletionToast(`Removed ${download.title ?? 'track'}`, result.freedBytes) showDeletionToast(`Removed ${result.deletedCount} downloads`, result.freedBytes)
}, } finally {
[deleteDownloads, showDeletionToast], setApplyingSuggestionId(null)
) }
}
const handleDeleteAll = useCallback(() => { const handleDeleteSingle = async (download: JellifyDownload) => {
const result = await deleteDownloads([download.item.Id as string])
if (result?.deletedCount)
showDeletionToast(`Removed ${download.title ?? 'track'}`, result.freedBytes)
}
const handleDeleteAll = () =>
Alert.alert( Alert.alert(
'Delete all downloads?', 'Delete all downloads?',
'This will remove all downloaded music from your device. This action cannot be undone.', 'This will remove all downloaded music from your device. This action cannot be undone.',
@@ -124,9 +106,8 @@ export default function StorageManagementScreen(): React.JSX.Element {
}, },
], ],
) )
}, [downloads, deleteDownloads, showDeletionToast])
const handleDeleteSelection = useCallback(() => { const handleDeleteSelection = () =>
Alert.alert( Alert.alert(
'Delete selected items?', 'Delete selected items?',
`Are you sure you want to delete ${selectedIds.length} items?`, `Are you sure you want to delete ${selectedIds.length} items?`,
@@ -148,20 +129,16 @@ export default function StorageManagementScreen(): React.JSX.Element {
}, },
], ],
) )
}, [selectedIds, deleteDownloads, showDeletionToast, clearSelection])
const renderDownloadItem: ListRenderItem<JellifyDownload> = useCallback( const renderDownloadItem: ListRenderItem<JellifyDownload> = ({ item }) => (
({ item }) => ( <DownloadRow
<DownloadRow download={item}
download={item} isSelected={Boolean(selection[item.item.Id as string])}
isSelected={Boolean(selection[item.item.Id as string])} onToggle={() => toggleSelection(item.item.Id as string)}
onToggle={() => toggleSelection(item.item.Id as string)} onDelete={() => {
onDelete={() => { void handleDeleteSingle(item)
void handleDeleteSingle(item) }}
}} />
/>
),
[selection, toggleSelection, handleDeleteSingle],
) )
const topPadding = 16 const topPadding = 16
+5 -10
View File
@@ -1,22 +1,17 @@
import { Miniplayer } from '../../components/Player/mini-player' import { Miniplayer } from '../../components/Player/mini-player'
import InternetConnectionWatcher from '../../components/Network/internetConnectionWatcher' import InternetConnectionWatcher from '../../components/Network/internetConnectionWatcher'
import { BottomTabBar, BottomTabBarProps } from '@react-navigation/bottom-tabs' import { BottomTabBar, BottomTabBarProps } from '@react-navigation/bottom-tabs'
import useAppActive from '../../hooks/use-app-active' import useIsMiniPlayerActive from '../../hooks/use-mini-player'
import { useCurrentTrack } from '../../stores/player/queue' import { useIsFocused } from '@react-navigation/native'
export default function TabBar({ ...props }: BottomTabBarProps): React.JSX.Element { export default function TabBar({ ...props }: BottomTabBarProps): React.JSX.Element {
const nowPlaying = useCurrentTrack() const isFocused = useIsFocused()
const appIsActive = useAppActive() const isMiniPlayerActive = useIsMiniPlayerActive()
const showMiniPlayer = nowPlaying && appIsActive
return ( return (
<> <>
{showMiniPlayer && ( {isMiniPlayerActive && isFocused && <Miniplayer />}
/* Hide miniplayer if the queue is empty */
<Miniplayer />
)}
<InternetConnectionWatcher /> <InternetConnectionWatcher />
<BottomTabBar {...props} /> <BottomTabBar {...props} />
+137
View File
@@ -0,0 +1,137 @@
import { mmkvStateStorage } from '../../constants/storage'
import { JellifyDownloadProgress } from '@/src/types/JellifyDownload'
import JellifyTrack from '@/src/types/JellifyTrack'
import { mapDtoToTrack } from '../../utils/mappings'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { useApi } from '..'
import { useDownloadingDeviceProfile } from '../device-profile'
type DownloadsStore = {
downloadProgress: JellifyDownloadProgress
setDownloadProgress: (progress: JellifyDownloadProgress) => void
pendingDownloads: JellifyTrack[]
setPendingDownloads: (items: JellifyTrack[]) => void
currentDownloads: JellifyTrack[]
setCurrentDownloads: (items: JellifyTrack[]) => void
completedDownloads: JellifyTrack[]
setCompletedDownloads: (items: JellifyTrack[]) => void
failedDownloads: JellifyTrack[]
setFailedDownloads: (items: JellifyTrack[]) => void
}
export const useDownloadsStore = create<DownloadsStore>()(
devtools(
persist(
(set) => ({
downloadProgress: {},
setDownloadProgress: (progress) =>
set({
downloadProgress: progress,
}),
pendingDownloads: [],
setPendingDownloads: (items) =>
set({
pendingDownloads: items,
}),
currentDownloads: [],
setCurrentDownloads: (items) => set({ currentDownloads: items }),
completedDownloads: [],
setCompletedDownloads: (items) => set({ completedDownloads: items }),
failedDownloads: [],
setFailedDownloads: (items) => set({ failedDownloads: items }),
}),
{
name: 'downloads-store',
storage: createJSONStorage(() => mmkvStateStorage),
},
),
),
)
export const useDownloadProgress = () => useDownloadsStore((state) => state.downloadProgress)
export const usePendingDownloads = () => useDownloadsStore((state) => state.pendingDownloads)
export const useCurrentDownloads = () => useDownloadsStore((state) => state.currentDownloads)
export const useFailedDownloads = () => useDownloadsStore((state) => state.failedDownloads)
export const useIsDownloading = (items: BaseItemDto[]) => {
const pendingDownloads = usePendingDownloads()
const currentDownloads = useCurrentDownloads()
const downloadQueue = new Set([
...pendingDownloads.map((download) => download.item.Id),
...currentDownloads.map((download) => download.item.Id),
])
const itemIds = items.map((item) => item.Id)
return itemIds.filter((id) => downloadQueue.has(id)).length === items.length
}
export const useAddToCompletedDownloads = () => {
const currentDownloads = useCurrentDownloads()
const setCompletedDownloads = useDownloadsStore((state) => state.setCompletedDownloads)
return (item: JellifyTrack) => setCompletedDownloads([...currentDownloads, item])
}
export const useAddToCurrentDownloads = () => {
const currentDownloads = useCurrentDownloads()
const setCurrentDownloads = useDownloadsStore((state) => state.setCurrentDownloads)
return (item: JellifyTrack) => setCurrentDownloads([...currentDownloads, item])
}
export const useRemoveFromCurrentDownloads = () => {
const currentDownloads = useCurrentDownloads()
const setCurrentDownloads = useDownloadsStore((state) => state.setCurrentDownloads)
return (item: JellifyTrack) =>
setCurrentDownloads(
currentDownloads.filter((download) => download.item.Id !== item.item.Id),
)
}
export const useRemoveFromPendingDownloads = () => {
const pendingDownloads = usePendingDownloads()
const setPendingDownloads = useDownloadsStore((state) => state.setPendingDownloads)
return (item: JellifyTrack) =>
setPendingDownloads(
pendingDownloads.filter((download) => download.item.Id !== item.item.Id),
)
}
export const useAddToFailedDownloads = () => (item: JellifyTrack) => {
const failedDownloads = useFailedDownloads()
const setFailedDownloads = useDownloadsStore((state) => state.setFailedDownloads)
return setFailedDownloads([...failedDownloads, item])
}
const useAddToPendingDownloads = () => {
const api = useApi()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const pendingDownloads = usePendingDownloads()
const setPendingDownloads = useDownloadsStore((state) => state.setPendingDownloads)
return (items: BaseItemDto[]) => {
const downloads = api
? items.map((item) => mapDtoToTrack(api, item, downloadingDeviceProfile))
: []
return setPendingDownloads([...pendingDownloads, ...downloads])
}
}
export default useAddToPendingDownloads
@@ -1,6 +1,6 @@
import { create } from 'zustand' import { create } from 'zustand'
import { devtools } from 'zustand/middleware' import { devtools } from 'zustand/middleware'
import { networkStatusTypes } from '../components/Network/internetConnectionWatcher' import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher'
type NetworkStore = { type NetworkStore = {
networkStatus: networkStatusTypes | null networkStatus: networkStatusTypes | null
+10 -6
View File
@@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { create } from 'zustand' import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware' import { devtools, persist } from 'zustand/middleware'
import { useCastState, CastState } from 'react-native-google-cast' import { useCastState, CastState } from 'react-native-google-cast'
@@ -31,12 +32,15 @@ const usePlayerEngineStore = create<playerEngineStore>()(
export const useSelectPlayerEngine = () => { export const useSelectPlayerEngine = () => {
const setPlayerEngineData = usePlayerEngineStore((state) => state.setPlayerEngineData) const setPlayerEngineData = usePlayerEngineStore((state) => state.setPlayerEngineData)
const castState = useCastState() const castState = useCastState()
if (castState === CastState.CONNECTED) {
setPlayerEngineData(PlayerEngine.GOOGLE_CAST) useEffect(() => {
TrackPlayer.pause() // pause the track player to avoid conflicts if (castState === CastState.CONNECTED) {
return setPlayerEngineData(PlayerEngine.GOOGLE_CAST)
} void TrackPlayer.pause() // pause the track player to avoid conflicts
setPlayerEngineData(PlayerEngine.REACT_NATIVE_TRACK_PLAYER) return
}
setPlayerEngineData(PlayerEngine.REACT_NATIVE_TRACK_PLAYER)
}, [castState, setPlayerEngineData])
} }
export default usePlayerEngineStore export default usePlayerEngineStore
+95 -4
View File
@@ -1,11 +1,27 @@
import { Queue } from '@/src/player/types/queue-item' import { Queue } from '@/src/player/types/queue-item'
import JellifyTrack from '@/src/types/JellifyTrack' import JellifyTrack, {
import { mmkvStateStorage } from '../../constants/storage' PersistedJellifyTrack,
toPersistedTrack,
fromPersistedTrack,
} from '../../types/JellifyTrack'
import { createVersionedMmkvStorage } from '../../constants/versioned-storage'
import { create } from 'zustand' import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware' import {
createJSONStorage,
devtools,
persist,
PersistStorage,
StorageValue,
} from 'zustand/middleware'
import { RepeatMode } from 'react-native-track-player' import { RepeatMode } from 'react-native-track-player'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
/**
* Maximum number of tracks to persist in storage.
* This prevents storage overflow when users have very large queues.
*/
const MAX_PERSISTED_QUEUE_SIZE = 500
type PlayerQueueStore = { type PlayerQueueStore = {
shuffled: boolean shuffled: boolean
setShuffled: (shuffled: boolean) => void setShuffled: (shuffled: boolean) => void
@@ -29,6 +45,81 @@ type PlayerQueueStore = {
setCurrentIndex: (index: number | undefined) => void setCurrentIndex: (index: number | undefined) => void
} }
/**
* Persisted state shape - uses slimmed track types to reduce storage size
*/
type PersistedPlayerQueueState = {
shuffled: boolean
repeatMode: RepeatMode
queueRef: Queue
unShuffledQueue: PersistedJellifyTrack[]
queue: PersistedJellifyTrack[]
currentTrack: PersistedJellifyTrack | undefined
currentIndex: number | undefined
}
/**
* Custom storage that serializes/deserializes tracks to their slim form
* This prevents the "RangeError: String length exceeds limit" error
*/
const queueStorage: PersistStorage<PlayerQueueStore> = {
getItem: (name) => {
const storage = createVersionedMmkvStorage('player-queue-storage')
const str = storage.getItem(name) as string | null
if (!str) return null
try {
const parsed = JSON.parse(str) as StorageValue<PersistedPlayerQueueState>
const state = parsed.state
// Hydrate persisted tracks back to full JellifyTrack format
return {
...parsed,
state: {
...state,
queue: (state.queue ?? []).map(fromPersistedTrack),
unShuffledQueue: (state.unShuffledQueue ?? []).map(fromPersistedTrack),
currentTrack: state.currentTrack
? fromPersistedTrack(state.currentTrack)
: undefined,
} as unknown as PlayerQueueStore,
}
} catch (e) {
console.error('[Queue Storage] Failed to parse stored queue:', e)
return null
}
},
setItem: (name, value) => {
const storage = createVersionedMmkvStorage('player-queue-storage')
const state = value.state
// Slim down tracks before persisting to prevent storage overflow
const persistedState: PersistedPlayerQueueState = {
shuffled: state.shuffled,
repeatMode: state.repeatMode,
queueRef: state.queueRef,
// Limit queue size to prevent storage overflow
queue: (state.queue ?? []).slice(0, MAX_PERSISTED_QUEUE_SIZE).map(toPersistedTrack),
unShuffledQueue: (state.unShuffledQueue ?? [])
.slice(0, MAX_PERSISTED_QUEUE_SIZE)
.map(toPersistedTrack),
currentTrack: state.currentTrack ? toPersistedTrack(state.currentTrack) : undefined,
currentIndex: state.currentIndex,
}
const toStore: StorageValue<PersistedPlayerQueueState> = {
...value,
state: persistedState,
}
storage.setItem(name, JSON.stringify(toStore))
},
removeItem: (name) => {
const storage = createVersionedMmkvStorage('player-queue-storage')
storage.removeItem(name)
},
}
export const usePlayerQueueStore = create<PlayerQueueStore>()( export const usePlayerQueueStore = create<PlayerQueueStore>()(
devtools( devtools(
persist( persist(
@@ -71,7 +162,7 @@ export const usePlayerQueueStore = create<PlayerQueueStore>()(
}), }),
{ {
name: 'player-queue-storage', name: 'player-queue-storage',
storage: createJSONStorage(() => mmkvStateStorage), storage: queueStorage,
}, },
), ),
), ),
+43
View File
@@ -41,4 +41,47 @@ interface JellifyTrack extends Track {
QueuingType?: QueuingType | undefined QueuingType?: QueuingType | undefined
} }
/**
* A slimmed-down version of JellifyTrack for persistence.
* Excludes large fields like mediaSourceInfo and transient data
* to prevent storage overflow (RangeError: String length exceeds limit).
*
* When hydrating from storage, these fields will need to be rebuilt
* from the API or left undefined until playback is requested.
*/
export type PersistedJellifyTrack = Omit<JellifyTrack, 'mediaSourceInfo' | 'headers'> & {
/** Store only essential media source fields for persistence */
mediaSourceInfo?: Pick<MediaSourceInfo, 'Id' | 'Container' | 'Bitrate'> | undefined
}
/**
* Converts a full JellifyTrack to a PersistedJellifyTrack for storage
*/
export function toPersistedTrack(track: JellifyTrack): PersistedJellifyTrack {
const { mediaSourceInfo, headers, ...rest } = track as JellifyTrack & { headers?: unknown }
return {
...rest,
// Only persist essential media source fields
mediaSourceInfo: mediaSourceInfo
? {
Id: mediaSourceInfo.Id,
Container: mediaSourceInfo.Container,
Bitrate: mediaSourceInfo.Bitrate,
}
: undefined,
}
}
/**
* Converts a PersistedJellifyTrack back to a JellifyTrack
* Note: Some fields like full mediaSourceInfo and headers will be undefined
* and need to be rebuilt when playback is requested
*/
export function fromPersistedTrack(persisted: PersistedJellifyTrack): JellifyTrack {
// Cast is safe because PersistedJellifyTrack has all required fields
// except the omitted ones (mediaSourceInfo, headers) which are optional in JellifyTrack
return persisted as unknown as JellifyTrack
}
export default JellifyTrack export default JellifyTrack