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

2
.github/FUNDING.yml vendored
View File

@@ -3,7 +3,7 @@
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
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
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username

80
App.tsx
View File

@@ -1,5 +1,5 @@
import './gesture-handler'
import React, { useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import 'react-native-url-polyfill/auto'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import Jellify from './src/components/jellify'
@@ -24,7 +24,7 @@ import ErrorBoundary from './src/components/ErrorBoundary'
import OTAUpdateScreen from './src/components/OtaUpdates'
import { usePerformanceMonitor } from './src/hooks/use-performance-monitor'
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'
LogBox.ignoreAllLogs()
@@ -34,47 +34,47 @@ export default function App(): React.JSX.Element {
const performanceMetrics = usePerformanceMonitor('App', 3)
const [playerIsReady, setPlayerIsReady] = useState<boolean>(false)
const playerInitializedRef = useRef<boolean>(false)
/**
* Enhanced Android buffer settings for gapless playback
*
* @see
*/
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
}
: {}
useEffect(() => {
// Guard against double initialization (React StrictMode, hot reload)
if (playerInitializedRef.current) return
playerInitializedRef.current = true
TrackPlayer.setupPlayer({
autoHandleInterruptions: true,
iosCategory: IOSCategory.Playback,
iosCategoryOptions: [IOSCategoryOptions.AllowAirPlay, IOSCategoryOptions.AllowBluetooth],
androidAudioContentType: AndroidAudioContentType.Music,
minBuffer: 30, // 30 seconds minimum buffer
...buffers,
})
.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,
},
}),
)
.finally(() => {
setPlayerIsReady(true)
requestStoragePermission()
TrackPlayer.setupPlayer({
autoHandleInterruptions: true,
iosCategory: IOSCategory.Playback,
iosCategoryOptions: [
IOSCategoryOptions.AllowAirPlay,
IOSCategoryOptions.AllowBluetooth,
],
androidAudioContentType: AndroidAudioContentType.Music,
minBuffer: 30, // 30 seconds minimum buffer
...BUFFERS,
})
.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)

View File

@@ -2,8 +2,8 @@
<img alt='Jellify logo' src='assets/transparent-banner.png' width="600" height="300" />
</p>
[![Latest Version](https://img.shields.io/github/package-json/version/anultravioletaurora/jellify?label=Latest%20Version&color=indigo)](https://github.com/anultravioletaurora/Jellify/releases)
[![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)
[![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)
[![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
[![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.
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
[![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
Join the [TestFlight](https://testflight.apple.com/join/etVSc7ZQ) and install the latest version from there

View File

@@ -91,8 +91,8 @@ android {
applicationId "com.cosmonautical.jellify"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 155
versionName "0.21.3"
versionCode 157
versionName "0.22.1"
}
signingConfigs {
debug {

View File

@@ -1,4 +1,8 @@
module.exports = {
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',
],
}

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "jellify",
@@ -11,19 +10,19 @@
"@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-vector-icons/material-design-icons": "12.4.0",
"@react-navigation/bottom-tabs": "7.8.6",
"@react-navigation/material-top-tabs": "7.4.4",
"@react-navigation/native": "7.1.21",
"@react-navigation/native-stack": "7.8.0",
"@react-navigation/bottom-tabs": "7.8.10",
"@react-navigation/material-top-tabs": "7.4.7",
"@react-navigation/native": "7.1.23",
"@react-navigation/native-stack": "7.8.4",
"@sentry/react-native": "7.6.0",
"@shopify/flash-list": "2.2.0",
"@tamagui/config": "1.137.1",
"@tanstack/query-async-storage-persister": "5.89.0",
"@tanstack/react-query": "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",
"axios": "1.12.2",
"axios": "1.13.2",
"bundle": "^2.1.0",
"dlx": "^0.2.1",
"invert-color": "^2.0.0",
@@ -45,13 +44,13 @@
"react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "3.3.3",
"react-native-nitro-fetch": "^0.1.6",
"react-native-nitro-modules": "^0.31.9",
"react-native-nitro-ota": "^0.4.0",
"react-native-nitro-modules": "0.31.10",
"react-native-nitro-ota": "0.7.2",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2",
"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-toast-message": "^2.3.3",
"react-native-track-player": "5.0.0-alpha0",
@@ -83,6 +82,7 @@
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "19.1.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8",
"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-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=="],
@@ -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=="],
"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=="],
@@ -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-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-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-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=="],
@@ -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-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=="],

View File

@@ -543,7 +543,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_BITCODE = NO;
@@ -554,7 +554,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.21.3;
MARKETING_VERSION = 0.22.1;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
@@ -585,7 +585,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -595,7 +595,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.21.3;
MARKETING_VERSION = 0.22.1;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
@@ -821,7 +821,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -832,7 +832,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.21.3;
MARKETING_VERSION = 0.22.1;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",

View File

@@ -42,7 +42,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- NitroModules (0.31.9):
- NitroModules (0.31.10):
- boost
- DoubleConversion
- fast_float
@@ -71,7 +71,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- NitroOta (0.4.0):
- NitroOta (0.7.2):
- boost
- DoubleConversion
- fast_float
@@ -102,7 +102,7 @@ PODS:
- SocketRocket
- SSZipArchive
- Yoga
- NitroOtaBundleManager (0.4.0):
- NitroOtaBundleManager (0.7.2):
- boost
- DoubleConversion
- fast_float
@@ -3449,9 +3449,9 @@ SPEC CHECKSUMS:
google-cast-sdk: 1fb6724e94cc5ff23b359176e0cf6360586bb97a
hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5
NitroFetch: 660adfb47f84b28db664f97b50e5dc28506ab6c1
NitroModules: 224bf833d249b0c7ce32831368f2887008579b13
NitroOta: b4f7cdbe660e8f07f80f5eb9f169d70f698ea284
NitroOtaBundleManager: 5e7c0f8c3f76cc06f9fe07a63879fe35496c27c7
NitroModules: 5bc319d441f4983894ea66b1d392c519536e6d23
NitroOta: 7755c4728f7348584cebb2d428480b1ed0cd2679
NitroOtaBundleManager: 482abb17f0ca629ad551da43f13e76e59dba9568
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
Protobuf: 164aea2ae380c3951abdc3e195220c01d17400e0
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669

View File

@@ -1,6 +1,6 @@
{
"name": "jellify",
"version": "0.21.3",
"version": "0.22.1",
"private": true,
"scripts": {
"init-android": "bun i",
@@ -43,19 +43,19 @@
"@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-vector-icons/material-design-icons": "12.4.0",
"@react-navigation/bottom-tabs": "7.8.6",
"@react-navigation/material-top-tabs": "7.4.4",
"@react-navigation/native": "7.1.21",
"@react-navigation/native-stack": "7.8.0",
"@react-navigation/bottom-tabs": "7.8.10",
"@react-navigation/material-top-tabs": "7.4.7",
"@react-navigation/native": "7.1.23",
"@react-navigation/native-stack": "7.8.4",
"@sentry/react-native": "7.6.0",
"@shopify/flash-list": "2.2.0",
"@tamagui/config": "1.137.1",
"@tanstack/query-async-storage-persister": "5.89.0",
"@tanstack/react-query": "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",
"axios": "1.12.2",
"axios": "1.13.2",
"bundle": "^2.1.0",
"dlx": "^0.2.1",
"invert-color": "^2.0.0",
@@ -77,13 +77,13 @@
"react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "3.3.3",
"react-native-nitro-fetch": "^0.1.6",
"react-native-nitro-modules": "^0.31.9",
"react-native-nitro-ota": "^0.4.0",
"react-native-nitro-modules": "0.31.10",
"react-native-nitro-ota": "0.7.2",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2",
"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-toast-message": "^2.3.3",
"react-native-track-player": "5.0.0-alpha0",
@@ -115,6 +115,7 @@
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "19.1.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",

View File

@@ -18,6 +18,27 @@ type DownloadedFileInfo = {
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 = {
deletedCount: number
freedBytes: number
@@ -29,23 +50,30 @@ export async function downloadJellyfinFile(
name: string,
songName: string,
setDownloadProgress: JellifyDownloadProgressState,
preferredExtension?: string | null,
): Promise<DownloadedFileInfo> {
try {
// Fetch the file
const headRes = await axios.head(url)
const contentType = headRes.headers['content-type']
const urlExtension = normalizeExtension(getExtensionFromUrl(url))
const hintedExtension = normalizeExtension(preferredExtension)
// Step 2: Get extension from content-type
let extension = 'mp3' // default extension
if (contentType && contentType.includes('/')) {
const parts = contentType.split('/')
const container = parts[1].split(';')[0] // handles "audio/m4a; charset=utf-8"
if (container !== 'mpeg') {
extension = container // don't use mpeg as an extension, use the default extension
let extension = urlExtension ?? hintedExtension ?? null
if (!extension) {
try {
const headRes = await axios.head(url)
const headExtension = extensionFromContentType(headRes.headers['content-type'])
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 downloadDest = `${RNFS.DocumentDirectoryPath}/${fileName}`
@@ -138,6 +166,7 @@ export const saveAudio = async (
track.item.Id as string,
track.title as string,
setDownloadProgress,
track.mediaSourceInfo?.Container,
)
let downloadedArtworkFile: DownloadedFileInfo | undefined
if (track.artwork) {
@@ -146,6 +175,7 @@ export const saveAudio = async (
track.item.Id as string,
track.title as string,
setDownloadProgress,
undefined,
)
}
track.url = downloadedTrackFile.uri

View File

@@ -2,21 +2,44 @@ import { Api } from '@jellyfin/sdk'
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
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(
api: Api | undefined,
item: BaseItemDto,
type: ImageType,
options?: ImageUrlOptions,
): string | undefined {
const { AlbumId, AlbumPrimaryImageTag, ImageTags, Id } = item
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
? getImageApi(api).getItemImageUrlById(AlbumId, type, {
...imageParams,
tag: AlbumPrimaryImageTag ?? undefined,
})
: Id
? getImageApi(api).getItemImageUrlById(Id, type, {
...imageParams,
tag: ImageTags ? ImageTags[type] : undefined,
})
: undefined

View File

@@ -31,6 +31,7 @@ const useStreamedMediaInfo = (itemId: string | null | undefined) => {
return useQuery({
queryKey: MediaInfoQueryKey({ 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
gcTime: ONE_DAY,
})
@@ -60,6 +61,7 @@ export const useDownloadedMediaInfo = (itemId: string | null | undefined) => {
return useQuery({
queryKey: MediaInfoQueryKey({ 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
gcTime: ONE_DAY,
})

View File

@@ -9,23 +9,24 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import InstantMixButton from '../Global/components/instant-mix-button'
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 Icon from '../Global/components/icon'
import { mapDtoToTrack } from '../../utils/mappings'
import { useNetworkContext } from '../../providers/Network'
import { useNetworkStatus } from '../../stores/network'
import { useLoadNewQueue } from '../../providers/Player/hooks/mutations'
import { QueuingType } from '../../enums/queuing-type'
import { useAlbumContext } from '../../providers/Album'
import { useNavigation } from '@react-navigation/native'
import HomeStackParamList from '../../screens/Home/types'
import LibraryStackParamList from '../../screens/Library/types'
import DiscoverStackParamList from '../../screens/Discover/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 { 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
@@ -35,37 +36,30 @@ import { useApi } from '../../stores'
*
* @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 { album, discs, isPending } = useAlbumContext()
const api = useApi()
const { addToDownloadQueue, pendingDownloads } = useNetworkContext()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const downloadAlbum = (item: BaseItemDto[]) => {
if (!api) return
const jellifyTracks = item.map((item) => mapDtoToTrack(api, item, downloadingDeviceProfile))
addToDownloadQueue(jellifyTracks)
}
const { data: discs, isPending } = useQuery({
queryKey: [QueryKeys.ItemTracks, album.Id],
queryFn: () => fetchAlbumDiscs(api, album),
})
const sections = useMemo(
() =>
(Array.isArray(discs) ? discs : []).map(({ title, data }) => ({
title,
data: Array.isArray(data) ? data : [],
})),
[discs],
)
const addToDownloadQueue = useAddToPendingDownloads()
const pendingDownloads = usePendingDownloads()
const downloadAlbum = (item: BaseItemDto[]) => addToDownloadQueue(item)
const sections = (Array.isArray(discs) ? discs : []).map(({ title, data }) => ({
title,
data: Array.isArray(data) ? data : [],
}))
const hasMultipleSections = sections.length > 1
const albumTrackList = useMemo(() => discs?.flatMap((disc) => disc.data), [discs])
const handleScrollBeginDrag = useCallback(() => {
closeAllSwipeableRows()
}, [])
const albumTrackList = discs?.flatMap((disc) => disc.data)
return (
<SectionList
@@ -100,7 +94,7 @@ export function Album(): React.JSX.Element {
</XStack>
) : null
}}
ListHeaderComponent={AlbumTrackListHeader}
ListHeaderComponent={() => <AlbumTrackListHeader album={album} />}
renderItem={({ item: track, index }) => (
<Track
navigation={navigation}
@@ -110,13 +104,13 @@ export function Album(): React.JSX.Element {
queue={album}
/>
)}
ListFooterComponent={AlbumTrackListFooter}
ListFooterComponent={() => <AlbumTrackListFooter album={album} />}
ListEmptyComponent={() => (
<YStack flex={1} alignContent='center'>
{isPending ? <Spinner color={'$primary'} /> : <Text>No tracks found</Text>}
</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
* @returns A React component
*/
function AlbumTrackListHeader(): React.JSX.Element {
function AlbumTrackListHeader({ album }: { album: BaseItemDto }): React.JSX.Element {
const api = useApi()
const { width } = useSafeAreaFrame()
@@ -138,7 +132,10 @@ function AlbumTrackListHeader(): React.JSX.Element {
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>>()
@@ -243,8 +240,7 @@ function AlbumTrackListHeader(): React.JSX.Element {
)
}
function AlbumTrackListFooter(): React.JSX.Element {
const { album } = useAlbumContext()
function AlbumTrackListFooter({ album }: { album: BaseItemDto }): React.JSX.Element {
const navigation =
useNavigation<
NativeStackNavigationProp<

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 React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react'
import React, { RefObject, useEffect, useRef } from 'react'
import { Text } from '../Global/helpers/text'
import { FlashList, FlashListRef } from '@shopify/flash-list'
import { UseInfiniteQueryResult } from '@tanstack/react-query'
@@ -13,6 +13,7 @@ import AZScroller, { useAlphabetSelector } from '../Global/components/alphabetic
import { isString } from 'lodash'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { useLibrarySortAndFilterContext } from '../../providers/Library'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
interface AlbumsProps {
albumsInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
@@ -38,55 +39,52 @@ export default function Albums({
const pendingLetterRef = useRef<string | null>(null)
// Memoize expensive stickyHeaderIndices calculation to prevent unnecessary re-computations
const stickyHeaderIndices = React.useMemo(() => {
if (!showAlphabeticalSelector || !albumsInfiniteQuery.data) return []
return albumsInfiniteQuery.data
.map((album, index) => (typeof album === 'string' ? index : 0))
.filter((value, index, indices) => indices.indexOf(value) === index)
}, [showAlphabeticalSelector, albumsInfiniteQuery.data])
const stickyHeaderIndices =
!showAlphabeticalSelector || !albumsInfiniteQuery.data
? []
: albumsInfiniteQuery.data
.map((album, index) => (typeof album === 'string' ? index : 0))
.filter((value, index, indices) => indices.indexOf(value) === index)
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
const refreshControl = useMemo(
() => (
<RefreshControl
refreshing={albumsInfiniteQuery.isFetching && !isAlphabetSelectorPending}
onRefresh={albumsInfiniteQuery.refetch}
tintColor={theme.primary.val}
/>
),
[albumsInfiniteQuery.isFetching, isAlphabetSelectorPending, albumsInfiniteQuery.refetch],
const refreshControl = (
<RefreshControl
refreshing={albumsInfiniteQuery.isFetching && !isAlphabetSelectorPending}
onRefresh={albumsInfiniteQuery.refetch}
tintColor={theme.primary.val}
/>
)
const ItemSeparatorComponent = useCallback(
({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) =>
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : (
<Separator />
),
[],
)
const ItemSeparatorComponent = ({
leadingItem,
trailingItem,
}: {
leadingItem: unknown
trailingItem: unknown
}) =>
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : <Separator />
const keyExtractor = useCallback(
(item: BaseItemDto | string | number) =>
typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!,
[],
)
const keyExtractor = (item: BaseItemDto | string | number) =>
typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!
const renderItem = useCallback(
({ index, item: album }: { index: number; item: BaseItemDto | string | number }) =>
typeof album === 'string' ? (
<FlashListStickyHeader text={album.toUpperCase()} />
) : typeof album === 'number' ? null : typeof album === 'object' ? (
<ItemRow item={album} navigation={navigation} />
) : null,
[navigation],
)
const renderItem = ({
index,
item: album,
}: {
index: number
item: BaseItemDto | string | number
}) =>
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()
}, [albumsInfiniteQuery.hasNextPage, albumsInfiniteQuery.fetchNextPage])
}
// Effect for handling the pending alphabet selector letter
useEffect(() => {
@@ -144,6 +142,7 @@ export default function Albums({
ItemSeparatorComponent={ItemSeparatorComponent}
refreshControl={refreshControl}
stickyHeaderIndices={stickyHeaderIndices}
onScrollBeginDrag={closeAllSwipeableRows}
removeClippedSubviews
/>

View File

@@ -77,11 +77,12 @@ export default function ArtistTabBar({
>
<Icon
name={
sortBy === ItemSortBy.DateCreated ? 'calendar' : 'sort-alphabetical'
sortBy === ItemSortBy.DateCreated
? 'calendar'
: 'sort-alphabetical-ascending'
}
color={'$borderColor'}
/>
/>{' '}
<Text color={'$borderColor'}>
{sortBy === ItemSortBy.DateCreated ? 'Date Added' : 'A-Z'}
</Text>

View File

@@ -76,6 +76,7 @@ export default function ArtistHeader(): React.JSX.Element {
height={'$20'}
type={ImageType.Backdrop}
cornered
imageOptions={{ maxWidth: width * 2, maxHeight: 640 }}
/>
<YStack alignItems='center' paddingHorizontal={'$3'}>

View File

@@ -1,5 +1,5 @@
import React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react'
import { getTokenValue, Separator, useTheme, XStack, YStack } from 'tamagui'
import React, { RefObject, useEffect, useRef } from 'react'
import { Separator, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { RefreshControl } from 'react-native'
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 LibraryStackParamList from '../../screens/Library/types'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
export interface ArtistsProps {
artistsInfiniteQuery: UseInfiniteQueryResult<
@@ -49,41 +50,41 @@ export default function Artists({
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
const stickyHeaderIndices = useMemo(() => {
if (!showAlphabeticalSelector || !artists) return []
const stickyHeaderIndices =
!showAlphabeticalSelector || !artists
? []
: artists
.map((artist, index, artists) => (typeof artist === 'string' ? index : 0))
.filter((value, index, indices) => indices.indexOf(value) === index)
return artists
.map((artist, index, artists) => (typeof artist === 'string' ? index : 0))
.filter((value, index, indices) => indices.indexOf(value) === index)
}, [showAlphabeticalSelector, artists])
const ItemSeparatorComponent = ({
leadingItem,
trailingItem,
}: {
leadingItem: unknown
trailingItem: unknown
}) =>
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : <Separator />
const ItemSeparatorComponent = useCallback(
({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) =>
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : (
<Separator />
),
[],
)
const KeyExtractor = (item: BaseItemDto | string | number, index: number) =>
typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!
const KeyExtractor = useCallback(
(item: BaseItemDto | string | number, index: number) =>
typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!,
[],
)
const renderItem = useCallback(
({ index, item: artist }: { index: number; item: BaseItemDto | number | string }) =>
typeof artist === 'string' ? (
// 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
index - 1 === artists.length || typeof artists[index + 1] !== 'object' ? null : (
<FlashListStickyHeader text={artist.toUpperCase()} />
)
) : typeof artist === 'number' ? null : typeof artist === 'object' ? (
<ItemRow circular item={artist} navigation={navigation} />
) : null,
[navigation],
)
const renderItem = ({
index,
item: artist,
}: {
index: number
item: BaseItemDto | number | string
}) =>
typeof artist === 'string' ? (
// 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
index - 1 === artists.length || typeof artists[index + 1] !== 'object' ? null : (
<FlashListStickyHeader text={artist.toUpperCase()} />
)
) : typeof artist === 'number' ? null : typeof artist === 'object' ? (
<ItemRow circular item={artist} navigation={navigation} />
) : null
// Effect for handling the pending alphabet selector letter
useEffect(() => {
@@ -155,6 +156,7 @@ export default function Artists({
if (artistsInfiniteQuery.hasNextPage && !artistsInfiniteQuery.isFetching)
artistsInfiniteQuery.fetchNextPage()
}}
onScrollBeginDrag={closeAllSwipeableRows}
removeClippedSubviews
/>

View File

@@ -3,7 +3,7 @@ import {
BaseItemKind,
MediaSourceInfo,
} 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 { Text } from '../Global/helpers/text'
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 { AddToQueueMutation } from '../../providers/Player/interfaces'
import { QueuingType } from '../../enums/queuing-type'
import { useCallback, useEffect, useMemo } from 'react'
import { useEffect } from 'react'
import navigationRef from '../../../navigation'
import { goToAlbumFromContextSheet, goToArtistFromContextSheet } from './utils/navigation'
import { getItemName } from '../../utils/text'
@@ -25,14 +25,17 @@ import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../Player/component.config'
import { useAddToQueue } from '../../providers/Player/hooks/mutations'
import { useNetworkStatus } from '../../stores/network'
import { useNetworkContext } from '../../providers/Network'
import { mapDtoToTrack } from '../../utils/mappings'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile'
import useStreamingDeviceProfile from '../../stores/device-profile'
import { useIsDownloaded } from '../../api/queries/download'
import { useDeleteDownloads } from '../../api/mutations/download'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { Platform } from 'react-native'
import { useApi } from '../../stores'
import useAddToPendingDownloads, {
useIsDownloading,
usePendingDownloads,
} from '../../stores/network/downloads'
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
type StackNavigation = Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
@@ -55,6 +58,8 @@ export default function ItemContext({
const trigger = useHapticFeedback()
const [networkStatus] = useNetworkStatus()
const isArtist = item.Type === BaseItemKind.MusicArtist
const isAlbum = item.Type === BaseItemKind.MusicAlbum
const isTrack = item.Type === BaseItemKind.Audio
@@ -98,12 +103,12 @@ export default function ItemContext({
: []
: []
const itemTracks = useMemo(() => {
const itemTracks = (() => {
if (isTrack) return [item]
else if (isAlbum && discs) return discs.flatMap((data) => data.data)
else if (isPlaylist && tracks) return tracks
else return []
}, [isTrack, isAlbum, discs, isPlaylist, tracks])
})()
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 {
const api = useApi()
const { addToDownloadQueue, pendingDownloads } = useNetworkContext()
const addToDownloadQueue = useAddToPendingDownloads()
const useRemoveDownload = useDeleteDownloads()
const deviceProfile = useDownloadingDeviceProfile()
const isDownloaded = useIsDownloaded(items.map(({ Id }) => Id))
const downloadItems = useCallback(() => {
if (!api) return
const removeDownloads = () => useRemoveDownload(items.map(({ Id }) => Id))
const tracks = items.map((item) => mapDtoToTrack(api, item, deviceProfile))
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],
)
const isPending = useIsDownloading(items)
return isPending ? (
<ListItem
@@ -293,7 +278,7 @@ function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element
backgroundColor={'transparent'}
gap={'$2.5'}
justifyContent='flex-start'
onPress={downloadItems}
onPress={() => addToDownloadQueue(items)}
pressStyle={{ opacity: 0.5 }}
>
<Icon
@@ -326,10 +311,10 @@ interface MenuRowProps {
}
function ViewAlbumMenuRow({ album: album, stackNavigation }: MenuRowProps): React.JSX.Element {
const goToAlbum = useCallback(() => {
const goToAlbum = () => {
if (stackNavigation && album) stackNavigation.navigate('Album', { album })
else goToAlbumFromContextSheet(album)
}, [album, stackNavigation, navigationRef])
}
return (
<ListItem
@@ -380,13 +365,10 @@ function ViewArtistMenuRow({
enabled: !!artistId,
})
const goToArtist = useCallback(
(artist: BaseItemDto) => {
if (stackNavigation) stackNavigation.navigate('Artist', { artist })
else goToArtistFromContextSheet(artist)
},
[stackNavigation, navigationRef],
)
const goToArtist = (artist: BaseItemDto) => {
if (stackNavigation) stackNavigation.navigate('Artist', { artist })
else goToArtistFromContextSheet(artist)
}
return artist ? (
<ListItem

View File

@@ -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 { getToken, Spinner, useTheme, View, YStack } from 'tamagui'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
@@ -61,78 +61,70 @@ export default function AZScroller({
})
}
const panGesture = useMemo(
() =>
Gesture.Pan()
.runOnJS(true)
.onBegin((e) => {
const relativeY = e.absoluteY - alphabetSelectorTopY.current
setOverlayPositionY(relativeY - letterHeight.current * 1.5)
const index = Math.floor(relativeY / letterHeight.current)
if (alphabet[index]) {
const letter = alphabet[index]
selectedLetter.value = letter
setOverlayLetter(letter)
scheduleOnRN(showOverlay)
}
})
.onUpdate((e) => {
const relativeY = e.absoluteY - alphabetSelectorTopY.current
setOverlayPositionY(relativeY - letterHeight.current * 1.5)
const index = Math.floor(relativeY / letterHeight.current)
if (alphabet[index]) {
const letter = alphabet[index]
selectedLetter.value = letter
setOverlayLetter(letter)
scheduleOnRN(showOverlay)
}
})
.onEnd(() => {
if (selectedLetter.value) {
scheduleOnRN(async () => {
setOperationPending(true)
onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
scheduleOnRN(hideOverlay)
setOperationPending(false)
})
})
} else {
const panGesture = Gesture.Pan()
.runOnJS(true)
.onBegin((e) => {
const relativeY = e.absoluteY - alphabetSelectorTopY.current
setOverlayPositionY(relativeY - letterHeight.current * 1.5)
const index = Math.floor(relativeY / letterHeight.current)
if (alphabet[index]) {
const letter = alphabet[index]
selectedLetter.value = letter
setOverlayLetter(letter)
scheduleOnRN(showOverlay)
}
})
.onUpdate((e) => {
const relativeY = e.absoluteY - alphabetSelectorTopY.current
setOverlayPositionY(relativeY - letterHeight.current * 1.5)
const index = Math.floor(relativeY / letterHeight.current)
if (alphabet[index]) {
const letter = alphabet[index]
selectedLetter.value = letter
setOverlayLetter(letter)
scheduleOnRN(showOverlay)
}
})
.onEnd(() => {
if (selectedLetter.value) {
scheduleOnRN(async () => {
setOperationPending(true)
onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
scheduleOnRN(hideOverlay)
}
}),
[onLetterSelect],
)
setOperationPending(false)
})
})
} else {
scheduleOnRN(hideOverlay)
}
})
const tapGesture = useMemo(
() =>
Gesture.Tap()
.runOnJS(true)
.onBegin((e) => {
const relativeY = e.absoluteY - alphabetSelectorTopY.current
setOverlayPositionY(relativeY - letterHeight.current * 1.5)
const index = Math.floor(relativeY / letterHeight.current)
if (alphabet[index]) {
const letter = alphabet[index]
selectedLetter.value = letter
setOverlayLetter(letter)
scheduleOnRN(showOverlay)
}
})
.onEnd(() => {
if (selectedLetter.value) {
scheduleOnRN(async () => {
setOperationPending(true)
onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
scheduleOnRN(hideOverlay)
setOperationPending(false)
})
})
} else {
const tapGesture = Gesture.Tap()
.runOnJS(true)
.onBegin((e) => {
const relativeY = e.absoluteY - alphabetSelectorTopY.current
setOverlayPositionY(relativeY - letterHeight.current * 1.5)
const index = Math.floor(relativeY / letterHeight.current)
if (alphabet[index]) {
const letter = alphabet[index]
selectedLetter.value = letter
setOverlayLetter(letter)
scheduleOnRN(showOverlay)
}
})
.onEnd(() => {
if (selectedLetter.value) {
scheduleOnRN(async () => {
setOperationPending(true)
onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
scheduleOnRN(hideOverlay)
}
}),
[onLetterSelect],
)
setOperationPending(false)
})
})
} else {
scheduleOnRN(hideOverlay)
}
})
const gesture = Gesture.Simultaneous(panGesture, tapGesture)

View File

@@ -2,7 +2,9 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item
import { FlashList, FlashListProps } from '@shopify/flash-list'
import React from 'react'
interface HorizontalCardListProps extends FlashListProps<BaseItemDto> {}
type HorizontalCardListProps = Omit<FlashListProps<BaseItemDto>, 'estimatedItemSize'> & {
estimatedItemSize?: number
}
/**
* Displays a Horizontal FlatList of 20 ItemCards
@@ -13,14 +15,17 @@ interface HorizontalCardListProps extends FlashListProps<BaseItemDto> {}
export default function HorizontalCardList({
data,
renderItem,
estimatedItemSize = 150,
...props
}: HorizontalCardListProps): React.JSX.Element {
return (
<FlashList
<FlashList<BaseItemDto>
horizontal
data={data}
renderItem={renderItem}
removeClippedSubviews
// @ts-expect-error - estimatedItemSize is required by FlashList but types are incorrect
estimatedItemSize={estimatedItemSize}
style={{
overflow: 'hidden',
}}

View File

@@ -1,5 +1,6 @@
import React from 'react'
import {
AnimationKeys,
ColorTokens,
getToken,
getTokens,
@@ -11,6 +12,7 @@ import {
YStack,
} from 'tamagui'
import MaterialDesignIcon from '@react-native-vector-icons/material-design-icons'
import { on } from 'events'
const smallSize = 28
@@ -42,8 +44,14 @@ export default function Icon({
const theme = useTheme()
const size = large ? largeSize : small ? smallSize : regularSize
const animation = onPress || onPressIn ? 'quick' : undefined
const pressStyle = animation ? { opacity: 0.6 } : undefined
return (
<YStack
animation={animation}
pressStyle={pressStyle}
alignContent='center'
justifyContent='center'
onPress={onPress}

View File

@@ -6,7 +6,7 @@ import { ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { Blurhash } from 'react-native-blurhash'
import { getBlurhashFromDto } from '../../../utils/blurhash'
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 { useApi } from '../../../stores'
@@ -18,6 +18,8 @@ interface ItemImageProps {
width?: Token | number | string | undefined
height?: Token | number | string | undefined
testID?: string | undefined
/** Image resolution options for requesting higher quality images */
imageOptions?: ImageUrlOptions
}
const ItemImage = memo(
@@ -29,10 +31,14 @@ const ItemImage = memo(
width,
height,
testID,
imageOptions,
}: ItemImageProps): React.JSX.Element {
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 ? (
<Image
@@ -56,7 +62,10 @@ const ItemImage = memo(
prevProps.circular === nextProps.circular &&
prevProps.width === nextProps.width &&
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 {

View File

@@ -39,7 +39,7 @@ function ItemCardComponent({
useEffect(() => {
if (item.Type === 'Audio') warmContext(item)
}, [item.Id, warmContext])
}, [item.Id, item.Type, warmContext])
const hoverStyle = useMemo(() => (onPress ? { scale: 0.925 } : undefined), [onPress])

View File

@@ -14,7 +14,7 @@ import { useNetworkStatus } from '../../../stores/network'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import useItemContext from '../../../hooks/use-item-context'
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 Animated, {
SharedValue,
@@ -30,12 +30,14 @@ import { useIsFavorite } from '../../../api/queries/user-data'
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
import { useApi } from '../../../stores'
import { useHideRunTimesSetting } from '../../../stores/settings/app'
import { Queue } from '../../../player/types/queue-item'
interface ItemRowProps {
item: BaseItemDto
circular?: boolean
onPress?: () => void
navigation?: Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
queueName?: Queue
}
/**
@@ -49,318 +51,264 @@ interface ItemRowProps {
* @param navigation - The navigation object.
* @returns
*/
const ItemRow = memo(
function ItemRow({ item, circular, navigation, onPress }: ItemRowProps): React.JSX.Element {
const artworkAreaWidth = useSharedValue(0)
function ItemRow({
item,
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 addToQueue = useAddToQueue()
const { mutate: addFavorite } = useAddFavorite()
const { mutate: removeFavorite } = useRemoveFavorite()
const [hideRunTimes] = useHideRunTimesSetting()
const loadNewQueue = useLoadNewQueue()
const addToQueue = useAddToQueue()
const { mutate: addFavorite } = useAddFavorite()
const { mutate: removeFavorite } = useRemoveFavorite()
const [hideRunTimes] = useHideRunTimesSetting()
const warmContext = useItemContext()
const { data: isFavorite } = useIsFavorite(item)
const warmContext = useItemContext()
const { data: isFavorite } = useIsFavorite(item)
const onPressIn = useCallback(() => warmContext(item), [warmContext, item.Id])
const onPressIn = () => warmContext(item)
const onLongPress = useCallback(
() =>
navigationRef.navigate('Context', {
item,
navigation,
}),
[navigationRef, navigation, item.Id],
)
const onLongPress = () =>
navigationRef.navigate('Context', {
item,
navigation,
})
const onPressCallback = useCallback(async () => {
if (onPress) await onPress()
else
switch (item.Type) {
case 'Audio': {
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({
const onPressCallback = async () => {
if (onPress) await onPress()
else
switch (item.Type) {
case 'Audio': {
loadNewQueue({
api,
deviceProfile,
networkStatus,
tracks: [item],
queuingType: QueuingType.DirectlyQueued,
}),
toggleFavorite: () =>
isFavorite ? removeFavorite({ item }) : addFavorite({ item }),
addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }),
}),
[
addToQueue,
deviceProfile,
track: item,
tracklist: [item],
index: 0,
queue: queueName ?? '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
}
}
}
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,
deviceProfile,
networkStatus,
item,
addFavorite,
removeFavorite,
isFavorite,
],
)
tracks: [item],
queuingType: QueuingType.DirectlyQueued,
}),
toggleFavorite: () => (isFavorite ? removeFavorite({ item }) : addFavorite({ item })),
addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }),
})
const swipeConfig = useMemo(
() =>
isAudio
? buildSwipeConfig({
left: leftSettings,
right: rightSettings,
handlers: swipeHandlers(),
})
: {},
[isAudio, leftSettings, rightSettings, swipeHandlers],
)
const swipeConfig = isAudio
? buildSwipeConfig({
left: leftSettings,
right: rightSettings,
handlers: swipeHandlers(),
})
: {}
const handleArtworkLayout = useCallback(
(event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout
artworkAreaWidth.value = width
},
[artworkAreaWidth],
)
const handleArtworkLayout = (event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout
artworkAreaWidth.value = width
}
const pressStyle = useMemo(() => ({ opacity: 0.5 }), [])
const pressStyle = {
opacity: 0.5,
}
return (
<SwipeableRow
disabled={!isAudio}
{...swipeConfig}
onLongPress={onLongPress}
return (
<SwipeableRow
disabled={!isAudio}
{...swipeConfig}
onLongPress={onLongPress}
onPress={onPressCallback}
>
<XStack
alignContent='center'
width={'100%'}
testID={item.Id ? `item-row-${item.Id}` : undefined}
onPressIn={onPressIn}
onPress={onPressCallback}
onLongPress={onLongPress}
animation={'quick'}
pressStyle={pressStyle}
paddingVertical={'$2'}
paddingRight={'$2'}
paddingLeft={'$1'}
backgroundColor={'$background'}
borderRadius={'$2'}
>
<XStack
alignContent='center'
width={'100%'}
testID={item.Id ? `item-row-${item.Id}` : undefined}
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>
<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>
</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}>
{item.Genres?.join(', ') ?? ''}
{item.ProductionYear?.toString() ?? 'Unknown Year'}
</Text>
)}
</YStack>
)
},
(prevProps, nextProps) => prevProps.item.Id === nextProps.item.Id,
)
<Text color={'$borderColor'}></Text>
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
</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
const HideableArtwork = memo(
function HideableArtwork({
item,
circular,
onLayout,
}: {
item: BaseItemDto
circular?: boolean
onLayout?: (event: LayoutChangeEvent) => void
}): React.JSX.Element {
const { tx } = useSwipeableRowContext()
// Hide artwork as soon as swiping starts (any non-zero tx)
const style = useAnimatedStyle(() => ({
opacity: tx.value === 0 ? withTiming(1) : 0,
}))
return (
<Animated.View style={style} onLayout={onLayout}>
<XStack marginHorizontal={'$3'} marginVertical={'auto'} alignItems='center'>
<ItemImage
item={item}
height={'$12'}
width={'$12'}
circular={item.Type === 'MusicArtist' || circular}
/>
</XStack>
</Animated.View>
)
},
(prevProps, nextProps) =>
prevProps.item.Id === nextProps.item.Id &&
prevProps.circular === nextProps.circular &&
!!prevProps.onLayout === !!nextProps.onLayout,
)
function HideableArtwork({
item,
circular,
onLayout,
}: {
item: BaseItemDto
circular?: boolean
onLayout?: (event: LayoutChangeEvent) => void
}): React.JSX.Element {
const { tx } = useSwipeableRowContext()
// Hide artwork as soon as swiping starts (any non-zero tx)
const style = useAnimatedStyle(() => ({
opacity: tx.value === 0 ? withTiming(1) : 0,
}))
return (
<Animated.View style={style} onLayout={onLayout}>
<XStack marginHorizontal={'$3'} marginVertical={'auto'} alignItems='center'>
<ItemImage
item={item}
height={'$12'}
width={'$12'}
circular={item.Type === 'MusicArtist' || circular}
/>
</XStack>
</Animated.View>
)
}
const SlidingTextArea = memo(
function SlidingTextArea({
leftGapWidth,
children,
}: {
leftGapWidth: SharedValue<number>
children: React.ReactNode
}): React.JSX.Element {
const { tx, rightWidth } = useSwipeableRowContext()
const tokenValue = getToken('$2', 'space')
const spacingValue =
typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`)
const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8
const style = useAnimatedStyle(() => {
const t = tx.value
let offset = 0
if (t > 0 && leftGapWidth.get() > 0) {
offset = -Math.min(t, leftGapWidth.get())
} else if (t < 0) {
const rightSpace = Math.max(0, rightWidth)
const compensate = Math.min(-t, rightSpace)
const progress = rightSpace > 0 ? compensate / rightSpace : 1
offset = compensate * 0.7 + quickActionBuffer * progress
}
return { transform: [{ translateX: offset }] }
})
const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8
return (
<Animated.View style={[{ flex: 5, paddingRight: paddingRightValue }, style]}>
{children}
</Animated.View>
)
},
(prevProps, nextProps) =>
prevProps.leftGapWidth === nextProps.leftGapWidth &&
prevProps.children?.valueOf() === nextProps.children?.valueOf(),
)
function SlidingTextArea({
leftGapWidth,
children,
}: {
leftGapWidth: SharedValue<number>
children: React.ReactNode
}): React.JSX.Element {
const { tx, rightWidth } = useSwipeableRowContext()
const tokenValue = getToken('$2', 'space')
const spacingValue = typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`)
const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8
const style = useAnimatedStyle(() => {
const t = tx.value
let offset = 0
if (t > 0 && leftGapWidth.get() > 0) {
offset = -Math.min(t, leftGapWidth.get())
} else if (t < 0) {
const rightSpace = Math.max(0, rightWidth)
const compensate = Math.min(-t, rightSpace)
const progress = rightSpace > 0 ? compensate / rightSpace : 1
offset = compensate * 0.7 + quickActionBuffer * progress
}
return { transform: [{ translateX: offset }] }
})
const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8
return (
<Animated.View style={[{ flex: 5, paddingRight: paddingRightValue }, style]}>
{children}
</Animated.View>
)
}
export default ItemRow

View File

@@ -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 { H2, Text } from '../helpers/text'
import Button from '../helpers/button'
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 { fetchUserViews } from '../../../api/queries/libraries'
import { useQuery } from '@tanstack/react-query'
@@ -57,7 +57,7 @@ export default function LibrarySelector({
const [selectedLibraryId, setSelectedLibraryId] = useState<string | undefined>(
library?.musicLibraryId,
)
const [playlistLibrary, setPlaylistLibrary] = useState<BaseItemDto | undefined>(undefined)
const playlistLibrary = useRef<BaseItemDto | undefined>(undefined)
const handleLibrarySelection = () => {
if (!selectedLibraryId || !libraries) return
@@ -65,23 +65,24 @@ export default function LibrarySelector({
const selectedLibrary = libraries.find((lib) => lib.Id === selectedLibraryId)
if (selectedLibrary) {
onLibrarySelected(selectedLibraryId, selectedLibrary, playlistLibrary)
onLibrarySelected(selectedLibraryId, selectedLibrary, playlistLibrary.current)
}
}
const hasMultipleLibraries = musicLibraries.length > 1
useEffect(() => {
if (libraries) {
setMusicLibraries(libraries.filter((library) => library.CollectionType === 'music'))
}
}, [libraries, isPending])
useEffect(() => {
if (!isPending && isSuccess && libraries) {
setMusicLibraries(
libraries.filter((library) => library.CollectionType === CollectionType.Music),
)
// Find the playlist library
const foundPlaylistLibrary = libraries.find((lib) => lib.CollectionType === 'playlists')
setPlaylistLibrary(foundPlaylistLibrary)
const foundPlaylistLibrary = libraries.find(
(lib) => lib.CollectionType === CollectionType.Playlists,
)
if (foundPlaylistLibrary) playlistLibrary.current = foundPlaylistLibrary
}
}, [isPending, isSuccess, libraries])

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 { Text } from '../helpers/text'
import { RunTimeTicks } from '../helpers/time-codes'
@@ -17,7 +17,6 @@ import ItemImage from './image'
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import { useAddToQueue, useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import useStreamedMediaInfo from '../../../api/queries/media'
import { useDownloadedTrack } from '../../../api/queries/download'
import SwipeableRow from './SwipeableRow'
import { useSwipeSettingsStore } from '../../../stores/settings/swipe'
@@ -29,6 +28,7 @@ import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favori
import { StackActions } from '@react-navigation/native'
import { useSwipeableRowContext } from './swipeable-row-context'
import { useHideRunTimesSetting } from '../../../stores/settings/app'
import useStreamedMediaInfo from '../../../api/queries/media'
export interface TrackProps {
track: BaseItemDto
@@ -45,98 +45,76 @@ export interface TrackProps {
editing?: boolean | undefined
}
const Track = memo(
function Track({
track,
navigation,
tracklist,
index,
queue,
showArtwork,
onPress,
onLongPress,
testID,
isNested,
invertedColors,
editing,
}: TrackProps): React.JSX.Element {
const theme = useTheme()
const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
export default function Track({
track,
navigation,
tracklist,
index,
queue,
showArtwork,
onPress,
onLongPress,
testID,
isNested,
invertedColors,
editing,
}: TrackProps): React.JSX.Element {
const theme = useTheme()
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 playQueue = usePlayQueue()
const loadNewQueue = useLoadNewQueue()
const addToQueue = useAddToQueue()
const [networkStatus] = useNetworkStatus()
const nowPlaying = useCurrentTrack()
const playQueue = usePlayQueue()
const loadNewQueue = useLoadNewQueue()
const addToQueue = useAddToQueue()
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: removeFavorite } = useRemoveFavorite()
const { data: isFavoriteTrack } = useIsFavorite(track)
const leftSettings = useSwipeSettingsStore((s) => s.left)
const rightSettings = useSwipeSettingsStore((s) => s.right)
const { mutate: addFavorite } = useAddFavorite()
const { mutate: removeFavorite } = useRemoveFavorite()
const { data: isFavoriteTrack } = useIsFavorite(track)
const leftSettings = useSwipeSettingsStore((s) => s.left)
const rightSettings = useSwipeSettingsStore((s) => s.right)
// Memoize expensive computations
const isPlaying = useMemo(
() => nowPlaying?.item.Id === track.Id,
[nowPlaying?.item.Id, track.Id],
)
// Memoize expensive computations
const isPlaying = nowPlaying?.item.Id === track.Id
const isOffline = useMemo(
() => networkStatus === networkStatusTypes.DISCONNECTED,
[networkStatus],
)
const isOffline = networkStatus === networkStatusTypes.DISCONNECTED
// Memoize tracklist for queue loading
const memoizedTracklist = useMemo(
() => tracklist ?? playQueue?.map((track) => track.item) ?? [],
[tracklist, playQueue],
)
// Memoize tracklist for queue loading
const memoizedTracklist = tracklist ?? playQueue?.map((track) => track.item) ?? []
// Memoize handlers to prevent recreation
const handlePress = useCallback(async () => {
if (onPress) {
await onPress()
} else {
loadNewQueue({
api,
deviceProfile,
networkStatus,
track,
index,
tracklist: memoizedTracklist,
queue,
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
}
}, [onPress, track, index, memoizedTracklist, queue, useLoadNewQueue])
// Memoize handlers to prevent recreation
const handlePress = async () => {
if (onPress) {
await onPress()
} else {
loadNewQueue({
api,
deviceProfile,
networkStatus,
track,
index,
tracklist: memoizedTracklist,
queue,
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
}
}
const handleLongPress = useCallback(() => {
if (onLongPress) {
onLongPress()
} 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(() => {
const handleLongPress = () => {
if (onLongPress) {
onLongPress()
} else {
navigationRef.navigate('Context', {
item: track,
navigation,
@@ -145,192 +123,165 @@ const Track = memo(
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}, [track, isNested, mediaInfo?.MediaSources, offlineAudio])
}
}
// Memoize text color to prevent recalculation
const textColor = useMemo(() => {
if (isPlaying) return theme.primary.val
if (isOffline) return offlineAudio ? theme.color : theme.neutral.val
return theme.color
}, [isPlaying, isOffline, offlineAudio, theme.primary.val, theme.color, theme.neutral.val])
const handleIconPress = () => {
navigationRef.navigate('Context', {
item: track,
navigation,
streamingMediaSourceInfo: mediaInfo?.MediaSources
? mediaInfo!.MediaSources![0]
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}
// Memoize artists text
const artistsText = useMemo(() => track.Artists?.join(', ') ?? '', [track.Artists])
// Memoize text color to prevent recalculation
const textColor = isPlaying
? theme.primary.val
: isOffline
? offlineAudio
? theme.color
: theme.neutral.val
: theme.color
// Memoize track name
const trackName = useMemo(() => track.Name ?? 'Untitled Track', [track.Name])
// Memoize artists text
const artistsText = track.Artists?.join(', ') ?? ''
// Memoize index number
const indexNumber = useMemo(() => track.IndexNumber?.toString() ?? '', [track.IndexNumber])
// Memoize track name
const trackName = track.Name ?? 'Untitled Track'
// Memoize show artists condition
const shouldShowArtists = useMemo(
() => showArtwork || (track.Artists && track.Artists.length > 1),
[showArtwork, track.Artists],
)
// Memoize index number
const indexNumber = track.IndexNumber?.toString() ?? ''
const swipeHandlers = useMemo(
() => ({
addToQueue: async () => {
console.info('Running add to queue swipe action')
await addToQueue({
api,
deviceProfile,
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,
// Memoize show artists condition
const shouldShowArtists = showArtwork || (track.Artists && track.Artists.length > 1)
const swipeHandlers = {
addToQueue: async () => {
console.info('Running add to queue swipe action')
await addToQueue({
api,
deviceProfile,
networkStatus,
track,
addFavorite,
removeFavorite,
isFavoriteTrack,
navigationRef,
],
)
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 }))
},
}
const swipeConfig = useMemo(
() =>
buildSwipeConfig({
left: leftSettings,
right: rightSettings,
handlers: swipeHandlers,
}),
[leftSettings, rightSettings, swipeHandlers],
)
const swipeConfig = buildSwipeConfig({
left: leftSettings,
right: rightSettings,
handlers: swipeHandlers,
})
const runtimeComponent = useMemo(
() =>
hideRunTimes ? (
<></>
) : (
<RunTimeTicks
key={`${track.Id}-runtime`}
props={{
style: {
textAlign: 'right',
minWidth: getToken('$10'),
alignSelf: 'center',
},
}}
>
{track.RunTimeTicks}
</RunTimeTicks>
),
[hideRunTimes, track.RunTimeTicks],
)
const runtimeComponent = hideRunTimes ? (
<></>
) : (
<RunTimeTicks
key={`${track.Id}-runtime`}
props={{
style: {
textAlign: 'right',
minWidth: getToken('$10'),
alignSelf: 'center',
},
}}
>
{track.RunTimeTicks}
</RunTimeTicks>
)
return (
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
<SwipeableRow
disabled={isNested === true}
{...swipeConfig}
onLongPress={handleLongPress}
onPress={handlePress}
return (
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
<SwipeableRow
disabled={isNested === true}
{...swipeConfig}
onLongPress={handleLongPress}
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
alignContent='center'
alignItems='center'
flex={1}
testID={testID ?? undefined}
paddingVertical={'$2'}
justifyContent='flex-start'
paddingRight={'$2'}
animation={'quick'}
pressStyle={{ opacity: 0.5 }}
backgroundColor={'$background'}
justifyContent='center'
marginHorizontal={showArtwork ? '$2' : '$1'}
onLayout={(e) => setArtworkAreaWidth(e.nativeEvent.layout.width)}
>
<XStack
alignContent='center'
justifyContent='center'
marginHorizontal={showArtwork ? '$2' : '$1'}
onLayout={(e) => setArtworkAreaWidth(e.nativeEvent.layout.width)}
>
{showArtwork ? (
<HideableArtwork>
<ItemImage item={track} width={'$12'} height={'$12'} />
</HideableArtwork>
) : (
<Text
key={`${track.Id}-number`}
color={textColor}
width={getToken('$12')}
textAlign='center'
fontVariant={['tabular-nums']}
>
{indexNumber}
</Text>
)}
</XStack>
{showArtwork ? (
<HideableArtwork>
<ItemImage item={track} width={'$12'} height={'$12'} />
</HideableArtwork>
) : (
<Text
key={`${track.Id}-number`}
color={textColor}
width={getToken('$12')}
textAlign='center'
fontVariant={['tabular-nums']}
>
{indexNumber}
</Text>
)}
</XStack>
<SlidingTextArea leftGapWidth={artworkAreaWidth} hasArtwork={!!showArtwork}>
<YStack alignItems='flex-start' justifyContent='center' flex={6}>
<SlidingTextArea leftGapWidth={artworkAreaWidth} hasArtwork={!!showArtwork}>
<YStack alignItems='flex-start' justifyContent='center' flex={1}>
<Text
key={`${track.Id}-name`}
bold
color={textColor}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{trackName}
</Text>
{shouldShowArtists && (
<Text
key={`${track.Id}-name`}
bold
color={textColor}
key={`${track.Id}-artists`}
lineBreakStrategyIOS='standard'
numberOfLines={1}
color={'$borderColor'}
>
{trackName}
{artistsText}
</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>
</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,
)
</XStack>
</SwipeableRow>
</Theme>
)
}
function HideableArtwork({ children }: { children: React.ReactNode }) {
const { tx } = useSwipeableRowContext()
@@ -362,7 +313,5 @@ function SlidingTextArea({
}
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

View File

@@ -2,7 +2,7 @@ import HorizontalCardList from '../../../components/Global/components/horizontal
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import React, { useCallback } from 'react'
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 { useDisplayContext } from '../../../providers/Display/display-provider'
import { useNavigation } from '@react-navigation/native'
@@ -11,6 +11,7 @@ import { RootStackParamList } from '../../../screens/types'
import { useFrequentlyPlayedArtists } from '../../../api/queries/frequents'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { pickFirstGenre } from '../../../utils/genre-formatting'
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
export default function FrequentArtists(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>()
@@ -42,14 +43,19 @@ export default function FrequentArtists(): React.JSX.Element {
[],
)
return (
<View>
return frequentArtistsInfiniteQuery.data ? (
<Animated.View
entering={FadeIn}
exiting={FadeOut}
layout={LinearTransition.springify()}
style={{
flex: 1,
}}
>
<XStack
alignItems='center'
onPress={() => {
navigation.navigate('MostPlayedArtists', {
artistsInfiniteQuery: frequentArtistsInfiniteQuery,
})
navigation.navigate('MostPlayedArtists')
}}
>
<H5 marginLeft={'$2'}>Most Played</H5>
@@ -57,9 +63,11 @@ export default function FrequentArtists(): React.JSX.Element {
</XStack>
<HorizontalCardList
data={frequentArtistsInfiniteQuery.data?.slice(0, horizontalItems) ?? []}
data={frequentArtistsInfiniteQuery.data.slice(0, horizontalItems) ?? []}
renderItem={renderItem}
/>
</View>
</Animated.View>
) : (
<></>
)
}

View File

@@ -1,5 +1,5 @@
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 { ItemCard } from '../../../components/Global/components/item-card'
import { QueuingType } from '../../../enums/queuing-type'
@@ -13,6 +13,7 @@ import { useNetworkStatus } from '../../../stores/network'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useFrequentlyPlayedTracks } from '../../../api/queries/frequents'
import { useApi } from '../../../stores'
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
export default function FrequentlyPlayedTracks(): React.JSX.Element {
const api = useApi()
@@ -30,14 +31,19 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
const loadNewQueue = useLoadNewQueue()
const { horizontalItems } = useDisplayContext()
return (
<View>
return tracksInfiniteQuery.data ? (
<Animated.View
entering={FadeIn}
exiting={FadeOut}
layout={LinearTransition.springify()}
style={{
flex: 1,
}}
>
<XStack
alignItems='center'
onPress={() => {
navigation.navigate('MostPlayedTracks', {
tracksInfiniteQuery,
})
navigation.navigate('MostPlayedTracks')
}}
>
<H5 marginLeft={'$2'}>On Repeat</H5>
@@ -46,8 +52,8 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
<HorizontalCardList
data={
(tracksInfiniteQuery.data?.length ?? 0 > horizontalItems)
? tracksInfiniteQuery.data?.slice(0, horizontalItems)
tracksInfiniteQuery.data.length > horizontalItems
? tracksInfiniteQuery.data.slice(0, horizontalItems)
: tracksInfiniteQuery.data
}
renderItem={({ item: track, index }) => (
@@ -81,6 +87,8 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
/>
)}
/>
</View>
</Animated.View>
) : (
<></>
)
}

View File

@@ -11,6 +11,7 @@ import HomeStackParamList from '../../../screens/Home/types'
import { useRecentArtists } from '../../../api/queries/recents'
import { pickFirstGenre } from '../../../utils/genre-formatting'
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 {
const recentArtistsInfiniteQuery = useRecentArtists()
@@ -22,10 +23,8 @@ export default function RecentArtists(): React.JSX.Element {
const { horizontalItems } = useDisplayContext()
const handleHeaderPress = useCallback(() => {
navigation.navigate('RecentArtists', {
artistsInfiniteQuery: recentArtistsInfiniteQuery,
})
}, [navigation, recentArtistsInfiniteQuery])
navigation.navigate('RecentArtists')
}, [navigation])
const renderItem = useCallback(
({ item: recentArtist }: { item: BaseItemDto }) => (
@@ -50,17 +49,26 @@ export default function RecentArtists(): React.JSX.Element {
[navigation, rootNavigation],
)
return (
<View>
return recentArtistsInfiniteQuery.data ? (
<Animated.View
entering={FadeIn}
exiting={FadeOut}
layout={LinearTransition.springify()}
style={{
flex: 1,
}}
>
<XStack alignItems='center' onPress={handleHeaderPress}>
<H5 marginLeft={'$2'}>Recent Artists</H5>
<Icon name='arrow-right' />
</XStack>
<HorizontalCardList
data={recentArtistsInfiniteQuery.data?.slice(0, horizontalItems) ?? []}
data={recentArtistsInfiniteQuery.data.slice(0, horizontalItems)}
renderItem={renderItem}
/>
</View>
</Animated.View>
) : (
<></>
)
}

View File

@@ -1,5 +1,5 @@
import React, { useMemo } from 'react'
import { H5, View, XStack } from 'tamagui'
import React from 'react'
import { H5, XStack } from 'tamagui'
import { ItemCard } from '../../Global/components/item-card'
import { RootStackParamList } from '../../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
@@ -13,8 +13,8 @@ import HomeStackParamList from '../../../screens/Home/types'
import { useNetworkStatus } from '../../../stores/network'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useRecentlyPlayedTracks } from '../../../api/queries/recents'
import { useCurrentTrack } from '../../../stores/player/queue'
import { useApi } from '../../../stores'
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
export default function RecentlyPlayed(): React.JSX.Element {
const api = useApi()
@@ -23,8 +23,6 @@ export default function RecentlyPlayed(): React.JSX.Element {
const deviceProfile = useStreamingDeviceProfile()
const nowPlaying = useCurrentTrack()
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>()
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
@@ -33,60 +31,66 @@ export default function RecentlyPlayed(): React.JSX.Element {
const tracksInfiniteQuery = useRecentlyPlayedTracks()
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
data={
(tracksInfiniteQuery.data?.length ?? 0 > horizontalItems)
? tracksInfiniteQuery.data?.slice(0, horizontalItems)
: tracksInfiniteQuery.data
}
renderItem={({ index, item: recentlyPlayedTrack }) => (
<ItemCard
size={'$11'}
caption={recentlyPlayedTrack.Name}
subCaption={`${recentlyPlayedTrack.Artists?.join(', ')}`}
squared
testId={`recently-played-${index}`}
item={recentlyPlayedTrack}
onPress={() => {
loadNewQueue({
api,
deviceProfile,
networkStatus,
track: recentlyPlayedTrack,
index: index,
tracklist: tracksInfiniteQuery.data ?? [recentlyPlayedTrack],
queue: 'Recently Played',
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
}}
onLongPress={() => {
rootNavigation.navigate('Context', {
item: recentlyPlayedTrack,
navigation,
})
}}
marginHorizontal={'$1'}
captionAlign='left'
/>
)}
/>
</View>
)
}, [tracksInfiniteQuery.data, nowPlaying])
return tracksInfiniteQuery.data ? (
<Animated.View
entering={FadeIn}
exiting={FadeOut}
layout={LinearTransition.springify()}
style={{
flex: 1,
}}
>
<XStack
alignItems='center'
onPress={() => {
navigation.navigate('RecentTracks')
}}
>
<H5 marginLeft={'$2'}>Play it again</H5>
<Icon name='arrow-right' />
</XStack>
<HorizontalCardList
data={
(tracksInfiniteQuery.data.length ?? 0 > horizontalItems)
? tracksInfiniteQuery.data.slice(0, horizontalItems)
: tracksInfiniteQuery.data
}
renderItem={({ index, item: recentlyPlayedTrack }) => (
<ItemCard
size={'$11'}
caption={recentlyPlayedTrack.Name}
subCaption={`${recentlyPlayedTrack.Artists?.join(', ')}`}
squared
testId={`recently-played-${index}`}
item={recentlyPlayedTrack}
onPress={() => {
loadNewQueue({
api,
deviceProfile,
networkStatus,
track: recentlyPlayedTrack,
index: index,
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>
) : (
<></>
)
}

View File

@@ -7,6 +7,8 @@ import FrequentlyPlayedTracks from './helpers/frequent-tracks'
import { usePreventRemove } from '@react-navigation/native'
import useHomeQueries from '../../api/mutations/home'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
import { useIsRestoring } from '@tanstack/react-query'
import { useRecentlyPlayedTracks } from '../../api/queries/recents'
const COMPONENT_NAME = 'Home'
@@ -17,18 +19,21 @@ export function Home(): React.JSX.Element {
usePerformanceMonitor(COMPONENT_NAME, 5)
const { isPending: refreshing, mutate: refresh } = useHomeQueries()
const { isPending: refreshing, mutateAsync: refresh } = useHomeQueries()
const { isPending: loadingInitialData } = useRecentlyPlayedTracks()
const isRestoring = useIsRestoring()
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
marginVertical: getToken('$4'),
marginHorizontal: getToken('$2'),
}}
refreshControl={
<RefreshControl
refreshing={refreshing}
refreshing={refreshing || isRestoring || loadingInitialData}
onRefresh={refresh}
tintColor={theme.primary.val}
/>

View File

@@ -21,6 +21,7 @@ export default function LibraryScreen({
<LibraryTabsNavigator.Navigator
tabBar={(props) => <LibraryTabBar {...props} />}
screenOptions={{
swipeEnabled: false, // Disable tab swiping to prevent conflicts with SwipeableRow gestures
tabBarIndicatorStyle: {
borderColor: theme.background.val,
borderBottomWidth: getTokenValue('$2'),

View File

@@ -4,7 +4,8 @@ import IconButton from '../../../components/Global/helpers/icon-button'
import { isUndefined } from 'lodash'
import { useTogglePlayback } from '../../../providers/Player/hooks/mutations'
import { usePlaybackState } from '../../../providers/Player/hooks/queries'
import React, { useMemo } from 'react'
import React from 'react'
import Icon from '../../Global/components/icon'
function PlayPauseButtonComponent({
size,
@@ -17,9 +18,9 @@ function PlayPauseButtonComponent({
const state = usePlaybackState()
const largeIcon = useMemo(() => isUndefined(size) || size >= 20, [size])
const largeIcon = isUndefined(size) || size >= 24
const button = useMemo(() => {
const button = (() => {
switch (state) {
case State.Playing: {
return (
@@ -56,7 +57,7 @@ function PlayPauseButtonComponent({
)
}
}
}, [state, size, largeIcon, togglePlayback])
})()
return (
<View justifyContent='center' alignItems='center' flex={flex}>
@@ -67,4 +68,28 @@ function 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

View File

@@ -1,6 +1,6 @@
import { XStack, YStack, Spacer, useTheme } from 'tamagui'
import { Text } from '../../Global/helpers/text'
import React, { useCallback, useMemo } from 'react'
import React from 'react'
import ItemImage from '../../Global/components/image'
import Animated, {
useAnimatedStyle,
@@ -20,16 +20,11 @@ export default function PlayerHeader(): React.JSX.Element {
const theme = useTheme()
// If the Queue is a BaseItemDto, display the name of it
const playingFrom = useMemo(
() =>
!queueRef
? 'Untitled'
: typeof queueRef === 'object'
? (queueRef.Name ?? 'Untitled')
: queueRef,
[queueRef],
)
const playingFrom = !queueRef
? 'Untitled'
: typeof queueRef === 'object'
? (queueRef.Name ?? 'Untitled')
: queueRef
return (
<YStack flexGrow={1} justifyContent='flex-start'>
@@ -75,10 +70,10 @@ function PlayerArtwork(): React.JSX.Element {
opacity: withTiming(nowPlaying ? 1 : 0),
}))
const handleLayout = useCallback((event: LayoutChangeEvent) => {
const handleLayout = (event: LayoutChangeEvent) => {
artworkMaxHeight.set(event.nativeEvent.layout.height)
artworkMaxWidth.set(event.nativeEvent.layout.height)
}, [])
}
return (
<YStack
@@ -98,7 +93,11 @@ function PlayerArtwork(): React.JSX.Element {
...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>
)}
</YStack>

View File

@@ -201,22 +201,36 @@ export default function Lyrics({
}
}, [lyrics])
const lyricStartTimes = useMemo(
() => parsedLyrics.map((line) => line.startTime),
[parsedLyrics],
)
// Track manually selected lyric for immediate feedback
const manuallySelectedIndex = useSharedValue(-1)
const manualSelectTimeout = useRef<NodeJS.Timeout | null>(null)
// Find current lyric line based on playback position
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
for (let i = parsedLyrics.length - 1; i >= 0; i--) {
if (position >= parsedLyrics[i].startTime) {
return i
// Binary search to find the last startTime <= position
let low = 0
let high = lyricStartTimes.length - 1
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
const scrollToCurrentLyric = useCallback(() => {

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 { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { Spacer, XStack, YStack } from 'tamagui'
@@ -42,14 +42,9 @@ export default function Scrubber(): React.JSX.Element {
const [displayAudioQualityBadge] = useDisplayAudioQualityBadge()
// Memoize expensive calculations
const maxDuration = useMemo(() => {
return Math.round(duration * ProgressMultiplier)
}, [duration])
const maxDuration = Math.round(duration * ProgressMultiplier)
const calculatedPosition = useMemo(() => {
return Math.round(position! * ProgressMultiplier)
}, [position])
const calculatedPosition = Math.round(position! * ProgressMultiplier)
// Optimized position update logic with throttling
useEffect(() => {
@@ -77,70 +72,57 @@ export default function Scrubber(): React.JSX.Element {
}
}, [nowPlaying?.id])
// Optimized seek handler with debouncing
const handleSeek = useCallback(
async (position: number) => {
const seekTime = Math.max(0, position / ProgressMultiplier)
lastSeekTimeRef.current = Date.now()
const handleSeek = async (position: number) => {
const seekTime = Math.max(0, position / ProgressMultiplier)
lastSeekTimeRef.current = Date.now()
try {
await seekTo(seekTime)
} catch (error) {
console.warn('handleSeek callback failed', error)
try {
await seekTo(seekTime)
} catch (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
setDisplayPosition(calculatedPosition)
} finally {
// Small delay to let the seek settle before allowing updates
setTimeout(() => {
isUserInteractingRef.current = false
}, 100)
}
}, 100)
}
}
const currentSeconds = Math.max(0, Math.round(displayPosition / ProgressMultiplier))
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
const currentSeconds = useMemo(() => {
return Math.max(0, Math.round(displayPosition / ProgressMultiplier))
}, [displayPosition])
// Update position with proper clamping
const clampedValue = Math.max(0, Math.min(value, maxDuration))
setDisplayPosition(clampedValue)
},
onSlideEnd: async (event: unknown, value: number) => {
trigger('notificationSuccess')
const totalSeconds = useMemo(() => {
return Math.round(duration)
}, [duration])
// Clamp final value and update display
const clampedValue = Math.max(0, Math.min(value, maxDuration))
setDisplayPosition(clampedValue)
// Memoize slider props to prevent recreation
const sliderProps = useMemo(
() => ({
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],
)
// Perform the seek operation
await handleSeek(clampedValue)
},
}
return (
<GestureDetector gesture={scrubGesture}>
@@ -157,16 +139,11 @@ export default function Scrubber(): React.JSX.Element {
/>
<XStack alignItems='center' paddingTop={'$2'}>
<YStack
alignItems='flex-start'
justifyContent='center'
flexShrink={1}
height={'$2'}
>
<YStack alignItems='flex-start' justifyContent='center' flex={1} height={'$2'}>
<RunTimeSeconds alignment='left'>{currentSeconds}</RunTimeSeconds>
</YStack>
<YStack alignItems='center' justifyContent='center' flexGrow={1} height={'$2'}>
<YStack alignItems='center' justifyContent='center' flex={1} height={'$2'}>
{nowPlaying?.mediaSourceInfo && displayAudioQualityBadge ? (
<QualityBadge
item={nowPlaying.item}
@@ -178,12 +155,7 @@ export default function Scrubber(): React.JSX.Element {
)}
</YStack>
<YStack
alignItems='flex-end'
justifyContent='center'
flexShrink={1}
height={'$2'}
>
<YStack alignItems='flex-end' justifyContent='center' flex={1} height={'$2'}>
<RunTimeSeconds alignment='right'>{totalSeconds}</RunTimeSeconds>
</YStack>
</XStack>

View File

@@ -1,11 +1,10 @@
import React, { useMemo, useCallback } from 'react'
import { getToken, Progress, XStack, YStack } from 'tamagui'
import React from 'react'
import { Progress, XStack, YStack } from 'tamagui'
import { useNavigation } from '@react-navigation/native'
import { Text } from '../Global/helpers/text'
import TextTicker from 'react-native-text-ticker'
import PlayPauseButton from './components/buttons'
import { PlayPauseIcon } from './components/buttons'
import { TextTickerConfig } from './component.config'
import { RunTimeSeconds } from '../Global/helpers/time-codes'
import { UPDATE_INTERVAL } from '../../player/config'
import { Progress as TrackPlayerProgress } from 'react-native-track-player'
import { useProgress } from '../../providers/Player/hooks/queries'
@@ -23,7 +22,7 @@ import { runOnJS } from 'react-native-worklets'
import { RootStackParamList } from '../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import ItemImage from '../Global/components/image'
import { usePrevious, useSkip } from '../../providers/Player/hooks/mutations'
import { usePrevious, useSkip, useTogglePlayback } from '../../providers/Player/hooks/mutations'
import { useCurrentTrack } from '../../stores/player/queue'
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 translateY = useSharedValue(0)
const handleSwipe = useCallback(
(direction: string) => {
if (direction === 'Swiped Left') {
// Inverted: Swipe left -> next
skip(undefined)
} else if (direction === 'Swiped Right') {
// Inverted: Swipe right -> previous
previous()
} else if (direction === 'Swiped Up') {
// Navigate to the big player
navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' })
const handleSwipe = (direction: string) => {
if (direction === 'Swiped Left') {
// Inverted: Swipe left -> next
skip(undefined)
} else if (direction === 'Swiped Right') {
// Inverted: Swipe right -> previous
previous()
} else if (direction === 'Swiped Up') {
// Navigate to the big player
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(
() =>
Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX
translateY.value = event.translationY
})
.onEnd((event) => {
const threshold = 100
const openPlayer = () => navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' })
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)
}
}),
[translateX, translateY, handleSwipe],
)
const openPlayer = useCallback(
() => navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' }),
[navigation],
)
const pressStyle = {
opacity: 0.6,
}
return (
<GestureDetector gesture={gesture}>
@@ -95,12 +88,10 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
<MiniPlayerProgress />
<XStack
alignItems='center'
pressStyle={{
opacity: 0.6,
}}
pressStyle={pressStyle}
animation={'quick'}
onPress={openPlayer}
paddingBottom={'$1'}
paddingVertical={'$2'}
>
<YStack justify='center' alignItems='center' marginLeft={'$2'}>
<Animated.View
@@ -108,7 +99,12 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
exiting={FadeOut}
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>
</YStack>
@@ -118,8 +114,6 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
marginLeft={'$2'}
flex={6}
>
<MiniPlayerRuntime duration={nowPlaying!.duration} />
<Animated.View
entering={FadeIn}
exiting={FadeOut}
@@ -148,7 +142,7 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
flex={2}
marginRight={'$2'}
>
<PlayPauseButton size={getToken('$12')} />
<PlayPauseIcon />
</XStack>
</XStack>
</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 {
const progress = useProgress(UPDATE_INTERVAL)
return (
<Progress
size={'$0.75'}
height={'$0.25'}
value={calculateProgressPercentage(progress)}
backgroundColor={'$borderColor'}
borderRadius={0}
borderBottomEndRadius={'$2'}
>
<Progress.Indicator borderColor={'$primary'} backgroundColor={'$primary'} />
</Progress>

View File

@@ -3,31 +3,33 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { H5, Spacer, XStack, YStack } from 'tamagui'
import InstantMixButton from '../../Global/components/instant-mix-button'
import Icon from '../../Global/components/icon'
import { usePlaylistContext } from '../../../providers/Playlist'
import { useNetworkStatus } from '../../../../src/stores/network'
import { useNetworkContext } from '../../../../src/providers/Network'
import { useNetworkStatus } from '../../../stores/network'
import { ActivityIndicator } from 'react-native'
import { mapDtoToTrack } from '../../../utils/mappings'
import { QueuingType } from '../../../enums/queuing-type'
import { useNavigation } from '@react-navigation/native'
import LibraryStackParamList from '@/src/screens/Library/types'
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import useStreamingDeviceProfile, {
useDownloadingDeviceProfile,
} from '../../../stores/device-profile'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import ItemImage from '../../Global/components/image'
import { useApi } from '../../../stores'
import Input from '../../Global/helpers/input'
import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated'
import { Dispatch, SetStateAction } from 'react'
import useAddToPendingDownloads, { usePendingDownloads } from '../../../stores/network/downloads'
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 {
const { playlist, playlistTracks, editing, setEditing, newName, setNewName } =
usePlaylistContext()
return (
<YStack justifyContent='center' alignItems='center' paddingTop={'$1'} marginBottom={'$2'}>
<YStack justifyContent='center' alignContent='center' padding={'$2'}>
@@ -68,10 +70,8 @@ export default function PlaylistTracklistHeader({
<Animated.View entering={FadeInDown} exiting={FadeOutDown}>
<PlaylistHeaderControls
editing={editing}
setEditing={setEditing}
playlist={playlist}
playlistTracks={playlistTracks ?? []}
canEdit={canEdit}
/>
</Animated.View>
) : (
@@ -82,21 +82,16 @@ export default function PlaylistTracklistHeader({
}
function PlaylistHeaderControls({
editing,
setEditing,
playlist,
playlistTracks,
canEdit,
}: {
editing: boolean
setEditing: (editing: boolean) => void
playlist: BaseItemDto
playlistTracks: BaseItemDto[]
canEdit: boolean | undefined
}): React.JSX.Element {
const { addToDownloadQueue, pendingDownloads } = useNetworkContext()
const addToDownloadQueue = useAddToPendingDownloads()
const pendingDownloads = usePendingDownloads()
const streamingDeviceProfile = useStreamingDeviceProfile()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const loadNewQueue = useLoadNewQueue()
const isDownloading = pendingDownloads.length != 0
const api = useApi()
@@ -105,13 +100,7 @@ function PlaylistHeaderControls({
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
const downloadPlaylist = () => {
if (!api) return
const jellifyTracks = playlistTracks.map((item) =>
mapDtoToTrack(api, item, downloadingDeviceProfile),
)
addToDownloadQueue(jellifyTracks)
}
const downloadPlaylist = () => addToDownloadQueue(playlistTracks)
const playPlaylist = (shuffled: boolean = false) => {
if (!playlistTracks || playlistTracks.length === 0) return
@@ -133,18 +122,7 @@ function PlaylistHeaderControls({
return (
<XStack justifyContent='center' marginVertical={'$1'} gap={'$2'} flexWrap='wrap'>
<YStack justifyContent='center' alignContent='center'>
{editing && canEdit ? (
<Icon
color={'$danger'}
name='delete-sweep-outline' // otherwise use "delete-circle"
onPress={() => {
navigation.push('DeletePlaylist', { playlist })
}}
small
/>
) : (
<InstantMixButton item={playlist} navigation={navigation} />
)}
<InstantMixButton item={playlist} navigation={navigation} />
</YStack>
<YStack justifyContent='center' alignContent='center'>
@@ -155,16 +133,6 @@ function PlaylistHeaderControls({
<Icon name='shuffle' onPress={() => playPlaylist(true)} small />
</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'>
{!isDownloading ? (
<Icon

View File

@@ -1,3 +1,4 @@
import React, { useCallback } from 'react'
import { RefreshControl } from 'react-native-gesture-handler'
import { Separator, useTheme } from 'tamagui'
import { FlashList } from '@shopify/flash-list'
@@ -7,6 +8,13 @@ import { FetchNextPageOptions } from '@tanstack/react-query'
import { useNavigation } from '@react-navigation/native'
import { BaseStackParamList } from '@/src/screens/types'
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 {
canEdit?: boolean | undefined
@@ -30,10 +38,29 @@ export default function Playlists({
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 (
<FlashList
contentInsetAdjustmentBehavior='automatic'
data={playlists}
keyExtractor={keyExtractor}
refreshControl={
<RefreshControl
refreshing={isPending || isFetchingNextPage}
@@ -41,15 +68,9 @@ export default function Playlists({
tintColor={theme.primary.val}
/>
}
ItemSeparatorComponent={() => <Separator />}
renderItem={({ index, item: playlist }) => (
<ItemRow item={playlist} navigation={navigation} />
)}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
}
}}
ItemSeparatorComponent={ListSeparator}
renderItem={renderItem}
onEndReached={handleEndReached}
removeClippedSubviews
/>
)

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'
import React, { useState } from 'react'
import Input from '../Global/helpers/input'
import ItemRow from '../Global/components/item-row'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
@@ -45,7 +45,7 @@ export default function Search({
queryFn: () => fetchSearchSuggestions(api, user, library?.musicLibraryId),
})
const search = useCallback(() => {
const search = () => {
let timeout: ReturnType<typeof setTimeout>
return () => {
@@ -55,16 +55,16 @@ export default function Search({
refetchSuggestions()
}, 1000)
}
}, [])
}
const handleSearchStringUpdate = (value: string | undefined) => {
setSearchString(value)
search()
}
const handleScrollBeginDrag = useCallback(() => {
const handleScrollBeginDrag = () => {
closeAllSwipeableRows()
}, [])
}
return (
<FlatList

View File

@@ -1,4 +1,3 @@
import { useCallback } from 'react'
import ItemRow from '../Global/components/item-row'
import { Text } from '../Global/helpers/text'
import { H3, Separator, YStack } from 'tamagui'
@@ -17,9 +16,9 @@ export default function Suggestions({
suggestions: BaseItemDto[] | undefined
}): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<SearchParamList>>()
const handleScrollBeginDrag = useCallback(() => {
const handleScrollBeginDrag = () => {
closeAllSwipeableRows()
}, [])
}
return (
<FlashList

View File

@@ -4,9 +4,9 @@ import RNFS from 'react-native-fs'
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
import { deleteAudioCache } from '../../api/mutations/download/offlineModeUtils'
import Icon from '../Global/components/icon'
import { useNetworkContext } from '../../providers/Network'
import { getToken, View } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { useDownloadProgress } from '@/src/stores/network/downloads'
// 🔹 Single Download Item with animated progress bar
function DownloadItem({
@@ -43,7 +43,7 @@ export default function StorageBar(): React.JSX.Element {
const [used, setUsed] = useState(0)
const [total, setTotal] = useState(1)
const { activeDownloads: activeDownloadsArray } = useNetworkContext()
const activeDownloadsArray = useDownloadProgress()
const usageShared = useSharedValue(0)
const percentUsed = used / total

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 { Separator, useTheme, XStack, YStack } from 'tamagui'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
@@ -35,29 +35,22 @@ export default function Tracks({
const pendingLetterRef = useRef<string | null>(null)
const stickyHeaderIndicies = useMemo(() => {
const stickyHeaderIndicies = (() => {
if (!showAlphabeticalSelector || !tracksInfiniteQuery.data) return []
return tracksInfiniteQuery.data
.map((track, index) => (typeof track === 'string' ? index : 0))
.filter((value, index, indices) => indices.indexOf(value) === index)
}, [showAlphabeticalSelector, tracksInfiniteQuery.data])
})()
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
// Memoize the expensive tracks processing to prevent memory leaks
const tracksToDisplay = React.useMemo(
() => tracksInfiniteQuery.data?.filter((track) => typeof track === 'object') ?? [],
[tracksInfiniteQuery.data],
)
const tracksToDisplay =
tracksInfiniteQuery.data?.filter((track) => typeof track === 'object') ?? []
// Memoize key extraction for FlashList performance
const keyExtractor = React.useCallback(
(item: string | number | BaseItemDto) =>
typeof item === 'object' ? item.Id! : item.toString(),
[],
)
const keyExtractor = (item: string | number | BaseItemDto) =>
typeof item === 'object' ? item.Id! : item.toString()
/**
* 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
* play that exact track, since the index was offset by the headings
*/
const renderItem = useCallback(
({ item: track, index }: { index: number; item: string | number | BaseItemDto }) =>
typeof track === 'string' ? (
<FlashListStickyHeader text={track.toUpperCase()} />
) : typeof track === 'number' ? null : typeof track === 'object' ? (
<Track
navigation={navigation}
showArtwork
index={0}
track={track}
testID={`track-item-${index}`}
tracklist={tracksToDisplay.slice(index, index + 50)}
queue={queue}
/>
) : null,
[tracksToDisplay, queue, navigation, queue],
)
const renderItem = ({
item: track,
index,
}: {
index: number
item: string | number | BaseItemDto
}) =>
typeof track === 'string' ? (
<FlashListStickyHeader text={track.toUpperCase()} />
) : typeof track === 'number' ? null : typeof track === 'object' ? (
<Track
navigation={navigation}
showArtwork
index={0}
track={track}
testID={`track-item-${index}`}
tracklist={tracksToDisplay.slice(index, index + 50)}
queue={queue}
/>
) : null
const ItemSeparatorComponent = useCallback(
({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) =>
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : (
<Separator />
),
[],
)
const ItemSeparatorComponent = ({
leadingItem,
trailingItem,
}: {
leadingItem: unknown
trailingItem: unknown
}) =>
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : <Separator />
// Effect for handling the pending alphabet selector letter
useEffect(() => {
@@ -129,9 +126,9 @@ export default function Tracks({
}
}, [pendingLetterRef.current, tracksInfiniteQuery.data])
const handleScrollBeginDrag = useCallback(() => {
const handleScrollBeginDrag = () => {
closeAllSwipeableRows()
}, [])
}
return (
<XStack flex={1}>

View File

@@ -2,7 +2,6 @@ import _ from 'lodash'
import React, { useEffect } from 'react'
import Root from '../screens'
import { PlayerProvider } from '../providers/Player'
import { NetworkContextProvider } from '../providers/Network'
import { DisplayProvider } from '../providers/Display/display-provider'
import {
createTelemetryDeck,
@@ -20,6 +19,7 @@ import { StorageProvider } from '../providers/Storage'
import { useSelectPlayerEngine } from '../stores/player/engine'
import { useSendMetricsSetting, useThemeSetting } from '../stores/settings/app'
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}
* @returns The {@link Jellify} component
@@ -76,14 +76,14 @@ function App(): React.JSX.Element {
}
}, [sendMetrics])
useDownloadProcessor()
return (
<NetworkContextProvider>
<StorageProvider>
<CarPlayProvider />
<PlayerProvider />
<Root />
<Toast topOffset={getToken('$12')} config={JellifyToastConfig(theme)} />
</StorageProvider>
</NetworkContextProvider>
<StorageProvider>
<CarPlayProvider />
<PlayerProvider />
<Root />
<Toast topOffset={getToken('$12')} config={JellifyToastConfig(theme)} />
</StorageProvider>
)
}

View File

@@ -0,0 +1 @@
export const MAX_CONCURRENT_DOWNLOADS = 1

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')
}

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

View File

@@ -7,7 +7,7 @@ import { fetchMediaInfo } from '../api/queries/media/utils'
import { fetchAlbumDiscs, fetchItem } from '../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
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 UserDataQueryKey from '../api/queries/user-data/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())
return useCallback(
(item: BaseItemDto) => {
const effectSig = `${item.Id}-${item.Type}`
return (item: BaseItemDto) => {
const effectSig = `${item.Id}-${item.Type}`
// If we've already warmed the cache for this item, return
if (prefetchedContext.current.has(effectSig)) return
// If we've already warmed the cache for this item, return
if (prefetchedContext.current.has(effectSig)) return
// Mark this item's context as warmed, preventing reruns
prefetchedContext.current.add(effectSig)
// Mark this item's context as warmed, preventing reruns
prefetchedContext.current.add(effectSig)
warmItemContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile)
},
[api, user, streamingDeviceProfile, downloadingDeviceProfile],
)
warmItemContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile)
}
}
function warmItemContext(

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
}

View File

@@ -7,6 +7,14 @@ interface PerformanceMetrics {
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
* @param componentName - Name of the component for logging
@@ -17,6 +25,7 @@ export function usePerformanceMonitor(
componentName: string,
threshold: number = 10,
): PerformanceMetrics {
// Skip all performance monitoring in production for zero overhead
const renderCount = useRef(0)
const renderTimes = useRef<number[]>([])
const lastRenderStart = useRef(Date.now())
@@ -56,6 +65,8 @@ export function usePerformanceMonitor(
lastRenderStart.current = Date.now()
})
if (!__DEV__) return EMPTY_METRICS
const averageRenderTime =
renderTimes.current.length > 0
? renderTimes.current.reduce((a, b) => a + b, 0) / renderTimes.current.length

View File

@@ -1,3 +1,5 @@
import { Platform } from 'react-native'
/**
* Interval in milliseconds for progress updates from the track player
* 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
*/
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
}
: {}

View File

@@ -1,12 +1,7 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
export type Queue =
| BaseItemDto
| 'Recently Played'
| 'Search'
| 'Favorite Tracks'
| 'Downloaded Tracks'
| 'On Repeat'
| 'Instant Mix'
| 'Library'
| 'Artist Tracks'
/**
* Describes where playback was initiated from.
* Allows known queue labels (e.g., "Recently Played") as well as dynamic strings like search terms.
*/
export type Queue = BaseItemDto | string

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)

View File

@@ -2,7 +2,7 @@ import fetchSimilar from '../../api/queries/similar'
import { QueryKeys } from '../../enums/query-keys'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { useQuery } from '@tanstack/react-query'
import { createContext, ReactNode, useCallback, useContext, useMemo } from 'react'
import { createContext, ReactNode, useContext } from 'react'
import { SharedValue, useSharedValue } from 'react-native-reanimated'
import { isUndefined } from 'lodash'
import { useArtistAlbums, useArtistFeaturedOn } from '../../api/queries/artist'
@@ -65,38 +65,25 @@ export const ArtistProvider = ({
enabled: !isUndefined(artist.Id),
})
const refresh = useCallback(() => {
const refresh = () => {
refetchAlbums()
refetchFeaturedOn()
refetchSimilar()
}, [refetchAlbums, refetchFeaturedOn, refetchSimilar])
}
const scroll = useSharedValue(0)
const value = useMemo(
() => ({
artist,
albums,
featuredOn,
similarArtists,
fetchingAlbums,
fetchingFeaturedOn,
fetchingSimilarArtists,
refresh,
scroll,
}),
[
artist,
albums,
featuredOn,
similarArtists,
fetchingAlbums,
fetchingFeaturedOn,
fetchingSimilarArtists,
refresh,
scroll,
],
)
const value = {
artist,
albums,
featuredOn,
similarArtists,
fetchingAlbums,
fetchingFeaturedOn,
fetchingSimilarArtists,
refresh,
scroll,
}
return <ArtistContext.Provider value={value}>{children}</ArtistContext.Provider>
}

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)

View File

@@ -7,7 +7,7 @@ import {
import usePlayerEngineStore from '../../../stores/player/engine'
import { PlayerEngine } from '../../../stores/player/engine'
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 => {
const { position, duration, buffered } = useProgressRNTP(UPDATE_INTERVAL)
@@ -58,16 +58,33 @@ export const usePlaybackState = (): State | undefined => {
const isCasting = playerEngineData === PlayerEngine.GOOGLE_CAST
const [playbackState, setPlaybackState] = useState<State | undefined>(state)
useMemo(() => {
useEffect(() => {
let unsubscribe: (() => void) | undefined
if (client && isCasting) {
client.onMediaStatusUpdated((status) => {
const handler = (status: { playerState?: MediaPlayerState | null } | null) => {
if (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 {
setPlaybackState(state)
}
return () => {
if (unsubscribe) unsubscribe()
}
}, [client, isCasting, state])
return playbackState

View File

@@ -1,6 +1,6 @@
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
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 JellifyTrack from '../../types/JellifyTrack'
import { useAutoDownload } from '../../stores/settings/usage'
@@ -43,69 +43,61 @@ export const PlayerProvider: () => React.JSX.Element = () => {
usePerformanceMonitor('PlayerProvider', 3)
const eventHandler = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async (event: any) => {
switch (event.type) {
case Event.PlaybackActiveTrackChanged: {
// When we load a new queue, our index is updated before RNTP
// Because of this, we only need to respond to this event
// if the index from the event differs from what we have stored
if (event.track && enableAudioNormalization) {
const volume = calculateTrackVolume(event.track)
await TrackPlayer.setVolume(volume)
} else if (event.track) {
try {
await reportPlaybackStarted(api, event.track)
} catch (error) {
console.error('Unable to report playback started for track', error)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const eventHandler = async (event: any) => {
switch (event.type) {
case Event.PlaybackActiveTrackChanged: {
// When we load a new queue, our index is updated before RNTP
// Because of this, we only need to respond to this event
// if the index from the event differs from what we have stored
if (event.track && enableAudioNormalization) {
const volume = calculateTrackVolume(event.track)
await TrackPlayer.setVolume(volume)
} else if (event.track) {
try {
await reportPlaybackStarted(api, event.track)
} catch (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: {
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
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
}
break
}
},
[api, autoDownload, enableAudioNormalization],
)
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: {
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)

View File

@@ -1,18 +1,11 @@
import React, {
PropsWithChildren,
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react'
import React, { PropsWithChildren, createContext, use, useContext, useState } from 'react'
import { useAllDownloadedTracks, useStorageInUse } from '../../api/queries/download'
import { JellifyDownload, JellifyDownloadProgress } from '../../types/JellifyDownload'
import {
DeleteDownloadsResult,
deleteDownloadsByIds,
} from '../../api/mutations/download/offlineModeUtils'
import { useNetworkContext } from '../Network'
import { useDownloadProgress } from '../../stores/network/downloads'
export type StorageSummary = {
totalSpace: number
@@ -74,18 +67,15 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
refetch: refetchStorageInfo,
isFetching: isFetchingStorage,
} = useStorageInUse()
const { activeDownloads } = useNetworkContext()
const activeDownloads = useDownloadProgress()
const [selection, setSelection] = useState<StorageSelectionState>({})
const [isDeleting, setIsDeleting] = useState(false)
const [isManuallyRefreshing, setIsManuallyRefreshing] = useState(false)
const activeDownloadsCount = useMemo(
() => Object.keys(activeDownloads ?? {}).length,
[activeDownloads],
)
const activeDownloadsCount = Object.keys(activeDownloads ?? {}).length
const summary = useMemo<StorageSummary | undefined>(() => {
const summary: StorageSummary | undefined = (() => {
if (!downloads || !storageInfo) return undefined
const audioBytes = downloads.reduce(
@@ -110,9 +100,9 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
artworkBytes,
audioBytes,
}
}, [downloads, storageInfo])
})()
const suggestions = useMemo<CleanupSuggestion[]>(() => {
const suggestions: CleanupSuggestion[] = (() => {
if (!downloads || downloads.length === 0) return []
const now = Date.now()
@@ -168,92 +158,75 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
})
return list
}, [downloads])
})()
const toggleSelection = useCallback((itemId: string) => {
const toggleSelection = (itemId: string) => {
setSelection((prev) => ({
...prev,
[itemId]: !prev[itemId],
}))
}, [])
}
const clearSelection = useCallback(() => setSelection({}), [])
const clearSelection = () => setSelection({})
const deleteDownloads = useCallback(
async (itemIds: string[]): Promise<DeleteDownloadsResult | undefined> => {
if (!itemIds.length) return undefined
setIsDeleting(true)
try {
const result = await deleteDownloadsByIds(itemIds)
await Promise.all([refetchDownloads(), refetchStorageInfo()])
setSelection((prev) => {
const updated = { ...prev }
itemIds.forEach((id) => delete updated[id])
return updated
})
return result
} finally {
setIsDeleting(false)
}
},
[refetchDownloads, refetchStorageInfo],
)
const deleteDownloads = async (
itemIds: string[],
): Promise<DeleteDownloadsResult | undefined> => {
if (!itemIds.length) return undefined
setIsDeleting(true)
try {
const result = await deleteDownloadsByIds(itemIds)
await Promise.all([refetchDownloads(), refetchStorageInfo()])
setSelection((prev) => {
const updated = { ...prev }
itemIds.forEach((id) => delete updated[id])
return updated
})
return result
} finally {
setIsDeleting(false)
}
}
const deleteSelection = useCallback(async () => {
const deleteSelection = async () => {
const idsToDelete = Object.entries(selection)
.filter(([, isSelected]) => isSelected)
.map(([id]) => id)
return deleteDownloads(idsToDelete)
}, [selection, deleteDownloads])
}
const refresh = useCallback(async () => {
const refresh = async () => {
setIsManuallyRefreshing(true)
try {
await Promise.all([refetchDownloads(), refetchStorageInfo()])
} finally {
setIsManuallyRefreshing(false)
}
}, [refetchDownloads, refetchStorageInfo])
}
const refreshing = isFetchingDownloads || isFetchingStorage || isManuallyRefreshing
const value = useMemo<StorageContextValue>(
() => ({
downloads,
summary,
suggestions,
selection,
toggleSelection,
clearSelection,
deleteSelection,
deleteDownloads,
isDeleting,
refresh,
refreshing,
activeDownloadsCount,
activeDownloads,
}),
[
downloads,
summary,
suggestions,
selection,
toggleSelection,
clearSelection,
deleteSelection,
deleteDownloads,
isDeleting,
refresh,
refreshing,
activeDownloadsCount,
],
)
const value: StorageContextValue = {
downloads,
summary,
suggestions,
selection,
toggleSelection,
clearSelection,
deleteSelection,
deleteDownloads,
isDeleting,
refresh,
refreshing,
activeDownloadsCount,
activeDownloads,
}
return <StorageContext.Provider value={value}>{children}</StorageContext.Provider>
}
export const useStorageContext = () => {
const context = useContext(StorageContext)
const context = use(StorageContext)
if (!context) throw new Error('StorageContext must be used within a StorageProvider')
return context
}

View File

@@ -1,13 +1,8 @@
import { Album } from '../../components/Album'
import { AlbumProps } from '../types'
import { AlbumProvider } from '../../providers/Album'
export default function AlbumScreen({ route, navigation }: AlbumProps): React.JSX.Element {
const { album } = route.params
return (
<AlbumProvider album={album}>
<Album />
</AlbumProvider>
)
return <Album album={album} />
}

View File

@@ -1,24 +1,13 @@
import { BaseStackParamList } from '../types'
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 & {
HomeScreen: undefined
RecentArtists: {
artistsInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error>
}
MostPlayedArtists: {
artistsInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error>
}
RecentTracks: {
tracksInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error>
}
MostPlayedTracks: {
tracksInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error>
}
RecentArtists: undefined
MostPlayedArtists: undefined
RecentTracks: undefined
MostPlayedTracks: undefined
}
export default HomeStackParamList

View File

@@ -1,9 +1,8 @@
import { BaseStackParamList, RootStackParamList } from '../types'
import { BaseStackParamList } from '../types'
import { RouteProp } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import React from 'react'
import Playlist from '../../components/Playlist/index'
import { PlaylistProvider } from '../../providers/Playlist'
export function PlaylistScreen({
route,
@@ -13,12 +12,10 @@ export function PlaylistScreen({
navigation: NativeStackNavigationProp<BaseStackParamList>
}): React.JSX.Element {
return (
<PlaylistProvider playlist={route.params.playlist}>
<Playlist
playlist={route.params.playlist}
navigation={navigation}
canEdit={route.params.canEdit}
/>
</PlaylistProvider>
<Playlist
playlist={route.params.playlist}
navigation={navigation}
canEdit={route.params.canEdit}
/>
)
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react'
import React, { useState } from 'react'
import { FlashList, ListRenderItem } from '@shopify/flash-list'
import { useFocusEffect, useNavigation } from '@react-navigation/native'
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 showDeletionToast = useDeletionToast()
useFocusEffect(
useCallback(() => {
void refresh()
}, [refresh]),
)
const sortedDownloads = !downloads
? []
: [...downloads].sort(
(a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime(),
)
const sortedDownloads = useMemo(() => {
if (!downloads) return []
return [...downloads].sort(
(a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime(),
)
}, [downloads])
const selectedIds = Object.entries(selection)
.filter(([, isSelected]) => isSelected)
.map(([id]) => id)
const selectedIds = useMemo(
() =>
Object.entries(selection)
.filter(([, isSelected]) => isSelected)
.map(([id]) => id),
[selection],
)
const selectedBytes =
!selectedIds.length || !downloads
? 0
: downloads.reduce((total, download) => {
return new Set(selectedIds).has(download.item.Id as string)
? total + getDownloadSize(download)
: total
}, 0)
const selectedBytes = useMemo(() => {
if (!selectedIds.length || !downloads) return 0
const selectedSet = new Set(selectedIds)
return downloads.reduce((total, download) => {
return selectedSet.has(download.item.Id as string)
? 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])
const handleApplySuggestion = async (suggestion: CleanupSuggestion) => {
if (!suggestion.itemIds.length) return
setApplyingSuggestionId(suggestion.id)
try {
const result = await deleteDownloads(suggestion.itemIds)
if (result?.deletedCount)
showDeletionToast(`Removed ${download.title ?? 'track'}`, result.freedBytes)
},
[deleteDownloads, showDeletionToast],
)
showDeletionToast(`Removed ${result.deletedCount} downloads`, result.freedBytes)
} finally {
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(
'Delete all downloads?',
'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(
'Delete selected 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(
({ item }) => (
<DownloadRow
download={item}
isSelected={Boolean(selection[item.item.Id as string])}
onToggle={() => toggleSelection(item.item.Id as string)}
onDelete={() => {
void handleDeleteSingle(item)
}}
/>
),
[selection, toggleSelection, handleDeleteSingle],
const renderDownloadItem: ListRenderItem<JellifyDownload> = ({ item }) => (
<DownloadRow
download={item}
isSelected={Boolean(selection[item.item.Id as string])}
onToggle={() => toggleSelection(item.item.Id as string)}
onDelete={() => {
void handleDeleteSingle(item)
}}
/>
)
const topPadding = 16

View File

@@ -1,22 +1,17 @@
import { Miniplayer } from '../../components/Player/mini-player'
import InternetConnectionWatcher from '../../components/Network/internetConnectionWatcher'
import { BottomTabBar, BottomTabBarProps } from '@react-navigation/bottom-tabs'
import useAppActive from '../../hooks/use-app-active'
import { useCurrentTrack } from '../../stores/player/queue'
import useIsMiniPlayerActive from '../../hooks/use-mini-player'
import { useIsFocused } from '@react-navigation/native'
export default function TabBar({ ...props }: BottomTabBarProps): React.JSX.Element {
const nowPlaying = useCurrentTrack()
const isFocused = useIsFocused()
const appIsActive = useAppActive()
const showMiniPlayer = nowPlaying && appIsActive
const isMiniPlayerActive = useIsMiniPlayerActive()
return (
<>
{showMiniPlayer && (
/* Hide miniplayer if the queue is empty */
<Miniplayer />
)}
{isMiniPlayerActive && isFocused && <Miniplayer />}
<InternetConnectionWatcher />
<BottomTabBar {...props} />

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

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { networkStatusTypes } from '../components/Network/internetConnectionWatcher'
import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher'
type NetworkStore = {
networkStatus: networkStatusTypes | null

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { useCastState, CastState } from 'react-native-google-cast'
@@ -31,12 +32,15 @@ const usePlayerEngineStore = create<playerEngineStore>()(
export const useSelectPlayerEngine = () => {
const setPlayerEngineData = usePlayerEngineStore((state) => state.setPlayerEngineData)
const castState = useCastState()
if (castState === CastState.CONNECTED) {
setPlayerEngineData(PlayerEngine.GOOGLE_CAST)
TrackPlayer.pause() // pause the track player to avoid conflicts
return
}
setPlayerEngineData(PlayerEngine.REACT_NATIVE_TRACK_PLAYER)
useEffect(() => {
if (castState === CastState.CONNECTED) {
setPlayerEngineData(PlayerEngine.GOOGLE_CAST)
void TrackPlayer.pause() // pause the track player to avoid conflicts
return
}
setPlayerEngineData(PlayerEngine.REACT_NATIVE_TRACK_PLAYER)
}, [castState, setPlayerEngineData])
}
export default usePlayerEngineStore

View File

@@ -1,11 +1,27 @@
import { Queue } from '@/src/player/types/queue-item'
import JellifyTrack from '@/src/types/JellifyTrack'
import { mmkvStateStorage } from '../../constants/storage'
import JellifyTrack, {
PersistedJellifyTrack,
toPersistedTrack,
fromPersistedTrack,
} from '../../types/JellifyTrack'
import { createVersionedMmkvStorage } from '../../constants/versioned-storage'
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 { 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 = {
shuffled: boolean
setShuffled: (shuffled: boolean) => void
@@ -29,6 +45,81 @@ type PlayerQueueStore = {
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>()(
devtools(
persist(
@@ -71,7 +162,7 @@ export const usePlayerQueueStore = create<PlayerQueueStore>()(
}),
{
name: 'player-queue-storage',
storage: createJSONStorage(() => mmkvStateStorage),
storage: queueStorage,
},
),
),

View File

@@ -41,4 +41,47 @@ interface JellifyTrack extends Track {
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