mirror of
https://github.com/Jellify-Music/App.git
synced 2026-04-21 09:08:56 -05:00
Merge branch '0.12.30' of https://github.com/Jellify-Music/App into carplay-stuff
This commit is contained in:
@@ -20,7 +20,7 @@ jobs:
|
||||
run: yarn init-ios:new-arch
|
||||
|
||||
- name: ➕ Version Up
|
||||
run: yarn react-native bump-version --type patch
|
||||
run: yarn react-native bump-version --type minor
|
||||
|
||||
- name: 💬 Echo package.json version to Github ENV
|
||||
run: echo VERSION_NUMBER=$(node -p -e "require('./package.json').version") >> $GITHUB_ENV
|
||||
|
||||
@@ -31,13 +31,22 @@ export default function App(): React.JSX.Element {
|
||||
autoHandleInterruptions: true,
|
||||
iosCategory: IOSCategory.Playback,
|
||||
iosCategoryOptions: [IOSCategoryOptions.AllowAirPlay, IOSCategoryOptions.AllowBluetooth],
|
||||
// Enhanced buffer settings for gapless playback
|
||||
maxCacheSize: 50 * 1024 * 1024, // 50MB cache
|
||||
maxBuffer: 30000, // 30 seconds buffer
|
||||
minBuffer: 15000, // 15 seconds minimum buffer
|
||||
playBuffer: 2500, // 2.5 seconds play buffer
|
||||
backBuffer: 5000, // 5 seconds back buffer
|
||||
})
|
||||
.then(() =>
|
||||
TrackPlayer.updateOptions({
|
||||
capabilities: CAPABILITIES,
|
||||
notificationCapabilities: CAPABILITIES,
|
||||
compactCapabilities: CAPABILITIES,
|
||||
progressUpdateEventInterval: 10,
|
||||
// Reduced interval for smoother progress tracking and earlier prefetch detection
|
||||
progressUpdateEventInterval: 5,
|
||||
// Enable gapless playback
|
||||
alwaysPauseOnInterruption: false,
|
||||
}),
|
||||
)
|
||||
.finally(() => {
|
||||
|
||||
@@ -499,10 +499,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-frameworks.sh\"\n";
|
||||
@@ -516,10 +520,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources.sh\"\n";
|
||||
@@ -640,11 +648,11 @@
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Jellify/Jellify.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development: Jack Caulfield (66Z9J9NX2X)";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development: Jack Caulfield (66Z9J9NX2X)";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 189;
|
||||
DEVELOPMENT_TEAM = WAH9CZ8BPG;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -664,7 +672,7 @@
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cosmonautical.jellify;
|
||||
PRODUCT_NAME = Jellify;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "match Development com.cosmonautical.jellify";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.cosmonautical.jellify";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Jellify-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -799,10 +807,7 @@
|
||||
"-DFOLLY_CFG_NO_COROUTINES=1",
|
||||
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
|
||||
);
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
);
|
||||
OTHER_LDFLAGS = "$(inherited) ";
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
@@ -888,10 +893,7 @@
|
||||
"-DFOLLY_CFG_NO_COROUTINES=1",
|
||||
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
|
||||
);
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
);
|
||||
OTHER_LDFLAGS = "$(inherited) ";
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
|
||||
@@ -1895,6 +1895,30 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- RNCPicker (2.11.0):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
- RCT-Folly (= 2024.11.18.00)
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-hermes
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- RNDeviceInfo (14.0.4):
|
||||
- React-Core
|
||||
- RNDnsLookup (1.0.6):
|
||||
@@ -2296,6 +2320,7 @@ DEPENDENCIES:
|
||||
- ReactCodegen (from `build/generated/ios`)
|
||||
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
|
||||
- "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)"
|
||||
- "RNCPicker (from `../node_modules/@react-native-picker/picker`)"
|
||||
- RNDeviceInfo (from `../node_modules/react-native-device-info`)
|
||||
- RNDnsLookup (from `../node_modules/react-native-dns-lookup`)
|
||||
- RNFastImage (from `../node_modules/react-native-fast-image`)
|
||||
@@ -2481,6 +2506,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
RNCMaskedView:
|
||||
:path: "../node_modules/@react-native-masked-view/masked-view"
|
||||
RNCPicker:
|
||||
:path: "../node_modules/@react-native-picker/picker"
|
||||
RNDeviceInfo:
|
||||
:path: "../node_modules/react-native-device-info"
|
||||
RNDnsLookup:
|
||||
@@ -2588,6 +2615,7 @@ SPEC CHECKSUMS:
|
||||
ReactCodegen: c63eda03ba1d94353fb97b031fc84f75a0d125ba
|
||||
ReactCommon: 76d2dc87136d0a667678668b86f0fca0c16fdeb0
|
||||
RNCMaskedView: ae521efb1c6c2b183ae0f8479487db03c826184c
|
||||
RNCPicker: e51a9eb07c7dfbfba3d6c6cc567e083376c717cc
|
||||
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
|
||||
RNDnsLookup: db4a89381b80ec1a5153088518d2c4f8e51f2521
|
||||
RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
git_url("https://github.com/Jellify-Music/Signing.git")
|
||||
git_branch("main")
|
||||
|
||||
storage_mode("git")
|
||||
|
||||
|
||||
+2
-1
@@ -38,6 +38,7 @@
|
||||
"@react-native-community/cli": "^18.0.0",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native-picker/picker": "^2.11.0",
|
||||
"@react-navigation/bottom-tabs": "^7.3.13",
|
||||
"@react-navigation/material-top-tabs": "^7.2.13",
|
||||
"@react-navigation/native": "^7.1.9",
|
||||
@@ -131,4 +132,4 @@
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useSafeAreaFrame } from 'react-native-safe-area-context'
|
||||
import Icon from '../Global/components/icon'
|
||||
import { mapDtoToTrack } from '../../helpers/mappings'
|
||||
import { useNetworkContext } from '../../providers/Network'
|
||||
import { useSettingsContext } from '../../providers/Settings'
|
||||
|
||||
/**
|
||||
* The screen for an Album's track list
|
||||
@@ -41,6 +42,7 @@ export function AlbumScreen({ route, navigation }: HomeAlbumProps): React.JSX.El
|
||||
downloadedTracks,
|
||||
failedDownloads,
|
||||
} = useNetworkContext()
|
||||
const { downloadQuality } = useSettingsContext()
|
||||
|
||||
const { data: discs, isPending } = useQuery({
|
||||
queryKey: [QueryKeys.ItemTracks, album.Id!],
|
||||
@@ -49,7 +51,9 @@ export function AlbumScreen({ route, navigation }: HomeAlbumProps): React.JSX.El
|
||||
|
||||
const downloadAlbum = (item: BaseItemDto[]) => {
|
||||
if (!api || !sessionId) return
|
||||
const jellifyTracks = item.map((item) => mapDtoToTrack(api, sessionId, item, []))
|
||||
const jellifyTracks = item.map((item) =>
|
||||
mapDtoToTrack(api, sessionId, item, [], undefined, downloadQuality),
|
||||
)
|
||||
useDownloadMultiple.mutate(jellifyTracks)
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mapDtoToTrack } from '../../helpers/mappings'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { CarPlay, ListTemplate } from 'react-native-carplay'
|
||||
import TrackPlayer from 'react-native-track-player'
|
||||
|
||||
@@ -1,17 +1,160 @@
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import SettingsListGroup from './settings-list-group'
|
||||
import { View } from 'tamagui'
|
||||
import { View, Text, Switch, Slider, XStack, YStack } from 'tamagui'
|
||||
import { useSettingsContext } from '../../../providers/Settings'
|
||||
import { MIN_CROSSFADE_DURATION, MAX_CROSSFADE_DURATION } from '../../../player/gapless-config'
|
||||
import { Picker } from '@react-native-picker/picker'
|
||||
import { useState } from 'react'
|
||||
import type { FadeCurve } from '../../../player/helpers/crossfade'
|
||||
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
|
||||
const gesture = Gesture.Pan()
|
||||
|
||||
export default function PlaybackTab(): React.JSX.Element {
|
||||
const {
|
||||
crossfadeEnabled,
|
||||
setCrossfadeEnabled,
|
||||
crossfadeDuration,
|
||||
setCrossfadeDuration,
|
||||
crossfadeCurve,
|
||||
setCrossfadeCurve,
|
||||
autoCrossfade,
|
||||
setAutoCrossfade,
|
||||
} = useSettingsContext()
|
||||
|
||||
const [showCurveOptions, setShowCurveOptions] = useState(false)
|
||||
|
||||
const fadeOptions = [
|
||||
{ label: 'Linear', value: 'linear' },
|
||||
{ label: 'Logarithmic (Recommended)', value: 'logarithmic' },
|
||||
{ label: 'Exponential', value: 'exponential' },
|
||||
]
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<SettingsListGroup
|
||||
settingsList={[
|
||||
{
|
||||
title: 'Coming soon...',
|
||||
subTitle: 'Settings to control playback',
|
||||
iconName: 'cassette',
|
||||
title: 'Crossfade',
|
||||
subTitle: 'Smooth transitions between tracks',
|
||||
iconName: 'transit-connection-variant',
|
||||
iconColor: '$borderColor',
|
||||
children: (
|
||||
<YStack>
|
||||
<SwitchWithLabel
|
||||
checked={crossfadeEnabled}
|
||||
onCheckedChange={setCrossfadeEnabled}
|
||||
label={crossfadeEnabled ? 'Enabled' : 'Disabled'}
|
||||
size='$2'
|
||||
width={100}
|
||||
/>
|
||||
{crossfadeEnabled && (
|
||||
<YStack space='$3' paddingLeft='$2'>
|
||||
<GestureDetector gesture={gesture}>
|
||||
{/* Duration Slider */}
|
||||
<YStack space='$2'>
|
||||
<XStack
|
||||
justifyContent='space-between'
|
||||
alignItems='center'
|
||||
>
|
||||
<Text fontSize='$4' fontWeight='500'>
|
||||
Duration
|
||||
</Text>
|
||||
<Text fontSize='$3' color='$color10'>
|
||||
{crossfadeDuration}s
|
||||
</Text>
|
||||
</XStack>
|
||||
<Slider
|
||||
value={[crossfadeDuration]}
|
||||
onValueChange={(value) =>
|
||||
setCrossfadeDuration(value[0])
|
||||
}
|
||||
min={MIN_CROSSFADE_DURATION}
|
||||
max={MAX_CROSSFADE_DURATION}
|
||||
step={0.5}
|
||||
size='$4'
|
||||
>
|
||||
<Slider.Track backgroundColor='$background'>
|
||||
<Slider.TrackActive backgroundColor='$blue10' />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb
|
||||
size='$2'
|
||||
index={0}
|
||||
circular
|
||||
backgroundColor='$blue10'
|
||||
/>
|
||||
</Slider>
|
||||
<XStack justifyContent='space-between'>
|
||||
<Text fontSize='$2' color='$color9'>
|
||||
{MIN_CROSSFADE_DURATION}s
|
||||
</Text>
|
||||
<Text fontSize='$2' color='$color9'>
|
||||
{MAX_CROSSFADE_DURATION}s
|
||||
</Text>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</GestureDetector>
|
||||
|
||||
{/* Fade Curve Picker */}
|
||||
<YStack space='$2'>
|
||||
<Text fontSize='$4' fontWeight='500'>
|
||||
Fade Curve
|
||||
</Text>
|
||||
<View
|
||||
borderWidth={1}
|
||||
borderColor='$borderColor'
|
||||
borderRadius='$4'
|
||||
overflow='hidden'
|
||||
backgroundColor='$background'
|
||||
>
|
||||
<Picker
|
||||
selectedValue={crossfadeCurve}
|
||||
onValueChange={(value: FadeCurve) =>
|
||||
setCrossfadeCurve(value)
|
||||
}
|
||||
style={{ height: 50 }}
|
||||
>
|
||||
{fadeOptions.map((option) => (
|
||||
<Picker.Item
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
value={option.value}
|
||||
/>
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
<Text fontSize='$2' color='$color9'>
|
||||
Logarithmic provides the most natural-sounding
|
||||
crossfade
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
{/* Auto Crossfade Toggle */}
|
||||
<XStack
|
||||
justifyContent='space-between'
|
||||
alignItems='center'
|
||||
paddingVertical='$2'
|
||||
>
|
||||
<YStack flex={1}>
|
||||
<Text fontSize='$4' fontWeight='500'>
|
||||
Auto Crossfade
|
||||
</Text>
|
||||
<Text fontSize='$3' color='$color10'>
|
||||
Automatically crossfade between consecutive
|
||||
tracks
|
||||
</Text>
|
||||
</YStack>
|
||||
<Switch
|
||||
checked={autoCrossfade}
|
||||
onCheckedChange={setAutoCrossfade}
|
||||
size='$3'
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import SettingsListGroup from './settings-list-group'
|
||||
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
|
||||
import { useSettingsContext } from '../../../providers/Settings'
|
||||
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
|
||||
import { useSettingsContext, DownloadQuality } from '../../../providers/Settings'
|
||||
import { useNetworkContext } from '../../../providers/Network'
|
||||
import { RadioGroup, YStack } from 'tamagui'
|
||||
import { Text } from '../../Global/helpers/text'
|
||||
|
||||
export default function StorageTab(): React.JSX.Element {
|
||||
const { autoDownload, setAutoDownload } = useSettingsContext()
|
||||
const { autoDownload, setAutoDownload, downloadQuality, setDownloadQuality } =
|
||||
useSettingsContext()
|
||||
const { downloadedTracks, storageUsage } = useNetworkContext()
|
||||
|
||||
const getQualityLabel = (quality: string) => {
|
||||
switch (quality) {
|
||||
case 'original':
|
||||
return 'Original Quality'
|
||||
case 'high':
|
||||
return 'High (320kbps)'
|
||||
case 'medium':
|
||||
return 'Medium (192kbps)'
|
||||
case 'low':
|
||||
return 'Low (128kbps)'
|
||||
default:
|
||||
return 'Medium (192kbps)'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<SettingsListGroup
|
||||
@@ -34,6 +53,46 @@ export default function StorageTab(): React.JSX.Element {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Download Quality',
|
||||
subTitle: `Current: ${getQualityLabel(downloadQuality)}`,
|
||||
iconName: 'music-note',
|
||||
iconColor: '$borderColor',
|
||||
children: (
|
||||
<YStack gap='$2' paddingVertical='$2'>
|
||||
<Text bold fontSize='$4'>
|
||||
Select Quality:
|
||||
</Text>
|
||||
<RadioGroup
|
||||
value={downloadQuality}
|
||||
onValueChange={(value) =>
|
||||
setDownloadQuality(value as DownloadQuality)
|
||||
}
|
||||
>
|
||||
<RadioGroupItemWithLabel
|
||||
size='$3'
|
||||
value='original'
|
||||
label='Original Quality'
|
||||
/>
|
||||
<RadioGroupItemWithLabel
|
||||
size='$3'
|
||||
value='high'
|
||||
label='High (320kbps)'
|
||||
/>
|
||||
<RadioGroupItemWithLabel
|
||||
size='$3'
|
||||
value='medium'
|
||||
label='Medium (192kbps)'
|
||||
/>
|
||||
<RadioGroupItemWithLabel
|
||||
size='$3'
|
||||
value='low'
|
||||
label='Low (128kbps)'
|
||||
/>
|
||||
</RadioGroup>
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
|
||||
@@ -14,7 +14,13 @@ export enum MMKVStorageKeys {
|
||||
LibraryIsFavorites = 'LibraryIsFavorites',
|
||||
SendMetrics = 'SEND_METRICS',
|
||||
AutoDownload = 'AutoDownload',
|
||||
DownloadQuality = 'DownloadQuality',
|
||||
LibraryIsDownloaded = 'LibraryIsDownloaded',
|
||||
DevTools = 'DevTools',
|
||||
LibraryArtistPageParam = 'LibraryArtistPageParam',
|
||||
// Crossfade settings
|
||||
CrossfadeEnabled = 'CrossfadeEnabled',
|
||||
CrossfadeDuration = 'CrossfadeDuration',
|
||||
CrossfadeCurve = 'CrossfadeCurve',
|
||||
AutoCrossfade = 'AutoCrossfade',
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { queryClient } from '../constants/query-client'
|
||||
import { QueryKeys } from '../enums/query-keys'
|
||||
import { Api } from '@jellyfin/sdk/lib/api'
|
||||
import RNFS from 'react-native-fs'
|
||||
import { DownloadQuality } from '../providers/Settings'
|
||||
|
||||
/**
|
||||
* The container that the Jellyfin server will attempt to transcode to
|
||||
@@ -24,6 +25,39 @@ import RNFS from 'react-native-fs'
|
||||
*/
|
||||
const transcodingContainer = 'ts'
|
||||
|
||||
/**
|
||||
* Gets quality-specific parameters for transcoding
|
||||
*
|
||||
* @param quality The desired download quality
|
||||
* @returns Object with bitrate and other quality parameters
|
||||
*/
|
||||
function getQualityParams(quality: DownloadQuality): { [key: string]: string } {
|
||||
switch (quality) {
|
||||
case 'original':
|
||||
return {}
|
||||
case 'high':
|
||||
return {
|
||||
AudioBitRate: '320000',
|
||||
MaxAudioBitDepth: '24',
|
||||
}
|
||||
case 'medium':
|
||||
return {
|
||||
AudioBitRate: '192000',
|
||||
MaxAudioBitDepth: '16',
|
||||
}
|
||||
case 'low':
|
||||
return {
|
||||
AudioBitRate: '128000',
|
||||
MaxAudioBitDepth: '16',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
AudioBitRate: '192000',
|
||||
MaxAudioBitDepth: '16',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A mapper function that can be used to get a RNTP `Track` compliant object
|
||||
* from a Jellyfin server `BaseItemDto`. Applies a queuing type to the track
|
||||
@@ -32,6 +66,7 @@ const transcodingContainer = 'ts'
|
||||
*
|
||||
* @param item The `BaseItemDto` of the track
|
||||
* @param queuingType The type of queuing we are performing
|
||||
* @param downloadQuality The quality to use for downloads/transcoding
|
||||
* @returns A `JellifyTrack`, which represents a Jellyfin library track queued in the player
|
||||
*/
|
||||
export function mapDtoToTrack(
|
||||
@@ -40,7 +75,9 @@ export function mapDtoToTrack(
|
||||
item: BaseItemDto,
|
||||
downloadedTracks: JellifyDownload[],
|
||||
queuingType?: QueuingType,
|
||||
downloadQuality: DownloadQuality = 'medium',
|
||||
): JellifyTrack {
|
||||
const qualityParams = getQualityParams(downloadQuality)
|
||||
const urlParams = {
|
||||
Container: item.Container!,
|
||||
TranscodingContainer: transcodingContainer,
|
||||
@@ -49,6 +86,7 @@ export function mapDtoToTrack(
|
||||
api_key: api.accessToken,
|
||||
StartTimeTicks: '0',
|
||||
PlaySessionId: sessionId,
|
||||
...qualityParams,
|
||||
}
|
||||
|
||||
console.debug(`Mapping BaseItemDTO to Track object`)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Configuration for gapless playback in Jellify
|
||||
*/
|
||||
|
||||
/**
|
||||
* Number of tracks to prefetch ahead of current track
|
||||
*/
|
||||
export const PREFETCH_TRACK_COUNT = 3
|
||||
|
||||
/**
|
||||
* Time in seconds before track end to start prefetching next tracks
|
||||
* Earlier prefetching ensures smooth transitions
|
||||
*/
|
||||
export const PREFETCH_THRESHOLD_SECONDS = 45
|
||||
|
||||
/**
|
||||
* Time in seconds before track end to add next track to TrackPlayer queue
|
||||
* This ensures RNTP has the track ready for gapless transition
|
||||
*/
|
||||
export const QUEUE_PREPARATION_THRESHOLD_SECONDS = 30
|
||||
|
||||
/**
|
||||
* Maximum number of tracks to keep in TrackPlayer queue ahead of current track
|
||||
*/
|
||||
export const MAX_QUEUE_LOOKAHEAD = 5
|
||||
|
||||
/**
|
||||
* Minimum buffer time in seconds for smooth playback
|
||||
*/
|
||||
export const MIN_BUFFER_SECONDS = 15
|
||||
|
||||
/**
|
||||
* Time threshold for considering a track "almost finished"
|
||||
* Used for scrobbling and cleanup logic
|
||||
*/
|
||||
export const TRACK_FINISH_THRESHOLD_SECONDS = 3
|
||||
|
||||
/**
|
||||
* Crossfade configuration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default crossfade duration in seconds
|
||||
*/
|
||||
export const DEFAULT_CROSSFADE_DURATION = 3
|
||||
|
||||
/**
|
||||
* Minimum crossfade duration in seconds
|
||||
*/
|
||||
export const MIN_CROSSFADE_DURATION = 0
|
||||
|
||||
/**
|
||||
* Maximum crossfade duration in seconds
|
||||
*/
|
||||
export const MAX_CROSSFADE_DURATION = 12
|
||||
|
||||
/**
|
||||
* Default fade curve type for crossfading
|
||||
* Options: 'linear', 'logarithmic', 'exponential'
|
||||
*/
|
||||
export const DEFAULT_FADE_CURVE = 'logarithmic' as const
|
||||
|
||||
/**
|
||||
* Crossfade update interval in milliseconds
|
||||
* How often to update volume during crossfade
|
||||
*/
|
||||
export const CROSSFADE_UPDATE_INTERVAL = 50
|
||||
|
||||
/**
|
||||
* Default settings for automatic crossfading
|
||||
*/
|
||||
export const DEFAULT_AUTO_CROSSFADE = true
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Crossfade utilities for smooth transitions between tracks
|
||||
*/
|
||||
|
||||
export type FadeCurve = 'linear' | 'logarithmic' | 'exponential'
|
||||
|
||||
/**
|
||||
* Calculate fade value based on progress and curve type
|
||||
* @param progress - Value from 0 to 1 representing fade progress
|
||||
* @param curve - Type of fade curve to apply
|
||||
* @returns Fade value from 0 to 1
|
||||
*/
|
||||
export const calculateFadeValue = (progress: number, curve: FadeCurve): number => {
|
||||
// Clamp progress to valid range
|
||||
const clampedProgress = Math.max(0, Math.min(1, progress))
|
||||
|
||||
switch (curve) {
|
||||
case 'linear':
|
||||
return clampedProgress
|
||||
|
||||
case 'logarithmic':
|
||||
// Logarithmic curve provides smooth fade that's perceptually more natural
|
||||
return clampedProgress === 0 ? 0 : Math.log10(clampedProgress * 9 + 1)
|
||||
|
||||
case 'exponential':
|
||||
// Exponential curve for more dramatic fades
|
||||
return Math.pow(clampedProgress, 2)
|
||||
|
||||
default:
|
||||
return clampedProgress
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate fade-out volume for the current track
|
||||
* @param progress - Crossfade progress from 0 to 1
|
||||
* @param curve - Fade curve type
|
||||
* @returns Volume from 0 to 1
|
||||
*/
|
||||
export const calculateFadeOutVolume = (progress: number, curve: FadeCurve): number => {
|
||||
return 1 - calculateFadeValue(progress, curve)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate fade-in volume for the next track
|
||||
* @param progress - Crossfade progress from 0 to 1
|
||||
* @param curve - Fade curve type
|
||||
* @returns Volume from 0 to 1
|
||||
*/
|
||||
export const calculateFadeInVolume = (progress: number, curve: FadeCurve): number => {
|
||||
return calculateFadeValue(progress, curve)
|
||||
}
|
||||
|
||||
/**
|
||||
* Crossfade state interface
|
||||
*/
|
||||
export interface CrossfadeState {
|
||||
isActive: boolean
|
||||
progress: number
|
||||
duration: number
|
||||
curve: FadeCurve
|
||||
startTime: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Create initial crossfade state
|
||||
*/
|
||||
export const createInitialCrossfadeState = (): CrossfadeState => ({
|
||||
isActive: false,
|
||||
progress: 0,
|
||||
duration: 0,
|
||||
curve: 'logarithmic',
|
||||
startTime: 0,
|
||||
})
|
||||
|
||||
/**
|
||||
* Update crossfade progress based on current time
|
||||
* @param state - Current crossfade state
|
||||
* @param currentTime - Current timestamp in milliseconds
|
||||
* @returns Updated crossfade state
|
||||
*/
|
||||
export const updateCrossfadeProgress = (
|
||||
state: CrossfadeState,
|
||||
currentTime: number,
|
||||
): CrossfadeState => {
|
||||
if (!state.isActive) {
|
||||
return state
|
||||
}
|
||||
|
||||
const elapsed = currentTime - state.startTime
|
||||
const progress = Math.min(elapsed / (state.duration * 1000), 1)
|
||||
|
||||
return {
|
||||
...state,
|
||||
progress,
|
||||
isActive: progress < 1,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new crossfade
|
||||
* @param duration - Crossfade duration in seconds
|
||||
* @param curve - Fade curve type
|
||||
* @param startTime - Start timestamp in milliseconds
|
||||
* @returns New crossfade state
|
||||
*/
|
||||
export const startCrossfade = (
|
||||
duration: number,
|
||||
curve: FadeCurve,
|
||||
startTime: number = Date.now(),
|
||||
): CrossfadeState => ({
|
||||
isActive: true,
|
||||
progress: 0,
|
||||
duration,
|
||||
curve,
|
||||
startTime,
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if crossfade should start based on track position and settings
|
||||
* @param currentPosition - Current track position in seconds
|
||||
* @param trackDuration - Total track duration in seconds
|
||||
* @param crossfadeDuration - Crossfade duration in seconds
|
||||
* @returns Whether crossfade should start
|
||||
*/
|
||||
export const shouldStartCrossfade = (
|
||||
currentPosition: number,
|
||||
trackDuration: number,
|
||||
crossfadeDuration: number,
|
||||
): boolean => {
|
||||
if (crossfadeDuration <= 0 || trackDuration <= crossfadeDuration) {
|
||||
return false
|
||||
}
|
||||
|
||||
const timeRemaining = trackDuration - currentPosition
|
||||
return timeRemaining <= crossfadeDuration
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { JellifyTrack } from '../../types/JellifyTrack'
|
||||
import TrackPlayer, { Track } from 'react-native-track-player'
|
||||
import {
|
||||
PREFETCH_TRACK_COUNT,
|
||||
MAX_QUEUE_LOOKAHEAD,
|
||||
QUEUE_PREPARATION_THRESHOLD_SECONDS,
|
||||
} from '../gapless-config'
|
||||
|
||||
/**
|
||||
* Enhanced gapless playback helper functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets tracks that should be prefetched based on current position in queue
|
||||
* @param playQueue The current play queue
|
||||
* @param currentIndex Current track index
|
||||
* @param prefetchedIds Set of already prefetched track IDs
|
||||
* @returns Array of tracks to prefetch
|
||||
*/
|
||||
export function getTracksToPreload(
|
||||
playQueue: JellifyTrack[],
|
||||
currentIndex: number,
|
||||
prefetchedIds: Set<string>,
|
||||
): JellifyTrack[] {
|
||||
const tracksToPreload: JellifyTrack[] = []
|
||||
|
||||
// Get next N tracks for prefetching
|
||||
for (let i = 1; i <= PREFETCH_TRACK_COUNT; i++) {
|
||||
const nextIndex = currentIndex + i
|
||||
if (nextIndex < playQueue.length) {
|
||||
const track = playQueue[nextIndex]
|
||||
if (!prefetchedIds.has(track.item.Id!)) {
|
||||
tracksToPreload.push(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tracksToPreload
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets tracks that should be added to the TrackPlayer queue for immediate playback
|
||||
* @param playQueue The current play queue
|
||||
* @param currentIndex Current track index
|
||||
* @returns Array of tracks to add to TrackPlayer queue
|
||||
*/
|
||||
export async function getTracksToAddToPlayerQueue(
|
||||
playQueue: JellifyTrack[],
|
||||
currentIndex: number,
|
||||
): Promise<JellifyTrack[]> {
|
||||
const currentPlayerQueue = await TrackPlayer.getQueue()
|
||||
const tracksToAdd: JellifyTrack[] = []
|
||||
|
||||
// Add upcoming tracks to player queue up to MAX_QUEUE_LOOKAHEAD
|
||||
for (let i = 1; i <= MAX_QUEUE_LOOKAHEAD; i++) {
|
||||
const nextIndex = currentIndex + i
|
||||
if (nextIndex < playQueue.length) {
|
||||
const track = playQueue[nextIndex]
|
||||
|
||||
// Check if track is already in player queue
|
||||
const isInPlayerQueue = currentPlayerQueue.some(
|
||||
(playerTrack: Track) => playerTrack.id === track.id,
|
||||
)
|
||||
|
||||
if (!isInPlayerQueue) {
|
||||
tracksToAdd.push(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tracksToAdd
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates if we should start prefetching based on current progress
|
||||
* @param position Current playback position in seconds
|
||||
* @param duration Track duration in seconds
|
||||
* @param thresholdSeconds Threshold before end to start prefetching
|
||||
* @returns Whether to start prefetching
|
||||
*/
|
||||
export function shouldStartPrefetching(
|
||||
position: number,
|
||||
duration: number,
|
||||
thresholdSeconds: number = QUEUE_PREPARATION_THRESHOLD_SECONDS,
|
||||
): boolean {
|
||||
if (!duration || duration <= 0) return false
|
||||
|
||||
const remainingSeconds = duration - position
|
||||
return remainingSeconds <= thresholdSeconds && remainingSeconds > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the size of the TrackPlayer queue to prevent memory issues
|
||||
* Removes tracks that are too far behind current position
|
||||
* @param currentIndex Current track index in play queue
|
||||
*/
|
||||
export async function cleanupPlayerQueue(currentIndex: number): Promise<void> {
|
||||
try {
|
||||
const playerQueue = await TrackPlayer.getQueue()
|
||||
const activeIndex = await TrackPlayer.getActiveTrackIndex()
|
||||
|
||||
if (activeIndex === null || activeIndex === undefined) return
|
||||
|
||||
// Remove tracks that are more than 2 positions behind current track
|
||||
const indicesToRemove: number[] = []
|
||||
|
||||
for (let i = 0; i < activeIndex - 2; i++) {
|
||||
if (i >= 0 && i < playerQueue.length) {
|
||||
indicesToRemove.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
if (indicesToRemove.length > 0) {
|
||||
console.debug(`Cleaning up ${indicesToRemove.length} old tracks from player queue`)
|
||||
await TrackPlayer.remove(indicesToRemove)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error cleaning up player queue:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimizes the player queue by ensuring upcoming tracks are loaded
|
||||
* while keeping the queue size manageable
|
||||
* @param playQueue The app's play queue
|
||||
* @param currentIndex Current track index
|
||||
*/
|
||||
export async function optimizePlayerQueue(
|
||||
playQueue: JellifyTrack[],
|
||||
currentIndex: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Clean up old tracks
|
||||
await cleanupPlayerQueue(currentIndex)
|
||||
|
||||
// Add upcoming tracks
|
||||
const tracksToAdd = await getTracksToAddToPlayerQueue(playQueue, currentIndex)
|
||||
|
||||
if (tracksToAdd.length > 0) {
|
||||
console.debug(
|
||||
`Adding ${tracksToAdd.length} tracks to player queue for gapless playback`,
|
||||
)
|
||||
await TrackPlayer.add(tracksToAdd)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error optimizing player queue:', error)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { QueryKeys } from '../../enums/query-keys'
|
||||
import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher'
|
||||
import DownloadProgress from '../../types/DownloadProgress'
|
||||
import { useJellifyContext } from '..'
|
||||
import { useSettingsContext } from '../Settings'
|
||||
import { isUndefined } from 'lodash'
|
||||
import RNFS from 'react-native-fs'
|
||||
import { JellifyStorage } from './types'
|
||||
@@ -37,6 +38,7 @@ interface NetworkContext {
|
||||
const MAX_CONCURRENT_DOWNLOADS = 1
|
||||
const NetworkContextInitializer = () => {
|
||||
const { api, sessionId } = useJellifyContext()
|
||||
const { downloadQuality } = useSettingsContext()
|
||||
|
||||
const [downloadProgress, setDownloadProgress] = useState<JellifyDownloadProgress>({})
|
||||
const [networkStatus, setNetworkStatus] = useState<networkStatusTypes | null>(null)
|
||||
@@ -86,7 +88,7 @@ const NetworkContextInitializer = () => {
|
||||
mutationFn: (trackItem: BaseItemDto) => {
|
||||
if (isUndefined(api)) throw new Error('API client not initialized')
|
||||
|
||||
const track = mapDtoToTrack(api, sessionId, trackItem, [])
|
||||
const track = mapDtoToTrack(api, sessionId, trackItem, [], undefined, downloadQuality)
|
||||
|
||||
return saveAudio(track, setDownloadProgress, false)
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
|
||||
import { createContext, ReactNode, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { JellifyTrack } from '../../types/JellifyTrack'
|
||||
import { storage } from '../../constants/storage'
|
||||
import { MMKVStorageKeys } from '../../enums/mmkv-storage-keys'
|
||||
@@ -21,6 +21,25 @@ import { networkStatusTypes } from '../../components/Network/internetConnectionW
|
||||
import { useJellifyContext } from '..'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { useSettingsContext } from '../Settings'
|
||||
import {
|
||||
getTracksToPreload,
|
||||
shouldStartPrefetching,
|
||||
optimizePlayerQueue,
|
||||
} from '../../player/helpers/gapless'
|
||||
import {
|
||||
PREFETCH_THRESHOLD_SECONDS,
|
||||
QUEUE_PREPARATION_THRESHOLD_SECONDS,
|
||||
CROSSFADE_UPDATE_INTERVAL,
|
||||
} from '../../player/gapless-config'
|
||||
import type { CrossfadeState, FadeCurve } from '../../player/helpers/crossfade'
|
||||
import {
|
||||
createInitialCrossfadeState,
|
||||
shouldStartCrossfade,
|
||||
startCrossfade,
|
||||
updateCrossfadeProgress,
|
||||
calculateFadeOutVolume,
|
||||
calculateFadeInVolume,
|
||||
} from '../../player/helpers/crossfade'
|
||||
|
||||
interface PlayerContext {
|
||||
nowPlaying: JellifyTrack | undefined
|
||||
@@ -55,6 +74,12 @@ const PlayerContextInitializer = () => {
|
||||
|
||||
const [initialized, setInitialized] = useState<boolean>(false)
|
||||
|
||||
// Crossfade state
|
||||
const [crossfadeState, setCrossfadeState] = useState<CrossfadeState>(
|
||||
createInitialCrossfadeState(),
|
||||
)
|
||||
const crossfadeIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
//#endregion State
|
||||
|
||||
//#region Functions
|
||||
@@ -74,6 +99,101 @@ const PlayerContextInitializer = () => {
|
||||
else console.warn('No now playing track found')
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up prefetched track IDs that are no longer relevant
|
||||
* to prevent memory leaks
|
||||
*/
|
||||
const cleanupPrefetchedIds = (currentIndex: number, playQueue: JellifyTrack[]) => {
|
||||
const idsToKeep = new Set<string>()
|
||||
|
||||
// Keep IDs for current track and next 5 tracks
|
||||
for (
|
||||
let i = Math.max(0, currentIndex - 1);
|
||||
i < Math.min(playQueue.length, currentIndex + 6);
|
||||
i++
|
||||
) {
|
||||
const track = playQueue[i]
|
||||
if (track?.item?.Id) {
|
||||
idsToKeep.add(track.item.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old IDs that are no longer relevant
|
||||
const oldSize = prefetchedTrackIds.current.size
|
||||
prefetchedTrackIds.current = new Set(
|
||||
[...prefetchedTrackIds.current].filter((id) => idsToKeep.has(id)),
|
||||
)
|
||||
|
||||
if (oldSize !== prefetchedTrackIds.current.size) {
|
||||
console.debug(
|
||||
`Cleaned up ${oldSize - prefetchedTrackIds.current.size} old prefetched track IDs`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start crossfade transition
|
||||
*/
|
||||
const startCrossfadeTransition = (duration: number, curve: FadeCurve) => {
|
||||
console.debug(`Starting crossfade transition: ${duration}s, curve: ${curve}`)
|
||||
|
||||
const newCrossfadeState = startCrossfade(duration, curve)
|
||||
setCrossfadeState(newCrossfadeState)
|
||||
|
||||
// Start crossfade update interval
|
||||
if (crossfadeIntervalRef.current) {
|
||||
clearInterval(crossfadeIntervalRef.current)
|
||||
}
|
||||
|
||||
crossfadeIntervalRef.current = setInterval(() => {
|
||||
setCrossfadeState((prevState: CrossfadeState) => {
|
||||
const updatedState = updateCrossfadeProgress(prevState, Date.now())
|
||||
|
||||
if (updatedState.isActive) {
|
||||
// Calculate and apply volume levels
|
||||
const fadeOutVolume = calculateFadeOutVolume(
|
||||
updatedState.progress,
|
||||
updatedState.curve,
|
||||
)
|
||||
const fadeInVolume = calculateFadeInVolume(
|
||||
updatedState.progress,
|
||||
updatedState.curve,
|
||||
)
|
||||
|
||||
// Apply crossfade volumes to track player
|
||||
TrackPlayer.setVolume(fadeOutVolume).catch(console.warn)
|
||||
|
||||
console.debug(
|
||||
`Crossfade progress: ${(updatedState.progress * 100).toFixed(1)}%, fadeOut: ${fadeOutVolume.toFixed(2)}, fadeIn: ${fadeInVolume.toFixed(2)}`,
|
||||
)
|
||||
} else {
|
||||
// Crossfade completed
|
||||
console.debug('Crossfade completed')
|
||||
TrackPlayer.setVolume(1).catch(console.warn)
|
||||
|
||||
if (crossfadeIntervalRef.current) {
|
||||
clearInterval(crossfadeIntervalRef.current)
|
||||
crossfadeIntervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
return updatedState
|
||||
})
|
||||
}, CROSSFADE_UPDATE_INTERVAL)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop active crossfade transition
|
||||
*/
|
||||
const stopCrossfadeTransition = () => {
|
||||
if (crossfadeIntervalRef.current) {
|
||||
clearInterval(crossfadeIntervalRef.current)
|
||||
crossfadeIntervalRef.current = null
|
||||
}
|
||||
setCrossfadeState(createInitialCrossfadeState())
|
||||
TrackPlayer.setVolume(1).catch(console.warn)
|
||||
}
|
||||
|
||||
//#endregion Functions
|
||||
|
||||
//#region Hooks
|
||||
@@ -135,8 +255,11 @@ const PlayerContextInitializer = () => {
|
||||
//#region RNTP Setup
|
||||
|
||||
const { state: playbackState } = usePlaybackState()
|
||||
const { useDownload, downloadedTracks, networkStatus } = useNetworkContext()
|
||||
const { autoDownload } = useSettingsContext()
|
||||
const { useDownload, useDownloadMultiple, downloadedTracks, networkStatus } =
|
||||
useNetworkContext()
|
||||
const { autoDownload, crossfadeEnabled, crossfadeDuration, crossfadeCurve, autoCrossfade } =
|
||||
useSettingsContext()
|
||||
const prefetchedTrackIds = useRef<Set<string>>(new Set())
|
||||
|
||||
/**
|
||||
* Use the {@link useTrackPlayerEvents} hook to listen for events from the player.
|
||||
@@ -168,6 +291,77 @@ const PlayerContextInitializer = () => {
|
||||
)
|
||||
useDownload.mutate(nowPlaying!.item)
|
||||
|
||||
// --- ENHANCED GAPLESS PLAYBACK LOGIC ---
|
||||
if (nowPlaying && playQueue && typeof currentIndex === 'number') {
|
||||
const position = Math.floor(event.position)
|
||||
const duration = Math.floor(event.duration)
|
||||
const timeRemaining = duration - position
|
||||
|
||||
// --- CROSSFADE LOGIC ---
|
||||
if (crossfadeEnabled && autoCrossfade && !crossfadeState.isActive) {
|
||||
const hasNextTrack = currentIndex < playQueue.length - 1
|
||||
|
||||
if (
|
||||
hasNextTrack &&
|
||||
shouldStartCrossfade(position, duration, crossfadeDuration)
|
||||
) {
|
||||
console.debug(`Starting crossfade: ${timeRemaining}s remaining`)
|
||||
startCrossfadeTransition(crossfadeDuration, crossfadeCurve)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should start prefetching tracks
|
||||
if (shouldStartPrefetching(position, duration, PREFETCH_THRESHOLD_SECONDS)) {
|
||||
const tracksToPreload = getTracksToPreload(
|
||||
playQueue,
|
||||
currentIndex,
|
||||
prefetchedTrackIds.current,
|
||||
)
|
||||
|
||||
if (tracksToPreload.length > 0) {
|
||||
console.debug(
|
||||
`Gapless: Found ${tracksToPreload.length} tracks to preload (${timeRemaining}s remaining)`,
|
||||
)
|
||||
|
||||
// Filter tracks that aren't already downloaded
|
||||
const tracksToDownload = tracksToPreload.filter(
|
||||
(track) =>
|
||||
downloadedTracks?.filter(
|
||||
(download) => download.item.Id === track.item.Id,
|
||||
).length === 0,
|
||||
)
|
||||
|
||||
if (
|
||||
tracksToDownload.length > 0 &&
|
||||
[networkStatusTypes.ONLINE, undefined, null].includes(
|
||||
networkStatus as networkStatusTypes,
|
||||
)
|
||||
) {
|
||||
console.debug(
|
||||
`Gapless: Starting download of ${tracksToDownload.length} tracks`,
|
||||
)
|
||||
useDownloadMultiple.mutate(tracksToDownload)
|
||||
// Mark tracks as prefetched
|
||||
tracksToDownload.forEach((track) => {
|
||||
if (track.item.Id) {
|
||||
prefetchedTrackIds.current.add(track.item.Id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optimize the TrackPlayer queue for smooth transitions
|
||||
if (timeRemaining <= QUEUE_PREPARATION_THRESHOLD_SECONDS) {
|
||||
console.debug(
|
||||
`Gapless: Optimizing player queue (${timeRemaining}s remaining)`,
|
||||
)
|
||||
optimizePlayerQueue(playQueue, currentIndex).catch((error) =>
|
||||
console.warn('Failed to optimize player queue:', error),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -212,6 +406,37 @@ const PlayerContextInitializer = () => {
|
||||
setInitialized(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Clean up prefetched track IDs when the current index changes significantly
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (playQueue.length > 0 && typeof currentIndex === 'number' && currentIndex > -1) {
|
||||
cleanupPrefetchedIds(currentIndex, playQueue)
|
||||
}
|
||||
}, [currentIndex, playQueue])
|
||||
|
||||
/**
|
||||
* Handle crossfade cleanup on track changes and component unmount
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Stop any active crossfade when track changes
|
||||
if (crossfadeState.isActive) {
|
||||
stopCrossfadeTransition()
|
||||
}
|
||||
}, [currentIndex])
|
||||
|
||||
/**
|
||||
* Cleanup crossfade interval on unmount
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (crossfadeIntervalRef.current) {
|
||||
clearInterval(crossfadeIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
//#endregion useEffects
|
||||
|
||||
//#region return
|
||||
|
||||
@@ -10,6 +10,7 @@ import { JellifyTrack } from '../../types/JellifyTrack'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { mapDtoToTrack } from '../../helpers/mappings'
|
||||
import { useNetworkContext } from '../Network'
|
||||
import { useSettingsContext } from '../Settings'
|
||||
import { QueuingType } from '../../enums/queuing-type'
|
||||
import TrackPlayer, { Event, useTrackPlayerEvents } from 'react-native-track-player'
|
||||
import { findPlayQueueIndexStart } from '../../player/helpers'
|
||||
@@ -118,6 +119,7 @@ const QueueContextInitailizer = () => {
|
||||
//#region Context
|
||||
const { api, sessionId, user } = useJellifyContext()
|
||||
const { downloadedTracks, networkStatus } = useNetworkContext()
|
||||
const { downloadQuality } = useSettingsContext()
|
||||
|
||||
//#endregion Context
|
||||
|
||||
@@ -159,6 +161,7 @@ const QueueContextInitailizer = () => {
|
||||
track,
|
||||
downloadedTracks ?? [],
|
||||
queueItem.QueuingType,
|
||||
downloadQuality,
|
||||
),
|
||||
queueItemIndex,
|
||||
)
|
||||
@@ -199,7 +202,14 @@ const QueueContextInitailizer = () => {
|
||||
console.debug(`Filtered start index is ${filteredStartIndex}`)
|
||||
|
||||
const queue = availableAudioItems.map((item) =>
|
||||
mapDtoToTrack(api!, sessionId, item, downloadedTracks ?? [], QueuingType.FromSelection),
|
||||
mapDtoToTrack(
|
||||
api!,
|
||||
sessionId,
|
||||
item,
|
||||
downloadedTracks ?? [],
|
||||
QueuingType.FromSelection,
|
||||
downloadQuality,
|
||||
),
|
||||
)
|
||||
|
||||
setQueueRef(queuingRef)
|
||||
@@ -224,6 +234,7 @@ const QueueContextInitailizer = () => {
|
||||
item,
|
||||
downloadedTracks ?? [],
|
||||
QueuingType.PlayingNext,
|
||||
downloadQuality,
|
||||
)
|
||||
|
||||
TrackPlayer.add([playNextTrack], currentIndex + 1)
|
||||
@@ -247,6 +258,7 @@ const QueueContextInitailizer = () => {
|
||||
item,
|
||||
downloadedTracks ?? [],
|
||||
QueuingType.DirectlyQueued,
|
||||
downloadQuality,
|
||||
),
|
||||
),
|
||||
insertIndex,
|
||||
|
||||
@@ -2,6 +2,15 @@ import { Platform } from 'react-native'
|
||||
import { storage } from '../../constants/storage'
|
||||
import { MMKVStorageKeys } from '../../enums/mmkv-storage-keys'
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import {
|
||||
DEFAULT_CROSSFADE_DURATION,
|
||||
DEFAULT_FADE_CURVE,
|
||||
DEFAULT_AUTO_CROSSFADE,
|
||||
} from '../../player/gapless-config'
|
||||
|
||||
type FadeCurve = 'linear' | 'logarithmic' | 'exponential'
|
||||
|
||||
export type DownloadQuality = 'original' | 'high' | 'medium' | 'low'
|
||||
|
||||
interface SettingsContext {
|
||||
sendMetrics: boolean
|
||||
@@ -10,6 +19,17 @@ interface SettingsContext {
|
||||
setAutoDownload: React.Dispatch<React.SetStateAction<boolean>>
|
||||
devTools: boolean
|
||||
setDevTools: React.Dispatch<React.SetStateAction<boolean>>
|
||||
downloadQuality: DownloadQuality
|
||||
setDownloadQuality: React.Dispatch<React.SetStateAction<DownloadQuality>>
|
||||
// Crossfade settings
|
||||
crossfadeEnabled: boolean
|
||||
setCrossfadeEnabled: React.Dispatch<React.SetStateAction<boolean>>
|
||||
crossfadeDuration: number
|
||||
setCrossfadeDuration: React.Dispatch<React.SetStateAction<number>>
|
||||
crossfadeCurve: FadeCurve
|
||||
setCrossfadeCurve: React.Dispatch<React.SetStateAction<FadeCurve>>
|
||||
autoCrossfade: boolean
|
||||
setAutoCrossfade: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,19 +45,38 @@ interface SettingsContext {
|
||||
*/
|
||||
const SettingsContextInitializer = () => {
|
||||
const sendMetricsInit = storage.getBoolean(MMKVStorageKeys.SendMetrics)
|
||||
|
||||
const autoDownloadInit = storage.getBoolean(MMKVStorageKeys.AutoDownload)
|
||||
|
||||
const devToolsInit = storage.getBoolean(MMKVStorageKeys.DevTools)
|
||||
|
||||
const downloadQualityInit = storage.getString(
|
||||
MMKVStorageKeys.DownloadQuality,
|
||||
) as DownloadQuality
|
||||
|
||||
const [sendMetrics, setSendMetrics] = useState(sendMetricsInit ?? false)
|
||||
// Crossfade settings initialization
|
||||
const crossfadeEnabledInit = storage.getBoolean(MMKVStorageKeys.CrossfadeEnabled)
|
||||
const crossfadeDurationInit = storage.getNumber(MMKVStorageKeys.CrossfadeDuration)
|
||||
const crossfadeCurveInit = storage.getString(MMKVStorageKeys.CrossfadeCurve) as FadeCurve
|
||||
const autoCrossfadeInit = storage.getBoolean(MMKVStorageKeys.AutoCrossfade)
|
||||
|
||||
const [autoDownload, setAutoDownload] = useState(
|
||||
autoDownloadInit ?? ['ios', 'android'].includes(Platform.OS),
|
||||
)
|
||||
|
||||
const [devTools, setDevTools] = useState(false)
|
||||
|
||||
const [downloadQuality, setDownloadQuality] = useState<DownloadQuality>(
|
||||
downloadQualityInit ?? 'medium',
|
||||
)
|
||||
// Crossfade state
|
||||
const [crossfadeEnabled, setCrossfadeEnabled] = useState(crossfadeEnabledInit ?? true)
|
||||
const [crossfadeDuration, setCrossfadeDuration] = useState(
|
||||
crossfadeDurationInit ?? DEFAULT_CROSSFADE_DURATION,
|
||||
)
|
||||
const [crossfadeCurve, setCrossfadeCurve] = useState<FadeCurve>(
|
||||
crossfadeCurveInit ?? DEFAULT_FADE_CURVE,
|
||||
)
|
||||
const [autoCrossfade, setAutoCrossfade] = useState(autoCrossfadeInit ?? DEFAULT_AUTO_CROSSFADE)
|
||||
|
||||
useEffect(() => {
|
||||
storage.set(MMKVStorageKeys.SendMetrics, sendMetrics)
|
||||
}, [sendMetrics])
|
||||
@@ -46,10 +85,31 @@ const SettingsContextInitializer = () => {
|
||||
storage.set(MMKVStorageKeys.AutoDownload, autoDownload)
|
||||
}, [autoDownload])
|
||||
|
||||
useEffect(() => {
|
||||
storage.set(MMKVStorageKeys.DownloadQuality, downloadQuality)
|
||||
}, [downloadQuality])
|
||||
|
||||
useEffect(() => {
|
||||
storage.set(MMKVStorageKeys.DevTools, devTools)
|
||||
}, [devTools])
|
||||
|
||||
// Crossfade effects
|
||||
useEffect(() => {
|
||||
storage.set(MMKVStorageKeys.CrossfadeEnabled, crossfadeEnabled)
|
||||
}, [crossfadeEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
storage.set(MMKVStorageKeys.CrossfadeDuration, crossfadeDuration)
|
||||
}, [crossfadeDuration])
|
||||
|
||||
useEffect(() => {
|
||||
storage.set(MMKVStorageKeys.CrossfadeCurve, crossfadeCurve)
|
||||
}, [crossfadeCurve])
|
||||
|
||||
useEffect(() => {
|
||||
storage.set(MMKVStorageKeys.AutoCrossfade, autoCrossfade)
|
||||
}, [autoCrossfade])
|
||||
|
||||
return {
|
||||
sendMetrics,
|
||||
setSendMetrics,
|
||||
@@ -57,6 +117,16 @@ const SettingsContextInitializer = () => {
|
||||
setAutoDownload,
|
||||
devTools,
|
||||
setDevTools,
|
||||
downloadQuality,
|
||||
setDownloadQuality,
|
||||
crossfadeEnabled,
|
||||
setCrossfadeEnabled,
|
||||
crossfadeDuration,
|
||||
setCrossfadeDuration,
|
||||
crossfadeCurve,
|
||||
setCrossfadeCurve,
|
||||
autoCrossfade,
|
||||
setAutoCrossfade,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +137,16 @@ export const SettingsContext = createContext<SettingsContext>({
|
||||
setAutoDownload: () => {},
|
||||
devTools: false,
|
||||
setDevTools: () => {},
|
||||
downloadQuality: 'medium',
|
||||
setDownloadQuality: () => {},
|
||||
crossfadeEnabled: false,
|
||||
setCrossfadeEnabled: () => {},
|
||||
crossfadeDuration: DEFAULT_CROSSFADE_DURATION,
|
||||
setCrossfadeDuration: () => {},
|
||||
crossfadeCurve: DEFAULT_FADE_CURVE,
|
||||
setCrossfadeCurve: () => {},
|
||||
autoCrossfade: DEFAULT_AUTO_CROSSFADE,
|
||||
setAutoCrossfade: () => {},
|
||||
})
|
||||
|
||||
export const SettingsProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
@@ -1772,6 +1772,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz#7064533a573e3539ec912f59c1f457371bf49dd9"
|
||||
integrity sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==
|
||||
|
||||
"@react-native-picker/picker@^2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@react-native-picker/picker/-/picker-2.11.0.tgz#4587fbce6a382adedad74311e96ee10bb2b2d63a"
|
||||
integrity sha512-QuZU6gbxmOID5zZgd/H90NgBnbJ3VV6qVzp6c7/dDrmWdX8S0X5YFYgDcQFjE3dRen9wB9FWnj2VVdPU64adSg==
|
||||
|
||||
"@react-native/assets-registry@0.79.2":
|
||||
version "0.79.2"
|
||||
resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.79.2.tgz#731963e664c8543f5b277e56c058bde612b69f50"
|
||||
|
||||
Reference in New Issue
Block a user