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