fix now playing swipe softlock

This commit is contained in:
Boof2015
2026-07-28 14:08:15 -04:00
parent 4c40af970b
commit f00cd0abcd
8 changed files with 328 additions and 103 deletions
@@ -44,7 +44,7 @@ class CatalogMigrationTest {
).use { cursor -> ).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
assertEquals("jellyfin:2", cursor.getString(0)) assertEquals("jellyfin:2", cursor.getString(0))
assertEquals(CURRENT_ARTIST_CREDIT_VERSION, cursor.getInt(1)) assertEquals(INITIAL_MULTI_ARTIST_CREDIT_VERSION, cursor.getInt(1))
assertTrue(cursor.moveToNext()) assertTrue(cursor.moveToNext())
assertEquals("local:1", cursor.getString(0)) assertEquals("local:1", cursor.getString(0))
assertEquals(LEGACY_ARTIST_CREDIT_VERSION, cursor.getInt(1)) assertEquals(LEGACY_ARTIST_CREDIT_VERSION, cursor.getInt(1))
@@ -10,8 +10,12 @@ import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import org.json.JSONArray import org.json.JSONArray
internal const val CURRENT_ARTIST_CREDIT_VERSION = 2
internal const val LEGACY_ARTIST_CREDIT_VERSION = 1 internal const val LEGACY_ARTIST_CREDIT_VERSION = 1
internal const val INITIAL_MULTI_ARTIST_CREDIT_VERSION = 2
// Version 3 removes synthesized ampersands from stored display strings.
// Keeping this independent of the Room schema version lets existing local
// version-2 catalogs use the normal stale-source full-reindex path.
internal const val CURRENT_ARTIST_CREDIT_VERSION = 3
internal data class ArtistCreditNames( internal data class ArtistCreditNames(
val artists: List<String> = emptyList(), val artists: List<String> = emptyList(),
@@ -33,13 +37,7 @@ internal fun normalizeArtistNames(values: Iterable<String?>): List<String> {
} }
internal fun formatArtistNames(values: Iterable<String?>): String { internal fun formatArtistNames(values: Iterable<String?>): String {
val names = normalizeArtistNames(values) return normalizeArtistNames(values).joinToString(", ")
return when (names.size) {
0 -> ""
1 -> names[0]
2 -> "${names[0]} & ${names[1]}"
else -> "${names.dropLast(1).joinToString(", ")} & ${names.last()}"
}
} }
internal fun serializeArtistNames(values: Iterable<String?>): String? { internal fun serializeArtistNames(values: Iterable<String?>): String? {
@@ -16,8 +16,9 @@ class ArtistCreditsTest {
assertEquals(listOf("Earth, Wind & Fire", "The Emotions"), credits.artists) assertEquals(listOf("Earth, Wind & Fire", "The Emotions"), credits.artists)
assertEquals(listOf("Curator One", "Curator Two"), credits.albumArtists) assertEquals(listOf("Curator One", "Curator Two"), credits.albumArtists)
assertEquals("Earth, Wind & Fire & The Emotions", formatArtistNames(credits.artists)) assertEquals("Earth, Wind & Fire, The Emotions", formatArtistNames(credits.artists))
assertEquals("Curator One & Curator Two", formatArtistNames(credits.albumArtists)) assertEquals("Curator One, Curator Two", formatArtistNames(credits.albumArtists))
assertEquals("1, 2, 3", formatArtistNames(listOf("1", "2", "3")))
} }
@Test @Test
+188 -70
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/immutability, react-hooks/preserve-manual-memoization -- Reanimated gesture state is intentionally mutable, and the pan recognizer must retain identity across renders. */
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { import {
BackHandler, BackHandler,
@@ -42,8 +43,10 @@ import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanion
import { PlayerStateIcon } from '@/components/player/PlayerStateIcon'; import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek'; import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
import { import {
resolveNowPlayingPanRelease,
resolveNowPlayingDismissSpring, resolveNowPlayingDismissSpring,
shouldEnableNowPlayingPan, shouldEnableNowPlayingPan,
shouldStartNowPlayingPan,
} from '@/components/player/nowPlayingDismiss'; } from '@/components/player/nowPlayingDismiss';
import { useDelayedUnmountPresence } from '@/components/delayedPresence'; import { useDelayedUnmountPresence } from '@/components/delayedPresence';
import { import {
@@ -73,8 +76,11 @@ import {
NOW_PLAYING_WIDE_PANE_GAP, NOW_PLAYING_WIDE_PANE_GAP,
} from '@/components/player/nowPlayingLayout'; } from '@/components/player/nowPlayingLayout';
import { useReturnToTabs } from '@/navigation/returnToTabs'; import { useReturnToTabs } from '@/navigation/returnToTabs';
import { resolveNavigationArtist, splitCollaborators } from '@/library/artistGrouping'; import { resolveNavigationArtist } from '@/library/artistGrouping';
import { buildArtistNameTokens } from '@/shared/library/artistCredits'; import {
buildArtistNameTokens,
parseArtistMetadata,
} from '@/shared/library/artistCredits';
import { import {
artworkThumbFromSource, artworkThumbFromSource,
playerBackdropArtworkSource, playerBackdropArtworkSource,
@@ -107,9 +113,6 @@ import {
} from '@/playback/playbackTargetPresentation'; } from '@/playback/playbackTargetPresentation';
import { formatSleepTimerStatus } from '@/audio/sleepTimerState'; import { formatSleepTimerStatus } from '@/audio/sleepTimerState';
const DISMISS_DISTANCE = 140;
const DISMISS_VELOCITY = 1000;
const HEADER_HEIGHT = NOW_PLAYING_HEADER_HEIGHT; const HEADER_HEIGHT = NOW_PLAYING_HEADER_HEIGHT;
const CONTENT_TOP_PADDING = NOW_PLAYING_CONTENT_TOP_PADDING; const CONTENT_TOP_PADDING = NOW_PLAYING_CONTENT_TOP_PADDING;
const CONTENT_BOTTOM_PADDING = NOW_PLAYING_CONTENT_BOTTOM_PADDING; const CONTENT_BOTTOM_PADDING = NOW_PLAYING_CONTENT_BOTTOM_PADDING;
@@ -123,6 +126,7 @@ const MENU_ANIMATION_IN_MS = 130;
const MENU_ANIMATION_OUT_MS = 100; const MENU_ANIMATION_OUT_MS = 100;
const MENU_ENTER_OFFSET_Y = -8; const MENU_ENTER_OFFSET_Y = -8;
const NOW_PLAYING_SPECTRUM_SMOOTHING = 0.85; const NOW_PLAYING_SPECTRUM_SMOOTHING = 0.85;
const PAN_DISMISS_HANDOFF_BACKSTOP_MS = 120;
interface NowPlayingMenuItem { interface NowPlayingMenuItem {
key: string; key: string;
@@ -151,8 +155,6 @@ export function NowPlayingOverlay() {
const surfacesLive = playerOpen && foreground; const surfacesLive = playerOpen && foreground;
const [queueOpen, setQueueOpen] = useState(false); const [queueOpen, setQueueOpen] = useState(false);
const [lyricsBodySwitching, setLyricsBodySwitching] = useState(false); const [lyricsBodySwitching, setLyricsBodySwitching] = useState(false);
// Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it.
const closeQueue = useCallback(() => setQueueOpen(false), []);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [targetPickerOpen, setTargetPickerOpen] = useState(false); const [targetPickerOpen, setTargetPickerOpen] = useState(false);
const [sleepTimerOpen, setSleepTimerOpen] = useState(false); const [sleepTimerOpen, setSleepTimerOpen] = useState(false);
@@ -256,6 +258,13 @@ export function NowPlayingOverlay() {
insets.right + insets.right +
contentPadding + contentPadding +
Math.max(0, (effectiveWidth - contentPadding * 2 - shellWidth) / 2); Math.max(0, (effectiveWidth - contentPadding * 2 - shellWidth) / 2);
const shellLeft =
insets.left +
contentPadding +
Math.max(0, (effectiveWidth - contentPadding * 2 - shellWidth) / 2);
const companionStartX = tabletCompanionLayout
? shellLeft + shellWidth - tabletCompanionLayout.companionWidth
: Number.POSITIVE_INFINITY;
const menuTop = insets.top + CONTENT_TOP_PADDING + HEADER_HEIGHT + spacing.xs; const menuTop = insets.top + CONTENT_TOP_PADDING + HEADER_HEIGHT + spacing.xs;
const libraryTrack = useMemo( const libraryTrack = useMemo(
() => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null), () => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null),
@@ -274,14 +283,41 @@ export function NowPlayingOverlay() {
: ''; : '';
const artistCreditTokens = useMemo(() => { const artistCreditTokens = useMemo(() => {
if (!track) return []; if (!track) return [];
const collaborators = if (track.artistNames && track.artistNames.length > 0) {
track.artistNames && track.artistNames.length > 0 return buildArtistNameTokens(track.artistNames);
? track.artistNames }
: splitCollaborators(track.artist); return parseArtistMetadata(track.artist);
return buildArtistNameTokens(collaborators.length > 0 ? collaborators : [track.artist]);
}, [track]); }, [track]);
const albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key; const albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key;
// The overlay stays mounted; open/close is this one shared value sliding the
// sheet on the UI thread. The gesture itself is memoized below, so changing
// child/body state updates these gates rather than replacing the recognizer.
const translateY = useSharedValue(windowHeight);
const panEnabled = useSharedValue(
shouldEnableNowPlayingPan(playerOpen, queueOpen, lyricsBodySwitching)
);
const panDismissRequested = useSharedValue(false);
const panExitPending = useSharedValue(false);
const panReleaseVelocity = useSharedValue(0);
const panReleaseRemainingDistance = useSharedValue(windowHeight);
const screenHeight = useSharedValue(windowHeight);
const companionTouchStartX = useSharedValue(companionStartX);
const menuProgress = useSharedValue(0);
const trackProgress = useSharedValue(1);
// ∿ engagement, shared by both scope styles: rail = art shrink + strip fade,
// rack = art face crossfading to the instrument rack. The presence gates keep
// both faces for the 220 ms transition, then release the invisible surface.
const stageProgress = useSharedValue(effectiveScopeStageVisible ? 1 : 0);
const suspendPanForChildTransition = () => {
panEnabled.value = false;
panDismissRequested.value = false;
panExitPending.value = false;
cancelAnimation(translateY);
translateY.value = 0;
};
useEffect(() => { useEffect(() => {
if (!hasTabletCompanion || isDesktopTarget) return; if (!hasTabletCompanion || isDesktopTarget) return;
if (queueOpen) { if (queueOpen) {
@@ -309,6 +345,7 @@ export function NowPlayingOverlay() {
} }
return; return;
} }
suspendPanForChildTransition();
setQueueOpen(true); setQueueOpen(true);
}; };
@@ -406,16 +443,6 @@ export function NowPlayingOverlay() {
}); });
} }
// The overlay stays mounted; open/close is this one shared value sliding the
// sheet on the UI thread. Starts off-screen so a pre-warmed mount never flashes.
const translateY = useSharedValue(windowHeight);
const menuProgress = useSharedValue(0);
const trackProgress = useSharedValue(1);
// ∿ engagement, shared by both scope styles: rail = art shrink + strip fade,
// rack = art face crossfading to the instrument rack. The presence gates keep
// both faces for the 220 ms transition, then release the invisible surface.
const stageProgress = useSharedValue(effectiveScopeStageVisible ? 1 : 0);
/** /**
* Swapping the phone player body unmounts every normal control underneath the * Swapping the phone player body unmounts every normal control underneath the
* parent pan detector. Suspend that pan for the commit frame and synchronously * parent pan detector. Suspend that pan for the commit frame and synchronously
@@ -424,8 +451,7 @@ export function NowPlayingOverlay() {
*/ */
const setPhoneLyricsVisible = (visible: boolean) => { const setPhoneLyricsVisible = (visible: boolean) => {
if (!playerOpen) return; if (!playerOpen) return;
cancelAnimation(translateY); suspendPanForChildTransition();
translateY.value = 0;
setLyricsBodySwitching(true); setLyricsBodySwitching(true);
void setLyricsVisible(visible); void setLyricsVisible(visible);
}; };
@@ -444,6 +470,22 @@ export function NowPlayingOverlay() {
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [lyricsBodySwitching, lyricsMode]); }, [lyricsBodySwitching, lyricsMode]);
useEffect(() => {
panEnabled.value = shouldEnableNowPlayingPan(
playerOpen,
queueOpen,
lyricsBodySwitching
);
}, [lyricsBodySwitching, panEnabled, playerOpen, queueOpen]);
useEffect(() => {
screenHeight.value = windowHeight;
}, [screenHeight, windowHeight]);
useEffect(() => {
companionTouchStartX.value = companionStartX;
}, [companionStartX, companionTouchStartX]);
useEffect(() => { useEffect(() => {
if (!transitionTrackKey) return; if (!transitionTrackKey) return;
trackProgress.value = 0; trackProgress.value = 0;
@@ -508,49 +550,6 @@ export function NowPlayingOverlay() {
); );
}; };
const pan = Gesture.Pan()
// A child sheet owns vertical gestures while it is visible. Replacing this
// gesture during the queue-button touch used to cancel a partially active
// pan and leave translateY off-screen while phase still said "open".
.enabled(shouldEnableNowPlayingPan(playerOpen, queueOpen, lyricsBodySwitching))
.activeOffsetY(14) // engage only on a downward drag
.failOffsetY(-14)
.failOffsetX([-24, 24]) // let the horizontal seek drag through
.onUpdate((e) => {
translateY.value = e.translationY > 0 ? e.translationY : 0;
})
.onEnd((e) => {
if (e.translationY > DISMISS_DISTANCE || e.velocityY > DISMISS_VELOCITY) {
const releaseSpring = resolveNowPlayingDismissSpring(
e.velocityY,
windowHeight - translateY.value
);
// Same commit-first contract as dismissSheet, with the release spring's
// velocity-matched shaping preserved.
runOnJS(beginDismiss)();
translateY.value = withSpring(
windowHeight,
{
damping: releaseSpring.damping,
stiffness: releaseSpring.stiffness,
velocity: releaseSpring.velocity,
overshootClamping: true,
energyThreshold: 1e-4,
},
(finished) => {
if (finished) runOnJS(commitClosed)();
}
);
} else {
translateY.value = withTiming(0, motion.snap);
}
})
.onFinalize((_event, success) => {
// RNGH does not call onEnd for a cancelled gesture. Never leave the
// overlay at its last partial translation in that path.
if (!success) translateY.value = withTiming(0, motion.snap);
});
// Enter animation. Keyed on `openRequest` as well as the phase, so asking for // Enter animation. Keyed on `openRequest` as well as the phase, so asking for
// a player that already believes it is open still re-runs the slide-in — that // a player that already believes it is open still re-runs the slide-in — that
// is the recovery path for a sheet stranded off-screen by an interrupted // is the recovery path for a sheet stranded off-screen by an interrupted
@@ -566,14 +565,46 @@ export function NowPlayingOverlay() {
// The gesture and button paths own the exit animation, including its // The gesture and button paths own the exit animation, including its
// velocity-matched spring shaping. Only animate here when `closing` // velocity-matched spring shaping. Only animate here when `closing`
// arrived from a direct closePlayer() with no animation attached. // arrived from a direct closePlayer() with no animation attached.
if (!exitAnimated) { if (exitAnimated && panExitPending.value) {
const releaseSpring = resolveNowPlayingDismissSpring(
panReleaseVelocity.value,
panReleaseRemainingDistance.value
);
panExitPending.value = false;
cancelAnimation(translateY);
translateY.value = withSpring(
screenHeight.value,
{
damping: releaseSpring.damping,
stiffness: releaseSpring.stiffness,
velocity: releaseSpring.velocity,
overshootClamping: true,
energyThreshold: 1e-4,
},
(finished) => {
if (finished) runOnJS(commitClosed)();
}
);
} else if (!exitAnimated) {
translateY.value = withTiming(windowHeight, { duration: 200 }); translateY.value = withTiming(windowHeight, { duration: 200 });
} }
return; return;
} }
translateY.value = withTiming(0, { duration: 240 }); translateY.value = withTiming(0, { duration: 240 });
// eslint-disable-next-line react-hooks/exhaustive-deps -- windowHeight excluded on purpose (see above) // eslint-disable-next-line react-hooks/exhaustive-deps -- windowHeight excluded on purpose (see above)
}, [phase, openRequest, exitAnimated, queueOpen, lyricsMode, translateY]); }, [
phase,
openRequest,
exitAnimated,
queueOpen,
lyricsMode,
commitClosed,
panExitPending,
panReleaseRemainingDistance,
panReleaseVelocity,
screenHeight,
translateY,
]);
// `closing` → `closed`, and `opening` → `open`. Both are timers rather than // `closing` → `closed`, and `opening` → `open`. Both are timers rather than
// animation callbacks, so a cancelled animation can never strand the phase. // animation callbacks, so a cancelled animation can never strand the phase.
@@ -599,6 +630,92 @@ export function NowPlayingOverlay() {
return undefined; return undefined;
}, [phase, openRequest]); }, [phase, openRequest]);
const pan = useMemo(
() => Gesture.Pan()
.activeOffsetY(14) // engage only on a downward drag
.failOffsetY(-14)
.failOffsetX([-24, 24]) // let the horizontal seek drag through
.onTouchesDown((event, stateManager) => {
const touchX = event.allTouches[0]?.absoluteX ?? Number.NaN;
if (
!shouldStartNowPlayingPan(
panEnabled.value,
touchX,
companionTouchStartX.value
)
) {
stateManager.fail();
}
})
.onTouchesMove((_event, stateManager) => {
if (!panEnabled.value) stateManager.fail();
})
.onStart(() => {
panDismissRequested.value = false;
panExitPending.value = false;
cancelAnimation(translateY);
})
.onUpdate((event) => {
if (!panEnabled.value) {
translateY.value = 0;
return;
}
translateY.value = event.translationY > 0 ? event.translationY : 0;
})
.onEnd((event, success) => {
const release = resolveNowPlayingPanRelease(
event.translationY,
event.velocityY,
success && panEnabled.value
);
if (release === 'dismiss') {
panDismissRequested.value = true;
panExitPending.value = true;
panReleaseVelocity.value = event.velocityY;
panReleaseRemainingDistance.value =
screenHeight.value - translateY.value;
// If the RN handoff is ever dropped, restore the partial drag instead
// of leaving an open player stranded. The phase effect cancels this
// delayed animation only after Zustand has entered `closing`.
translateY.value = withDelay(
PAN_DISMISS_HANDOFF_BACKSTOP_MS,
withTiming(0, motion.snap)
);
runOnJS(beginDismiss)();
return;
}
panDismissRequested.value = false;
panExitPending.value = false;
translateY.value = withTiming(0, motion.snap);
})
.onFinalize(() => {
// Successful dismissals retain the short handoff backstop above. Every
// other terminal path, including cancellation, re-anchors immediately.
if (!panDismissRequested.value) {
translateY.value = withTiming(0, motion.snap);
}
}),
[
beginDismiss,
companionTouchStartX,
panDismissRequested,
panEnabled,
panExitPending,
panReleaseRemainingDistance,
panReleaseVelocity,
screenHeight,
translateY,
]
);
// Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it.
const closeQueue = useCallback(() => {
suspendPanForChildTransition();
setQueueOpen(false);
// Shared values and the state setter remain stable for this overlay mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Hardware back, innermost layer first: menu → queue tray → player. Registered // Hardware back, innermost layer first: menu → queue tray → player. Registered
// only while open, so it sits above the focused screen's own handlers (LIFO) // only while open, so it sits above the focused screen's own handlers (LIFO)
// — e.g. the library-detail back interceptor underneath. // — e.g. the library-detail back interceptor underneath.
@@ -622,7 +739,7 @@ export function NowPlayingOverlay() {
return true; return true;
} }
if (queueOpen) { if (queueOpen) {
setQueueOpen(false); closeQueue();
return true; return true;
} }
dismissSheet(); dismissSheet();
@@ -637,6 +754,7 @@ export function NowPlayingOverlay() {
sleepTimerOpen, sleepTimerOpen,
playlistActionTrack, playlistActionTrack,
queueOpen, queueOpen,
closeQueue,
windowHeight, windowHeight,
]); ]);
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
resolveNowPlayingPanRelease,
resolveNowPlayingDismissSpring, resolveNowPlayingDismissSpring,
shouldEnableNowPlayingPan, shouldEnableNowPlayingPan,
shouldStartNowPlayingPan,
} from './nowPlayingDismiss.ts'; } from './nowPlayingDismiss.ts';
test('player pan yields to child sheets and body replacements', () => { test('player pan yields to child sheets and body replacements', () => {
@@ -13,6 +15,30 @@ test('player pan yields to child sheets and body replacements', () => {
assert.equal(shouldEnableNowPlayingPan(false, false, false), false); assert.equal(shouldEnableNowPlayingPan(false, false, false), false);
}); });
test('player pan starts outside child-owned companion scrolling', () => {
assert.equal(
shouldStartNowPlayingPan(true, 900, Number.POSITIVE_INFINITY),
true,
'phones have no companion boundary'
);
assert.equal(shouldStartNowPlayingPan(true, 719, 720), true);
assert.equal(shouldStartNowPlayingPan(true, 720, 720), false);
assert.equal(shouldStartNowPlayingPan(true, 900, 720), false);
assert.equal(shouldStartNowPlayingPan(false, 100, 720), false);
});
test('only a successfully ended threshold release dismisses the player', () => {
assert.equal(resolveNowPlayingPanRelease(141, 0, true), 'dismiss');
assert.equal(resolveNowPlayingPanRelease(0, 1001, true), 'dismiss');
assert.equal(resolveNowPlayingPanRelease(140, 1000, true), 'restore');
assert.equal(resolveNowPlayingPanRelease(-200, -2000, true), 'restore');
assert.equal(resolveNowPlayingPanRelease(500, 5000, false), 'restore');
assert.equal(
resolveNowPlayingPanRelease(Number.POSITIVE_INFINITY, Number.NaN, true),
'restore'
);
});
test('preserves ordinary downward release velocity and spring', () => { test('preserves ordinary downward release velocity and spring', () => {
for (const velocity of [0, 1, 500, 999, 1000]) { for (const velocity of [0, 1, 500, 999, 1000]) {
assert.deepEqual(resolveNowPlayingDismissSpring(velocity, 760), { assert.deepEqual(resolveNowPlayingDismissSpring(velocity, 760), {
@@ -6,6 +6,8 @@ const BASE_DAMPING = 28;
const MAX_DAMPING = 60; const MAX_DAMPING = 60;
const MIN_REMAINING_DISTANCE = 120; const MIN_REMAINING_DISTANCE = 120;
const VELOCITY_TRAVEL_DAMPING = 1.35; const VELOCITY_TRAVEL_DAMPING = 1.35;
const DISMISS_DISTANCE = 140;
const DISMISS_VELOCITY = 1000;
interface NowPlayingDismissSpring { interface NowPlayingDismissSpring {
velocity: number; velocity: number;
@@ -13,6 +15,8 @@ interface NowPlayingDismissSpring {
damping: number; damping: number;
} }
export type NowPlayingPanRelease = 'dismiss' | 'restore';
/** /**
* The overlay pan must briefly yield when its body is being replaced. Otherwise * The overlay pan must briefly yield when its body is being replaced. Otherwise
* RNGH can cancel the old child tree after it has already written a partial * RNGH can cancel the old child tree after it has already written a partial
@@ -26,6 +30,41 @@ export function shouldEnableNowPlayingPan(
return playerOpen && !childSheetOpen && !bodySwitching; return playerOpen && !childSheetOpen && !bodySwitching;
} }
/**
* Queue/lyrics companion rails own their scroll gestures. A non-finite boundary
* means there is no companion rail, so the whole phone player remains eligible.
*/
export function shouldStartNowPlayingPan(
panEnabled: boolean,
absoluteX: number,
companionStartX: number
): boolean {
'worklet';
if (!panEnabled) return false;
if (!Number.isFinite(companionStartX)) return true;
return Number.isFinite(absoluteX) && absoluteX < companionStartX;
}
/**
* Cancellation is authoritative even if the last event carried a large
* translation or velocity. Only a successfully ended gesture may dismiss.
*/
export function resolveNowPlayingPanRelease(
translationY: number,
velocityY: number,
success: boolean
): NowPlayingPanRelease {
'worklet';
if (!success) return 'restore';
const distance = Number.isFinite(translationY) ? Math.max(0, translationY) : 0;
const velocity = Number.isFinite(velocityY) ? Math.max(0, velocityY) : 0;
return distance > DISMISS_DISTANCE || velocity > DISMISS_VELOCITY
? 'dismiss'
: 'restore';
}
/** /**
* Preserve the exact release velocity, then make the spring progressively * Preserve the exact release velocity, then make the spring progressively
* stronger and more damped for hard flicks so it sheds speed after handoff. * stronger and more damped for hard flicks so it sheds speed after handoff.
@@ -1,8 +1,11 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { parseNowPlayingCompanion } from './nowPlayingPreferences.ts'; import { parseNowPlayingCompanion } from './nowPlayingPreferences.ts';
import { splitCollaborators } from '../../shared/library/albumGrouping.ts'; import {
import { buildArtistNameTokens } from '../../shared/library/artistCredits.ts'; buildArtistNameTokens,
formatArtistNames,
parseArtistMetadata,
} from '../../shared/library/artistCredits.ts';
test('defaults missing and invalid companion preferences to queue', () => { test('defaults missing and invalid companion preferences to queue', () => {
assert.equal(parseNowPlayingCompanion(null), 'queue'); assert.equal(parseNowPlayingCompanion(null), 'queue');
@@ -16,18 +19,33 @@ test('restores persisted queue and lyrics companion preferences', () => {
}); });
test('builds separate clickable credits for collaborative track artists', () => { test('builds separate clickable credits for collaborative track artists', () => {
const artists = splitCollaborators('Dazbee feat. 9Lana & ValkyR'); const artists = ['Dazbee', '9Lana', 'ValkyR'];
assert.deepEqual(artists, ['Dazbee', '9Lana', 'ValkyR']); assert.equal(formatArtistNames(artists), 'Dazbee, 9Lana, ValkyR');
assert.deepEqual(buildArtistNameTokens(artists), [ assert.deepEqual(buildArtistNameTokens(artists), [
{ artist: 'Dazbee', separator: ', ' }, { artist: 'Dazbee', separator: ', ' },
{ artist: '9Lana', separator: ' & ' }, { artist: '9Lana', separator: ', ' },
{ artist: 'ValkyR', separator: null }, { artist: 'ValkyR', separator: null },
]); ]);
}); });
test('structured credits preserve commas and ampersands inside one artist name', () => { test('structured credits preserve commas and ampersands inside one artist name', () => {
assert.deepEqual(buildArtistNameTokens(['Earth, Wind & Fire', 'The Emotions']), [ assert.deepEqual(buildArtistNameTokens(['Earth, Wind & Fire', 'The Emotions']), [
{ artist: 'Earth, Wind & Fire', separator: ' & ' }, { artist: 'Earth, Wind & Fire', separator: ', ' },
{ artist: 'The Emotions', separator: null }, { artist: 'The Emotions', separator: null },
]); ]);
}); });
test('legacy display credits keep their literal separators', () => {
assert.deepEqual(parseArtistMetadata('1, 2, 3'), [
{ artist: '1', separator: ', ' },
{ artist: '2', separator: ', ' },
{ artist: '3', separator: null },
]);
assert.deepEqual(parseArtistMetadata('Simon & Garfunkel'), [
{ artist: 'Simon & Garfunkel', separator: null },
]);
assert.deepEqual(parseArtistMetadata('One; Two'), [
{ artist: 'One', separator: '; ' },
{ artist: 'Two', separator: null },
]);
});
+41 -16
View File
@@ -1,5 +1,6 @@
// Port of desktop astra/src/shared/library/artistCredits.ts — keep semantically // Shared credit normalization. Structured arrays retain exact artist
// identical so the album-grouping algorithm stays in sync across apps. // boundaries; display punctuation must come from the tags or use neutral
// comma separators rather than inventing collaboration punctuation.
export interface ArtistNameToken { export interface ArtistNameToken {
artist: string; artist: string;
@@ -44,22 +45,46 @@ export function deserializeArtistNames(value: unknown): string[] {
} }
export function formatArtistNames(names: readonly unknown[] | null | undefined): string { export function formatArtistNames(names: readonly unknown[] | null | undefined): string {
const normalized = normalizeArtistNames(names); return normalizeArtistNames(names).join(', ');
if (normalized.length === 0) return '';
if (normalized.length === 1) return normalized[0];
if (normalized.length === 2) return `${normalized[0]} & ${normalized[1]}`;
return `${normalized.slice(0, -1).join(', ')} & ${normalized[normalized.length - 1]}`;
} }
export function buildArtistNameTokens(names: readonly unknown[] | null | undefined): ArtistNameToken[] { export function buildArtistNameTokens(names: readonly unknown[] | null | undefined): ArtistNameToken[] {
const normalized = normalizeArtistNames(names); const normalized = normalizeArtistNames(names);
return normalized.map((artist, index) => { return normalized.map((artist, index) => ({
let separator: string | null = null; artist,
if (index < normalized.length - 2) { separator: index < normalized.length - 1 ? ', ' : null,
separator = ', '; }));
} else if (index === normalized.length - 2) { }
separator = ' & ';
} const LITERAL_SEPARATOR_PATTERN = /([,;])/;
return { artist, separator };
}); /**
* Build clickable fallback credits from a legacy display string while keeping
* its literal punctuation. Ampersands are intentionally not split: without
* structured metadata, they may be part of an artist's actual name or tag.
*/
export function parseArtistMetadata(artistText: string): ArtistNameToken[] {
const normalized = normalizeArtistName(artistText);
if (!normalized) return [];
const tokens: ArtistNameToken[] = [];
let pendingSeparator: ',' | ';' | null = null;
for (const part of normalized.split(LITERAL_SEPARATOR_PATTERN)) {
if (part === ',' || part === ';') {
pendingSeparator = part;
continue;
}
const artist = normalizeArtistName(part);
if (!artist) continue;
if (pendingSeparator && tokens.length > 0) {
tokens[tokens.length - 1].separator = `${pendingSeparator} `;
}
tokens.push({ artist, separator: null });
pendingSeparator = null;
}
return tokens.length > 0 ? tokens : [{ artist: normalized, separator: null }];
} }