Cut queue remap churn, speed lyric lookup, clean cast listener cleanup, and avoid redundant HEADs on downloads

This commit is contained in:
skalthoff
2025-11-27 22:32:11 -08:00
parent e265b02069
commit f9e0e82e57
5 changed files with 105 additions and 39 deletions

View File

@@ -94,8 +94,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
CFE47DDB2EA56B0200EB6067 /* icons */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = icons;
sourceTree = "<group>";
};
@@ -399,10 +397,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";
@@ -416,10 +418,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";
@@ -583,7 +589,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Jellify/Jellify.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 264;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
@@ -606,7 +612,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.cosmonautical.jellify;
PRODUCT_NAME = Jellify;
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.cosmonautical.jellify";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.cosmonautical.jellify";
SWIFT_OBJC_BRIDGING_HEADER = "Jellify-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -706,10 +712,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;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -796,10 +799,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;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -928,10 +928,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;
STRING_CATALOG_GENERATE_SYMBOLS = YES;

View File

@@ -18,6 +18,27 @@ type DownloadedFileInfo = {
size: number
}
const getExtensionFromUrl = (url: string): string | null => {
const sanitized = url.split('?')[0]
const lastSegment = sanitized.split('/').pop() ?? ''
const match = lastSegment.match(/\.([a-zA-Z0-9]+)$/)
return match?.[1] ?? null
}
const normalizeExtension = (ext: string | undefined | null) => {
if (!ext) return null
const clean = ext.toLowerCase()
return clean === 'mpeg' ? 'mp3' : clean
}
const extensionFromContentType = (contentType: string | undefined): string | null => {
if (!contentType) return null
if (!contentType.includes('/')) return null
const [, subtypeRaw] = contentType.split('/')
const container = subtypeRaw.split(';')[0]
return normalizeExtension(container)
}
export type DeleteDownloadsResult = {
deletedCount: number
freedBytes: number
@@ -29,23 +50,30 @@ export async function downloadJellyfinFile(
name: string,
songName: string,
setDownloadProgress: JellifyDownloadProgressState,
preferredExtension?: string | null,
): Promise<DownloadedFileInfo> {
try {
// Fetch the file
const headRes = await axios.head(url)
const contentType = headRes.headers['content-type']
const urlExtension = normalizeExtension(getExtensionFromUrl(url))
const hintedExtension = normalizeExtension(preferredExtension)
// Step 2: Get extension from content-type
let extension = 'mp3' // default extension
if (contentType && contentType.includes('/')) {
const parts = contentType.split('/')
const container = parts[1].split(';')[0] // handles "audio/m4a; charset=utf-8"
if (container !== 'mpeg') {
extension = container // don't use mpeg as an extension, use the default extension
let extension = urlExtension ?? hintedExtension ?? null
if (!extension) {
try {
const headRes = await axios.head(url)
const headExtension = extensionFromContentType(headRes.headers['content-type'])
if (headExtension) extension = headExtension
} catch (error) {
console.warn(
'HEAD request failed when determining download type, using default',
error,
)
}
}
// Step 3: Build path
if (!extension) extension = 'bin' // fallback without assuming a specific codec
// Build path
const fileName = `${name}.${extension}`
const downloadDest = `${RNFS.DocumentDirectoryPath}/${fileName}`
@@ -138,6 +166,7 @@ export const saveAudio = async (
track.item.Id as string,
track.title as string,
setDownloadProgress,
track.mediaSourceInfo?.Container,
)
let downloadedArtworkFile: DownloadedFileInfo | undefined
if (track.artwork) {
@@ -146,6 +175,7 @@ export const saveAudio = async (
track.item.Id as string,
track.title as string,
setDownloadProgress,
undefined,
)
}
track.url = downloadedTrackFile.uri

View File

@@ -31,6 +31,7 @@ import { useHideRunTimesSetting } from '../../../stores/settings/app'
import { queryClient, ONE_HOUR } from '../../../constants/query-client'
import { fetchMediaInfo } from '../../../api/queries/media/utils'
import MediaInfoQueryKey from '../../../api/queries/media/keys'
import JellifyTrack from '../../../types/JellifyTrack'
export interface TrackProps {
track: BaseItemDto
@@ -48,6 +49,19 @@ export interface TrackProps {
editing?: boolean | undefined
}
const queueItemsCache = new WeakMap<JellifyTrack[], BaseItemDto[]>()
const getQueueItems = (queue: JellifyTrack[] | undefined): BaseItemDto[] => {
if (!queue?.length) return []
const cached = queueItemsCache.get(queue)
if (cached) return cached
const mapped = queue.map((entry) => entry.item)
queueItemsCache.set(queue, mapped)
return mapped
}
export default function Track({
track,
navigation,
@@ -99,7 +113,7 @@ export default function Track({
// Memoize tracklist for queue loading
const memoizedTracklist = useMemo(
() => tracklist ?? playQueue?.map((track) => track.item) ?? [],
() => tracklist ?? getQueueItems(playQueue),
[tracklist, playQueue],
)

View File

@@ -201,22 +201,36 @@ export default function Lyrics({
}
}, [lyrics])
const lyricStartTimes = useMemo(
() => parsedLyrics.map((line) => line.startTime),
[parsedLyrics],
)
// Track manually selected lyric for immediate feedback
const manuallySelectedIndex = useSharedValue(-1)
const manualSelectTimeout = useRef<NodeJS.Timeout | null>(null)
// Find current lyric line based on playback position
const currentLyricIndex = useMemo(() => {
if (!position || parsedLyrics.length === 0) return -1
if (position === null || position === undefined || lyricStartTimes.length === 0) return -1
// Find the last lyric that has started
for (let i = parsedLyrics.length - 1; i >= 0; i--) {
if (position >= parsedLyrics[i].startTime) {
return i
// Binary search to find the last startTime <= position
let low = 0
let high = lyricStartTimes.length - 1
let found = -1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
if (position >= lyricStartTimes[mid]) {
found = mid
low = mid + 1
} else {
high = mid - 1
}
}
return -1
}, [position, parsedLyrics])
return found
}, [position, lyricStartTimes])
// Simple auto-scroll that keeps highlighted lyric in center
const scrollToCurrentLyric = useCallback(() => {

View File

@@ -7,7 +7,7 @@ import {
import usePlayerEngineStore from '../../../stores/player/engine'
import { PlayerEngine } from '../../../stores/player/engine'
import { MediaPlayerState, useRemoteMediaClient, useStreamPosition } from 'react-native-google-cast'
import { useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
export const useProgress = (UPDATE_INTERVAL: number): Progress => {
const { position, duration, buffered } = useProgressRNTP(UPDATE_INTERVAL)
@@ -58,16 +58,27 @@ export const usePlaybackState = (): State | undefined => {
const isCasting = playerEngineData === PlayerEngine.GOOGLE_CAST
const [playbackState, setPlaybackState] = useState<State | undefined>(state)
useMemo(() => {
useEffect(() => {
let unsubscribe: (() => void) | undefined
if (client && isCasting) {
client.onMediaStatusUpdated((status) => {
const handler = (status: { playerState?: MediaPlayerState } | null) => {
if (status?.playerState) {
setPlaybackState(castToRNTPState(status.playerState))
}
})
}
const maybeUnsubscribe = client.onMediaStatusUpdated(handler)
if (typeof maybeUnsubscribe === 'function') {
unsubscribe = maybeUnsubscribe
}
} else {
setPlaybackState(state)
}
return () => {
if (unsubscribe) unsubscribe()
}
}, [client, isCasting, state])
return playbackState