Merge branch '0.12.30' of https://github.com/Jellify-Music/App into carplay-stuff

This commit is contained in:
Violet Caulfield
2025-05-28 06:11:13 -05:00
21 changed files with 1001 additions and 32 deletions
+1 -1
View File
@@ -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
+10 -1
View File
@@ -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(() => {
+14 -12
View File
@@ -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;
+28
View File
@@ -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
View File
@@ -1,5 +1,4 @@
git_url("https://github.com/Jellify-Music/Signing.git")
git_branch("main")
storage_mode("git")
+2 -1
View File
@@ -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"
}
}
}
+5 -1
View File
@@ -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
View File
@@ -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>
View File
+6
View File
@@ -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',
}
+38
View File
@@ -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`)
+72
View File
@@ -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
+137
View File
@@ -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
}
+148
View File
@@ -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)
}
}
+3 -1
View File
@@ -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)
},
+228 -3
View File
@@ -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
+13 -1
View File
@@ -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,
+83 -3
View File
@@ -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 }) => {
+5
View File
@@ -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"