From 8880d9e4118c6db1befbc57c00ed6de1e7e2524a Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:16:30 -0400 Subject: [PATCH] queue improvements --- src/audio/playbackService.ts | 17 ++++- src/components/queue/QueueTray.tsx | 113 ++++++++++++++++++++++------- 2 files changed, 103 insertions(+), 27 deletions(-) diff --git a/src/audio/playbackService.ts b/src/audio/playbackService.ts index 8f79b8a..a15e5cb 100644 --- a/src/audio/playbackService.ts +++ b/src/audio/playbackService.ts @@ -4,6 +4,8 @@ import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync'; import { applyNormalizationForActiveTrack } from './applyNormalization'; import { ensureGainRegistryStarted } from './gainRegistry'; import { ensureEQRouteSyncStarted } from './eqRouteSync'; +import { nativeIndexToAbsolute } from './queueLoader'; +import { useQueueStore } from '@/stores/queueStore'; /** * RNTP playback service — registered in `index.js`. Runs in a headless context @@ -43,7 +45,20 @@ export async function PlaybackService(): Promise { // already plays at its natively-registered (or fallback) gain from sample // zero; this only late-corrects unanalyzed tracks. Rapid skips coalesce. let normalizeTimer: ReturnType | null = null; - TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, () => { + TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, (event) => { + // Keep the queue mirror's active index fresh while the tray is unmounted. + // Natural track advances otherwise leave it stale, and the tray's + // synchronous first paint would show the old head for a frame before its + // refreshActiveIndex correction lands (visible flicker on open). Skips and + // jumps already update it via playbackController; this covers the rest. + // Cheap: the event carries the index, no queue marshal. Skipped while the + // mirror is cold so a headless Auto/Bluetooth session never pays for it. + const queueStore = useQueueStore.getState(); + if (queueStore.hasSnapshot) { + queueStore.setActiveIndex( + event.index != null ? nativeIndexToAbsolute(event.index) : -1 + ); + } scheduleSync(); // Apply normalization here too (not just in the UI hook) so playback started from // Android Auto / Bluetooth with the app closed is still normalized. diff --git a/src/components/queue/QueueTray.tsx b/src/components/queue/QueueTray.tsx index ec82975..1963c36 100644 --- a/src/components/queue/QueueTray.tsx +++ b/src/components/queue/QueueTray.tsx @@ -152,12 +152,13 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: // momentarily fail to constrain the list, so FlashList measures its viewport // as the full CONTENT height (~100k dp for a long queue), believes every row // is visible, and mounts thousands of views — a multi-second main-thread - // freeze. Clamping the list container to the window height caps the viewport - // no matter what the sheet reports; both snap points stay unaffected. - const listClampStyle = useMemo(() => ({ maxHeight: windowHeight }), [windowHeight]); - const embeddedListStyle = useMemo( - () => ({ maxHeight: windowHeight, flex: 1 }), - [windowHeight] + // freeze. Clamping the list area to the window height caps the viewport no + // matter what the sheet reports; both snap points stay unaffected. The flex + // matches FlashList's own root default, so the wrapper reproduces the exact + // box the list had when the clamp sat on it directly. + const listAreaStyle = useMemo( + () => [styles.listArea, { maxHeight: windowHeight }], + [styles, windowHeight] ); // Same bug, milder symptom: a viewport measured during the open animation can // stick at the clamp height (taller than the sheet's real content area), which @@ -167,6 +168,14 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: const onSheetChange = useCallback((index: number) => { if (index >= 0) setListReady(true); }, []); + // The gate leaves the list area blank while the sheet opens (plus FlashList's + // first-layout frame), which reads as content popping in. A static, + // pixel-identical preview of the first screenful of rows fills the slot from + // the tray's first frame and unmounts once the real list has painted (onLoad + // fires a frame after first layout completes, so rows are already underneath). + const [listPainted, setListPainted] = useState(false); + const onListLoad = useCallback(() => setListPainted(true), []); + const previewCount = Math.ceil(windowHeight / QUEUE_ROW_HEIGHT); // Bottom padding clears the gesture-nav inset so the last row is fully // scrollable into view at the 100% snap. const listContentStyle = useMemo( @@ -706,26 +715,35 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: Up next - {listReady ? ( - item.key} - drawDistance={QUEUE_ROW_HEIGHT * 12} - maintainVisibleContentPosition={{ disabled: true }} - renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent} - renderItem={renderItem} - extraData={listExtraData} - contentContainerStyle={embedded - ? styles.embeddedListContent - : editMode && selectedCount > 0 - ? listContentEditStyle - : listContentStyle} - showsVerticalScrollIndicator={false} - ListEmptyComponent={renderEmpty} - /> - ) : null} + + {listReady ? ( + item.key} + drawDistance={QUEUE_ROW_HEIGHT * 12} + maintainVisibleContentPosition={{ disabled: true }} + renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent} + renderItem={renderItem} + extraData={listExtraData} + onLoad={onListLoad} + contentContainerStyle={embedded + ? styles.embeddedListContent + : editMode && selectedCount > 0 + ? listContentEditStyle + : listContentStyle} + showsVerticalScrollIndicator={false} + ListEmptyComponent={renderEmpty} + /> + ) : null} + {!listPainted && entries.length > 0 ? ( + + {entries.slice(0, previewCount).map((entry) => ( + + ))} + + ) : null} + {editMode && selectedCount > 0 ? ( + + + + + {title} + + + {trackArtist(entry.track)} + + + + + + + + ); +}); + interface QueueRowProps { entry: QueueEntry; actionsEnabled: boolean; @@ -1095,9 +1140,25 @@ const useStyles = createThemedStyles((colors) => ({ borderWidth: StyleSheet.hairlineWidth, backgroundColor: colors.glassBg, }, + listArea: { + flex: 1, + overflow: 'hidden', + }, + listPreview: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, listContent: { flexGrow: 1, }, + // Matches the idle rowSurfaceStyle background the real rows get from their + // worklet; the preview has no worklets so it carries the color statically. + rowRest: { + backgroundColor: colors.bgSecondary, + }, empty: { alignItems: 'center', justifyContent: 'center',