mirror of
https://github.com/Jellify-Music/App.git
synced 2026-03-17 10:40:38 -05:00
Merge main into Playlist-improvements, keeping unmemoized version
This commit is contained in:
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -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
80
App.tsx
@@ -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)
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -2,8 +2,8 @@
|
||||
<img alt='Jellify logo' src='assets/transparent-banner.png' width="600" height="300" />
|
||||
</p>
|
||||
|
||||
[](https://github.com/anultravioletaurora/Jellify/releases)
|
||||
[](https://github.com/anultravioletaurora/Jellify/actions/workflows/publish-beta.yml) [](https://github.com/Jellify-Music/App/actions/workflows/publish-ota-update.yml)
|
||||
[](https://github.com/anultravioletaurora/Jellify/releases) [](https://apps.apple.com/us/app/jellify/id6736884612) [](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify)
|
||||
|
||||
|
||||
[](https://github.com/sponsors/anultravioletaurora) [](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
|
||||
|
||||
[](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
|
||||
|
||||
[](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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
}
|
||||
|
||||
42
bun.lock
42
bun.lock
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
|
||||
21
package.json
21
package.json
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
1
src/configs/download.config.ts
Normal file
1
src/configs/download.config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MAX_CONCURRENT_DOWNLOADS = 1
|
||||
74
src/constants/versioned-storage.ts
Normal file
74
src/constants/versioned-storage.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { MMKV } from 'react-native-mmkv'
|
||||
import { StateStorage } from 'zustand/middleware'
|
||||
import { storage } from './storage'
|
||||
|
||||
// Import app version from package.json
|
||||
const APP_VERSION = '0.21.3' // This should match package.json version
|
||||
|
||||
const STORAGE_VERSION_KEY = 'storage-schema-version'
|
||||
|
||||
/**
|
||||
* Storage schema versions - increment when making breaking changes to persisted state
|
||||
* This allows clearing specific stores when their schema changes
|
||||
*/
|
||||
export const STORAGE_SCHEMA_VERSIONS: Record<string, number> = {
|
||||
'player-queue-storage': 2, // Bumped to v2 for slim persistence
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific store needs to be cleared due to version bump
|
||||
* and clears it if necessary
|
||||
*/
|
||||
export function migrateStorageIfNeeded(storeName: string, storage: MMKV): void {
|
||||
const versionKey = `${STORAGE_VERSION_KEY}:${storeName}`
|
||||
const storedVersion = storage.getNumber(versionKey)
|
||||
const currentVersion = STORAGE_SCHEMA_VERSIONS[storeName] ?? 1
|
||||
|
||||
if (storedVersion !== currentVersion) {
|
||||
// Clear the stale storage for this specific store
|
||||
storage.delete(storeName)
|
||||
// Update the version
|
||||
storage.set(versionKey, currentVersion)
|
||||
console.log(
|
||||
`[Storage] Migrated ${storeName} from v${storedVersion ?? 0} to v${currentVersion}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a versioned MMKV state storage that automatically clears stale data
|
||||
* when the schema version changes. This is useful for stores that persist
|
||||
* data that may become incompatible between app versions.
|
||||
*
|
||||
* @param storeName The unique name for this store (used as the MMKV key)
|
||||
* @returns A StateStorage compatible object for Zustand's persist middleware
|
||||
*/
|
||||
export function createVersionedMmkvStorage(storeName: string): StateStorage {
|
||||
// Run migration check on storage creation
|
||||
migrateStorageIfNeeded(storeName, storage)
|
||||
|
||||
return {
|
||||
getItem: (key: string) => {
|
||||
const value = storage.getString(key)
|
||||
return value === undefined ? null : value
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value)
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all versioned storage entries. Useful for debugging or forcing
|
||||
* a complete cache reset.
|
||||
*/
|
||||
export function clearAllVersionedStorage(): void {
|
||||
Object.keys(STORAGE_SCHEMA_VERSIONS).forEach((storeName) => {
|
||||
storage.delete(storeName)
|
||||
storage.delete(`${STORAGE_VERSION_KEY}:${storeName}`)
|
||||
})
|
||||
console.log('[Storage] Cleared all versioned storage')
|
||||
}
|
||||
64
src/hooks/use-download-processor.ts
Normal file
64
src/hooks/use-download-processor.ts
Normal 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
|
||||
@@ -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(
|
||||
|
||||
10
src/hooks/use-mini-player.ts
Normal file
10
src/hooks/use-mini-player.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
: {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
19
src/screens/Home/types.d.ts
vendored
19
src/screens/Home/types.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
137
src/stores/network/downloads.ts
Normal file
137
src/stores/network/downloads.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { mmkvStateStorage } from '../../constants/storage'
|
||||
import { JellifyDownloadProgress } from '@/src/types/JellifyDownload'
|
||||
import JellifyTrack from '@/src/types/JellifyTrack'
|
||||
import { mapDtoToTrack } from '../../utils/mappings'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
|
||||
import { useApi } from '..'
|
||||
import { useDownloadingDeviceProfile } from '../device-profile'
|
||||
|
||||
type DownloadsStore = {
|
||||
downloadProgress: JellifyDownloadProgress
|
||||
setDownloadProgress: (progress: JellifyDownloadProgress) => void
|
||||
pendingDownloads: JellifyTrack[]
|
||||
setPendingDownloads: (items: JellifyTrack[]) => void
|
||||
currentDownloads: JellifyTrack[]
|
||||
setCurrentDownloads: (items: JellifyTrack[]) => void
|
||||
completedDownloads: JellifyTrack[]
|
||||
setCompletedDownloads: (items: JellifyTrack[]) => void
|
||||
failedDownloads: JellifyTrack[]
|
||||
setFailedDownloads: (items: JellifyTrack[]) => void
|
||||
}
|
||||
|
||||
export const useDownloadsStore = create<DownloadsStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
downloadProgress: {},
|
||||
setDownloadProgress: (progress) =>
|
||||
set({
|
||||
downloadProgress: progress,
|
||||
}),
|
||||
pendingDownloads: [],
|
||||
setPendingDownloads: (items) =>
|
||||
set({
|
||||
pendingDownloads: items,
|
||||
}),
|
||||
currentDownloads: [],
|
||||
setCurrentDownloads: (items) => set({ currentDownloads: items }),
|
||||
completedDownloads: [],
|
||||
setCompletedDownloads: (items) => set({ completedDownloads: items }),
|
||||
failedDownloads: [],
|
||||
setFailedDownloads: (items) => set({ failedDownloads: items }),
|
||||
}),
|
||||
{
|
||||
name: 'downloads-store',
|
||||
storage: createJSONStorage(() => mmkvStateStorage),
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export const useDownloadProgress = () => useDownloadsStore((state) => state.downloadProgress)
|
||||
|
||||
export const usePendingDownloads = () => useDownloadsStore((state) => state.pendingDownloads)
|
||||
|
||||
export const useCurrentDownloads = () => useDownloadsStore((state) => state.currentDownloads)
|
||||
|
||||
export const useFailedDownloads = () => useDownloadsStore((state) => state.failedDownloads)
|
||||
|
||||
export const useIsDownloading = (items: BaseItemDto[]) => {
|
||||
const pendingDownloads = usePendingDownloads()
|
||||
const currentDownloads = useCurrentDownloads()
|
||||
|
||||
const downloadQueue = new Set([
|
||||
...pendingDownloads.map((download) => download.item.Id),
|
||||
...currentDownloads.map((download) => download.item.Id),
|
||||
])
|
||||
|
||||
const itemIds = items.map((item) => item.Id)
|
||||
|
||||
return itemIds.filter((id) => downloadQueue.has(id)).length === items.length
|
||||
}
|
||||
|
||||
export const useAddToCompletedDownloads = () => {
|
||||
const currentDownloads = useCurrentDownloads()
|
||||
const setCompletedDownloads = useDownloadsStore((state) => state.setCompletedDownloads)
|
||||
|
||||
return (item: JellifyTrack) => setCompletedDownloads([...currentDownloads, item])
|
||||
}
|
||||
|
||||
export const useAddToCurrentDownloads = () => {
|
||||
const currentDownloads = useCurrentDownloads()
|
||||
const setCurrentDownloads = useDownloadsStore((state) => state.setCurrentDownloads)
|
||||
|
||||
return (item: JellifyTrack) => setCurrentDownloads([...currentDownloads, item])
|
||||
}
|
||||
|
||||
export const useRemoveFromCurrentDownloads = () => {
|
||||
const currentDownloads = useCurrentDownloads()
|
||||
|
||||
const setCurrentDownloads = useDownloadsStore((state) => state.setCurrentDownloads)
|
||||
|
||||
return (item: JellifyTrack) =>
|
||||
setCurrentDownloads(
|
||||
currentDownloads.filter((download) => download.item.Id !== item.item.Id),
|
||||
)
|
||||
}
|
||||
|
||||
export const useRemoveFromPendingDownloads = () => {
|
||||
const pendingDownloads = usePendingDownloads()
|
||||
|
||||
const setPendingDownloads = useDownloadsStore((state) => state.setPendingDownloads)
|
||||
|
||||
return (item: JellifyTrack) =>
|
||||
setPendingDownloads(
|
||||
pendingDownloads.filter((download) => download.item.Id !== item.item.Id),
|
||||
)
|
||||
}
|
||||
|
||||
export const useAddToFailedDownloads = () => (item: JellifyTrack) => {
|
||||
const failedDownloads = useFailedDownloads()
|
||||
|
||||
const setFailedDownloads = useDownloadsStore((state) => state.setFailedDownloads)
|
||||
|
||||
return setFailedDownloads([...failedDownloads, item])
|
||||
}
|
||||
|
||||
const useAddToPendingDownloads = () => {
|
||||
const api = useApi()
|
||||
|
||||
const downloadingDeviceProfile = useDownloadingDeviceProfile()
|
||||
|
||||
const pendingDownloads = usePendingDownloads()
|
||||
|
||||
const setPendingDownloads = useDownloadsStore((state) => state.setPendingDownloads)
|
||||
|
||||
return (items: BaseItemDto[]) => {
|
||||
const downloads = api
|
||||
? items.map((item) => mapDtoToTrack(api, item, downloadingDeviceProfile))
|
||||
: []
|
||||
|
||||
return setPendingDownloads([...pendingDownloads, ...downloads])
|
||||
}
|
||||
}
|
||||
|
||||
export default useAddToPendingDownloads
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { networkStatusTypes } from '../components/Network/internetConnectionWatcher'
|
||||
import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher'
|
||||
|
||||
type NetworkStore = {
|
||||
networkStatus: networkStatusTypes | null
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user