From 0de38b6ede0a7c291bcadd3eba51865b20e718e3 Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Sun, 31 May 2026 18:32:20 +1000 Subject: [PATCH 01/18] refactor(player): extract shuffle overlay + queue actions into shared hooks New useShuffleOverlay/usePlayerActions hooks and ShuffleOverlay component; consumers migrate in follow-up commits. --- src/components/ShuffleOverlay.tsx | 61 ++++++++++++++++++++++++++++++ src/hooks/usePlayerActions.ts | 63 +++++++++++++++++++++++++++++++ src/hooks/useShuffleOverlay.ts | 57 ++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 src/components/ShuffleOverlay.tsx create mode 100644 src/hooks/usePlayerActions.ts create mode 100644 src/hooks/useShuffleOverlay.ts diff --git a/src/components/ShuffleOverlay.tsx b/src/components/ShuffleOverlay.tsx new file mode 100644 index 0000000..cd8d18e --- /dev/null +++ b/src/components/ShuffleOverlay.tsx @@ -0,0 +1,61 @@ +import Ionicons from "@react-native-vector-icons/ionicons/static"; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native'; +import Animated from 'react-native-reanimated'; + +import { type ThemeColors } from '../constants/theme'; +import { absoluteFill } from '../utils/styles'; + +export interface ShuffleOverlayProps { + visible: boolean; + overlayStyle: StyleProp; + spinStyle: StyleProp; + colors: ThemeColors; +} + +/** Full-screen "Shuffling…" spin overlay. Drive with `useShuffleOverlay`. */ +export const ShuffleOverlay = memo(function ShuffleOverlay({ + visible, + overlayStyle, + spinStyle, + colors, +}: ShuffleOverlayProps) { + const { t } = useTranslation(); + + if (!visible) return null; + + return ( + + + + + + + {t('shuffling')} + + + + ); +}); + +const styles = StyleSheet.create({ + shuffleOverlay: { + ...absoluteFill, + backgroundColor: 'rgba(0,0,0,0.5)', + alignItems: 'center', + justifyContent: 'center', + zIndex: 20, + }, + shuffleCard: { + borderRadius: 16, + paddingHorizontal: 32, + paddingVertical: 24, + alignItems: 'center', + gap: 12, + }, + shuffleText: { + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/src/hooks/usePlayerActions.ts b/src/hooks/usePlayerActions.ts new file mode 100644 index 0000000..9c78abf --- /dev/null +++ b/src/hooks/usePlayerActions.ts @@ -0,0 +1,63 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useThemedAlert } from './useThemedAlert'; +import { clearQueue, seekTo, skipToTrack } from '../services/playerService'; +import { createShareStore } from '../store/createShareStore'; +import { moreOptionsStore, type MoreOptionsSource } from '../store/moreOptionsStore'; +import { playerStore } from '../store/playerStore'; +import { type Child } from '../services/subsonicService'; + +export interface UsePlayerActionsOptions { + /** Identifies which player UI opened the more-options sheet. */ + source: MoreOptionsSource; + /** Override for the clear-queue confirm action (defaults to `clearQueue`). */ + onClearConfirmed?: () => void; +} + +/** + * Shared queue/playback handlers used by every player surface. Playback + * business logic lives in `playerService`; these are the thin UI wrappers. + */ +export function usePlayerActions({ source, onClearConfirmed }: UsePlayerActionsOptions) { + const { t } = useTranslation(); + const { alert } = useThemedAlert(); + + const handleSeek = useCallback((seconds: number) => { + seekTo(seconds); + }, []); + + const handleQueueItemPress = useCallback((index: number) => { + skipToTrack(index); + }, []); + + const handleQueueItemLongPress = useCallback((track: Child) => { + moreOptionsStore.getState().show({ type: 'song', item: track }, source); + }, [source]); + + const handleShareQueue = useCallback(() => { + const ids = playerStore.getState().queue.map((track) => track.id); + if (ids.length > 0) { + createShareStore.getState().showQueue(ids); + } + }, []); + + const handleClearQueue = useCallback(() => { + alert( + t('clearQueue'), + t('clearQueueMessage'), + [ + { text: t('cancel'), style: 'cancel' }, + { text: t('clear'), style: 'destructive', onPress: onClearConfirmed ?? clearQueue }, + ], + ); + }, [alert, t, onClearConfirmed]); + + return { + handleSeek, + handleQueueItemPress, + handleQueueItemLongPress, + handleShareQueue, + handleClearQueue, + }; +} diff --git a/src/hooks/useShuffleOverlay.ts b/src/hooks/useShuffleOverlay.ts new file mode 100644 index 0000000..9a5eeb3 --- /dev/null +++ b/src/hooks/useShuffleOverlay.ts @@ -0,0 +1,57 @@ +import { useCallback, useState } from 'react'; +import { + Easing, + cancelAnimation, + interpolate, + runOnJS, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; + +import { shuffleQueue } from '../services/playerService'; + +/** + * Owns the shuffle action plus its full-screen "Shuffling…" spin overlay. + * Pair with the `` component, passing the returned + * `overlayStyle`/`spinStyle` and gating render on `shuffling`. + */ +export function useShuffleOverlay() { + const [shuffling, setShuffling] = useState(false); + const overlayOpacity = useSharedValue(0); + const spinAnim = useSharedValue(0); + + const overlayStyle = useAnimatedStyle(() => ({ + opacity: overlayOpacity.value, + })); + + const spinStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${interpolate(spinAnim.value, [0, 1], [0, 360])}deg` }], + })); + + const handleShuffle = useCallback(async () => { + if (shuffling) return; + setShuffling(true); + spinAnim.value = 0; + + overlayOpacity.value = withTiming(1, { duration: 250 }); + spinAnim.value = withRepeat( + withTiming(1, { duration: 800, easing: Easing.linear }), + -1, + ); + + const MIN_DISPLAY = 2000; + await Promise.all([ + shuffleQueue(), + new Promise((r) => setTimeout(r, MIN_DISPLAY)), + ]); + + cancelAnimation(spinAnim); + overlayOpacity.value = withTiming(0, { duration: 300 }, (finished) => { + if (finished) runOnJS(setShuffling)(false); + }); + }, [shuffling, overlayOpacity, spinAnim]); + + return { shuffling, handleShuffle, overlayStyle, spinStyle }; +} From 4d6d250c06da9cbdc4add1c133c51562db335c28 Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Sun, 31 May 2026 18:43:11 +1000 Subject: [PATCH 02/18] refactor(player): migrate PlayerView onto shared shuffle/queue hooks Consume useShuffleOverlay/usePlayerActions + ; type the shared animated styles as AnimatedStyle. Folds in this file's earlier control-layout work (shuffle in the transport row, skip-interval buttons in the secondary row, control spacing) since it shares the file. --- src/components/ShuffleButton.tsx | 4 +- src/components/ShuffleOverlay.tsx | 8 +- src/hooks/useShuffleOverlay.ts | 5 +- src/screens/player-view.tsx | 199 +++++++++++------------------- 4 files changed, 82 insertions(+), 134 deletions(-) diff --git a/src/components/ShuffleButton.tsx b/src/components/ShuffleButton.tsx index c1c4cfb..c2768af 100644 --- a/src/components/ShuffleButton.tsx +++ b/src/components/ShuffleButton.tsx @@ -8,11 +8,13 @@ import { useTheme } from '../hooks/useTheme'; export interface ShuffleButtonProps { onPress: () => void; disabled?: boolean; + size?: number; } export const ShuffleButton = memo(function ShuffleButton({ onPress, disabled = false, + size = 20, }: ShuffleButtonProps) { const { t } = useTranslation(); const { colors } = useTheme(); @@ -29,7 +31,7 @@ export const ShuffleButton = memo(function ShuffleButton({ (pressed || disabled) && styles.pressed, ]} > - + ); }); diff --git a/src/components/ShuffleOverlay.tsx b/src/components/ShuffleOverlay.tsx index cd8d18e..122a14e 100644 --- a/src/components/ShuffleOverlay.tsx +++ b/src/components/ShuffleOverlay.tsx @@ -1,16 +1,16 @@ import Ionicons from "@react-native-vector-icons/ionicons/static"; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native'; -import Animated from 'react-native-reanimated'; +import { StyleSheet, Text, View, type ViewStyle } from 'react-native'; +import Animated, { type AnimatedStyle } from 'react-native-reanimated'; import { type ThemeColors } from '../constants/theme'; import { absoluteFill } from '../utils/styles'; export interface ShuffleOverlayProps { visible: boolean; - overlayStyle: StyleProp; - spinStyle: StyleProp; + overlayStyle: AnimatedStyle; + spinStyle: AnimatedStyle; colors: ThemeColors; } diff --git a/src/hooks/useShuffleOverlay.ts b/src/hooks/useShuffleOverlay.ts index 9a5eeb3..41dfcee 100644 --- a/src/hooks/useShuffleOverlay.ts +++ b/src/hooks/useShuffleOverlay.ts @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react'; +import { type ViewStyle } from 'react-native'; import { Easing, cancelAnimation, @@ -22,11 +23,11 @@ export function useShuffleOverlay() { const overlayOpacity = useSharedValue(0); const spinAnim = useSharedValue(0); - const overlayStyle = useAnimatedStyle(() => ({ + const overlayStyle = useAnimatedStyle(() => ({ opacity: overlayOpacity.value, })); - const spinStyle = useAnimatedStyle(() => ({ + const spinStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${interpolate(spinAnim.value, [0, 1], [0, 360])}deg` }], })); diff --git a/src/screens/player-view.tsx b/src/screens/player-view.tsx index f4989b2..a7a9882 100644 --- a/src/screens/player-view.tsx +++ b/src/screens/player-view.tsx @@ -14,13 +14,10 @@ import { } from 'react-native'; import Animated, { Easing, - cancelAnimation, interpolate, useAnimatedStyle, useSharedValue, - withRepeat, withTiming, - runOnJS, } from 'react-native-reanimated'; import { Pressable as GHPressable } from 'react-native-gesture-handler'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -37,6 +34,7 @@ import { PlayerProgressBar } from '../components/PlayerProgressBar'; import { PlayerTabBar, type PlayerTab } from '../components/PlayerTabBar'; import { RepeatButton } from '../components/RepeatButton'; import { ShuffleButton } from '../components/ShuffleButton'; +import { ShuffleOverlay } from '../components/ShuffleOverlay'; import { SkipIntervalButton } from '../components/SkipIntervalButton'; import { SleepTimerButton } from '../components/SleepTimerButton'; import { SleepTimerCapsule } from '../components/SleepTimerCapsule'; @@ -46,9 +44,9 @@ import { type ThemeColors } from '../constants/theme'; import { useCanSkip } from '../hooks/useCanSkip'; import { useImagePalette } from '../hooks/useImagePalette'; import { useIsStarred } from '../hooks/useIsStarred'; +import { usePlayerActions } from '../hooks/usePlayerActions'; +import { useShuffleOverlay } from '../hooks/useShuffleOverlay'; import { useTheme } from '../hooks/useTheme'; -import { ThemedAlert } from '../components/ThemedAlert'; -import { useThemedAlert } from '../hooks/useThemedAlert'; import { toggleStar } from '../services/moreOptionsService'; import { buildAutoName, capturePlayerSnapshot, commitBookmark } from '../services/bookmarkService'; import { bookmarkSheetStore } from '../store/bookmarkSheetStore'; @@ -58,11 +56,8 @@ import { offlineModeStore } from '../store/offlineModeStore'; import { clearQueue, retryPlayback, - seekTo, - shuffleQueue, skipToNext, skipToPrevious, - skipToTrack, togglePlayPause, } from '../services/playerService'; import { sanitizeBiographyText } from '../utils/formatters'; @@ -70,7 +65,6 @@ import { type Child } from '../services/subsonicService'; import { usePlayerAlbumInfo } from '../hooks/usePlayerAlbumInfo'; import { usePlayerLyrics } from '../hooks/usePlayerLyrics'; import { playbackSettingsStore } from '../store/playbackSettingsStore'; -import { createShareStore } from '../store/createShareStore'; import { moreOptionsStore } from '../store/moreOptionsStore'; import { playerStore } from '../store/playerStore'; import { mixHexColors } from '../utils/colors'; @@ -92,7 +86,6 @@ const QUEUE_CONTENT_CONTAINER_STYLE = { paddingBottom: 12 } as const; export function PlayerView() { const { colors } = useTheme(); const { t } = useTranslation(); - const { alert } = useThemedAlert(); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const router = useRouter(); @@ -224,85 +217,30 @@ export function PlayerView() { }); }, [currentTrack, navigation, onClose, colors.textPrimary]); - const handleSeek = useCallback((seconds: number) => { - seekTo(seconds); - }, []); - - const handleQueueItemPress = useCallback((index: number) => { - skipToTrack(index); - }, []); - - const handleQueueItemLongPress = useCallback((track: Child) => { - moreOptionsStore.getState().show({ type: 'song', item: track }, 'player'); - }, []); - - const handleClearQueue = useCallback(() => { - alert( - t('clearQueue'), - t('clearQueueMessage'), - [ - { text: t('cancel'), style: 'cancel' }, - { - text: t('clear'), - style: 'destructive', - onPress: () => { - onClose(); - setTimeout(() => { - clearQueue(); - }, 350); - }, - }, - ], - ); + const onClearConfirmed = useCallback(() => { + onClose(); + setTimeout(() => clearQueue(), 350); }, [onClose]); - // --- Shuffle overlay state --- - const [shuffling, setShuffling] = useState(false); - const overlayOpacity = useSharedValue(0); - const spinAnim = useSharedValue(0); + const { + handleSeek, + handleQueueItemPress, + handleQueueItemLongPress, + handleShareQueue, + handleClearQueue, + } = usePlayerActions({ source: 'player', onClearConfirmed }); + + const { + shuffling, + handleShuffle, + overlayStyle, + spinStyle, + } = useShuffleOverlay(); const gradientAnimatedStyle = useAnimatedStyle(() => ({ opacity: gradientOpacity.value, })); - const overlayAnimatedStyle = useAnimatedStyle(() => ({ - opacity: overlayOpacity.value, - })); - - const spinStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `${interpolate(spinAnim.value, [0, 1], [0, 360])}deg` }], - })); - - const handleShuffle = useCallback(async () => { - if (shuffling) return; - setShuffling(true); - spinAnim.value = 0; - - overlayOpacity.value = withTiming(1, { duration: 250 }); - spinAnim.value = withRepeat( - withTiming(1, { duration: 800, easing: Easing.linear }), - -1, - ); - - const MIN_DISPLAY = 2000; - await Promise.all([ - shuffleQueue(), - new Promise((r) => setTimeout(r, MIN_DISPLAY)), - ]); - - cancelAnimation(spinAnim); - overlayOpacity.value = withTiming(0, { duration: 300 }, (finished) => { - if (finished) runOnJS(setShuffling)(false); - }); - }, [shuffling, overlayOpacity, spinAnim]); - - const handleShareQueue = useCallback(() => { - const ids = queue.map((t) => t.id); - if (ids.length > 0) { - createShareStore.getState().showQueue(ids); - } - }, [queue]); - // Muted primary for active queue item highlight const queueColors = useMemo(() => ({ ...colors, @@ -405,6 +343,8 @@ export function PlayerView() { colors={colors} queueLoading={queueLoading} handleSeek={handleSeek} + handleShuffle={handleShuffle} + shuffling={shuffling} /> @@ -469,21 +409,12 @@ export function PlayerView() { {/* Shuffle overlay */} - {shuffling && ( - - - - - - - {t('shuffling')} - - - - )} + ); @@ -582,6 +513,8 @@ interface PlayerContentProps { colors: ThemeColors; queueLoading: boolean; handleSeek: (seconds: number) => void; + handleShuffle: () => void; + shuffling: boolean; } const PlayerContent = memo(function PlayerContent({ @@ -589,6 +522,8 @@ const PlayerContent = memo(function PlayerContent({ colors, queueLoading, handleSeek, + handleShuffle, + shuffling, }: PlayerContentProps) { const { t } = useTranslation(); const { height: windowHeight, width: windowWidth } = useWindowDimensions(); @@ -599,6 +534,7 @@ const PlayerContent = memo(function PlayerContent({ const bufferedPosition = playerStore((s) => s.bufferedPosition); const error = playerStore((s) => s.error); const retrying = playerStore((s) => s.retrying); + const queueLength = playerStore((s) => s.queue.length); const showSkipInterval = playbackSettingsStore((s) => s.showSkipIntervalButtons); const showSleepTimer = playbackSettingsStore((s) => s.showSleepTimerButton); @@ -691,11 +627,21 @@ const PlayerContent = memo(function PlayerContent({ /> + {/* Three equal flex spacers (above the primary row, between the rows, + and below the secondary row) evenly distribute the controls in the + space under the progress bar — this centers the primary row between + the progress bar and the secondary row. */} + + {/* Playback controls */} - {/* Playback rate toggle */} + {/* Shuffle toggle */} - + {/* Transport controls */} @@ -713,10 +659,6 @@ const PlayerContent = memo(function PlayerContent({ /> - {showSkipInterval && ( - - )} - [ @@ -737,10 +679,6 @@ const PlayerContent = memo(function PlayerContent({ )} - {showSkipInterval && ( - - )} - - {/* Secondary controls row — mirrors primary controls layout */} + {/* Middle spacer — equal to the spacers above and below the rows. */} + + + {/* Secondary controls row — mirrors primary controls layout. Skip-interval + buttons sit under prev/next with the playback rate between them. */} {showSleepTimer && } - + + {showSkipInterval && ( + + )} + + + + {showSkipInterval && ( + + )} + @@ -1055,10 +1007,21 @@ const styles = StyleSheet.create({ secondaryCenter: { width: 248, }, + secondaryCenterRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-evenly', + }, + secondaryRateSlot: { + width: 64, + alignItems: 'center', + justifyContent: 'center', + }, secondaryControls: { flexDirection: 'row', alignItems: 'center', height: 40, + marginTop: 20, paddingHorizontal: HERO_PADDING, maxWidth: 464, width: '100%', @@ -1126,22 +1089,4 @@ const styles = StyleSheet.create({ lyricsContainer: { flex: 1, }, - shuffleOverlay: { - ...absoluteFill, - backgroundColor: 'rgba(0,0,0,0.5)', - alignItems: 'center', - justifyContent: 'center', - zIndex: 20, - }, - shuffleCard: { - borderRadius: 16, - paddingHorizontal: 32, - paddingVertical: 24, - alignItems: 'center', - gap: 12, - }, - shuffleText: { - fontSize: 16, - fontWeight: '600', - }, }); From 371e93ac151459e13f087c3a58a0b82d20fafd70 Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Sun, 31 May 2026 18:50:08 +1000 Subject: [PATCH 03/18] refactor(player): migrate ExpandedPlayerView onto shared shuffle/queue hooks Consume useShuffleOverlay/usePlayerActions + (alias the shuffle overlay style to avoid colliding with the expand-entrance overlayStyle). Fixes the literal 'Shuffling\u2026' overlay text via t('shuffling'). Folds in this file's earlier control-layout work. --- src/components/ExpandedPlayerView.tsx | 184 ++++++++------------------ 1 file changed, 55 insertions(+), 129 deletions(-) diff --git a/src/components/ExpandedPlayerView.tsx b/src/components/ExpandedPlayerView.tsx index 6928aa6..53999aa 100644 --- a/src/components/ExpandedPlayerView.tsx +++ b/src/components/ExpandedPlayerView.tsx @@ -13,15 +13,9 @@ import { type LayoutChangeEvent, } from 'react-native'; import Animated, { - Easing, Extrapolation, - cancelAnimation, interpolate, useAnimatedStyle, - useSharedValue, - withRepeat, - withTiming, - runOnJS, type SharedValue, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -36,6 +30,7 @@ import { PlayerProgressBar } from './PlayerProgressBar'; import { QueueItemRow } from './QueueItemRow'; import { RepeatButton } from './RepeatButton'; import { ShuffleButton } from './ShuffleButton'; +import { ShuffleOverlay } from './ShuffleOverlay'; import { SkipIntervalButton } from './SkipIntervalButton'; import { SleepTimerButton } from './SleepTimerButton'; import { SleepTimerCapsule } from './SleepTimerCapsule'; @@ -44,17 +39,14 @@ import { useCanSkip } from '../hooks/useCanSkip'; import { useImagePalette } from '../hooks/useImagePalette'; import { mixHexColors } from '../utils/colors'; import { useIsStarred } from '../hooks/useIsStarred'; +import { usePlayerActions } from '../hooks/usePlayerActions'; +import { useShuffleOverlay } from '../hooks/useShuffleOverlay'; import { useTheme } from '../hooks/useTheme'; -import { useThemedAlert } from '../hooks/useThemedAlert'; import { toggleStar } from '../services/moreOptionsService'; import { - clearQueue, retryPlayback, - seekTo, - shuffleQueue, skipToNext, skipToPrevious, - skipToTrack, togglePlayPause, } from '../services/playerService'; import { sanitizeBiographyText } from '../utils/formatters'; @@ -62,7 +54,6 @@ import { type Child } from '../services/subsonicService'; import { usePlayerAlbumInfo } from '../hooks/usePlayerAlbumInfo'; import { usePlayerLyrics } from '../hooks/usePlayerLyrics'; import { playbackSettingsStore } from '../store/playbackSettingsStore'; -import { createShareStore } from '../store/createShareStore'; import { moreOptionsStore } from '../store/moreOptionsStore'; import { offlineModeStore } from '../store/offlineModeStore'; import { playerStore } from '../store/playerStore'; @@ -82,7 +73,6 @@ export function ExpandedPlayerView({ }: ExpandedPlayerViewProps) { const { t } = useTranslation(); const { colors } = useTheme(); - const { alert } = useThemedAlert(); const insets = useSafeAreaInsets(); const currentTrack = playerStore((s) => s.currentTrack); const currentTrackIndex = playerStore((s) => s.currentTrackIndex); @@ -214,71 +204,20 @@ export function ExpandedPlayerView({ tabletLayoutStore.getState().setPlayerExpanded(false); }, []); - const handleSeek = useCallback((seconds: number) => { - seekTo(seconds); - }, []); - - const handleQueueItemPress = useCallback((index: number) => { - skipToTrack(index); - }, []); - - const handleQueueItemLongPress = useCallback((track: Child) => { - moreOptionsStore.getState().show({ type: 'song', item: track }, 'playerexpanded'); - }, []); - - const handleClearQueue = useCallback(() => { - alert( - t('clearQueue'), - t('clearQueueMessage'), - [ - { text: t('cancel'), style: 'cancel' }, - { text: t('clear'), style: 'destructive', onPress: clearQueue }, - ], - ); - }, []); - - const handleShareQueue = useCallback(() => { - const ids = queue.map((t) => t.id); - if (ids.length > 0) { - createShareStore.getState().showQueue(ids); - } - }, [queue]); - - // --- Shuffle overlay --- - const [shuffling, setShuffling] = useState(false); - const shuffleOverlayOpacity = useSharedValue(0); - const spinAnim = useSharedValue(0); - - const shuffleOverlayStyle = useAnimatedStyle(() => ({ - opacity: shuffleOverlayOpacity.value, - })); - - const spinStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `${interpolate(spinAnim.value, [0, 1], [0, 360])}deg` }], - })); + const { + handleSeek, + handleQueueItemPress, + handleQueueItemLongPress, + handleShareQueue, + handleClearQueue, + } = usePlayerActions({ source: 'playerexpanded' }); - const handleShuffle = useCallback(async () => { - if (shuffling) return; - setShuffling(true); - spinAnim.value = 0; - - shuffleOverlayOpacity.value = withTiming(1, { duration: 250 }); - spinAnim.value = withRepeat( - withTiming(1, { duration: 800, easing: Easing.linear }), - -1, - ); - - const MIN_DISPLAY = 2000; - await Promise.all([ - shuffleQueue(), - new Promise((r) => setTimeout(r, MIN_DISPLAY)), - ]); - - cancelAnimation(spinAnim); - shuffleOverlayOpacity.value = withTiming(0, { duration: 300 }, (finished) => { - if (finished) runOnJS(setShuffling)(false); - }); - }, [shuffling, shuffleOverlayOpacity, spinAnim]); + const { + shuffling, + handleShuffle, + overlayStyle: shuffleOverlayStyle, + spinStyle, + } = useShuffleOverlay(); // --- Queue rendering --- @@ -439,7 +378,11 @@ export function ExpandedPlayerView({ {/* Transport controls */} - + @@ -452,10 +395,6 @@ export function ExpandedPlayerView({ - {showSkipInterval && ( - - )} - [ @@ -476,10 +415,6 @@ export function ExpandedPlayerView({ )} - {showSkipInterval && ( - - )} - - {/* Secondary controls row — sleep timer button */} - {showSleepTimer && ( - - - + {/* Secondary controls row — sleep timer, then skip-interval + buttons under prev/next with the playback rate between them */} + + + {showSleepTimer && } + + + {showSkipInterval && ( + + )} + + - - + {showSkipInterval && ( + + )} - )} + + {/* Quality badge */} {qualityLabel && ( @@ -654,21 +598,12 @@ export function ExpandedPlayerView({ {/* Shuffle overlay */} - {shuffling && ( - - - - - - - Shuffling\u2026 - - - - )} + ); @@ -831,11 +766,22 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', height: 36, + marginTop: 20, marginBottom: 8, }, secondaryCenter: { width: 248, }, + secondaryCenterRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-evenly', + }, + secondaryRateSlot: { + width: 64, + alignItems: 'center', + justifyContent: 'center', + }, controlSideLeft: { flex: 1, alignItems: 'flex-start', @@ -928,26 +874,6 @@ const styles = StyleSheet.create({ padding: 4, }, - /* --- Shuffle overlay --- */ - shuffleOverlay: { - ...absoluteFill, - backgroundColor: 'rgba(0,0,0,0.5)', - alignItems: 'center', - justifyContent: 'center', - zIndex: 20, - }, - shuffleCard: { - borderRadius: 16, - paddingHorizontal: 32, - paddingVertical: 24, - alignItems: 'center', - gap: 12, - }, - shuffleText: { - fontSize: 16, - fontWeight: '600', - }, - pressed: { opacity: 0.6, }, From 0fdb134f1a39dbf2886095256efb7a7e4cbd428c Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Sun, 31 May 2026 18:54:04 +1000 Subject: [PATCH 04/18] refactor(player): migrate PlayerPanel onto shared shuffle/queue hooks Consume useShuffleOverlay/usePlayerActions + (drops the panel's entire reanimated import). Folds in this file's earlier change removing the secondary-row/skip-interval/sleep-timer UI from the panel. --- src/components/PlayerPanel.tsx | 173 +++++---------------------------- 1 file changed, 23 insertions(+), 150 deletions(-) diff --git a/src/components/PlayerPanel.tsx b/src/components/PlayerPanel.tsx index 5b41b05..24b8275 100644 --- a/src/components/PlayerPanel.tsx +++ b/src/components/PlayerPanel.tsx @@ -1,6 +1,6 @@ import Ionicons from "@react-native-vector-icons/ionicons/static"; import { FlashList } from '@shopify/flash-list'; -import { memo, useCallback, useMemo, useState } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, @@ -9,16 +9,6 @@ import { Text, View, } from 'react-native'; -import Animated, { - Easing, - cancelAnimation, - interpolate, - useAnimatedStyle, - useSharedValue, - withRepeat, - withTiming, - runOnJS, -} from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { GradientBackground } from './GradientBackground'; @@ -30,43 +20,35 @@ import { PlayerProgressBar } from './PlayerProgressBar'; import { QueueItemRow } from './QueueItemRow'; import { RepeatButton } from './RepeatButton'; import { ShuffleButton } from './ShuffleButton'; -import { SkipIntervalButton } from './SkipIntervalButton'; -import { SleepTimerButton } from './SleepTimerButton'; +import { ShuffleOverlay } from './ShuffleOverlay'; import { SleepTimerCapsule } from './SleepTimerCapsule'; import { closeOpenRow } from './SwipeableRow'; import { type ThemeColors } from '../constants/theme'; import { useCanSkip } from '../hooks/useCanSkip'; import { useIsStarred } from '../hooks/useIsStarred'; import { mixHexColors } from '../utils/colors'; +import { usePlayerActions } from '../hooks/usePlayerActions'; +import { useShuffleOverlay } from '../hooks/useShuffleOverlay'; import { useTheme } from '../hooks/useTheme'; -import { useThemedAlert } from '../hooks/useThemedAlert'; import { toggleStar } from '../services/moreOptionsService'; import { - clearQueue, retryPlayback, - seekTo, - shuffleQueue, skipToNext, skipToPrevious, - skipToTrack, togglePlayPause, } from '../services/playerService'; import { type Child } from '../services/subsonicService'; -import { playbackSettingsStore } from '../store/playbackSettingsStore'; -import { createShareStore } from '../store/createShareStore'; import { moreOptionsStore } from '../store/moreOptionsStore'; import { offlineModeStore } from '../store/offlineModeStore'; import { playerStore } from '../store/playerStore'; import { tabletLayoutStore } from '../store/tabletLayoutStore'; -import { absoluteFill } from '../utils/styles'; const COVER_SIZE = 300; const PADDING = 16; export function PlayerPanel() { const { t } = useTranslation(); const { colors } = useTheme(); - const { alert } = useThemedAlert(); const insets = useSafeAreaInsets(); const queueContentContainerStyle = useMemo( () => ({ paddingBottom: insets.bottom + 16 }), @@ -77,75 +59,24 @@ export function PlayerPanel() { const queue = playerStore((s) => s.queue); const queueLoading = playerStore((s) => s.queueLoading); - const handleSeek = useCallback((seconds: number) => { - seekTo(seconds); - }, []); - - const handleQueueItemPress = useCallback((index: number) => { - skipToTrack(index); - }, []); - - const handleQueueItemLongPress = useCallback((track: Child) => { - moreOptionsStore.getState().show({ type: 'song', item: track }, 'playerpanel'); - }, []); - - const handleClearQueue = useCallback(() => { - alert( - t('clearQueue'), - t('clearQueueMessage'), - [ - { text: t('cancel'), style: 'cancel' }, - { text: t('clear'), style: 'destructive', onPress: clearQueue }, - ], - ); - }, []); - - const handleShareQueue = useCallback(() => { - const ids = queue.map((t) => t.id); - if (ids.length > 0) { - createShareStore.getState().showQueue(ids); - } - }, [queue]); + const { + handleSeek, + handleQueueItemPress, + handleQueueItemLongPress, + handleShareQueue, + handleClearQueue, + } = usePlayerActions({ source: 'playerpanel' }); const handleExpand = useCallback(() => { tabletLayoutStore.getState().setPlayerExpanded(true); }, []); - // --- Shuffle overlay state --- - const [shuffling, setShuffling] = useState(false); - const overlayOpacity = useSharedValue(0); - const spinAnim = useSharedValue(0); - - const overlayAnimatedStyle = useAnimatedStyle(() => ({ - opacity: overlayOpacity.value, - })); - - const spinStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `${interpolate(spinAnim.value, [0, 1], [0, 360])}deg` }], - })); - - const handleShuffle = useCallback(async () => { - if (shuffling) return; - setShuffling(true); - spinAnim.value = 0; - - overlayOpacity.value = withTiming(1, { duration: 250 }); - spinAnim.value = withRepeat( - withTiming(1, { duration: 800, easing: Easing.linear }), - -1, - ); - - const MIN_DISPLAY = 2000; - await Promise.all([ - shuffleQueue(), - new Promise((r) => setTimeout(r, MIN_DISPLAY)), - ]); - - cancelAnimation(spinAnim); - overlayOpacity.value = withTiming(0, { duration: 300 }, (finished) => { - if (finished) runOnJS(setShuffling)(false); - }); - }, [shuffling, overlayOpacity, spinAnim]); + const { + shuffling, + handleShuffle, + overlayStyle, + spinStyle, + } = useShuffleOverlay(); // Muted primary for active queue item highlight const queueColors = useMemo(() => ({ @@ -258,21 +189,12 @@ export function PlayerPanel() { {/* Shuffle overlay */} - {shuffling && ( - - - - - - - Shuffling… - - - - )} + ); } @@ -343,8 +265,6 @@ const PanelHeader = memo(function PanelHeader({ const error = playerStore((s) => s.error); const retrying = playerStore((s) => s.retrying); - const showSkipInterval = playbackSettingsStore((s) => s.showSkipIntervalButtons); - const showSleepTimer = playbackSettingsStore((s) => s.showSleepTimerButton); const { canSkipNext, canSkipPrevious } = useCanSkip(); const isPlaying = @@ -444,10 +364,6 @@ const PanelHeader = memo(function PanelHeader({ - {showSkipInterval && ( - - )} - [ @@ -468,10 +384,6 @@ const PanelHeader = memo(function PanelHeader({ )} - {showSkipInterval && ( - - )} - - {/* Secondary controls row — sleep timer button */} - {showSleepTimer && ( - - - - - - - - )} - ); }); @@ -595,16 +496,6 @@ const styles = StyleSheet.create({ paddingHorizontal: PADDING, marginBottom: 8, }, - secondaryControls: { - flexDirection: 'row', - alignItems: 'center', - height: 36, - paddingHorizontal: PADDING, - marginBottom: 8, - }, - secondaryCenter: { - width: 190, - }, controlSideLeft: { flex: 1, alignItems: 'flex-start', @@ -670,22 +561,4 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: '600', }, - shuffleOverlay: { - ...absoluteFill, - backgroundColor: 'rgba(0,0,0,0.5)', - alignItems: 'center', - justifyContent: 'center', - zIndex: 20, - }, - shuffleCard: { - borderRadius: 16, - paddingHorizontal: 32, - paddingVertical: 24, - alignItems: 'center', - gap: 12, - }, - shuffleText: { - fontSize: 16, - fontWeight: '600', - }, }); From 59c9fb1a3109c0a51932e3c6ba11f6cf0e03c537 Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Sun, 31 May 2026 18:59:37 +1000 Subject: [PATCH 05/18] feat(player): add UpNextPanel inline draggable queue/info/lyrics panel In-tree (non-modal) sliding panel for the tablet-portrait player; reuses QueueItemRow/AlbumInfoContent/LyricsContent + the player data hooks. Driven by a parent-owned panelHeight SharedValue + pan gesture on the drag handle. --- src/components/UpNextPanel.tsx | 329 +++++++++++++++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 src/components/UpNextPanel.tsx diff --git a/src/components/UpNextPanel.tsx b/src/components/UpNextPanel.tsx new file mode 100644 index 0000000..e0bac1f --- /dev/null +++ b/src/components/UpNextPanel.tsx @@ -0,0 +1,329 @@ +import Ionicons from "@react-native-vector-icons/ionicons/static"; +import MaterialCommunityIcons from "@react-native-vector-icons/material-design-icons/static"; +import { FlashList } from '@shopify/flash-list'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { GestureDetector, type PanGesture } from 'react-native-gesture-handler'; +import Animated, { useAnimatedStyle, type SharedValue } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { AlbumInfoContent } from './AlbumInfoContent'; +import { LyricsContent } from './LyricsContent'; +import { QueueItemRow } from './QueueItemRow'; +import { closeOpenRow } from './SwipeableRow'; +import { type ThemeColors } from '../constants/theme'; +import { usePlayerAlbumInfo } from '../hooks/usePlayerAlbumInfo'; +import { usePlayerLyrics } from '../hooks/usePlayerLyrics'; +import { type Child } from '../services/subsonicService'; +import { sanitizeBiographyText } from '../utils/formatters'; + +type PanelMode = 'queue' | 'info' | 'lyrics'; + +export interface UpNextPanelProps { + /** Visible height of the panel in px (parent-owned, driven by the drag). */ + panelHeight: SharedValue; + /** Full (maximum) panel height — the panel slides within this fixed height. */ + maxHeight: number; + /** Pan gesture (owned by the parent) attached to the drag handle. */ + panGesture: PanGesture; + currentTrack: Child; + queue: Child[]; + currentTrackIndex: number; + colors: ThemeColors; + /** Muted-primary variant for the active queue row highlight. */ + queueColors: ThemeColors; + offlineMode: boolean; + onQueueItemPress: (index: number) => void; + onQueueItemLongPress: (track: Child) => void; + onShareQueue: () => void; + onClearQueue: () => void; +} + +/** + * Inline draggable "Up Next" panel for the tablet-portrait player. Hosts the + * Queue / Album Info / Lyrics views with a toggle, sliding between detents via + * `panelHeight`. Deliberately in-tree (NOT a Modal) so the global + * MoreOptionsSheet can open over it without stacking two native modals. + */ +export const UpNextPanel = memo(function UpNextPanel({ + panelHeight, + maxHeight, + panGesture, + currentTrack, + queue, + currentTrackIndex, + colors, + queueColors, + offlineMode, + onQueueItemPress, + onQueueItemLongPress, + onShareQueue, + onClearQueue, +}: UpNextPanelProps) { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const [mode, setMode] = useState('queue'); + + // Info/Lyrics are hidden offline — fall back to the queue if they vanish. + useEffect(() => { + if (offlineMode && mode !== 'queue') setMode('queue'); + }, [offlineMode, mode]); + + // Album info — only fetch while the info view is actually showing. + const albumId = currentTrack.albumId ?? null; + const { + entry: albumInfoEntry, + loading: albumInfoLoading, + error: albumInfoError, + refreshing: albumInfoRefreshing, + handleRetry: handleRetryAlbumInfo, + handleRefresh: handleRefreshAlbumInfo, + } = usePlayerAlbumInfo(albumId, currentTrack.artist, currentTrack.album, { + enabled: mode === 'info', + }); + + const sanitizedNotes = useMemo(() => { + const serverNotes = albumInfoEntry?.albumInfo.notes; + if (serverNotes) { + const sanitized = sanitizeBiographyText(serverNotes); + if (sanitized) return sanitized; + } + return albumInfoEntry?.enrichedNotes ?? null; + }, [albumInfoEntry?.albumInfo.notes, albumInfoEntry?.enrichedNotes]); + + const notesAttributionUrl = albumInfoEntry?.enrichedNotesUrl ?? null; + + const trackId = currentTrack.id; + const { + entry: lyricsEntry, + loading: lyricsLoading, + error: lyricsError, + handleRetry: handleRetryLyrics, + } = usePlayerLyrics(trackId, currentTrack.artist, currentTrack.title); + + const renderQueueItem = useCallback( + ({ item, index }: { item: Child; index: number }) => ( + + ), + [currentTrackIndex, queueColors, onQueueItemPress, onQueueItemLongPress], + ); + + const keyExtractor = useCallback( + (item: Child, index: number) => `${item.id}-${index}`, + [], + ); + + const slideStyle = useAnimatedStyle( + () => ({ + transform: [{ translateY: maxHeight - panelHeight.value }], + }), + [maxHeight], + ); + + const listContentStyle = useMemo( + () => ({ paddingBottom: insets.bottom + 16 }), + [insets.bottom], + ); + + return ( + + + + + + + setMode('queue')} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('showQueue')} + style={({ pressed }) => [styles.toggleButton, pressed && styles.pressed]} + > + + + {!offlineMode && ( + setMode('info')} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('showAlbumInfo')} + style={({ pressed }) => [styles.toggleButton, pressed && styles.pressed]} + > + + + )} + {!offlineMode && ( + setMode('lyrics')} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('showLyrics')} + style={({ pressed }) => [styles.toggleButton, pressed && styles.pressed]} + > + + + )} + + {mode === 'queue' && queue.length > 0 && ( + + [styles.queueActionButton, pressed && styles.pressed]} + > + + + [styles.queueActionButton, pressed && styles.pressed]} + > + + {t('clear')} + + + + )} + + + + + + {mode === 'queue' ? ( + + ) : mode === 'info' ? ( + + ) : ( + + + + )} + + + ); +}); + +const styles = StyleSheet.create({ + panel: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + borderTopWidth: StyleSheet.hairlineWidth, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: -4 }, + shadowOpacity: 0.2, + shadowRadius: 12, + elevation: 16, + }, + header: { + paddingTop: 8, + paddingHorizontal: 16, + paddingBottom: 8, + }, + grabber: { + alignSelf: 'center', + width: 36, + height: 4, + borderRadius: 2, + opacity: 0.4, + marginBottom: 12, + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + toggleButtons: { + flexDirection: 'row', + gap: 16, + }, + toggleButton: { + padding: 4, + }, + queueActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + queueActionButton: { + alignItems: 'center', + justifyContent: 'center', + padding: 4, + }, + clearButtonText: { + fontSize: 14, + fontWeight: '600', + }, + body: { + flex: 1, + }, + lyricsContainer: { + flex: 1, + }, + pressed: { + opacity: 0.6, + }, +}); From 2a9a235ce82836e59c541e2d8c5b8677b77c8082 Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Sun, 31 May 2026 19:09:08 +1000 Subject: [PATCH 06/18] feat(player): add TabletPortraitPlayer screen Tablet-portrait now-playing: collapsing hero+controls over an inline draggable UpNextPanel with PEEK/HALF/FULL detents; hero collapses into a compact control strip at full. Reuses shared hooks + leaf components. Hero is vertically sized so controls stay visible above the panel at HALF. --- src/components/UpNextPanel.tsx | 2 +- src/screens/tablet-portrait-player.tsx | 780 +++++++++++++++++++++++++ 2 files changed, 781 insertions(+), 1 deletion(-) create mode 100644 src/screens/tablet-portrait-player.tsx diff --git a/src/components/UpNextPanel.tsx b/src/components/UpNextPanel.tsx index e0bac1f..4e21f79 100644 --- a/src/components/UpNextPanel.tsx +++ b/src/components/UpNextPanel.tsx @@ -29,7 +29,7 @@ export interface UpNextPanelProps { panGesture: PanGesture; currentTrack: Child; queue: Child[]; - currentTrackIndex: number; + currentTrackIndex: number | null; colors: ThemeColors; /** Muted-primary variant for the active queue row highlight. */ queueColors: ThemeColors; diff --git a/src/screens/tablet-portrait-player.tsx b/src/screens/tablet-portrait-player.tsx new file mode 100644 index 0000000..168c082 --- /dev/null +++ b/src/screens/tablet-portrait-player.tsx @@ -0,0 +1,780 @@ +import Ionicons from "@react-native-vector-icons/ionicons/static"; +import { Stack, useNavigation, useRouter } from 'expo-router'; +import { LinearGradient } from 'expo-linear-gradient'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ActivityIndicator, + Platform, + Pressable, + StyleSheet, + Text, + View, + useWindowDimensions, +} from 'react-native'; +import { Gesture, GestureDetector, Pressable as GHPressable } from 'react-native-gesture-handler'; +import Animated, { + Extrapolation, + interpolate, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { CachedImage } from '../components/CachedImage'; +import { EmptyState } from '../components/EmptyState'; +import { MarqueeText } from '../components/MarqueeText'; +import { MoreOptionsButton } from '../components/MoreOptionsButton'; +import { PlaybackRateButton } from '../components/PlaybackRateButton'; +import { PlayerProgressBar } from '../components/PlayerProgressBar'; +import { RepeatButton } from '../components/RepeatButton'; +import { ShuffleButton } from '../components/ShuffleButton'; +import { ShuffleOverlay } from '../components/ShuffleOverlay'; +import { SkipIntervalButton } from '../components/SkipIntervalButton'; +import { SleepTimerButton } from '../components/SleepTimerButton'; +import { SleepTimerCapsule } from '../components/SleepTimerCapsule'; +import { UpNextPanel } from '../components/UpNextPanel'; +import { type ThemeColors } from '../constants/theme'; +import { useCanSkip } from '../hooks/useCanSkip'; +import { useImagePalette } from '../hooks/useImagePalette'; +import { useIsStarred } from '../hooks/useIsStarred'; +import { usePlayerActions } from '../hooks/usePlayerActions'; +import { useShuffleOverlay } from '../hooks/useShuffleOverlay'; +import { useTheme } from '../hooks/useTheme'; +import { buildAutoName, capturePlayerSnapshot, commitBookmark } from '../services/bookmarkService'; +import { toggleStar } from '../services/moreOptionsService'; +import { + clearQueue, + retryPlayback, + skipToNext, + skipToPrevious, + togglePlayPause, +} from '../services/playerService'; +import { type Child } from '../services/subsonicService'; +import { bookmarkSheetStore } from '../store/bookmarkSheetStore'; +import { bookmarksStore } from '../store/bookmarksStore'; +import { moreOptionsStore } from '../store/moreOptionsStore'; +import { offlineModeStore } from '../store/offlineModeStore'; +import { playbackSettingsStore } from '../store/playbackSettingsStore'; +import { playbackToastStore } from '../store/playbackToastStore'; +import { playerStore } from '../store/playerStore'; +import { mixHexColors } from '../utils/colors'; +import { absoluteFill } from '../utils/styles'; + +const HERO_PADDING = 24; +const HERO_COVER_SIZE = 600; +const HEADER_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : 56; +const STRIP_HEIGHT = 112; +const PEEK_HEIGHT = 132; + +/** + * Tablet-portrait full-screen Now Playing. A large hero + controls up top with + * an inline draggable "Up Next" panel (Queue/Info/Lyrics) below. As the panel + * is dragged to full, the hero collapses into a compact control strip so + * play/pause + scrubber stay reachable. Used by the /player route only on + * tablets in portrait (see useIsTabletPortrait); phone + landscape are unchanged. + */ +export function TabletPortraitPlayer() { + const { colors } = useTheme(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const router = useRouter(); + const { height: screenH, width: screenW } = useWindowDimensions(); + + const currentTrack = playerStore((s) => s.currentTrack); + const currentTrackIndex = playerStore((s) => s.currentTrackIndex); + const queue = playerStore((s) => s.queue); + const offlineMode = offlineModeStore((s) => s.offlineMode); + + const onClose = useCallback(() => router.back(), [router]); + + const onClearConfirmed = useCallback(() => { + onClose(); + setTimeout(() => clearQueue(), 350); + }, [onClose]); + + const { + handleSeek, + handleQueueItemPress, + handleQueueItemLongPress, + handleShareQueue, + handleClearQueue, + } = usePlayerActions({ source: 'player', onClearConfirmed }); + + const { + shuffling, + handleShuffle, + overlayStyle, + spinStyle, + } = useShuffleOverlay(); + + // Auto-dismiss when the queue is externally cleared while this screen is open. + const [wasPopulated, setWasPopulated] = useState(false); + useEffect(() => { + if (currentTrack) { + setWasPopulated(true); + } else if (wasPopulated) { + onClose(); + } + }, [currentTrack, wasPopulated, onClose]); + + const { primary, secondary, gradientOpacity } = useImagePalette( + currentTrack ? (currentTrack.albumId ?? currentTrack.id) : undefined, + ); + const gradientTopColor = secondary ?? primary ?? colors.background; + const gradientColors: readonly [string, string, ...string[]] = [gradientTopColor, colors.background]; + const gradientLocations: readonly [number, number, ...number[]] = [0, 0.6]; + + const gradientAnimatedStyle = useAnimatedStyle(() => ({ + opacity: gradientOpacity.value, + })); + + /* ---- Header: dismiss + more options ---- */ + useEffect(() => { + if (Platform.OS === 'ios') return; + navigation.setOptions({ + headerLeft: () => ( + [{ opacity: 1 }, pressed && styles.pressed]} + > + + + ), + headerRight: () => + currentTrack ? ( + + moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player') + } + color={colors.textPrimary} + /> + ) : null, + }); + }, [currentTrack, navigation, onClose, colors.textPrimary]); + + /* ---- Drag detents ---- */ + const headerSpace = insets.top + HEADER_BAR_HEIGHT; + const fullHeight = Math.max(Math.round(screenH - headerSpace - STRIP_HEIGHT), 320); + const halfHeight = Math.min( + Math.max(Math.round(screenH * 0.46), PEEK_HEIGHT + 120), + fullHeight - 80, + ); + + const panelHeight = useSharedValue(halfHeight); + const startHeight = useSharedValue(0); + + const panGesture = useMemo( + () => + Gesture.Pan() + .onStart(() => { + 'worklet'; + startHeight.value = panelHeight.value; + }) + .onUpdate((e) => { + 'worklet'; + const next = startHeight.value - e.translationY; + panelHeight.value = Math.min(fullHeight, Math.max(PEEK_HEIGHT, next)); + }) + .onEnd((e) => { + 'worklet'; + const projected = panelHeight.value - e.velocityY * 0.08; + const dPeek = Math.abs(projected - PEEK_HEIGHT); + const dHalf = Math.abs(projected - halfHeight); + const dFull = Math.abs(projected - fullHeight); + let target = PEEK_HEIGHT; + if (dHalf <= dPeek && dHalf <= dFull) target = halfHeight; + else if (dFull <= dPeek && dFull <= dHalf) target = fullHeight; + panelHeight.value = withSpring(target, { damping: 28, stiffness: 220, mass: 0.9 }); + }), + [fullHeight, halfHeight, panelHeight, startHeight], + ); + + const fullContentStyle = useAnimatedStyle(() => { + const c = interpolate(panelHeight.value, [halfHeight, fullHeight], [0, 1], Extrapolation.CLAMP); + return { + opacity: 1 - c, + transform: [{ translateY: -24 * c }, { scale: 1 - 0.04 * c }], + pointerEvents: c < 0.5 ? ('auto' as const) : ('none' as const), + }; + }, [halfHeight, fullHeight]); + + const stripStyle = useAnimatedStyle(() => { + const c = interpolate(panelHeight.value, [halfHeight, fullHeight], [0, 1], Extrapolation.CLAMP); + return { + opacity: c, + pointerEvents: c > 0.5 ? ('auto' as const) : ('none' as const), + }; + }, [halfHeight, fullHeight]); + + const queueColors = useMemo( + () => ({ ...colors, primary: mixHexColors(colors.primary, colors.textPrimary, 0.45) }), + [colors], + ); + + // Size the hero so the controls stay visible above the panel at its HALF + // detent — everything below the panel's top edge is covered by the panel. + const heroSize = useMemo(() => { + const NON_HERO_CHROME = 286; // track info + progress + 2 control rows + hero padding + const verticalBudget = screenH - halfHeight - headerSpace - 8 - NON_HERO_CHROME; + const widthBudget = screenW - 2 * HERO_PADDING; + return Math.max(Math.min(widthBudget, 420, verticalBudget), 140); + }, [screenW, screenH, halfHeight, headerSpace]); + + if (!currentTrack) { + return ( + + + + ); + } + + return ( + <> + {Platform.OS === 'ios' && ( + <> + + + + + moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player')} + /> + + + )} + + {/* Gradient background */} + + + + + + {/* Full hero + controls (collapses as the panel expands) */} + + + + + + + + + + + + + + + {currentTrack.title} + + + {currentTrack.artist ?? t('unknownArtist')} + + + + + + + + + + + + + + {/* Compact strip — fades in when the panel is full */} + + + + + + {currentTrack.title} + + + {currentTrack.artist ?? t('unknownArtist')} + + + + + + + + + + {/* Up Next panel */} + + + {/* Shuffle overlay */} + + + + ); +} + +/* ------------------------------------------------------------------ */ +/* Progress bar (subscribes to playback position) */ +/* ------------------------------------------------------------------ */ + +const ProgressBar = memo(function ProgressBar({ + colors, + handleSeek, +}: { + colors: ThemeColors; + handleSeek: (seconds: number) => void; +}) { + const position = playerStore((s) => s.position); + const duration = playerStore((s) => s.duration); + const bufferedPosition = playerStore((s) => s.bufferedPosition); + const playbackState = playerStore((s) => s.playbackState); + const error = playerStore((s) => s.error); + const retrying = playerStore((s) => s.retrying); + const isBuffering = playbackState === 'buffering' || playbackState === 'loading'; + + return ( + + ); +}); + +/* ------------------------------------------------------------------ */ +/* Playback controls (two rows) */ +/* ------------------------------------------------------------------ */ + +const PlaybackControls = memo(function PlaybackControls({ + colors, + shuffling, + handleShuffle, + queueLength, +}: { + colors: ThemeColors; + shuffling: boolean; + handleShuffle: () => void; + queueLength: number; +}) { + const playbackState = playerStore((s) => s.playbackState); + const showSkipInterval = playbackSettingsStore((s) => s.showSkipIntervalButtons); + const showSleepTimer = playbackSettingsStore((s) => s.showSleepTimerButton); + const { canSkipNext, canSkipPrevious } = useCanSkip(); + + const isPlaying = playbackState === 'playing' || playbackState === 'buffering'; + const isBuffering = playbackState === 'buffering' || playbackState === 'loading'; + + return ( + <> + + + + + + + [pressed && styles.pressed, !canSkipPrevious && styles.disabled]} + > + + + + [ + styles.playPauseButton, + { backgroundColor: colors.textPrimary }, + pressed && styles.playPausePressed, + ]} + > + {isBuffering ? ( + + ) : ( + + )} + + + [pressed && styles.pressed, !canSkipNext && styles.disabled]} + > + + + + + + + + + + + + {showSleepTimer && } + + + {showSkipInterval && } + + + + {showSkipInterval && } + + + + + + + ); +}); + +/* ------------------------------------------------------------------ */ +/* Compact-strip play/pause */ +/* ------------------------------------------------------------------ */ + +const StripPlayButton = memo(function StripPlayButton({ colors }: { colors: ThemeColors }) { + const playbackState = playerStore((s) => s.playbackState); + const isPlaying = playbackState === 'playing' || playbackState === 'buffering'; + const isBuffering = playbackState === 'buffering' || playbackState === 'loading'; + + return ( + [pressed && styles.pressed]} + > + {isBuffering ? ( + + ) : ( + + )} + + ); +}); + +/* ------------------------------------------------------------------ */ +/* Favorite button */ +/* ------------------------------------------------------------------ */ + +const FavoriteButton = memo(function FavoriteButton({ + trackId, + colors, +}: { + trackId: string; + colors: { red: string; textSecondary: string }; +}) { + const { t } = useTranslation(); + const starred = useIsStarred('song', trackId); + const offlineMode = offlineModeStore((s) => s.offlineMode); + + const handleToggle = useCallback(() => { + toggleStar('song', trackId); + }, [trackId]); + + return ( + [ + styles.favoriteButton, + pressed && !offlineMode && styles.pressed, + offlineMode && styles.disabled, + ]} + > + + + ); +}); + +/* ------------------------------------------------------------------ */ +/* Bookmark button */ +/* ------------------------------------------------------------------ */ + +const BookmarkButton = memo(function BookmarkButton({ colors }: { colors: ThemeColors }) { + const { t, i18n } = useTranslation(); + const autoName = bookmarksStore((s) => s.autoName); + const queueLength = playerStore((s) => s.queue.length); + const disabled = queueLength === 0; + + const handlePress = useCallback(() => { + const snapshot = capturePlayerSnapshot(); + if (!snapshot) return; + const existingNames = Object.values(bookmarksStore.getState().bookmarks).map((b) => b.name); + const suggested = buildAutoName(t, i18n.language, existingNames); + if (autoName) { + commitBookmark(snapshot, suggested); + playbackToastStore.getState().flashSuccess(t('bookmarkSaved')); + } else { + bookmarkSheetStore.getState().showCreate(suggested, snapshot); + } + }, [autoName, t, i18n.language]); + + return ( + [ + styles.favoriteButton, + pressed && !disabled && styles.pressed, + disabled && styles.disabled, + ]} + > + + + ); +}); + +/* ------------------------------------------------------------------ */ +/* Styles */ +/* ------------------------------------------------------------------ */ + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + fullContent: { + ...absoluteFill, + paddingHorizontal: HERO_PADDING, + }, + hero: { + width: '100%', + alignItems: 'center', + paddingTop: 8, + paddingBottom: 24, + }, + heroImageWrap: { + borderRadius: 14, + overflow: 'hidden', + backgroundColor: 'rgba(0,0,0,0.06)', + shadowColor: '#000', + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.3, + shadowRadius: 18, + elevation: 12, + }, + heroImage: { + width: '100%', + height: '100%', + }, + sleepCapsuleOverlay: { + position: 'absolute', + left: 0, + right: 0, + bottom: 12, + alignItems: 'center', + justifyContent: 'center', + }, + trackInfo: { + width: '100%', + maxWidth: 520, + alignSelf: 'center', + marginBottom: 16, + }, + trackInfoRow: { + flexDirection: 'row', + alignItems: 'center', + }, + trackInfoText: { + flex: 1, + minWidth: 0, + }, + trackTitle: { + fontSize: 24, + fontWeight: '700', + }, + trackArtist: { + fontSize: 17, + marginTop: 4, + }, + favoriteButton: { + paddingLeft: 12, + paddingVertical: 4, + }, + progressSection: { + width: '100%', + maxWidth: 520, + alignSelf: 'center', + marginBottom: 8, + }, + controls: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 8, + maxWidth: 520, + width: '100%', + alignSelf: 'center', + }, + controlSideLeft: { + flex: 1, + alignItems: 'flex-start', + justifyContent: 'center', + }, + controlSideRight: { + flex: 1, + alignItems: 'flex-end', + justifyContent: 'center', + }, + transportControls: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-evenly', + width: 260, + }, + secondaryControls: { + flexDirection: 'row', + alignItems: 'center', + height: 40, + marginTop: 20, + maxWidth: 520, + width: '100%', + alignSelf: 'center', + }, + secondaryCenter: { + width: 260, + }, + secondaryCenterRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-evenly', + }, + secondaryRateSlot: { + width: 64, + alignItems: 'center', + justifyContent: 'center', + }, + playPauseButton: { + width: 68, + height: 68, + borderRadius: 34, + alignItems: 'center', + justifyContent: 'center', + }, + playPausePressed: { + opacity: 0.7, + }, + playIcon: { + marginLeft: 3, + }, + strip: { + position: 'absolute', + left: 0, + right: 0, + paddingHorizontal: HERO_PADDING, + justifyContent: 'center', + }, + stripRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + stripArt: { + width: 44, + height: 44, + borderRadius: 6, + }, + stripInfo: { + flex: 1, + minWidth: 0, + }, + stripTitle: { + fontSize: 15, + fontWeight: '700', + }, + stripArtist: { + fontSize: 13, + marginTop: 2, + }, + stripProgress: { + marginTop: 8, + }, + pressed: { + opacity: 0.6, + }, + disabled: { + opacity: 0.4, + }, +}); From b95109562dd4a6cb639678d48bf97839452fbdcd Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Sun, 31 May 2026 19:11:08 +1000 Subject: [PATCH 07/18] feat(player): route /player to TabletPortraitPlayer on tablet portrait New useIsTabletPortrait hook (min screen dim >= 600 + portrait, matching IS_TABLET); the /player route picks the tablet-portrait layout there, else the phone PlayerView. Phone and tablet-landscape paths unchanged. --- src/app/player.tsx | 5 ++++- src/hooks/useIsTabletPortrait.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useIsTabletPortrait.ts diff --git a/src/app/player.tsx b/src/app/player.tsx index 52be6d3..9739866 100644 --- a/src/app/player.tsx +++ b/src/app/player.tsx @@ -1,5 +1,8 @@ +import { useIsTabletPortrait } from '@/hooks/useIsTabletPortrait'; import { PlayerView } from '@/screens/player-view'; +import { TabletPortraitPlayer } from '@/screens/tablet-portrait-player'; export default function PlayerRoute() { - return ; + const tabletPortrait = useIsTabletPortrait(); + return tabletPortrait ? : ; } diff --git a/src/hooks/useIsTabletPortrait.ts b/src/hooks/useIsTabletPortrait.ts new file mode 100644 index 0000000..196c796 --- /dev/null +++ b/src/hooks/useIsTabletPortrait.ts @@ -0,0 +1,12 @@ +import { useWindowDimensions } from 'react-native'; + +/** + * True when the device is a tablet (smallest screen dimension ≥ 600dp, matching + * the `IS_TABLET` rule in app/_layout.tsx) currently held in portrait. Used to + * route the /player screen to the tablet-portrait layout; phones and + * tablet-landscape are unaffected. + */ +export function useIsTabletPortrait(): boolean { + const { width, height } = useWindowDimensions(); + return height >= width && Math.min(width, height) >= 600; +} From ef9d1e3526fc4951d32482fc49c57e923b4a6ba1 Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Sun, 31 May 2026 19:13:29 +1000 Subject: [PATCH 08/18] test(player): expect 2 shuffle buttons in queue tab (controls + header) --- src/screens/__tests__/player-view.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/screens/__tests__/player-view.test.tsx b/src/screens/__tests__/player-view.test.tsx index 3fdae19..255c9ed 100644 --- a/src/screens/__tests__/player-view.test.tsx +++ b/src/screens/__tests__/player-view.test.tsx @@ -432,13 +432,14 @@ describe('PlayerView', () => { }); it('renders shuffle button in queue tab', () => { - const { getByLabelText, getByTestId } = render(); + const { getByLabelText, getAllByTestId } = render(); act(() => { fireEvent.press(getByLabelText('Queue')); }); - expect(getByTestId('shuffle-button')).toBeTruthy(); + // Shuffle now appears both in the player controls and the queue header. + expect(getAllByTestId('shuffle-button').length).toBeGreaterThanOrEqual(2); }); it('calls share queue when share button pressed', () => { From 67ea841d5c5b58d5b06d0ef48032a9b033cb1f01 Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Sun, 31 May 2026 19:30:53 +1000 Subject: [PATCH 09/18] refactor(player): extract shared FavoriteButton + BookmarkButton Consumers migrate in the follow-up commit. --- src/components/BookmarkButton.tsx | 74 +++++++++++++++++++++++++++++++ src/components/FavoriteButton.tsx | 62 ++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/components/BookmarkButton.tsx create mode 100644 src/components/FavoriteButton.tsx diff --git a/src/components/BookmarkButton.tsx b/src/components/BookmarkButton.tsx new file mode 100644 index 0000000..0df314a --- /dev/null +++ b/src/components/BookmarkButton.tsx @@ -0,0 +1,74 @@ +import Ionicons from "@react-native-vector-icons/ionicons/static"; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; + +import { useTheme } from '../hooks/useTheme'; +import { buildAutoName, capturePlayerSnapshot, commitBookmark } from '../services/bookmarkService'; +import { bookmarkSheetStore } from '../store/bookmarkSheetStore'; +import { bookmarksStore } from '../store/bookmarksStore'; +import { playbackToastStore } from '../store/playbackToastStore'; +import { playerStore } from '../store/playerStore'; + +export interface BookmarkButtonProps { + size?: number; + /** Container padding/spacing — varies per player surface. */ + style?: StyleProp; +} + +/** + * Saves a play-queue bookmark. Auto-names and commits immediately when the + * auto-name preference is on; otherwise opens the manual-name sheet. Shared + * across player surfaces. + */ +export const BookmarkButton = memo(function BookmarkButton({ + size = 24, + style, +}: BookmarkButtonProps) { + const { t, i18n } = useTranslation(); + const { colors } = useTheme(); + const autoName = bookmarksStore((s) => s.autoName); + const queueLength = playerStore((s) => s.queue.length); + const disabled = queueLength === 0; + + const handlePress = useCallback(() => { + // Capture the queue/position NOW, at tap time, regardless of which path we + // take — the manual-name sheet commits this same snapshot on Save. + const snapshot = capturePlayerSnapshot(); + if (!snapshot) return; + const existingNames = Object.values(bookmarksStore.getState().bookmarks).map((b) => b.name); + const suggested = buildAutoName(t, i18n.language, existingNames); + if (autoName) { + commitBookmark(snapshot, suggested); + playbackToastStore.getState().flashSuccess(t('bookmarkSaved')); + } else { + bookmarkSheetStore.getState().showCreate(suggested, snapshot); + } + }, [autoName, t, i18n.language]); + + return ( + [ + style, + pressed && !disabled && styles.pressed, + disabled && styles.disabled, + ]} + > + + + ); +}); + +const styles = StyleSheet.create({ + pressed: { + opacity: 0.6, + }, + disabled: { + opacity: 0.4, + }, +}); diff --git a/src/components/FavoriteButton.tsx b/src/components/FavoriteButton.tsx new file mode 100644 index 0000000..253e4f8 --- /dev/null +++ b/src/components/FavoriteButton.tsx @@ -0,0 +1,62 @@ +import Ionicons from "@react-native-vector-icons/ionicons/static"; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; + +import { useIsStarred } from '../hooks/useIsStarred'; +import { useTheme } from '../hooks/useTheme'; +import { toggleStar } from '../services/moreOptionsService'; +import { offlineModeStore } from '../store/offlineModeStore'; + +export interface FavoriteButtonProps { + trackId: string; + size?: number; + /** Container padding/spacing — varies per player surface. */ + style?: StyleProp; +} + +/** Heart toggle for a song. Disabled offline. Shared across all player surfaces. */ +export const FavoriteButton = memo(function FavoriteButton({ + trackId, + size = 24, + style, +}: FavoriteButtonProps) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const starred = useIsStarred('song', trackId); + const offlineMode = offlineModeStore((s) => s.offlineMode); + + const handleToggle = useCallback(() => { + toggleStar('song', trackId); + }, [trackId]); + + return ( + [ + style, + pressed && !offlineMode && styles.pressed, + offlineMode && styles.disabled, + ]} + > + + + ); +}); + +const styles = StyleSheet.create({ + pressed: { + opacity: 0.6, + }, + disabled: { + opacity: 0.4, + }, +}); From 971a3a7557cf0bfd3fe53fec546c52c79ccf497c Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Sun, 31 May 2026 19:38:00 +1000 Subject: [PATCH 10/18] refactor(player): use shared Favorite/Bookmark buttons in all 4 players Remove the four local copies (FavoriteButton/BookmarkButton/Expanded-/Panel-) in favour of the shared components; each call site passes its own favoriteButton padding + size. Drops now-dead favorite/bookmark imports. --- src/components/ExpandedPlayerView.tsx | 46 +----------- src/components/PlayerPanel.tsx | 47 +------------ src/screens/player-view.tsx | 96 ++------------------------ src/screens/tablet-portrait-player.tsx | 94 ++----------------------- 4 files changed, 12 insertions(+), 271 deletions(-) diff --git a/src/components/ExpandedPlayerView.tsx b/src/components/ExpandedPlayerView.tsx index 53999aa..62b85b4 100644 --- a/src/components/ExpandedPlayerView.tsx +++ b/src/components/ExpandedPlayerView.tsx @@ -23,6 +23,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { AlbumInfoContent } from './AlbumInfoContent'; import { LyricsContent } from './LyricsContent'; import { CachedImage } from './CachedImage'; +import { FavoriteButton } from './FavoriteButton'; import { MarqueeText } from './MarqueeText'; import { MoreOptionsButton } from './MoreOptionsButton'; import { PlaybackRateButton } from './PlaybackRateButton'; @@ -38,11 +39,9 @@ import { closeOpenRow } from './SwipeableRow'; import { useCanSkip } from '../hooks/useCanSkip'; import { useImagePalette } from '../hooks/useImagePalette'; import { mixHexColors } from '../utils/colors'; -import { useIsStarred } from '../hooks/useIsStarred'; import { usePlayerActions } from '../hooks/usePlayerActions'; import { useShuffleOverlay } from '../hooks/useShuffleOverlay'; import { useTheme } from '../hooks/useTheme'; -import { toggleStar } from '../services/moreOptionsService'; import { retryPlayback, skipToNext, @@ -342,7 +341,7 @@ export function ExpandedPlayerView({ } color={colors.textPrimary} /> - + s.offlineMode); - - const handleToggle = useCallback(() => { - toggleStar('song', trackId); - }, [trackId]); - - return ( - [ - styles.favoriteButton, - pressed && !offlineMode && styles.pressed, - offlineMode && styles.disabled, - ]} - > - - - ); -}); - /* ------------------------------------------------------------------ */ /* Styles */ diff --git a/src/components/PlayerPanel.tsx b/src/components/PlayerPanel.tsx index 24b8275..d3ac387 100644 --- a/src/components/PlayerPanel.tsx +++ b/src/components/PlayerPanel.tsx @@ -13,6 +13,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { GradientBackground } from './GradientBackground'; import { CachedImage } from './CachedImage'; +import { FavoriteButton } from './FavoriteButton'; import { MarqueeText } from './MarqueeText'; import { MoreOptionsButton } from './MoreOptionsButton'; import { PlaybackRateButton } from './PlaybackRateButton'; @@ -25,12 +26,10 @@ import { SleepTimerCapsule } from './SleepTimerCapsule'; import { closeOpenRow } from './SwipeableRow'; import { type ThemeColors } from '../constants/theme'; import { useCanSkip } from '../hooks/useCanSkip'; -import { useIsStarred } from '../hooks/useIsStarred'; import { mixHexColors } from '../utils/colors'; import { usePlayerActions } from '../hooks/usePlayerActions'; import { useShuffleOverlay } from '../hooks/useShuffleOverlay'; import { useTheme } from '../hooks/useTheme'; -import { toggleStar } from '../services/moreOptionsService'; import { retryPlayback, skipToNext, @@ -39,7 +38,6 @@ import { } from '../services/playerService'; import { type Child } from '../services/subsonicService'; import { moreOptionsStore } from '../store/moreOptionsStore'; -import { offlineModeStore } from '../store/offlineModeStore'; import { playerStore } from '../store/playerStore'; import { tabletLayoutStore } from '../store/tabletLayoutStore'; @@ -199,47 +197,6 @@ export function PlayerPanel() { ); } -/* ------------------------------------------------------------------ */ -/* Favorite button */ -/* ------------------------------------------------------------------ */ - -const PanelFavoriteButton = memo(function PanelFavoriteButton({ - trackId, - colors, -}: { - trackId: string; - colors: { red: string; textSecondary: string }; -}) { - const { t } = useTranslation(); - const starred = useIsStarred('song', trackId); - const offlineMode = offlineModeStore((s) => s.offlineMode); - - const handleToggle = useCallback(() => { - toggleStar('song', trackId); - }, [trackId]); - - return ( - [ - styles.favoriteButton, - pressed && !offlineMode && styles.pressed, - offlineMode && styles.disabled, - ]} - > - - - ); -}); - /* ------------------------------------------------------------------ */ /* Panel header: cover art, controls, queue heading */ /* ------------------------------------------------------------------ */ @@ -329,7 +286,7 @@ const PanelHeader = memo(function PanelHeader({ {currentTrack.artist ?? t('unknownArtist')} - + diff --git a/src/screens/player-view.tsx b/src/screens/player-view.tsx index a7a9882..b3d2a07 100644 --- a/src/screens/player-view.tsx +++ b/src/screens/player-view.tsx @@ -25,7 +25,9 @@ import { useTranslation } from 'react-i18next'; import { AlbumInfoContent } from '../components/AlbumInfoContent'; import { LyricsContent } from '../components/LyricsContent'; +import { BookmarkButton } from '../components/BookmarkButton'; import { CachedImage } from '../components/CachedImage'; +import { FavoriteButton } from '../components/FavoriteButton'; import { EmptyState } from '../components/EmptyState'; import { MarqueeText } from '../components/MarqueeText'; import { MoreOptionsButton } from '../components/MoreOptionsButton'; @@ -43,15 +45,9 @@ import { closeOpenRow } from '../components/SwipeableRow'; import { type ThemeColors } from '../constants/theme'; import { useCanSkip } from '../hooks/useCanSkip'; import { useImagePalette } from '../hooks/useImagePalette'; -import { useIsStarred } from '../hooks/useIsStarred'; import { usePlayerActions } from '../hooks/usePlayerActions'; import { useShuffleOverlay } from '../hooks/useShuffleOverlay'; import { useTheme } from '../hooks/useTheme'; -import { toggleStar } from '../services/moreOptionsService'; -import { buildAutoName, capturePlayerSnapshot, commitBookmark } from '../services/bookmarkService'; -import { bookmarkSheetStore } from '../store/bookmarkSheetStore'; -import { bookmarksStore } from '../store/bookmarksStore'; -import { playbackToastStore } from '../store/playbackToastStore'; import { offlineModeStore } from '../store/offlineModeStore'; import { clearQueue, @@ -420,90 +416,6 @@ export function PlayerView() { ); } -/* ------------------------------------------------------------------ */ -/* Favorite button */ -/* ------------------------------------------------------------------ */ - -const FavoriteButton = memo(function FavoriteButton({ - trackId, - colors, -}: { - trackId: string; - colors: { red: string; textSecondary: string }; -}) { - const { t } = useTranslation(); - const starred = useIsStarred('song', trackId); - const offlineMode = offlineModeStore((s) => s.offlineMode); - - const handleToggle = useCallback(() => { - toggleStar('song', trackId); - }, [trackId]); - - return ( - [ - styles.favoriteButton, - pressed && !offlineMode && styles.pressed, - offlineMode && styles.disabled, - ]} - > - - - ); -}); - -/* ------------------------------------------------------------------ */ -/* Bookmark button */ -/* ------------------------------------------------------------------ */ - -const BookmarkButton = memo(function BookmarkButton({ colors }: { colors: ThemeColors }) { - const { t, i18n } = useTranslation(); - const autoName = bookmarksStore((s) => s.autoName); - const queueLength = playerStore((s) => s.queue.length); - const disabled = queueLength === 0; - - const handlePress = useCallback(() => { - // Capture the queue/position NOW, at tap time, regardless of which path we - // take — the manual-name sheet commits this same snapshot on Save. - const snapshot = capturePlayerSnapshot(); - if (!snapshot) return; - const existingNames = Object.values(bookmarksStore.getState().bookmarks).map((b) => b.name); - const suggested = buildAutoName(t, i18n.language, existingNames); - if (autoName) { - commitBookmark(snapshot, suggested); - playbackToastStore.getState().flashSuccess(t('bookmarkSaved')); - } else { - bookmarkSheetStore.getState().showCreate(suggested, snapshot); - } - }, [autoName, t, i18n.language]); - - return ( - [ - styles.favoriteButton, - pressed && !disabled && styles.pressed, - disabled && styles.disabled, - ]} - > - - - ); -}); - /* ------------------------------------------------------------------ */ /* Player content (hero, controls) — "Player" tab */ /* ------------------------------------------------------------------ */ @@ -608,7 +520,7 @@ const PlayerContent = memo(function PlayerContent({ {currentTrack.artist ?? t('unknownArtist')} - + @@ -720,7 +632,7 @@ const PlayerContent = memo(function PlayerContent({ )} - + diff --git a/src/screens/tablet-portrait-player.tsx b/src/screens/tablet-portrait-player.tsx index 168c082..7870902 100644 --- a/src/screens/tablet-portrait-player.tsx +++ b/src/screens/tablet-portrait-player.tsx @@ -22,7 +22,9 @@ import Animated, { } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { BookmarkButton } from '../components/BookmarkButton'; import { CachedImage } from '../components/CachedImage'; +import { FavoriteButton } from '../components/FavoriteButton'; import { EmptyState } from '../components/EmptyState'; import { MarqueeText } from '../components/MarqueeText'; import { MoreOptionsButton } from '../components/MoreOptionsButton'; @@ -38,12 +40,9 @@ import { UpNextPanel } from '../components/UpNextPanel'; import { type ThemeColors } from '../constants/theme'; import { useCanSkip } from '../hooks/useCanSkip'; import { useImagePalette } from '../hooks/useImagePalette'; -import { useIsStarred } from '../hooks/useIsStarred'; import { usePlayerActions } from '../hooks/usePlayerActions'; import { useShuffleOverlay } from '../hooks/useShuffleOverlay'; import { useTheme } from '../hooks/useTheme'; -import { buildAutoName, capturePlayerSnapshot, commitBookmark } from '../services/bookmarkService'; -import { toggleStar } from '../services/moreOptionsService'; import { clearQueue, retryPlayback, @@ -52,12 +51,9 @@ import { togglePlayPause, } from '../services/playerService'; import { type Child } from '../services/subsonicService'; -import { bookmarkSheetStore } from '../store/bookmarkSheetStore'; -import { bookmarksStore } from '../store/bookmarksStore'; import { moreOptionsStore } from '../store/moreOptionsStore'; import { offlineModeStore } from '../store/offlineModeStore'; import { playbackSettingsStore } from '../store/playbackSettingsStore'; -import { playbackToastStore } from '../store/playbackToastStore'; import { playerStore } from '../store/playerStore'; import { mixHexColors } from '../utils/colors'; import { absoluteFill } from '../utils/styles'; @@ -286,7 +282,7 @@ export function TabletPortraitPlayer() { {currentTrack.artist ?? t('unknownArtist')} - + @@ -490,7 +486,7 @@ const PlaybackControls = memo(function PlaybackControls({ {showSkipInterval && } - + @@ -525,88 +521,6 @@ const StripPlayButton = memo(function StripPlayButton({ colors }: { colors: Them ); }); -/* ------------------------------------------------------------------ */ -/* Favorite button */ -/* ------------------------------------------------------------------ */ - -const FavoriteButton = memo(function FavoriteButton({ - trackId, - colors, -}: { - trackId: string; - colors: { red: string; textSecondary: string }; -}) { - const { t } = useTranslation(); - const starred = useIsStarred('song', trackId); - const offlineMode = offlineModeStore((s) => s.offlineMode); - - const handleToggle = useCallback(() => { - toggleStar('song', trackId); - }, [trackId]); - - return ( - [ - styles.favoriteButton, - pressed && !offlineMode && styles.pressed, - offlineMode && styles.disabled, - ]} - > - - - ); -}); - -/* ------------------------------------------------------------------ */ -/* Bookmark button */ -/* ------------------------------------------------------------------ */ - -const BookmarkButton = memo(function BookmarkButton({ colors }: { colors: ThemeColors }) { - const { t, i18n } = useTranslation(); - const autoName = bookmarksStore((s) => s.autoName); - const queueLength = playerStore((s) => s.queue.length); - const disabled = queueLength === 0; - - const handlePress = useCallback(() => { - const snapshot = capturePlayerSnapshot(); - if (!snapshot) return; - const existingNames = Object.values(bookmarksStore.getState().bookmarks).map((b) => b.name); - const suggested = buildAutoName(t, i18n.language, existingNames); - if (autoName) { - commitBookmark(snapshot, suggested); - playbackToastStore.getState().flashSuccess(t('bookmarkSaved')); - } else { - bookmarkSheetStore.getState().showCreate(suggested, snapshot); - } - }, [autoName, t, i18n.language]); - - return ( - [ - styles.favoriteButton, - pressed && !disabled && styles.pressed, - disabled && styles.disabled, - ]} - > - - - ); -}); - /* ------------------------------------------------------------------ */ /* Styles */ /* ------------------------------------------------------------------ */ From faf0eb257aea0ff66b1c61eaefb4dc0087eb8dd2 Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Sun, 31 May 2026 20:28:15 +1000 Subject: [PATCH 11/18] feat(player): rework tablet-portrait top section to a horizontal band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover art (left) + title/progress/controls (right), centered in the space above the sheet. Bigger art, grouped controls (capped cluster), shorter default sheet (~44%), and 3 detents (low/default/full) so the sheet pulls back down — the band re-centers as it moves. --- src/screens/tablet-portrait-player.tsx | 144 +++++++++++++------------ 1 file changed, 73 insertions(+), 71 deletions(-) diff --git a/src/screens/tablet-portrait-player.tsx b/src/screens/tablet-portrait-player.tsx index 7870902..94d67c6 100644 --- a/src/screens/tablet-portrait-player.tsx +++ b/src/screens/tablet-portrait-player.tsx @@ -62,7 +62,9 @@ const HERO_PADDING = 24; const HERO_COVER_SIZE = 600; const HEADER_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : 56; const STRIP_HEIGHT = 112; -const PEEK_HEIGHT = 132; +const COLUMN_GAP = 24; +const LOW_PEEK_HEIGHT = 160; // collapsed sheet — handle + toggle only +const ART_MAX = 440; /** * Tablet-portrait full-screen Now Playing. A large hero + controls up top with @@ -152,15 +154,25 @@ export function TabletPortraitPlayer() { }); }, [currentTrack, navigation, onClose, colors.textPrimary]); - /* ---- Drag detents ---- */ + /* ---- Layout: a horizontal band (art left, controls right) centered in the + space above the sheet. Three detents: low (pulled down) / default / full + (band collapses into the strip). ---- */ const headerSpace = insets.top + HEADER_BAR_HEIGHT; - const fullHeight = Math.max(Math.round(screenH - headerSpace - STRIP_HEIGHT), 320); - const halfHeight = Math.min( - Math.max(Math.round(screenH * 0.46), PEEK_HEIGHT + 120), + + // Prominent square art on the left, capped so the right column keeps room for + // the controls on narrower tablets. + const artSize = Math.min( + Math.round((screenW - 2 * HERO_PADDING - COLUMN_GAP) * 0.5), + ART_MAX, + ); + + const fullHeight = Math.max(screenH - headerSpace - STRIP_HEIGHT, 320); + const defaultHeight = Math.min( + Math.max(Math.round(screenH * 0.44), LOW_PEEK_HEIGHT + 120), fullHeight - 80, ); - const panelHeight = useSharedValue(halfHeight); + const panelHeight = useSharedValue(defaultHeight); const startHeight = useSharedValue(0); const panGesture = useMemo( @@ -173,53 +185,47 @@ export function TabletPortraitPlayer() { .onUpdate((e) => { 'worklet'; const next = startHeight.value - e.translationY; - panelHeight.value = Math.min(fullHeight, Math.max(PEEK_HEIGHT, next)); + panelHeight.value = Math.min(fullHeight, Math.max(LOW_PEEK_HEIGHT, next)); }) .onEnd((e) => { 'worklet'; const projected = panelHeight.value - e.velocityY * 0.08; - const dPeek = Math.abs(projected - PEEK_HEIGHT); - const dHalf = Math.abs(projected - halfHeight); + const dLow = Math.abs(projected - LOW_PEEK_HEIGHT); + const dDef = Math.abs(projected - defaultHeight); const dFull = Math.abs(projected - fullHeight); - let target = PEEK_HEIGHT; - if (dHalf <= dPeek && dHalf <= dFull) target = halfHeight; - else if (dFull <= dPeek && dFull <= dHalf) target = fullHeight; + let target = LOW_PEEK_HEIGHT; + if (dDef <= dLow && dDef <= dFull) target = defaultHeight; + else if (dFull <= dLow && dFull <= dDef) target = fullHeight; panelHeight.value = withSpring(target, { damping: 28, stiffness: 220, mass: 0.9 }); }), - [fullHeight, halfHeight, panelHeight, startHeight], + [defaultHeight, fullHeight, panelHeight, startHeight], ); + // Band fades out as the sheet expands to full; it also rides to stay + // vertically centered in the (shrinking/growing) space above the sheet. const fullContentStyle = useAnimatedStyle(() => { - const c = interpolate(panelHeight.value, [halfHeight, fullHeight], [0, 1], Extrapolation.CLAMP); + const c = interpolate(panelHeight.value, [defaultHeight, fullHeight], [0, 1], Extrapolation.CLAMP); + const centerY = (headerSpace - panelHeight.value) / 2; return { opacity: 1 - c, - transform: [{ translateY: -24 * c }, { scale: 1 - 0.04 * c }], + transform: [{ translateY: centerY - 16 * c }], pointerEvents: c < 0.5 ? ('auto' as const) : ('none' as const), }; - }, [halfHeight, fullHeight]); + }, [defaultHeight, fullHeight, headerSpace]); const stripStyle = useAnimatedStyle(() => { - const c = interpolate(panelHeight.value, [halfHeight, fullHeight], [0, 1], Extrapolation.CLAMP); + const c = interpolate(panelHeight.value, [defaultHeight, fullHeight], [0, 1], Extrapolation.CLAMP); return { opacity: c, pointerEvents: c > 0.5 ? ('auto' as const) : ('none' as const), }; - }, [halfHeight, fullHeight]); + }, [defaultHeight, fullHeight]); const queueColors = useMemo( () => ({ ...colors, primary: mixHexColors(colors.primary, colors.textPrimary, 0.45) }), [colors], ); - // Size the hero so the controls stay visible above the panel at its HALF - // detent — everything below the panel's top edge is covered by the panel. - const heroSize = useMemo(() => { - const NON_HERO_CHROME = 286; // track info + progress + 2 control rows + hero padding - const verticalBudget = screenH - halfHeight - headerSpace - 8 - NON_HERO_CHROME; - const widthBudget = screenW - 2 * HERO_PADDING; - return Math.max(Math.min(widthBudget, 420, verticalBudget), 140); - }, [screenW, screenH, halfHeight, headerSpace]); - if (!currentTrack) { return ( @@ -254,12 +260,11 @@ export function TabletPortraitPlayer() { - {/* Full hero + controls (collapses as the panel expands) */} - - - + {/* Top band: cover art (left) + info/progress/controls (right). + Collapses into the compact strip as the panel expands. */} + + + - - - - - - {currentTrack.title} - - - {currentTrack.artist ?? t('unknownArtist')} - + + + + + {currentTrack.title} + + + {currentTrack.artist ?? t('unknownArtist')} + + + - - - - - - + + + - + + + {/* Compact strip — fades in when the panel is full */} @@ -532,12 +537,16 @@ const styles = StyleSheet.create({ fullContent: { ...absoluteFill, paddingHorizontal: HERO_PADDING, + justifyContent: 'center', }, - hero: { - width: '100%', + band: { + flexDirection: 'row', alignItems: 'center', - paddingTop: 8, - paddingBottom: 24, + gap: COLUMN_GAP, + }, + bandContent: { + flex: 1, + justifyContent: 'center', }, heroImageWrap: { borderRadius: 14, @@ -561,15 +570,10 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - trackInfo: { - width: '100%', - maxWidth: 520, - alignSelf: 'center', - marginBottom: 16, - }, trackInfoRow: { flexDirection: 'row', alignItems: 'center', + marginBottom: 12, }, trackInfoText: { flex: 1, @@ -589,8 +593,6 @@ const styles = StyleSheet.create({ }, progressSection: { width: '100%', - maxWidth: 520, - alignSelf: 'center', marginBottom: 8, }, controls: { @@ -598,8 +600,8 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', paddingVertical: 8, - maxWidth: 520, width: '100%', + maxWidth: 420, alignSelf: 'center', }, controlSideLeft: { @@ -616,19 +618,19 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-evenly', - width: 260, + width: 248, }, secondaryControls: { flexDirection: 'row', alignItems: 'center', height: 40, marginTop: 20, - maxWidth: 520, width: '100%', + maxWidth: 420, alignSelf: 'center', }, secondaryCenter: { - width: 260, + width: 248, }, secondaryCenterRow: { flexDirection: 'row', From b4fb20bd23691463d4c0b398d1fec93b50b17d0e Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Sun, 31 May 2026 20:45:21 +1000 Subject: [PATCH 12/18] feat(player): add bookmark button to expanded player secondary row --- src/components/ExpandedPlayerView.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/ExpandedPlayerView.tsx b/src/components/ExpandedPlayerView.tsx index 62b85b4..6fc48a9 100644 --- a/src/components/ExpandedPlayerView.tsx +++ b/src/components/ExpandedPlayerView.tsx @@ -22,6 +22,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { AlbumInfoContent } from './AlbumInfoContent'; import { LyricsContent } from './LyricsContent'; +import { BookmarkButton } from './BookmarkButton'; import { CachedImage } from './CachedImage'; import { FavoriteButton } from './FavoriteButton'; import { MarqueeText } from './MarqueeText'; @@ -446,7 +447,9 @@ export function ExpandedPlayerView({ )} - + + + {/* Quality badge */} From 37a4783962bbc17b86a267f3af58f4250de5a7b0 Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Sun, 31 May 2026 23:10:34 +1000 Subject: [PATCH 13/18] fix(player): align secondary control row under the primary row Inset the secondary-row left slot 4px so the sleep-timer lines up under the shuffle (which has built-in padding), and make the expanded player's bookmark flush-right to match repeat. Applied to all three players. --- src/components/ExpandedPlayerView.tsx | 10 ++++++++-- src/screens/player-view.tsx | 5 ++++- src/screens/tablet-portrait-player.tsx | 5 ++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/components/ExpandedPlayerView.tsx b/src/components/ExpandedPlayerView.tsx index 6fc48a9..a56d0b1 100644 --- a/src/components/ExpandedPlayerView.tsx +++ b/src/components/ExpandedPlayerView.tsx @@ -433,7 +433,7 @@ export function ExpandedPlayerView({ {/* Secondary controls row — sleep timer, then skip-interval buttons under prev/next with the playback rate between them */} - + {showSleepTimer && } @@ -448,7 +448,7 @@ export function ExpandedPlayerView({ )} - + @@ -776,6 +776,12 @@ const styles = StyleSheet.create({ favoriteButton: { padding: 4, }, + bookmarkButton: { + paddingVertical: 4, + }, + secondaryLeftInset: { + paddingLeft: 4, + }, qualityBadge: { fontSize: 12, fontWeight: '500', diff --git a/src/screens/player-view.tsx b/src/screens/player-view.tsx index b3d2a07..a6b45e2 100644 --- a/src/screens/player-view.tsx +++ b/src/screens/player-view.tsx @@ -617,7 +617,7 @@ const PlayerContent = memo(function PlayerContent({ {/* Secondary controls row — mirrors primary controls layout. Skip-interval buttons sit under prev/next with the playback rate between them. */} - + {showSleepTimer && } @@ -889,6 +889,9 @@ const styles = StyleSheet.create({ paddingLeft: 12, paddingVertical: 4, }, + secondaryLeftInset: { + paddingLeft: 4, + }, progressSection: { paddingHorizontal: HERO_PADDING, maxWidth: 464, diff --git a/src/screens/tablet-portrait-player.tsx b/src/screens/tablet-portrait-player.tsx index 94d67c6..acbf6aa 100644 --- a/src/screens/tablet-portrait-player.tsx +++ b/src/screens/tablet-portrait-player.tsx @@ -480,7 +480,7 @@ const PlaybackControls = memo(function PlaybackControls({ - + {showSleepTimer && } @@ -591,6 +591,9 @@ const styles = StyleSheet.create({ paddingLeft: 12, paddingVertical: 4, }, + secondaryLeftInset: { + paddingLeft: 4, + }, progressSection: { width: '100%', marginBottom: 8, From 9ef7d6bfe93fff9496b3a77e5a9df1c01ce71ea8 Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Mon, 1 Jun 2026 16:31:11 +1000 Subject: [PATCH 14/18] feat(player+cache): player reorg, tablet-portrait rework, canonical cover-art IDs - group/rename players under player/ (PlayerPhonePortrait, PlayerTabletPortrait, PlayerTabletLandscape, PlayerTabletSplitview, PlayerPhoneMini, PlayerModeContent); rename MoreOptionsSource values to player-* names - tablet-portrait: drop the draggable sheet for a fixed split over one page-wide gradient with a centered Queue/Info/Lyrics toggle; AlbumInfoContent gains compilation/not-found placeholders + an animated skeleton - cover-art: key off the entity ID via utils/coverArtId.ts everywhere (prefetch, downloads, store warmers, render); raw coverArt field retained - image cache: log the previously-silent missing-id placeholder; filter falsy-id shelf items (undefined FlashList keys); clear failedRemoteIds on foreground/server-reachable so transient blips self-heal - strip all console.* from production builds via babel-plugin-transform-remove-console --- .../routing-and-navigation.instructions.md | 2 +- babel.config.js | 11 + package-lock.json | 12 +- package.json | 1 + src/app/_layout.tsx | 10 +- src/app/player.tsx | 6 +- src/components/AlbumInfoContent.tsx | 175 ++++--- src/components/BottomChrome.tsx | 12 +- src/components/CachedImage.tsx | 14 +- src/components/MoreOptionsSheet.tsx | 23 +- src/components/PlaybackToast.tsx | 6 +- src/components/UpNextPanel.tsx | 329 -------------- .../__tests__/AlbumInfoContent.test.tsx | 60 ++- .../__tests__/BottomChrome.test.tsx | 10 +- .../__tests__/PlaybackToast.test.tsx | 10 +- src/components/player/PlayerModeContent.tsx | 243 ++++++++++ .../PlayerPhoneMini.tsx} | 22 +- .../PlayerTabletLandscape.tsx} | 80 ++-- .../PlayerTabletSplitview.tsx} | 54 +-- src/hooks/usePlayerAlbumInfo.ts | 4 +- src/hooks/usePlayerLyrics.ts | 4 +- src/i18n/locales/en.json | 2 + src/screens/home.tsx | 6 +- src/screens/my-listening.tsx | 2 +- .../__tests__/player-phone-portrait.test.tsx} | 118 ++--- .../player-phone-portrait.tsx} | 84 ++-- .../player-tablet-portrait.tsx} | 429 ++++++++---------- .../__tests__/imageCacheService.queue.test.ts | 5 +- .../__tests__/imageCacheService.test.ts | 25 + .../__tests__/musicCacheService.test.ts | 7 +- src/services/__tests__/playerService.test.ts | 4 +- src/services/imageCacheService.ts | 79 +++- src/services/musicCacheService.ts | 34 +- src/services/playerHelpers.ts | 6 +- src/services/playerService.ts | 10 +- src/store/__tests__/moreOptionsStore.test.ts | 16 +- src/store/albumDetailStore.ts | 4 +- src/store/favoritesStore.ts | 7 +- src/store/moreOptionsStore.ts | 7 +- src/store/playlistDetailStore.ts | 4 +- src/utils/__tests__/coverArtId.test.ts | 66 +++ src/utils/colors.ts | 2 +- 42 files changed, 1089 insertions(+), 916 deletions(-) delete mode 100644 src/components/UpNextPanel.tsx create mode 100644 src/components/player/PlayerModeContent.tsx rename src/components/{MiniPlayer.tsx => player/PlayerPhoneMini.tsx} (93%) rename src/components/{ExpandedPlayerView.tsx => player/PlayerTabletLandscape.tsx} (92%) rename src/components/{PlayerPanel.tsx => player/PlayerTabletSplitview.tsx} (89%) rename src/screens/{__tests__/player-view.test.tsx => player/__tests__/player-phone-portrait.test.tsx} (79%) rename src/screens/{player-view.tsx => player/player-phone-portrait.tsx} (92%) rename src/screens/{tablet-portrait-player.tsx => player/player-tablet-portrait.tsx} (56%) create mode 100644 src/utils/__tests__/coverArtId.test.ts diff --git a/.github/instructions/routing-and-navigation.instructions.md b/.github/instructions/routing-and-navigation.instructions.md index d4890a2..78b32a4 100644 --- a/.github/instructions/routing-and-navigation.instructions.md +++ b/.github/instructions/routing-and-navigation.instructions.md @@ -47,7 +47,7 @@ router.replace('/login'); ## Layout Files - `app/_layout.tsx` – Root Stack with auth guard, splash screen, and theme-aware header styles. -- `app/(tabs)/_layout.tsx` – Tab navigator with `MiniPlayer` above the tab bar and `SearchableHeader` as custom header. +- `app/(tabs)/_layout.tsx` – Tab navigator with `PlayerPhoneMini` (via `BottomChrome`) above the tab bar and `SearchableHeader` as custom header. ## Auth Guard diff --git a/babel.config.js b/babel.config.js index 9d89e13..7a007c1 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,5 +2,16 @@ module.exports = function (api) { api.cache(true); return { presets: ['babel-preset-expo'], + env: { + // Production builds (BABEL_ENV/NODE_ENV=production, e.g. EAS release + // builds and `expo export`) strip ALL console.* calls. The app has its + // own opt-in file-based logging (see the Logging screen / imageCacheLogger + // + diagnostics stores) for anything user-facing, so console output is + // dev-only noise that isn't visible to users — no point shipping it or + // spending cycles on it. Dev builds keep console intact. + production: { + plugins: ['transform-remove-console'], + }, + }, }; }; diff --git a/package-lock.json b/package-lock.json index d689ff2..0fc840f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "substreamer", - "version": "8.0.65", + "version": "8.0.68", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "substreamer", - "version": "8.0.65", + "version": "8.0.68", "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { @@ -75,6 +75,7 @@ "@testing-library/react-native": "^13.3.3", "@types/jest": "~29.5.14", "@types/react": "^19.2.15", + "babel-plugin-transform-remove-console": "^6.9.4", "expo-dev-client": "~56.0.16", "jest": "~29.7.0", "jest-expo": "~56.0.4", @@ -4547,6 +4548,13 @@ "@babel/plugin-syntax-flow": "^7.12.1" } }, + "node_modules/babel-plugin-transform-remove-console": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz", + "integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.2.0", "dev": true, diff --git a/package.json b/package.json index 34034de..e7985fd 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "@testing-library/react-native": "^13.3.3", "@types/jest": "~29.5.14", "@types/react": "^19.2.15", + "babel-plugin-transform-remove-console": "^6.9.4", "expo-dev-client": "~56.0.16", "jest": "~29.7.0", "jest-expo": "~56.0.4", diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 367440c..5a76874 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -37,8 +37,8 @@ import { mixHexColors } from '../utils/colors'; import AnimatedSplashScreen from '../components/AnimatedSplashScreen'; import { CertificatePromptModal } from '../components/CertificatePromptModal'; import { CreateShareSheet } from '../components/CreateShareSheet'; -import { ExpandedPlayerView } from '../components/ExpandedPlayerView'; -import { PlayerPanel } from '../components/PlayerPanel'; +import { PlayerTabletLandscape } from '../components/player/PlayerTabletLandscape'; +import { PlayerTabletSplitview } from '../components/player/PlayerTabletSplitview'; import { SplitLayout } from '../components/SplitLayout'; import { MbidSearchSheet } from '../components/MbidSearchSheet'; import { MoreOptionsSheet } from '../components/MoreOptionsSheet'; @@ -725,13 +725,13 @@ export default function RootLayout() { } - panel={showPanel ? : null} + panel={showPanel ? : null} panelPlaceholder={{null}} /> {/* Full-screen expanded player — covers everything including SplitLayout */} {showPanel && ( - + )} {/* Global more-options bottom sheet driven by moreOptionsStore */} @@ -777,7 +777,7 @@ export default function RootLayout() { {/* Global error pill. Used by `playerService.fail(...)` to surface genuine playback failures (offline + no cached tracks, RNTP errors). Lifts itself above the BottomChrome (DownloadBanner + - MiniPlayer) when present so it doesn't stack on top. */} + mini player) when present so it doesn't stack on top. */} diff --git a/src/app/player.tsx b/src/app/player.tsx index 9739866..9272750 100644 --- a/src/app/player.tsx +++ b/src/app/player.tsx @@ -1,8 +1,8 @@ import { useIsTabletPortrait } from '@/hooks/useIsTabletPortrait'; -import { PlayerView } from '@/screens/player-view'; -import { TabletPortraitPlayer } from '@/screens/tablet-portrait-player'; +import { PlayerPhonePortrait } from '@/screens/player/player-phone-portrait'; +import { PlayerTabletPortrait } from '@/screens/player/player-tablet-portrait'; export default function PlayerRoute() { const tabletPortrait = useIsTabletPortrait(); - return tabletPortrait ? : ; + return tabletPortrait ? : ; } diff --git a/src/components/AlbumInfoContent.tsx b/src/components/AlbumInfoContent.tsx index eeb6bd1..47a790b 100644 --- a/src/components/AlbumInfoContent.tsx +++ b/src/components/AlbumInfoContent.tsx @@ -1,6 +1,6 @@ import FontAwesome5 from "@react-native-vector-icons/fontawesome5/static"; import Ionicons from "@react-native-vector-icons/ionicons/static"; -import { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Linking, Pressable, @@ -10,12 +10,19 @@ import { Text, View, } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; import i18next from 'i18next'; import { useTranslation } from 'react-i18next'; import { FormatBadge } from './FormatBadge'; import { useRefreshControlKey } from '../hooks/useRefreshControlKey'; -import { type Child } from '../services/subsonicService'; +import { isVariousArtists, type Child } from '../services/subsonicService'; import { hexWithAlpha } from '../utils/colors'; import { getEffectiveFormat } from '../utils/effectiveFormat'; import { getGenreNames } from '../utils/genreHelpers'; @@ -115,15 +122,26 @@ export const AlbumInfoContent = memo(function AlbumInfoContent({ return phrases; }, [track, t]); - // Build credit rows (album artist if different, composer) + // Compilation = album credited to "Various Artists" (any casing). This is how + // Navidrome/OpenSubsonic surfaces compilations; the per-album `isCompilation` + // flag only rides on AlbumID3 (getAlbum), which the player never fetches. + const isCompilation = isVariousArtists(track.displayAlbumArtist ?? track.artist); + + // Build credit rows (album artist if different, composer). For compilations + // the "Various Artists" album-artist row is redundant with the placeholder, so + // skip it. const credits = useMemo(() => { const rows: { label: string; value: string }[] = []; - if (track.displayAlbumArtist && track.displayAlbumArtist !== track.artist) { + if ( + !isCompilation && + track.displayAlbumArtist && + track.displayAlbumArtist !== track.artist + ) { rows.push({ label: t('detailAlbumArtist'), value: track.displayAlbumArtist }); } if (track.displayComposer) rows.push({ label: t('detailComposer'), value: track.displayComposer }); return rows; - }, [track, t]); + }, [track, t, isCompilation]); const handleLastFm = useCallback(() => { if (albumInfo?.lastFmUrl) Linking.openURL(albumInfo.lastFmUrl); @@ -187,51 +205,7 @@ export const AlbumInfoContent = memo(function AlbumInfoContent({ )} ) : (albumInfoLoading || refreshing) ? ( - /* Skeleton placeholder — mirrors the real layout */ - ((() => { - // Theme-aware skeleton fill: derives from `textSecondary` so light - // mode gets a dark-gray bar (visible on white) and dark mode gets - // a light-gray bar (visible on black). The hardcoded white-alpha - // previously used was invisible on light backgrounds. - const skeletonFill = { backgroundColor: hexWithAlpha(colors.textSecondary, 0.2) }; - return ( - - {/* Hero block */} - - - - - - {[72, 96, 60, 84].map((w, i) => ( - - ))} - - - - {/* Inline metadata strip */} - - - {/* Description */} - - - {[1, 0.97, 1, 0.95, 0.98, 1, 0.93, 0.96, 1, 0.6].map((w, i) => ( - - ))} - - - {/* External links */} - - - {[90, 110, 95].map((w, i) => ( - - ))} - - - ); - })()) + ) : ( <> {/* ── Hero header block (centered) ── */} @@ -334,7 +308,25 @@ export const AlbumInfoContent = memo(function AlbumInfoContent({ )} - ) : null} + ) : ( + /* No description — show a friendly placeholder in the bio slot, + styled like the "no lyrics available" empty state for + consistency across player segments. */ + + + + + + {isCompilation ? t('albumDetailsCompilation') : t('albumDetailsNotFound')} + + + + )} )} {/* ── External links (centered) ── */} @@ -385,6 +377,76 @@ export const AlbumInfoContent = memo(function AlbumInfoContent({ ); }); +/* ------------------------------------------------------------------ */ +/* Skeleton placeholder — mirrors the real layout, with a looping */ +/* opacity pulse so it reads as "loading" rather than a frozen frame. */ +/* ------------------------------------------------------------------ */ + +const AlbumInfoSkeleton = memo(function AlbumInfoSkeleton({ + colors, +}: { + colors: AlbumInfoContentProps['colors']; +}) { + // Theme-aware skeleton fill: derives from `textSecondary` so light mode gets + // a dark-gray bar (visible on white) and dark mode a light-gray bar (visible + // on black). + const skeletonFill = { backgroundColor: hexWithAlpha(colors.textSecondary, 0.2) }; + + // Looping pulse. Starts at 1 (never 0) and breathes between 0.4 and 1 — the + // mount-and-repeat shape used elsewhere (e.g. tuned-in) so it can't get stuck + // invisible. Only mounted while loading, so it stops on unmount. + const pulse = useSharedValue(1); + useEffect(() => { + pulse.value = withRepeat( + withSequence( + withTiming(0.4, { duration: 700 }), + withTiming(1, { duration: 700 }), + ), + -1, + ); + }, [pulse]); + + const pulseStyle = useAnimatedStyle(() => ({ opacity: pulse.value })); + + return ( + + {/* Hero block */} + + + + + + {[72, 96, 60, 84].map((w, i) => ( + + ))} + + + + {/* Inline metadata strip */} + + + {/* Description */} + + + {[1, 0.97, 1, 0.95, 0.98, 1, 0.93, 0.96, 1, 0.6].map((w, i) => ( + + ))} + + + {/* External links */} + + + {[90, 110, 95].map((w, i) => ( + + ))} + + + ); +}); + /* ------------------------------------------------------------------ */ /* Styles */ /* ------------------------------------------------------------------ */ @@ -470,6 +532,17 @@ const styles = StyleSheet.create({ textAlign: 'right', }, + /* No-description placeholder (compilation / not found) */ + placeholderBlock: { + marginBottom: 4, + }, + placeholderInner: { + alignItems: 'center', + paddingVertical: 28, + paddingHorizontal: 32, + gap: 12, + }, + /* Album description */ descriptionSection: { marginBottom: 4, diff --git a/src/components/BottomChrome.tsx b/src/components/BottomChrome.tsx index 4912e81..528ab2b 100644 --- a/src/components/BottomChrome.tsx +++ b/src/components/BottomChrome.tsx @@ -2,7 +2,7 @@ import { StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { DownloadBanner } from './DownloadBanner'; -import { MiniPlayer } from './MiniPlayer'; +import { PlayerPhoneMini } from './player/PlayerPhoneMini'; import { useLayoutMode } from '../hooks/useLayoutMode'; import { authStore } from '../store/authStore'; import { musicCacheStore } from '../store/musicCacheStore'; @@ -12,12 +12,12 @@ import { playerStore } from '../store/playerStore'; * Single shared bottom-chrome stack rendered both inside the tabs * `renderTabBar` callback and as a footer on every non-tab Stack screen. * - * Composes `` above `` with **independent** + * Composes `` above `` with **independent** * visibility: * - banner is visible whenever the download queue has any * downloading/queued/error rows; - * - MiniPlayer is visible whenever there is a current track AND the - * layout is compact (wide layouts don't show the MiniPlayer). + * - PlayerPhoneMini is visible whenever there is a current track AND the + * layout is compact (wide layouts don't show the mini player). * * Either piece can be on while the other is off — e.g. a download * starts before the user plays anything, or the user clears the play @@ -52,7 +52,7 @@ export function BottomChrome({ withSafeAreaPadding = false }: BottomChromeProps const insets = useSafeAreaInsets(); if (!isLoggedIn) return null; - // On wide layouts the MiniPlayer never renders, so the chrome only has a + // On wide layouts the mini player never renders, so the chrome only has a // reason to mount when there are downloads. if (isWide && !hasDownloads) return null; // On compact layouts we need EITHER a track or an active download. @@ -66,7 +66,7 @@ export function BottomChrome({ withSafeAreaPadding = false }: BottomChromeProps ]} > {hasDownloads && } - {!isWide && hasCurrentTrack && } + {!isWide && hasCurrentTrack && } ); } diff --git a/src/components/CachedImage.tsx b/src/components/CachedImage.tsx index 68a827a..7131a88 100644 --- a/src/components/CachedImage.tsx +++ b/src/components/CachedImage.tsx @@ -236,7 +236,17 @@ export const CachedImage = memo(function CachedImage({ // One log line per state transition. Kept minimal so user logs are // scannable; the service has its own logs for downloads/retries. useEffect(() => { - if (!coverArtId) return; + if (!coverArtId) { + // No usable id AND no bundled fallback → a genuine missing-id + // placeholder: the parent handed us an entity with no id. This is the + // otherwise-silent stuck case (every other branch is gated on a truthy + // id), so log it to make recurrence diagnosable. Sentinels render a + // bundled fallbackUri and are intentionally skipped. + if (!fallbackUri) { + logImageCache(`CachedImage placeholder id-missing size=${size}`); + } + return; + } const where = cachedUri && !localErroredRef.current ? 'local' : isRemote @@ -245,7 +255,7 @@ export const CachedImage = memo(function CachedImage({ logImageCache( `CachedImage state id=${coverArtId} size=${size} ${where} remoteFailed=${remoteFailed}`, ); - }, [coverArtId, size, cachedUri, isRemote, remoteFailed]); + }, [coverArtId, size, cachedUri, isRemote, remoteFailed, fallbackUri]); const flatStyle = StyleSheet.flatten(style) as (ImageStyle & ViewStyle) | undefined; const logoSize = computeLogoSize( diff --git a/src/components/MoreOptionsSheet.tsx b/src/components/MoreOptionsSheet.tsx index ba15bcb..99dbe93 100644 --- a/src/components/MoreOptionsSheet.tsx +++ b/src/components/MoreOptionsSheet.tsx @@ -22,6 +22,7 @@ import { useDownloadStatus, type DownloadStatus } from '../hooks/useDownloadStat import { useIsStarred } from '../hooks/useIsStarred'; import { useRating } from '../hooks/useRating'; import { useTheme } from '../hooks/useTheme'; +import { coverArtIdForEntity } from '../utils/coverArtId'; import { tabletLayoutStore } from '../store/tabletLayoutStore'; import { addAlbumToQueue, @@ -103,13 +104,9 @@ function getSubtitle(entity: MoreOptionsEntity, t: (key: string, options?: Recor } function getCoverArtId(entity: MoreOptionsEntity): string | undefined { - // Cover-art lookups key off the entity ID, not the server's - // `coverArt` field. Same canonical ID for every track in an album so - // MiniPlayer / lock-screen / queue rows all share one cached file. - if (entity.type === 'song') { - return (entity.item as Child).albumId ?? entity.item.id; - } - return entity.item.id; + // Cover-art lookups key off the entity ID, not the server's `coverArt` + // field — the single rule lives in src/utils/coverArtId.ts. + return coverArtIdForEntity(entity.item); } function isStarrable(entity: MoreOptionsEntity): boolean { @@ -193,7 +190,7 @@ export function MoreOptionsSheet() { const entity = moreOptionsStore((s) => s.entity); const source = moreOptionsStore((s) => s.source); const hide = moreOptionsStore((s) => s.hide); - const isPlayerSource = source === 'player' || source === 'playerpanel' || source === 'playerexpanded'; + const isPlayerSource = source !== 'default'; const starType: 'song' | 'album' | 'artist' = entity?.type === 'album' || entity?.type === 'artist' ? entity.type : 'song'; @@ -358,7 +355,7 @@ export function MoreOptionsSheet() { const handleGoToArtist = useCallback(() => { if (!entity) return; handleClose(); - if (source === 'playerexpanded') { + if (source === 'player-tablet-landscape') { tabletLayoutStore.getState().setPlayerExpanded(false); } const artistId = @@ -375,7 +372,7 @@ export function MoreOptionsSheet() { const handleGoToAlbum = useCallback(() => { if (!entity || entity.type !== 'song') return; handleClose(); - if (source === 'playerexpanded') { + if (source === 'player-tablet-landscape') { tabletLayoutStore.getState().setPlayerExpanded(false); } const albumId = (entity.item as Child).albumId; @@ -466,10 +463,8 @@ export function MoreOptionsSheet() { const handleSetRating = useCallback(async () => { if (!entity || !isRatable(entity)) return; // Entity-ID based cover art (see src/utils/coverArtId.ts) — songs key - // off the parent album so MiniPlayer / rating sheet share one cache. - const coverArtId = entity.type === 'song' - ? ((entity.item as Child).albumId ?? entity.item.id) - : entity.item.id; + // off the parent album so mini player / rating sheet share one cache. + const coverArtId = coverArtIdForEntity(entity.item); await moreOptionsStore.getState().hideAndAwait(); setRatingStore.getState().show( entity.type as 'song' | 'album' | 'artist', diff --git a/src/components/PlaybackToast.tsx b/src/components/PlaybackToast.tsx index f1cc8c0..42efe39 100644 --- a/src/components/PlaybackToast.tsx +++ b/src/components/PlaybackToast.tsx @@ -31,7 +31,7 @@ const CAPSULE_BORDER_RADIUS = CAPSULE_HEIGHT / 2; const SUCCESS_DISPLAY_MS = 1400; const ERROR_DISPLAY_MS = 2200; const BOTTOM_OFFSET = 24; -/** Keep in sync with MINI_PLAYER_HEIGHT in `MiniPlayer.tsx`. */ +/** Keep in sync with MINI_PLAYER_HEIGHT in `PlayerPhoneMini.tsx`. */ const MINI_PLAYER_HEIGHT = 56; const SPRING_CONFIG = { damping: 14, stiffness: 200, mass: 0.8 }; @@ -45,11 +45,11 @@ export function PlaybackToast() { const hide = playbackToastStore((s) => s.hide); const { colors } = useTheme(); const insets = useSafeAreaInsets(); - // Lift the pill above the bottom chrome (DownloadBanner + MiniPlayer) + // Lift the pill above the bottom chrome (DownloadBanner + mini player) // when either is rendered so they don't stack. The chrome lives in // `BottomChrome` (per-screen and inside the tabs `renderTabBar`); its // visibility rules must match this predicate so the offsets align. - // Banner visibility is independent of MiniPlayer visibility — the banner + // Banner visibility is independent of mini player visibility — the banner // can be on screen with no track playing (downloads queued, queue // cleared while downloading, etc.), so it gets its own offset term. const isLoggedIn = authStore((s) => s.isLoggedIn); diff --git a/src/components/UpNextPanel.tsx b/src/components/UpNextPanel.tsx deleted file mode 100644 index 4e21f79..0000000 --- a/src/components/UpNextPanel.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import Ionicons from "@react-native-vector-icons/ionicons/static"; -import MaterialCommunityIcons from "@react-native-vector-icons/material-design-icons/static"; -import { FlashList } from '@shopify/flash-list'; -import { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; -import { GestureDetector, type PanGesture } from 'react-native-gesture-handler'; -import Animated, { useAnimatedStyle, type SharedValue } from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { AlbumInfoContent } from './AlbumInfoContent'; -import { LyricsContent } from './LyricsContent'; -import { QueueItemRow } from './QueueItemRow'; -import { closeOpenRow } from './SwipeableRow'; -import { type ThemeColors } from '../constants/theme'; -import { usePlayerAlbumInfo } from '../hooks/usePlayerAlbumInfo'; -import { usePlayerLyrics } from '../hooks/usePlayerLyrics'; -import { type Child } from '../services/subsonicService'; -import { sanitizeBiographyText } from '../utils/formatters'; - -type PanelMode = 'queue' | 'info' | 'lyrics'; - -export interface UpNextPanelProps { - /** Visible height of the panel in px (parent-owned, driven by the drag). */ - panelHeight: SharedValue; - /** Full (maximum) panel height — the panel slides within this fixed height. */ - maxHeight: number; - /** Pan gesture (owned by the parent) attached to the drag handle. */ - panGesture: PanGesture; - currentTrack: Child; - queue: Child[]; - currentTrackIndex: number | null; - colors: ThemeColors; - /** Muted-primary variant for the active queue row highlight. */ - queueColors: ThemeColors; - offlineMode: boolean; - onQueueItemPress: (index: number) => void; - onQueueItemLongPress: (track: Child) => void; - onShareQueue: () => void; - onClearQueue: () => void; -} - -/** - * Inline draggable "Up Next" panel for the tablet-portrait player. Hosts the - * Queue / Album Info / Lyrics views with a toggle, sliding between detents via - * `panelHeight`. Deliberately in-tree (NOT a Modal) so the global - * MoreOptionsSheet can open over it without stacking two native modals. - */ -export const UpNextPanel = memo(function UpNextPanel({ - panelHeight, - maxHeight, - panGesture, - currentTrack, - queue, - currentTrackIndex, - colors, - queueColors, - offlineMode, - onQueueItemPress, - onQueueItemLongPress, - onShareQueue, - onClearQueue, -}: UpNextPanelProps) { - const { t } = useTranslation(); - const insets = useSafeAreaInsets(); - const [mode, setMode] = useState('queue'); - - // Info/Lyrics are hidden offline — fall back to the queue if they vanish. - useEffect(() => { - if (offlineMode && mode !== 'queue') setMode('queue'); - }, [offlineMode, mode]); - - // Album info — only fetch while the info view is actually showing. - const albumId = currentTrack.albumId ?? null; - const { - entry: albumInfoEntry, - loading: albumInfoLoading, - error: albumInfoError, - refreshing: albumInfoRefreshing, - handleRetry: handleRetryAlbumInfo, - handleRefresh: handleRefreshAlbumInfo, - } = usePlayerAlbumInfo(albumId, currentTrack.artist, currentTrack.album, { - enabled: mode === 'info', - }); - - const sanitizedNotes = useMemo(() => { - const serverNotes = albumInfoEntry?.albumInfo.notes; - if (serverNotes) { - const sanitized = sanitizeBiographyText(serverNotes); - if (sanitized) return sanitized; - } - return albumInfoEntry?.enrichedNotes ?? null; - }, [albumInfoEntry?.albumInfo.notes, albumInfoEntry?.enrichedNotes]); - - const notesAttributionUrl = albumInfoEntry?.enrichedNotesUrl ?? null; - - const trackId = currentTrack.id; - const { - entry: lyricsEntry, - loading: lyricsLoading, - error: lyricsError, - handleRetry: handleRetryLyrics, - } = usePlayerLyrics(trackId, currentTrack.artist, currentTrack.title); - - const renderQueueItem = useCallback( - ({ item, index }: { item: Child; index: number }) => ( - - ), - [currentTrackIndex, queueColors, onQueueItemPress, onQueueItemLongPress], - ); - - const keyExtractor = useCallback( - (item: Child, index: number) => `${item.id}-${index}`, - [], - ); - - const slideStyle = useAnimatedStyle( - () => ({ - transform: [{ translateY: maxHeight - panelHeight.value }], - }), - [maxHeight], - ); - - const listContentStyle = useMemo( - () => ({ paddingBottom: insets.bottom + 16 }), - [insets.bottom], - ); - - return ( - - - - - - - setMode('queue')} - hitSlop={8} - accessibilityRole="button" - accessibilityLabel={t('showQueue')} - style={({ pressed }) => [styles.toggleButton, pressed && styles.pressed]} - > - - - {!offlineMode && ( - setMode('info')} - hitSlop={8} - accessibilityRole="button" - accessibilityLabel={t('showAlbumInfo')} - style={({ pressed }) => [styles.toggleButton, pressed && styles.pressed]} - > - - - )} - {!offlineMode && ( - setMode('lyrics')} - hitSlop={8} - accessibilityRole="button" - accessibilityLabel={t('showLyrics')} - style={({ pressed }) => [styles.toggleButton, pressed && styles.pressed]} - > - - - )} - - {mode === 'queue' && queue.length > 0 && ( - - [styles.queueActionButton, pressed && styles.pressed]} - > - - - [styles.queueActionButton, pressed && styles.pressed]} - > - - {t('clear')} - - - - )} - - - - - - {mode === 'queue' ? ( - - ) : mode === 'info' ? ( - - ) : ( - - - - )} - - - ); -}); - -const styles = StyleSheet.create({ - panel: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - borderTopWidth: StyleSheet.hairlineWidth, - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: -4 }, - shadowOpacity: 0.2, - shadowRadius: 12, - elevation: 16, - }, - header: { - paddingTop: 8, - paddingHorizontal: 16, - paddingBottom: 8, - }, - grabber: { - alignSelf: 'center', - width: 36, - height: 4, - borderRadius: 2, - opacity: 0.4, - marginBottom: 12, - }, - toggleRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - toggleButtons: { - flexDirection: 'row', - gap: 16, - }, - toggleButton: { - padding: 4, - }, - queueActions: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - }, - queueActionButton: { - alignItems: 'center', - justifyContent: 'center', - padding: 4, - }, - clearButtonText: { - fontSize: 14, - fontWeight: '600', - }, - body: { - flex: 1, - }, - lyricsContainer: { - flex: 1, - }, - pressed: { - opacity: 0.6, - }, -}); diff --git a/src/components/__tests__/AlbumInfoContent.test.tsx b/src/components/__tests__/AlbumInfoContent.test.tsx index 0eb03fa..4ba37e2 100644 --- a/src/components/__tests__/AlbumInfoContent.test.tsx +++ b/src/components/__tests__/AlbumInfoContent.test.tsx @@ -13,6 +13,19 @@ jest.mock('react-native-svg', () => { return { __esModule: true, default: View, Path: View }; }); +jest.mock('react-native-reanimated', () => { + const { View } = require('react-native'); + return { + __esModule: true, + default: { View }, + useSharedValue: (init: number) => ({ value: init }), + useAnimatedStyle: (fn: () => object) => fn(), + withRepeat: (val: any) => val, + withSequence: (...args: any[]) => args[args.length - 1], + withTiming: (val: number) => val, + }; +}); + import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import { Linking } from 'react-native'; @@ -344,7 +357,7 @@ describe('AlbumInfoContent', () => { it('renders displayAlbumArtist when different from artist', () => { const trackWithAlbumArtist = { ...MOCK_TRACK, - displayAlbumArtist: 'Various Artists', + displayAlbumArtist: 'The Producers', } as Child; const { getByText } = render( @@ -362,7 +375,50 @@ describe('AlbumInfoContent', () => { ); expect(getByText('Album Artist')).toBeTruthy(); - expect(getByText('Various Artists')).toBeTruthy(); + expect(getByText('The Producers')).toBeTruthy(); + }); + + it('shows the compilation placeholder and hides the VA credit row for Various Artists', () => { + const compilationTrack = { + ...MOCK_TRACK, + displayAlbumArtist: 'Various Artists', + } as Child; + + const { getByText, queryByText } = render( + , + ); + + expect(getByText("Album details aren't available for compilations.")).toBeTruthy(); + // The redundant "Album Artist: Various Artists" credit row is suppressed. + expect(queryByText('Album Artist')).toBeNull(); + }); + + it('shows the not-found placeholder when no notes are available', () => { + const { getByText } = render( + , + ); + + expect(getByText('No album details available for this track.')).toBeTruthy(); }); it('does not render album artist row when same as artist', () => { diff --git a/src/components/__tests__/BottomChrome.test.tsx b/src/components/__tests__/BottomChrome.test.tsx index 058bb8d..66bdda9 100644 --- a/src/components/__tests__/BottomChrome.test.tsx +++ b/src/components/__tests__/BottomChrome.test.tsx @@ -1,8 +1,8 @@ jest.mock('../../store/persistence/kvStorage', () => require('../../store/persistence/__mocks__/kvStorage')); -jest.mock('../MiniPlayer', () => { +jest.mock('../player/PlayerPhoneMini', () => { const { View } = require('react-native'); - return { MiniPlayer: () => }; + return { PlayerPhoneMini: () => }; }); jest.mock('../DownloadBanner', () => { @@ -67,7 +67,7 @@ beforeEach(() => { describe('BottomChrome', () => { /* ---- visibility table ---- */ - it('compact + has-track + no-downloads → MiniPlayer only, banner unmounted', () => { + it('compact + has-track + no-downloads → mini player only, banner unmounted', () => { playerStore.setState({ currentTrack: TRACK }); const { getByTestId, queryByTestId } = render(); expect(getByTestId('mini-player')).toBeTruthy(); @@ -76,7 +76,7 @@ describe('BottomChrome', () => { expect(queryByTestId('download-banner')).toBeNull(); }); - it('compact + no-track + has-downloads → banner only, MiniPlayer absent', () => { + it('compact + no-track + has-downloads → banner only, mini player absent', () => { musicCacheStore.setState({ downloadQueue: [makeQueueItem({ status: 'downloading' })], }); @@ -107,7 +107,7 @@ describe('BottomChrome', () => { expect(toJSON()).toBeNull(); }); - it('wide + has-downloads → banner only (no MiniPlayer on wide)', () => { + it('wide + has-downloads → banner only (no mini player on wide)', () => { mockUseLayoutMode.mockReturnValue('wide'); playerStore.setState({ currentTrack: TRACK }); musicCacheStore.setState({ diff --git a/src/components/__tests__/PlaybackToast.test.tsx b/src/components/__tests__/PlaybackToast.test.tsx index 760d632..625f185 100644 --- a/src/components/__tests__/PlaybackToast.test.tsx +++ b/src/components/__tests__/PlaybackToast.test.tsx @@ -125,7 +125,7 @@ describe('PlaybackToast bottom offset', () => { expect(renderToast()).toBe(SAFE_AREA_BOTTOM + BOTTOM_OFFSET); }); - it('MiniPlayer only (track playing, no downloads): adds MiniPlayer height', () => { + it('mini player only (track playing, no downloads): adds mini player height', () => { playerStore.setState({ currentTrack: { id: 't1' } as any }); expect(renderToast()).toBe(SAFE_AREA_BOTTOM + BOTTOM_OFFSET + MINI_PLAYER_HEIGHT); }); @@ -137,7 +137,7 @@ describe('PlaybackToast bottom offset', () => { expect(renderToast()).toBe(SAFE_AREA_BOTTOM + BOTTOM_OFFSET + BANNER_HEIGHT); }); - it('both visible: adds banner + MiniPlayer heights', () => { + it('both visible: adds banner + mini player heights', () => { playerStore.setState({ currentTrack: { id: 't1' } as any }); musicCacheStore.setState({ downloadQueue: [makeQueueItem({ status: 'downloading' })], @@ -147,17 +147,17 @@ describe('PlaybackToast bottom offset', () => { ); }); - it('wide layout: MiniPlayer is hidden so its height is NOT added', () => { + it('wide layout: mini player is hidden so its height is NOT added', () => { mockLayoutMode = 'wide'; playerStore.setState({ currentTrack: { id: 't1' } as any }); musicCacheStore.setState({ downloadQueue: [makeQueueItem({ status: 'downloading' })], }); - // Banner still on, MiniPlayer suppressed by isWide. + // Banner still on, mini player suppressed by isWide. expect(renderToast()).toBe(SAFE_AREA_BOTTOM + BOTTOM_OFFSET + BANNER_HEIGHT); }); - it('logged out: MiniPlayer is hidden so its height is NOT added', () => { + it('logged out: mini player is hidden so its height is NOT added', () => { authStore.setState({ isLoggedIn: false }); playerStore.setState({ currentTrack: { id: 't1' } as any }); expect(renderToast()).toBe(SAFE_AREA_BOTTOM + BOTTOM_OFFSET); diff --git a/src/components/player/PlayerModeContent.tsx b/src/components/player/PlayerModeContent.tsx new file mode 100644 index 0000000..666cfa4 --- /dev/null +++ b/src/components/player/PlayerModeContent.tsx @@ -0,0 +1,243 @@ +import Ionicons from "@react-native-vector-icons/ionicons/static"; +import { FlashList } from '@shopify/flash-list'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { AlbumInfoContent } from '@/components/AlbumInfoContent'; +import { LyricsContent } from '@/components/LyricsContent'; +import { QueueItemRow } from '@/components/QueueItemRow'; +import { closeOpenRow } from '@/components/SwipeableRow'; +import { type ThemeColors } from '@/constants/theme'; +import { usePlayerAlbumInfo } from '@/hooks/usePlayerAlbumInfo'; +import { usePlayerLyrics } from '@/hooks/usePlayerLyrics'; +import { type Child } from '@/services/subsonicService'; +import { sanitizeBiographyText } from '@/utils/formatters'; + +export type PlayerMode = 'queue' | 'info' | 'lyrics'; + +export interface PlayerModeContentProps { + /** Active content view — owned by the parent screen (the centered toggle). */ + mode: PlayerMode; + currentTrack: Child; + queue: Child[]; + currentTrackIndex: number | null; + colors: ThemeColors; + /** Muted-primary variant for the active queue row highlight. */ + queueColors: ThemeColors; + offlineMode: boolean; + onQueueItemPress: (index: number) => void; + onQueueItemLongPress: (track: Child) => void; + onShareQueue: () => void; + onClearQueue: () => void; +} + +/** + * Inline "Up Next" content host for the tablet-portrait player. Renders the + * Queue / Album Info / Lyrics views (selected by the parent's centered toggle) + * directly on the page — transparent, no chrome — so the single page-wide + * gradient shows through. Deliberately in-tree (NOT a Modal) so the global + * MoreOptionsSheet can open over it without stacking two native modals. + */ +export const PlayerModeContent = memo(function PlayerModeContent({ + mode, + currentTrack, + queue, + currentTrackIndex, + colors, + queueColors, + offlineMode, + onQueueItemPress, + onQueueItemLongPress, + onShareQueue, + onClearQueue, +}: PlayerModeContentProps) { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + + // Album info — only fetch while the info view is actually showing. + const albumId = currentTrack.albumId ?? null; + const { + entry: albumInfoEntry, + loading: albumInfoLoading, + error: albumInfoError, + refreshing: albumInfoRefreshing, + handleRetry: handleRetryAlbumInfo, + handleRefresh: handleRefreshAlbumInfo, + } = usePlayerAlbumInfo(albumId, currentTrack.artist, currentTrack.album, { + enabled: mode === 'info', + }); + + const sanitizedNotes = useMemo(() => { + const serverNotes = albumInfoEntry?.albumInfo.notes; + if (serverNotes) { + const sanitized = sanitizeBiographyText(serverNotes); + if (sanitized) return sanitized; + } + return albumInfoEntry?.enrichedNotes ?? null; + }, [albumInfoEntry?.albumInfo.notes, albumInfoEntry?.enrichedNotes]); + + const notesAttributionUrl = albumInfoEntry?.enrichedNotesUrl ?? null; + + const trackId = currentTrack.id; + const { + entry: lyricsEntry, + loading: lyricsLoading, + error: lyricsError, + handleRetry: handleRetryLyrics, + } = usePlayerLyrics(trackId, currentTrack.artist, currentTrack.title); + + const renderQueueItem = useCallback( + ({ item, index }: { item: Child; index: number }) => ( + + ), + [currentTrackIndex, queueColors, onQueueItemPress, onQueueItemLongPress], + ); + + const keyExtractor = useCallback( + (item: Child, index: number) => `${item.id}-${index}`, + [], + ); + + const listContentStyle = useMemo( + () => ({ paddingBottom: insets.bottom + 16 }), + [insets.bottom], + ); + + return ( + + {mode === 'queue' ? ( + <> + {queue.length > 0 && ( + + + {t('queue')} + + + [styles.queueActionButton, pressed && styles.pressed]} + > + + + [styles.queueActionButton, pressed && styles.pressed]} + > + + {t('clear')} + + + + + )} + + + ) : mode === 'info' ? ( + // AlbumInfoContent relies on the parent for side padding (landscape gets + // it from the right column). Provide it here, aligned to the hero band. + + + + ) : ( + + + + )} + + ); +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + queueHeaderRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: 8, + paddingBottom: 12, + }, + queueHeaderText: { + fontSize: 12, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + queueActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + queueActionButton: { + alignItems: 'center', + justifyContent: 'center', + padding: 4, + }, + clearButtonText: { + fontSize: 14, + fontWeight: '600', + }, + infoWrap: { + // Constrain to a centered readable column. AlbumInfoContent's credit rows + // use space-between, which flings label/value to opposite edges across the + // full-width portrait panel; a capped, centered width keeps them legible + // (and mirrors the narrower landscape column it was designed for). + flex: 1, + width: '100%', + maxWidth: 720, + alignSelf: 'center', + paddingHorizontal: 24, + }, + lyricsContainer: { + flex: 1, + }, + pressed: { + opacity: 0.6, + }, +}); diff --git a/src/components/MiniPlayer.tsx b/src/components/player/PlayerPhoneMini.tsx similarity index 93% rename from src/components/MiniPlayer.tsx rename to src/components/player/PlayerPhoneMini.tsx index 3b60f1a..8329152 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/player/PlayerPhoneMini.tsx @@ -6,21 +6,21 @@ import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-nati import { useTranslation } from 'react-i18next'; import Animated, { useAnimatedStyle } from 'react-native-reanimated'; -import { CachedImage } from './CachedImage'; -import { MarqueeText } from './MarqueeText'; -import WaveformLogo from './WaveformLogo'; -import { useImagePalette } from '../hooks/useImagePalette'; -import { useTheme } from '../hooks/useTheme'; -import { skipToNext, togglePlayPause } from '../services/playerService'; -import { playbackSettingsStore } from '../store/playbackSettingsStore'; -import { playerStore } from '../store/playerStore'; - -import { absoluteFill } from '../utils/styles'; +import { CachedImage } from '@/components/CachedImage'; +import { MarqueeText } from '@/components/MarqueeText'; +import WaveformLogo from '@/components/WaveformLogo'; +import { useImagePalette } from '@/hooks/useImagePalette'; +import { useTheme } from '@/hooks/useTheme'; +import { skipToNext, togglePlayPause } from '@/services/playerService'; +import { playbackSettingsStore } from '@/store/playbackSettingsStore'; +import { playerStore } from '@/store/playerStore'; + +import { absoluteFill } from '@/utils/styles'; const MINI_PLAYER_HEIGHT = 56; /** Matches the placeholder cover art background (rgb 150,150,150). */ const PLACEHOLDER_BG = '#969696'; -export function MiniPlayer() { +export function PlayerPhoneMini() { const { colors } = useTheme(); const { t } = useTranslation(); const currentTrack = playerStore((s) => s.currentTrack); diff --git a/src/components/ExpandedPlayerView.tsx b/src/components/player/PlayerTabletLandscape.tsx similarity index 92% rename from src/components/ExpandedPlayerView.tsx rename to src/components/player/PlayerTabletLandscape.tsx index a56d0b1..507bd29 100644 --- a/src/components/ExpandedPlayerView.tsx +++ b/src/components/player/PlayerTabletLandscape.tsx @@ -20,57 +20,57 @@ import Animated, { } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { AlbumInfoContent } from './AlbumInfoContent'; -import { LyricsContent } from './LyricsContent'; -import { BookmarkButton } from './BookmarkButton'; -import { CachedImage } from './CachedImage'; -import { FavoriteButton } from './FavoriteButton'; -import { MarqueeText } from './MarqueeText'; -import { MoreOptionsButton } from './MoreOptionsButton'; -import { PlaybackRateButton } from './PlaybackRateButton'; -import { PlayerProgressBar } from './PlayerProgressBar'; -import { QueueItemRow } from './QueueItemRow'; -import { RepeatButton } from './RepeatButton'; -import { ShuffleButton } from './ShuffleButton'; -import { ShuffleOverlay } from './ShuffleOverlay'; -import { SkipIntervalButton } from './SkipIntervalButton'; -import { SleepTimerButton } from './SleepTimerButton'; -import { SleepTimerCapsule } from './SleepTimerCapsule'; -import { closeOpenRow } from './SwipeableRow'; -import { useCanSkip } from '../hooks/useCanSkip'; -import { useImagePalette } from '../hooks/useImagePalette'; -import { mixHexColors } from '../utils/colors'; -import { usePlayerActions } from '../hooks/usePlayerActions'; -import { useShuffleOverlay } from '../hooks/useShuffleOverlay'; -import { useTheme } from '../hooks/useTheme'; +import { AlbumInfoContent } from '@/components/AlbumInfoContent'; +import { LyricsContent } from '@/components/LyricsContent'; +import { BookmarkButton } from '@/components/BookmarkButton'; +import { CachedImage } from '@/components/CachedImage'; +import { FavoriteButton } from '@/components/FavoriteButton'; +import { MarqueeText } from '@/components/MarqueeText'; +import { MoreOptionsButton } from '@/components/MoreOptionsButton'; +import { PlaybackRateButton } from '@/components/PlaybackRateButton'; +import { PlayerProgressBar } from '@/components/PlayerProgressBar'; +import { QueueItemRow } from '@/components/QueueItemRow'; +import { RepeatButton } from '@/components/RepeatButton'; +import { ShuffleButton } from '@/components/ShuffleButton'; +import { ShuffleOverlay } from '@/components/ShuffleOverlay'; +import { SkipIntervalButton } from '@/components/SkipIntervalButton'; +import { SleepTimerButton } from '@/components/SleepTimerButton'; +import { SleepTimerCapsule } from '@/components/SleepTimerCapsule'; +import { closeOpenRow } from '@/components/SwipeableRow'; +import { useCanSkip } from '@/hooks/useCanSkip'; +import { useImagePalette } from '@/hooks/useImagePalette'; +import { mixHexColors } from '@/utils/colors'; +import { usePlayerActions } from '@/hooks/usePlayerActions'; +import { useShuffleOverlay } from '@/hooks/useShuffleOverlay'; +import { useTheme } from '@/hooks/useTheme'; import { retryPlayback, skipToNext, skipToPrevious, togglePlayPause, -} from '../services/playerService'; -import { sanitizeBiographyText } from '../utils/formatters'; -import { type Child } from '../services/subsonicService'; -import { usePlayerAlbumInfo } from '../hooks/usePlayerAlbumInfo'; -import { usePlayerLyrics } from '../hooks/usePlayerLyrics'; -import { playbackSettingsStore } from '../store/playbackSettingsStore'; -import { moreOptionsStore } from '../store/moreOptionsStore'; -import { offlineModeStore } from '../store/offlineModeStore'; -import { playerStore } from '../store/playerStore'; -import { tabletLayoutStore } from '../store/tabletLayoutStore'; - -import { absoluteFill } from '../utils/styles'; +} from '@/services/playerService'; +import { sanitizeBiographyText } from '@/utils/formatters'; +import { type Child } from '@/services/subsonicService'; +import { usePlayerAlbumInfo } from '@/hooks/usePlayerAlbumInfo'; +import { usePlayerLyrics } from '@/hooks/usePlayerLyrics'; +import { playbackSettingsStore } from '@/store/playbackSettingsStore'; +import { moreOptionsStore } from '@/store/moreOptionsStore'; +import { offlineModeStore } from '@/store/offlineModeStore'; +import { playerStore } from '@/store/playerStore'; +import { tabletLayoutStore } from '@/store/tabletLayoutStore'; + +import { absoluteFill } from '@/utils/styles'; const HERO_COVER_SIZE = 600; const CONTENT_PADDING = 40; const COLUMN_GAP = 32; -interface ExpandedPlayerViewProps { +interface PlayerTabletLandscapeProps { expandProgress: SharedValue; } -export function ExpandedPlayerView({ +export function PlayerTabletLandscape({ expandProgress, -}: ExpandedPlayerViewProps) { +}: PlayerTabletLandscapeProps) { const { t } = useTranslation(); const { colors } = useTheme(); const insets = useSafeAreaInsets(); @@ -210,7 +210,7 @@ export function ExpandedPlayerView({ handleQueueItemLongPress, handleShareQueue, handleClearQueue, - } = usePlayerActions({ source: 'playerexpanded' }); + } = usePlayerActions({ source: 'player-tablet-landscape' }); const { shuffling, @@ -338,7 +338,7 @@ export function ExpandedPlayerView({ - moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'playerexpanded') + moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player-tablet-landscape') } color={colors.textPrimary} /> diff --git a/src/components/PlayerPanel.tsx b/src/components/player/PlayerTabletSplitview.tsx similarity index 89% rename from src/components/PlayerPanel.tsx rename to src/components/player/PlayerTabletSplitview.tsx index d3ac387..ac3dc61 100644 --- a/src/components/PlayerPanel.tsx +++ b/src/components/player/PlayerTabletSplitview.tsx @@ -11,40 +11,40 @@ import { } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { GradientBackground } from './GradientBackground'; -import { CachedImage } from './CachedImage'; -import { FavoriteButton } from './FavoriteButton'; -import { MarqueeText } from './MarqueeText'; -import { MoreOptionsButton } from './MoreOptionsButton'; -import { PlaybackRateButton } from './PlaybackRateButton'; -import { PlayerProgressBar } from './PlayerProgressBar'; -import { QueueItemRow } from './QueueItemRow'; -import { RepeatButton } from './RepeatButton'; -import { ShuffleButton } from './ShuffleButton'; -import { ShuffleOverlay } from './ShuffleOverlay'; -import { SleepTimerCapsule } from './SleepTimerCapsule'; -import { closeOpenRow } from './SwipeableRow'; -import { type ThemeColors } from '../constants/theme'; -import { useCanSkip } from '../hooks/useCanSkip'; -import { mixHexColors } from '../utils/colors'; -import { usePlayerActions } from '../hooks/usePlayerActions'; -import { useShuffleOverlay } from '../hooks/useShuffleOverlay'; -import { useTheme } from '../hooks/useTheme'; +import { GradientBackground } from '@/components/GradientBackground'; +import { CachedImage } from '@/components/CachedImage'; +import { FavoriteButton } from '@/components/FavoriteButton'; +import { MarqueeText } from '@/components/MarqueeText'; +import { MoreOptionsButton } from '@/components/MoreOptionsButton'; +import { PlaybackRateButton } from '@/components/PlaybackRateButton'; +import { PlayerProgressBar } from '@/components/PlayerProgressBar'; +import { QueueItemRow } from '@/components/QueueItemRow'; +import { RepeatButton } from '@/components/RepeatButton'; +import { ShuffleButton } from '@/components/ShuffleButton'; +import { ShuffleOverlay } from '@/components/ShuffleOverlay'; +import { SleepTimerCapsule } from '@/components/SleepTimerCapsule'; +import { closeOpenRow } from '@/components/SwipeableRow'; +import { type ThemeColors } from '@/constants/theme'; +import { useCanSkip } from '@/hooks/useCanSkip'; +import { mixHexColors } from '@/utils/colors'; +import { usePlayerActions } from '@/hooks/usePlayerActions'; +import { useShuffleOverlay } from '@/hooks/useShuffleOverlay'; +import { useTheme } from '@/hooks/useTheme'; import { retryPlayback, skipToNext, skipToPrevious, togglePlayPause, -} from '../services/playerService'; -import { type Child } from '../services/subsonicService'; -import { moreOptionsStore } from '../store/moreOptionsStore'; -import { playerStore } from '../store/playerStore'; -import { tabletLayoutStore } from '../store/tabletLayoutStore'; +} from '@/services/playerService'; +import { type Child } from '@/services/subsonicService'; +import { moreOptionsStore } from '@/store/moreOptionsStore'; +import { playerStore } from '@/store/playerStore'; +import { tabletLayoutStore } from '@/store/tabletLayoutStore'; const COVER_SIZE = 300; const PADDING = 16; -export function PlayerPanel() { +export function PlayerTabletSplitview() { const { t } = useTranslation(); const { colors } = useTheme(); const insets = useSafeAreaInsets(); @@ -63,7 +63,7 @@ export function PlayerPanel() { handleQueueItemLongPress, handleShareQueue, handleClearQueue, - } = usePlayerActions({ source: 'playerpanel' }); + } = usePlayerActions({ source: 'player-tablet-splitview' }); const handleExpand = useCallback(() => { tabletLayoutStore.getState().setPlayerExpanded(true); @@ -251,7 +251,7 @@ const PanelHeader = memo(function PanelHeader({ - moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'playerpanel') + moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player-tablet-splitview') } color={colors.textPrimary} /> diff --git a/src/hooks/usePlayerAlbumInfo.ts b/src/hooks/usePlayerAlbumInfo.ts index 9549e1d..f89ee3c 100644 --- a/src/hooks/usePlayerAlbumInfo.ts +++ b/src/hooks/usePlayerAlbumInfo.ts @@ -1,8 +1,8 @@ /** * Shared player album-info fetch coordination. Owns the store selectors, * the fetch-attempt guard ref, the gated effect, retry/refresh handlers, - * and refreshing state. The phone (`player-view.tsx`) and tablet - * (`ExpandedPlayerView.tsx`) used to implement this independently — see + * and refreshing state. The phone (`player-phone-portrait.tsx`) and tablet + * (`PlayerTabletLandscape.tsx`) used to implement this independently — see * Phase 6 of `plans/2026-05-22-audit-remediation-roadmap.md` for the * full rationale. * diff --git a/src/hooks/usePlayerLyrics.ts b/src/hooks/usePlayerLyrics.ts index 197260e..6775b6c 100644 --- a/src/hooks/usePlayerLyrics.ts +++ b/src/hooks/usePlayerLyrics.ts @@ -1,8 +1,8 @@ /** * Shared player lyrics fetch coordination. Owns the store selectors, the * fetch-attempt guard ref, the gated effect, and the retry handler. The - * phone (`player-view.tsx`) and tablet (`ExpandedPlayerView.tsx`) used to - * implement this independently — see Phase 6 of + * phone (`player-phone-portrait.tsx`) and tablet (`PlayerTabletLandscape.tsx`) + * used to implement this independently — see Phase 6 of * `plans/2026-05-22-audit-remediation-roadmap.md` for full rationale. * * No refresh handler: neither surface exposes one for lyrics, matching the diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ffceed2..72fc3a6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -860,6 +860,8 @@ "failedToLoad": "Failed to load", "albumInfoFailedToLoad": "Couldn't load album info.", "albumInfoTimedOut": "Couldn't load album info — the server took too long to respond.", + "albumDetailsNotFound": "No album details available for this track.", + "albumDetailsCompilation": "Album details aren't available for compilations.", "buildAMix": "Build a Mix", "pickGenresDecadesMore": "Pick genres, decades & more", "searchGenresPlaceholder": "Search genres...", diff --git a/src/screens/home.tsx b/src/screens/home.tsx index 9cad242..edb6f7f 100644 --- a/src/screens/home.tsx +++ b/src/screens/home.tsx @@ -202,7 +202,11 @@ function AlbumSection({ ) : ( a.id).slice(0, LIST_LENGTH_DISPLAY_CAP)} renderItem={renderItem} keyExtractor={keyExtractor} horizontal diff --git a/src/screens/my-listening.tsx b/src/screens/my-listening.tsx index 9aabf31..10d9310 100644 --- a/src/screens/my-listening.tsx +++ b/src/screens/my-listening.tsx @@ -384,7 +384,7 @@ export function MyListeningScreen() { subtitle={item.artist} count={item.count} maxCount={analytics.topAlbums[0].count} - coverArtId={item.albumId ?? item.coverArt} + coverArtId={item.albumId} colors={colors} index={i} onPress={onOpenAlbum(item.albumId)} diff --git a/src/screens/__tests__/player-view.test.tsx b/src/screens/player/__tests__/player-phone-portrait.test.tsx similarity index 79% rename from src/screens/__tests__/player-view.test.tsx rename to src/screens/player/__tests__/player-phone-portrait.test.tsx index 255c9ed..d664ac1 100644 --- a/src/screens/__tests__/player-view.test.tsx +++ b/src/screens/player/__tests__/player-phone-portrait.test.tsx @@ -1,6 +1,6 @@ -jest.mock('../../store/persistence/kvStorage', () => require('../../store/persistence/__mocks__/kvStorage')); +jest.mock('@/store/persistence/kvStorage', () => require('@/store/persistence/__mocks__/kvStorage')); -jest.mock('../../hooks/useTheme', () => ({ +jest.mock('@/hooks/useTheme', () => ({ useTheme: () => ({ theme: 'dark', colors: { @@ -17,7 +17,7 @@ jest.mock('../../hooks/useTheme', () => ({ }), })); -jest.mock('../../hooks/useImagePalette', () => ({ +jest.mock('@/hooks/useImagePalette', () => ({ useImagePalette: () => ({ primary: '#333333', secondary: null, @@ -25,15 +25,15 @@ jest.mock('../../hooks/useImagePalette', () => ({ }), })); -jest.mock('../../hooks/useCanSkip', () => ({ +jest.mock('@/hooks/useCanSkip', () => ({ useCanSkip: () => ({ canSkipNext: true, canSkipPrevious: true }), })); -jest.mock('../../hooks/useIsStarred', () => ({ +jest.mock('@/hooks/useIsStarred', () => ({ useIsStarred: () => false, })); -jest.mock('../../hooks/useThemedAlert', () => ({ +jest.mock('@/hooks/useThemedAlert', () => ({ useThemedAlert: () => ({ alert: jest.fn(), }), @@ -84,76 +84,76 @@ jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), })); -jest.mock('../../components/CachedImage', () => { +jest.mock('@/components/CachedImage', () => { const { View } = require('react-native'); return { CachedImage: (props: { coverArtId?: string }) => }; }); -jest.mock('../../components/MarqueeText', () => { +jest.mock('@/components/MarqueeText', () => { const { Text } = require('react-native'); return { MarqueeText: ({ children, style }: { children: React.ReactNode; style?: object }) => {children} }; }); -jest.mock('../../components/PlayerProgressBar', () => { +jest.mock('@/components/PlayerProgressBar', () => { const { View } = require('react-native'); return { PlayerProgressBar: () => }; }); -jest.mock('../../components/PlaybackRateButton', () => { +jest.mock('@/components/PlaybackRateButton', () => { const { View } = require('react-native'); return { PlaybackRateButton: () => }; }); -jest.mock('../../components/RepeatButton', () => { +jest.mock('@/components/RepeatButton', () => { const { View } = require('react-native'); return { RepeatButton: () => }; }); -jest.mock('../../components/ShuffleButton', () => { +jest.mock('@/components/ShuffleButton', () => { const { View } = require('react-native'); return { ShuffleButton: () => }; }); -jest.mock('../../components/SkipIntervalButton', () => { +jest.mock('@/components/SkipIntervalButton', () => { const { View } = require('react-native'); return { SkipIntervalButton: () => }; }); -jest.mock('../../components/QueueItemRow', () => { +jest.mock('@/components/QueueItemRow', () => { const { Text } = require('react-native'); return { QueueItemRow: ({ track }: { track: { title: string } }) => {track.title} }; }); -jest.mock('../../components/SwipeableRow', () => ({ +jest.mock('@/components/SwipeableRow', () => ({ closeOpenRow: jest.fn(), })); -jest.mock('../../components/MoreOptionsButton', () => { +jest.mock('@/components/MoreOptionsButton', () => { const { View } = require('react-native'); return { MoreOptionsButton: () => }; }); -jest.mock('../../components/ThemedAlert', () => { +jest.mock('@/components/ThemedAlert', () => { const { View } = require('react-native'); return { ThemedAlert: () => }; }); -jest.mock('../../components/EmptyState', () => { +jest.mock('@/components/EmptyState', () => { const { Text } = require('react-native'); return { EmptyState: ({ title }: { title: string }) => {title} }; }); -jest.mock('../../components/AlbumInfoContent', () => { +jest.mock('@/components/AlbumInfoContent', () => { const { Text } = require('react-native'); return { AlbumInfoContent: () => AlbumInfoContent }; }); -jest.mock('../../components/LyricsContent', () => { +jest.mock('@/components/LyricsContent', () => { const { Text } = require('react-native'); return { LyricsContent: () => LyricsContent }; }); -jest.mock('../../store/lyricsStore', () => { +jest.mock('@/store/lyricsStore', () => { const fetchLyrics = jest.fn(); const state = { entries: {}, @@ -168,7 +168,7 @@ jest.mock('../../store/lyricsStore', () => { return { lyricsStore: store }; }); -jest.mock('../../services/playerService', () => ({ +jest.mock('@/services/playerService', () => ({ clearQueue: jest.fn(), retryPlayback: jest.fn(), seekTo: jest.fn(), @@ -179,15 +179,15 @@ jest.mock('../../services/playerService', () => ({ togglePlayPause: jest.fn(), })); -jest.mock('../../services/moreOptionsService', () => ({ +jest.mock('@/services/moreOptionsService', () => ({ toggleStar: jest.fn(), })); -jest.mock('../../utils/formatters', () => ({ +jest.mock('@/utils/formatters', () => ({ sanitizeBiographyText: jest.fn((text: string) => text), })); -jest.mock('../../utils/stringHelpers', () => ({ +jest.mock('@/utils/stringHelpers', () => ({ minDelay: () => Promise.resolve(), })); @@ -221,11 +221,11 @@ jest.mock('@shopify/flash-list', () => { import React from 'react'; import { render, fireEvent, act } from '@testing-library/react-native'; -import { playerStore } from '../../store/playerStore'; -import { type Child } from '../../services/subsonicService'; +import { playerStore } from '@/store/playerStore'; +import { type Child } from '@/services/subsonicService'; // Must import after mocks -const { PlayerView } = require('../player-view'); +const { PlayerPhonePortrait } = require('@/screens/player/player-phone-portrait'); const MOCK_TRACK: Child = { id: 'track-1', @@ -259,15 +259,15 @@ beforeEach(() => { }); }); -describe('PlayerView', () => { +describe('PlayerPhonePortrait', () => { it('renders empty state when no current track', () => { playerStore.setState({ currentTrack: null }); - const { getByText } = render(); + const { getByText } = render(); expect(getByText('Nothing Playing')).toBeTruthy(); }); it('renders player content by default (player tab)', () => { - const { getByText, queryByText } = render(); + const { getByText, queryByText } = render(); // Hero player content visible expect(getByText('Test Song')).toBeTruthy(); @@ -283,7 +283,7 @@ describe('PlayerView', () => { }); it('switches to queue tab when queue icon pressed', () => { - const { getByLabelText, getByText } = render(); + const { getByLabelText, getByText } = render(); act(() => { fireEvent.press(getByLabelText('Queue')); @@ -295,7 +295,7 @@ describe('PlayerView', () => { }); it('shows queue header with shuffle, share, clear actions', () => { - const { getByLabelText } = render(); + const { getByLabelText } = render(); act(() => { fireEvent.press(getByLabelText('Queue')); @@ -306,7 +306,7 @@ describe('PlayerView', () => { }); it('switches to lyrics tab and mounts LyricsContent', () => { - const { getByLabelText, getByText } = render(); + const { getByLabelText, getByText } = render(); act(() => { fireEvent.press(getByLabelText('Lyrics')); @@ -316,7 +316,7 @@ describe('PlayerView', () => { }); it('switches to info tab showing album info', () => { - const { getByLabelText, getByText } = render(); + const { getByLabelText, getByText } = render(); act(() => { fireEvent.press(getByLabelText('Album Info')); @@ -326,7 +326,7 @@ describe('PlayerView', () => { }); it('returns to player tab when Now Playing pressed', () => { - const { getByLabelText, getAllByText } = render(); + const { getByLabelText, getAllByText } = render(); // Switch to queue act(() => { @@ -344,12 +344,12 @@ describe('PlayerView', () => { it('renders loading state when queue is loading', () => { playerStore.setState({ queueLoading: true }); - const { getByText } = render(); + const { getByText } = render(); expect(getByText('Loading\u2026')).toBeTruthy(); }); it('renders transport control buttons', () => { - const { getByText } = render(); + const { getByText } = render(); expect(getByText('play-back')).toBeTruthy(); expect(getByText('pause')).toBeTruthy(); // playing state shows pause @@ -358,24 +358,24 @@ describe('PlayerView', () => { it('renders play icon when paused', () => { playerStore.setState({ playbackState: 'paused' }); - const { getByText } = render(); + const { getByText } = render(); expect(getByText('play')).toBeTruthy(); }); it('renders favorite button', () => { - const { getByLabelText } = render(); + const { getByLabelText } = render(); expect(getByLabelText('Add to Favorites')).toBeTruthy(); }); it('presses favorite button without error', () => { - const { getByLabelText } = render(); + const { getByLabelText } = render(); fireEvent.press(getByLabelText('Add to Favorites')); - const { toggleStar } = require('../../services/moreOptionsService'); + const { toggleStar } = require('@/services/moreOptionsService'); expect(toggleStar).toHaveBeenCalledWith('song', 'track-1'); }); it('presses play/pause button', () => { - const { getByText } = render(); + const { getByText } = render(); // Find the pause icon (since state is 'playing') const pauseIcon = getByText('pause'); // The icon is inside a Pressable; fire on the closest pressable parent @@ -383,22 +383,22 @@ describe('PlayerView', () => { }); it('presses skip forward button', () => { - const { getByText } = render(); + const { getByText } = render(); fireEvent.press(getByText('play-forward')); - const { skipToNext } = require('../../services/playerService'); + const { skipToNext } = require('@/services/playerService'); expect(skipToNext).toHaveBeenCalled(); }); it('presses skip backward button', () => { - const { getByText } = render(); + const { getByText } = render(); fireEvent.press(getByText('play-back')); - const { skipToPrevious } = require('../../services/playerService'); + const { skipToPrevious } = require('@/services/playerService'); expect(skipToPrevious).toHaveBeenCalled(); }); it('renders queue empty state when queue has no items', () => { playerStore.setState({ queue: [] }); - const { getByLabelText, queryByLabelText } = render(); + const { getByLabelText, queryByLabelText } = render(); act(() => { fireEvent.press(getByLabelText('Queue')); @@ -410,14 +410,14 @@ describe('PlayerView', () => { it('shows buffering indicator when buffering', () => { playerStore.setState({ playbackState: 'buffering' }); - const { queryByText } = render(); + const { queryByText } = render(); // In buffering state, the play icon should not be shown (ActivityIndicator shows instead) expect(queryByText('play')).toBeNull(); expect(queryByText('pause')).toBeNull(); }); it('mounts info tab lazily on first selection', () => { - const { getByLabelText, queryByText, getByText } = render(); + const { getByLabelText, queryByText, getByText } = render(); // Info tab should not be mounted initially expect(queryByText('AlbumInfoContent')).toBeNull(); @@ -432,7 +432,7 @@ describe('PlayerView', () => { }); it('renders shuffle button in queue tab', () => { - const { getByLabelText, getAllByTestId } = render(); + const { getByLabelText, getAllByTestId } = render(); act(() => { fireEvent.press(getByLabelText('Queue')); @@ -443,7 +443,7 @@ describe('PlayerView', () => { }); it('calls share queue when share button pressed', () => { - const { getByLabelText } = render(); + const { getByLabelText } = render(); act(() => { fireEvent.press(getByLabelText('Queue')); @@ -453,7 +453,7 @@ describe('PlayerView', () => { }); it('calls clear queue when clear button pressed', () => { - const { getByLabelText, getByText } = render(); + const { getByLabelText, getByText } = render(); act(() => { fireEvent.press(getByLabelText('Queue')); @@ -463,7 +463,7 @@ describe('PlayerView', () => { }); it('renders all queue items in queue tab', () => { - const { getByLabelText, getByText, getAllByText } = render(); + const { getByLabelText, getByText, getAllByText } = render(); act(() => { fireEvent.press(getByLabelText('Queue')); @@ -479,7 +479,7 @@ describe('PlayerView', () => { const routerBack = jest.fn(); jest.spyOn(require('expo-router'), 'useRouter').mockReturnValue({ back: routerBack }); - render(); + render(); // Simulate track being cleared after being populated act(() => { @@ -490,7 +490,7 @@ describe('PlayerView', () => { }); it('invokes skipToTrack when queue item pressed', () => { - const { getByLabelText, getByText } = render(); + const { getByLabelText, getByText } = render(); act(() => { fireEvent.press(getByLabelText('Queue')); @@ -501,14 +501,14 @@ describe('PlayerView', () => { }); it('invokes seekTo when progress bar seeks', () => { - // This exercises the handleSeek callback defined in PlayerView + // This exercises the handleSeek callback defined in PlayerPhonePortrait // The progress bar is mocked, so we verify it renders without error - const { getByTestId } = render(); + const { getByTestId } = render(); expect(getByTestId('progress-bar')).toBeTruthy(); }); it('preserves mounted tabs when switching between them', () => { - const { getByLabelText, getByText } = render(); + const { getByLabelText, getByText } = render(); // Mount queue tab act(() => { diff --git a/src/screens/player-view.tsx b/src/screens/player/player-phone-portrait.tsx similarity index 92% rename from src/screens/player-view.tsx rename to src/screens/player/player-phone-portrait.tsx index a6b45e2..baa06fc 100644 --- a/src/screens/player-view.tsx +++ b/src/screens/player/player-phone-portrait.tsx @@ -23,50 +23,50 @@ import { Pressable as GHPressable } from 'react-native-gesture-handler'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; -import { AlbumInfoContent } from '../components/AlbumInfoContent'; -import { LyricsContent } from '../components/LyricsContent'; -import { BookmarkButton } from '../components/BookmarkButton'; -import { CachedImage } from '../components/CachedImage'; -import { FavoriteButton } from '../components/FavoriteButton'; -import { EmptyState } from '../components/EmptyState'; -import { MarqueeText } from '../components/MarqueeText'; -import { MoreOptionsButton } from '../components/MoreOptionsButton'; -import { PlaybackRateButton } from '../components/PlaybackRateButton'; -import { PlayerProgressBar } from '../components/PlayerProgressBar'; -import { PlayerTabBar, type PlayerTab } from '../components/PlayerTabBar'; -import { RepeatButton } from '../components/RepeatButton'; -import { ShuffleButton } from '../components/ShuffleButton'; -import { ShuffleOverlay } from '../components/ShuffleOverlay'; -import { SkipIntervalButton } from '../components/SkipIntervalButton'; -import { SleepTimerButton } from '../components/SleepTimerButton'; -import { SleepTimerCapsule } from '../components/SleepTimerCapsule'; -import { QueueItemRow } from '../components/QueueItemRow'; -import { closeOpenRow } from '../components/SwipeableRow'; -import { type ThemeColors } from '../constants/theme'; -import { useCanSkip } from '../hooks/useCanSkip'; -import { useImagePalette } from '../hooks/useImagePalette'; -import { usePlayerActions } from '../hooks/usePlayerActions'; -import { useShuffleOverlay } from '../hooks/useShuffleOverlay'; -import { useTheme } from '../hooks/useTheme'; -import { offlineModeStore } from '../store/offlineModeStore'; +import { AlbumInfoContent } from '@/components/AlbumInfoContent'; +import { LyricsContent } from '@/components/LyricsContent'; +import { BookmarkButton } from '@/components/BookmarkButton'; +import { CachedImage } from '@/components/CachedImage'; +import { FavoriteButton } from '@/components/FavoriteButton'; +import { EmptyState } from '@/components/EmptyState'; +import { MarqueeText } from '@/components/MarqueeText'; +import { MoreOptionsButton } from '@/components/MoreOptionsButton'; +import { PlaybackRateButton } from '@/components/PlaybackRateButton'; +import { PlayerProgressBar } from '@/components/PlayerProgressBar'; +import { PlayerTabBar, type PlayerTab } from '@/components/PlayerTabBar'; +import { RepeatButton } from '@/components/RepeatButton'; +import { ShuffleButton } from '@/components/ShuffleButton'; +import { ShuffleOverlay } from '@/components/ShuffleOverlay'; +import { SkipIntervalButton } from '@/components/SkipIntervalButton'; +import { SleepTimerButton } from '@/components/SleepTimerButton'; +import { SleepTimerCapsule } from '@/components/SleepTimerCapsule'; +import { QueueItemRow } from '@/components/QueueItemRow'; +import { closeOpenRow } from '@/components/SwipeableRow'; +import { type ThemeColors } from '@/constants/theme'; +import { useCanSkip } from '@/hooks/useCanSkip'; +import { useImagePalette } from '@/hooks/useImagePalette'; +import { usePlayerActions } from '@/hooks/usePlayerActions'; +import { useShuffleOverlay } from '@/hooks/useShuffleOverlay'; +import { useTheme } from '@/hooks/useTheme'; +import { offlineModeStore } from '@/store/offlineModeStore'; import { clearQueue, retryPlayback, skipToNext, skipToPrevious, togglePlayPause, -} from '../services/playerService'; -import { sanitizeBiographyText } from '../utils/formatters'; -import { type Child } from '../services/subsonicService'; -import { usePlayerAlbumInfo } from '../hooks/usePlayerAlbumInfo'; -import { usePlayerLyrics } from '../hooks/usePlayerLyrics'; -import { playbackSettingsStore } from '../store/playbackSettingsStore'; -import { moreOptionsStore } from '../store/moreOptionsStore'; -import { playerStore } from '../store/playerStore'; -import { mixHexColors } from '../utils/colors'; - - -import { absoluteFill } from '../utils/styles'; +} from '@/services/playerService'; +import { sanitizeBiographyText } from '@/utils/formatters'; +import { type Child } from '@/services/subsonicService'; +import { usePlayerAlbumInfo } from '@/hooks/usePlayerAlbumInfo'; +import { usePlayerLyrics } from '@/hooks/usePlayerLyrics'; +import { playbackSettingsStore } from '@/store/playbackSettingsStore'; +import { moreOptionsStore } from '@/store/moreOptionsStore'; +import { playerStore } from '@/store/playerStore'; +import { mixHexColors } from '@/utils/colors'; + + +import { absoluteFill } from '@/utils/styles'; const HERO_PADDING = 32; const HERO_COVER_SIZE = 600; const HEADER_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : 56; @@ -79,7 +79,7 @@ const TAB_SLIDE_DISTANCE = 12; * handed a fresh object on every parent re-render. */ const QUEUE_CONTENT_CONTAINER_STYLE = { paddingBottom: 12 } as const; -export function PlayerView() { +export function PlayerPhonePortrait() { const { colors } = useTheme(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -205,7 +205,7 @@ export function PlayerView() { currentTrack ? ( - moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player') + moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player-phone-portrait') } color={colors.textPrimary} /> @@ -224,7 +224,7 @@ export function PlayerView() { handleQueueItemLongPress, handleShareQueue, handleClearQueue, - } = usePlayerActions({ source: 'player', onClearConfirmed }); + } = usePlayerActions({ source: 'player-phone-portrait', onClearConfirmed }); const { shuffling, @@ -302,7 +302,7 @@ export function PlayerView() { moreOptionsStore.getState().show({ type: 'song', item: currentTrack! }, 'player')} + onPress={() => moreOptionsStore.getState().show({ type: 'song', item: currentTrack! }, 'player-phone-portrait')} hidden={!currentTrack} /> diff --git a/src/screens/tablet-portrait-player.tsx b/src/screens/player/player-tablet-portrait.tsx similarity index 56% rename from src/screens/tablet-portrait-player.tsx rename to src/screens/player/player-tablet-portrait.tsx index acbf6aa..97b9797 100644 --- a/src/screens/tablet-portrait-player.tsx +++ b/src/screens/player/player-tablet-portrait.tsx @@ -1,4 +1,5 @@ import Ionicons from "@react-native-vector-icons/ionicons/static"; +import MaterialCommunityIcons from "@react-native-vector-icons/material-design-icons/static"; import { Stack, useNavigation, useRouter } from 'expo-router'; import { LinearGradient } from 'expo-linear-gradient'; import { memo, useCallback, useEffect, useMemo, useState } from 'react'; @@ -12,68 +13,59 @@ import { View, useWindowDimensions, } from 'react-native'; -import { Gesture, GestureDetector, Pressable as GHPressable } from 'react-native-gesture-handler'; -import Animated, { - Extrapolation, - interpolate, - useAnimatedStyle, - useSharedValue, - withSpring, -} from 'react-native-reanimated'; +import { Pressable as GHPressable } from 'react-native-gesture-handler'; +import Animated, { useAnimatedStyle } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { BookmarkButton } from '../components/BookmarkButton'; -import { CachedImage } from '../components/CachedImage'; -import { FavoriteButton } from '../components/FavoriteButton'; -import { EmptyState } from '../components/EmptyState'; -import { MarqueeText } from '../components/MarqueeText'; -import { MoreOptionsButton } from '../components/MoreOptionsButton'; -import { PlaybackRateButton } from '../components/PlaybackRateButton'; -import { PlayerProgressBar } from '../components/PlayerProgressBar'; -import { RepeatButton } from '../components/RepeatButton'; -import { ShuffleButton } from '../components/ShuffleButton'; -import { ShuffleOverlay } from '../components/ShuffleOverlay'; -import { SkipIntervalButton } from '../components/SkipIntervalButton'; -import { SleepTimerButton } from '../components/SleepTimerButton'; -import { SleepTimerCapsule } from '../components/SleepTimerCapsule'; -import { UpNextPanel } from '../components/UpNextPanel'; -import { type ThemeColors } from '../constants/theme'; -import { useCanSkip } from '../hooks/useCanSkip'; -import { useImagePalette } from '../hooks/useImagePalette'; -import { usePlayerActions } from '../hooks/usePlayerActions'; -import { useShuffleOverlay } from '../hooks/useShuffleOverlay'; -import { useTheme } from '../hooks/useTheme'; +import { BookmarkButton } from '@/components/BookmarkButton'; +import { CachedImage } from '@/components/CachedImage'; +import { FavoriteButton } from '@/components/FavoriteButton'; +import { EmptyState } from '@/components/EmptyState'; +import { MarqueeText } from '@/components/MarqueeText'; +import { MoreOptionsButton } from '@/components/MoreOptionsButton'; +import { PlaybackRateButton } from '@/components/PlaybackRateButton'; +import { PlayerProgressBar } from '@/components/PlayerProgressBar'; +import { RepeatButton } from '@/components/RepeatButton'; +import { ShuffleButton } from '@/components/ShuffleButton'; +import { ShuffleOverlay } from '@/components/ShuffleOverlay'; +import { SkipIntervalButton } from '@/components/SkipIntervalButton'; +import { SleepTimerButton } from '@/components/SleepTimerButton'; +import { SleepTimerCapsule } from '@/components/SleepTimerCapsule'; +import { PlayerModeContent, type PlayerMode } from '@/components/player/PlayerModeContent'; +import { type ThemeColors } from '@/constants/theme'; +import { useCanSkip } from '@/hooks/useCanSkip'; +import { useImagePalette } from '@/hooks/useImagePalette'; +import { usePlayerActions } from '@/hooks/usePlayerActions'; +import { useShuffleOverlay } from '@/hooks/useShuffleOverlay'; +import { useTheme } from '@/hooks/useTheme'; import { clearQueue, retryPlayback, skipToNext, skipToPrevious, togglePlayPause, -} from '../services/playerService'; -import { type Child } from '../services/subsonicService'; -import { moreOptionsStore } from '../store/moreOptionsStore'; -import { offlineModeStore } from '../store/offlineModeStore'; -import { playbackSettingsStore } from '../store/playbackSettingsStore'; -import { playerStore } from '../store/playerStore'; -import { mixHexColors } from '../utils/colors'; -import { absoluteFill } from '../utils/styles'; +} from '@/services/playerService'; +import { moreOptionsStore } from '@/store/moreOptionsStore'; +import { offlineModeStore } from '@/store/offlineModeStore'; +import { playbackSettingsStore } from '@/store/playbackSettingsStore'; +import { playerStore } from '@/store/playerStore'; +import { mixHexColors } from '@/utils/colors'; +import { absoluteFill } from '@/utils/styles'; const HERO_PADDING = 24; const HERO_COVER_SIZE = 600; const HEADER_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : 56; -const STRIP_HEIGHT = 112; const COLUMN_GAP = 24; -const LOW_PEEK_HEIGHT = 160; // collapsed sheet — handle + toggle only const ART_MAX = 440; /** - * Tablet-portrait full-screen Now Playing. A large hero + controls up top with - * an inline draggable "Up Next" panel (Queue/Info/Lyrics) below. As the panel - * is dragged to full, the hero collapses into a compact control strip so - * play/pause + scrubber stay reachable. Used by the /player route only on - * tablets in portrait (see useIsTabletPortrait); phone + landscape are unchanged. + * Tablet-portrait full-screen Now Playing. A fixed vertical split over a single + * page-wide gradient: a large hero band (art + controls) up top, a centered + * Queue/Info/Lyrics toggle in the middle, and the selected content filling the + * bottom. Used by the /player route only on tablets in portrait (see + * useIsTabletPortrait); phone + landscape are unchanged. */ -export function TabletPortraitPlayer() { +export function PlayerTabletPortrait() { const { colors } = useTheme(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -86,6 +78,13 @@ export function TabletPortraitPlayer() { const queue = playerStore((s) => s.queue); const offlineMode = offlineModeStore((s) => s.offlineMode); + const [mode, setMode] = useState('queue'); + + // Info/Lyrics are hidden offline — fall back to the queue if they vanish. + useEffect(() => { + if (offlineMode && mode !== 'queue') setMode('queue'); + }, [offlineMode, mode]); + const onClose = useCallback(() => router.back(), [router]); const onClearConfirmed = useCallback(() => { @@ -99,7 +98,7 @@ export function TabletPortraitPlayer() { handleQueueItemLongPress, handleShareQueue, handleClearQueue, - } = usePlayerActions({ source: 'player', onClearConfirmed }); + } = usePlayerActions({ source: 'player-tablet-portrait', onClearConfirmed }); const { shuffling, @@ -146,7 +145,7 @@ export function TabletPortraitPlayer() { currentTrack ? ( - moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player') + moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player-tablet-portrait') } color={colors.textPrimary} /> @@ -154,72 +153,18 @@ export function TabletPortraitPlayer() { }); }, [currentTrack, navigation, onClose, colors.textPrimary]); - /* ---- Layout: a horizontal band (art left, controls right) centered in the - space above the sheet. Three detents: low (pulled down) / default / full - (band collapses into the strip). ---- */ const headerSpace = insets.top + HEADER_BAR_HEIGHT; - // Prominent square art on the left, capped so the right column keeps room for - // the controls on narrower tablets. + // Prominent square art on the left of the band, capped so the right column + // keeps room for the controls on narrower tablets. const artSize = Math.min( Math.round((screenW - 2 * HERO_PADDING - COLUMN_GAP) * 0.5), ART_MAX, ); - const fullHeight = Math.max(screenH - headerSpace - STRIP_HEIGHT, 320); - const defaultHeight = Math.min( - Math.max(Math.round(screenH * 0.44), LOW_PEEK_HEIGHT + 120), - fullHeight - 80, - ); - - const panelHeight = useSharedValue(defaultHeight); - const startHeight = useSharedValue(0); - - const panGesture = useMemo( - () => - Gesture.Pan() - .onStart(() => { - 'worklet'; - startHeight.value = panelHeight.value; - }) - .onUpdate((e) => { - 'worklet'; - const next = startHeight.value - e.translationY; - panelHeight.value = Math.min(fullHeight, Math.max(LOW_PEEK_HEIGHT, next)); - }) - .onEnd((e) => { - 'worklet'; - const projected = panelHeight.value - e.velocityY * 0.08; - const dLow = Math.abs(projected - LOW_PEEK_HEIGHT); - const dDef = Math.abs(projected - defaultHeight); - const dFull = Math.abs(projected - fullHeight); - let target = LOW_PEEK_HEIGHT; - if (dDef <= dLow && dDef <= dFull) target = defaultHeight; - else if (dFull <= dLow && dFull <= dDef) target = fullHeight; - panelHeight.value = withSpring(target, { damping: 28, stiffness: 220, mass: 0.9 }); - }), - [defaultHeight, fullHeight, panelHeight, startHeight], - ); - - // Band fades out as the sheet expands to full; it also rides to stay - // vertically centered in the (shrinking/growing) space above the sheet. - const fullContentStyle = useAnimatedStyle(() => { - const c = interpolate(panelHeight.value, [defaultHeight, fullHeight], [0, 1], Extrapolation.CLAMP); - const centerY = (headerSpace - panelHeight.value) / 2; - return { - opacity: 1 - c, - transform: [{ translateY: centerY - 16 * c }], - pointerEvents: c < 0.5 ? ('auto' as const) : ('none' as const), - }; - }, [defaultHeight, fullHeight, headerSpace]); - - const stripStyle = useAnimatedStyle(() => { - const c = interpolate(panelHeight.value, [defaultHeight, fullHeight], [0, 1], Extrapolation.CLAMP); - return { - opacity: c, - pointerEvents: c > 0.5 ? ('auto' as const) : ('none' as const), - }; - }, [defaultHeight, fullHeight]); + // Bottom content height — preserves the split roughly where the old sheet sat + // (~44% of the screen), leaving the band centered in the space above. + const bottomSectionHeight = Math.max(Math.round(screenH * 0.44), 320); const queueColors = useMemo( () => ({ ...colors, primary: mixHexColors(colors.primary, colors.textPrimary, 0.45) }), @@ -248,103 +193,81 @@ export function TabletPortraitPlayer() { moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player')} + onPress={() => moreOptionsStore.getState().show({ type: 'song', item: currentTrack }, 'player-tablet-portrait')} /> )} - {/* Gradient background */} + {/* Single page-wide gradient background */} - {/* Top band: cover art (left) + info/progress/controls (right). - Collapses into the compact strip as the panel expands. */} - - - - - - + + {/* Top band: cover art (left) + info/progress/controls (right) */} + + + + + + + - - - - - - {currentTrack.title} - - - {currentTrack.artist ?? t('unknownArtist')} - + + + + + {currentTrack.title} + + + {currentTrack.artist ?? t('unknownArtist')} + + + - - - - - + + + - + + - - {/* Compact strip — fades in when the panel is full */} - - - + + {/* Bottom content: queue / info / lyrics on the page gradient */} + + - - - {currentTrack.title} - - - {currentTrack.artist ?? t('unknownArtist')} - - - - - - - - - {/* Up Next panel */} - + {/* Shuffle overlay */} void; + colors: ThemeColors; + offlineMode: boolean; +}) { + const { t } = useTranslation(); + return ( + + onSelect('queue')} + hitSlop={8} + accessibilityRole="tab" + accessibilityState={{ selected: mode === 'queue' }} + accessibilityLabel={t('showQueue')} + style={({ pressed }) => [styles.toggleButton, pressed && styles.pressed]} + > + + + {!offlineMode && ( + onSelect('info')} + hitSlop={8} + accessibilityRole="tab" + accessibilityState={{ selected: mode === 'info' }} + accessibilityLabel={t('showAlbumInfo')} + style={({ pressed }) => [styles.toggleButton, pressed && styles.pressed]} + > + + + )} + {!offlineMode && ( + onSelect('lyrics')} + hitSlop={8} + accessibilityRole="tab" + accessibilityState={{ selected: mode === 'lyrics' }} + accessibilityLabel={t('showLyrics')} + style={({ pressed }) => [styles.toggleButton, pressed && styles.pressed]} + > + + + )} + + ); +}); + /* ------------------------------------------------------------------ */ /* Progress bar (subscribes to playback position) */ /* ------------------------------------------------------------------ */ @@ -498,34 +489,6 @@ const PlaybackControls = memo(function PlaybackControls({ ); }); -/* ------------------------------------------------------------------ */ -/* Compact-strip play/pause */ -/* ------------------------------------------------------------------ */ - -const StripPlayButton = memo(function StripPlayButton({ colors }: { colors: ThemeColors }) { - const playbackState = playerStore((s) => s.playbackState); - const isPlaying = playbackState === 'playing' || playbackState === 'buffering'; - const isBuffering = playbackState === 'buffering' || playbackState === 'loading'; - - return ( - [pressed && styles.pressed]} - > - {isBuffering ? ( - - ) : ( - - )} - - ); -}); - /* ------------------------------------------------------------------ */ /* Styles */ /* ------------------------------------------------------------------ */ @@ -534,10 +497,13 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - fullContent: { - ...absoluteFill, - paddingHorizontal: HERO_PADDING, + content: { + flex: 1, + }, + topSection: { + flex: 1, justifyContent: 'center', + paddingHorizontal: HERO_PADDING, }, band: { flexDirection: 'row', @@ -658,37 +624,18 @@ const styles = StyleSheet.create({ playIcon: { marginLeft: 3, }, - strip: { - position: 'absolute', - left: 0, - right: 0, - paddingHorizontal: HERO_PADDING, - justifyContent: 'center', - }, - stripRow: { + toggleRow: { flexDirection: 'row', alignItems: 'center', - gap: 12, - }, - stripArt: { - width: 44, - height: 44, - borderRadius: 6, - }, - stripInfo: { - flex: 1, - minWidth: 0, - }, - stripTitle: { - fontSize: 15, - fontWeight: '700', + justifyContent: 'center', + gap: 36, + paddingVertical: 12, }, - stripArtist: { - fontSize: 13, - marginTop: 2, + toggleButton: { + padding: 4, }, - stripProgress: { - marginTop: 8, + bottomSection: { + width: '100%', }, pressed: { opacity: 0.6, diff --git a/src/services/__tests__/imageCacheService.queue.test.ts b/src/services/__tests__/imageCacheService.queue.test.ts index 445697e..52d7176 100644 --- a/src/services/__tests__/imageCacheService.queue.test.ts +++ b/src/services/__tests__/imageCacheService.queue.test.ts @@ -54,7 +54,10 @@ jest.mock('../../store/offlineModeStore', () => ({ const mockConnectivity = { isInternetReachable: true, isServerReachable: true }; jest.mock('../../store/connectivityStore', () => ({ - connectivityStore: { getState: () => mockConnectivity }, + connectivityStore: { + getState: () => mockConnectivity, + subscribe: jest.fn(() => () => {}), + }, })); jest.mock('../connectivityService', () => ({ diff --git a/src/services/__tests__/imageCacheService.test.ts b/src/services/__tests__/imageCacheService.test.ts index d4a4f1a..3d185a4 100644 --- a/src/services/__tests__/imageCacheService.test.ts +++ b/src/services/__tests__/imageCacheService.test.ts @@ -134,6 +134,7 @@ const mockConnectivity = { jest.mock('../../store/connectivityStore', () => ({ connectivityStore: { getState: jest.fn(() => mockConnectivity), + subscribe: jest.fn(() => () => {}), // no-op unsubscribe }, })); @@ -284,6 +285,7 @@ import { refreshCoverArt, reconcileImageCache, repairIncompleteImages, + prefetchCoverArt, __resetRetryStateForTest, } from '../imageCacheService'; @@ -682,6 +684,29 @@ describe('download pipeline — cacheAllSizes + processQueue', () => { }); }); +describe('prefetchCoverArt — keys off entity ID, not the coverArt field', () => { + it('warms the cache for the album id, never the server coverArt value', async () => { + const { getCoverArtUrl: mockGetCoverArtUrl } = jest.requireMock( + '../subsonicService', + ) as { getCoverArtUrl: jest.Mock }; + mockGetCoverArtUrl.mockReturnValue('https://example.com/cover.jpg'); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => 'image/jpeg' }, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)), + }); + + // id and coverArt deliberately differ (Navidrome `al-` style). + prefetchCoverArt([{ id: 'al-key', coverArt: 'cover-other' } as any]); + await new Promise((r) => setTimeout(r, 0)); + + const calledIds = mockGetCoverArtUrl.mock.calls.map((c) => c[0]); + expect(calledIds).toContain('al-key'); + expect(calledIds).not.toContain('cover-other'); + mockGetCoverArtUrl.mockReturnValue(null); + }); +}); + describe('downloadSourceImage — response.ok === false', () => { it('returns null and does not create files when server returns non-ok', async () => { const id = 'not-ok'; diff --git a/src/services/__tests__/musicCacheService.test.ts b/src/services/__tests__/musicCacheService.test.ts index 84cd593..334ac95 100644 --- a/src/services/__tests__/musicCacheService.test.ts +++ b/src/services/__tests__/musicCacheService.test.ts @@ -914,7 +914,7 @@ describe('enqueueAlbumDownload', () => { expect(queue[0].totalSongs).toBe(2); }); - it('caches album cover + track covers', async () => { + it('caches album cover + track covers by album ID (not coverArt field)', async () => { mockCheckStorageLimit.mockReturnValue(true); mockFetchAlbum.mockResolvedValue({ id: 'album-1', @@ -923,7 +923,10 @@ describe('enqueueAlbumDownload', () => { song: [makeChild('t1', { coverArt: 'tc' })], }); await enqueueAlbumDownload('album-1'); - expect(ensureCached).toHaveBeenCalledWith('ac'); + // Cover art keys off the album ID, never the server `coverArt` field + // (see src/utils/coverArtId.ts). + expect(ensureCached).toHaveBeenCalledWith('album-1'); + expect(ensureCached).not.toHaveBeenCalledWith('ac'); expect(prefetchCoverArt).toHaveBeenCalled(); }); diff --git a/src/services/__tests__/playerService.test.ts b/src/services/__tests__/playerService.test.ts index 7948f58..dde0acd 100644 --- a/src/services/__tests__/playerService.test.ts +++ b/src/services/__tests__/playerService.test.ts @@ -345,7 +345,7 @@ describe('playTrack', () => { expect(mockTP.skip).toHaveBeenCalledWith(1); expect(mockTP.play).toHaveBeenCalled(); // No "Starting playback" / "Now Playing" pill — those routine - // acknowledgements were removed; the MiniPlayer + DownloadBanner + // acknowledgements were removed; the mini player + DownloadBanner // chrome is the persistent confirmation. expect(mockToastShow).not.toHaveBeenCalled(); expect(mockToastSucceed).not.toHaveBeenCalled(); @@ -1403,7 +1403,7 @@ describe('PlaybackEndedWithReason event handler', () => { endedHandler({ reason: 'playedUntilEnd', track: 't1', position: 150 }); - // MiniPlayer and PlayerProgressBar read the same store — this write + // mini player and PlayerProgressBar read the same store — this write // ensures both show 100% when a track finishes naturally. expect(mockSetProgress).toHaveBeenCalledWith(200, 200, 200); }); diff --git a/src/services/imageCacheService.ts b/src/services/imageCacheService.ts index 3cdafd0..fbfda1f 100644 --- a/src/services/imageCacheService.ts +++ b/src/services/imageCacheService.ts @@ -71,7 +71,12 @@ import { logImageCache } from './imageCacheLogger'; import { ensureCoverArtAuth, getCoverArtUrl, + type AlbumID3, + type ArtistID3, + type Child, + type Playlist, } from './subsonicService'; +import { coverArtIdForEntity } from '../utils/coverArtId'; // Sentinel cover-art IDs rendered from bundled assets via // `CachedImage.tsx`, never downloaded. Inlined here (not imported) @@ -222,6 +227,27 @@ function notifyImageCacheUpdate(coverArtId: string): void { } } +/** + * Drop ALL remote-failed markers and notify every affected CachedImage so it + * re-derives its URI on the next render. Used by the coarse recovery paths + * (offline→online toggle, app foreground, server-reachable-again) so a cover + * that hit a transient remote error self-heals WITHOUT an app restart — even + * when `offlineMode` never flipped (a brief server blip while online). + */ +function clearFailedRemoteIds(reason: string): void { + if (failedRemoteIds.size === 0) return; + const ids = Array.from(failedRemoteIds); + failedRemoteIds.clear(); + logImageCache(`clearFailedRemoteIds reason=${reason} count=${ids.length}`); + for (const id of ids) { + const listeners = cacheUpdateListeners.get(id); + if (!listeners) continue; + for (const listener of listeners) { + try { listener(); } catch { /* swallow */ } + } + } +} + function uriCacheKey(coverArtId: string, size: number): string { return `${coverArtId}:${size}`; } @@ -372,6 +398,11 @@ export function initImageCache(): void { if (offlineModeStore.getState().offlineMode) return; await awaitFirstPing(); if (offlineModeStore.getState().offlineMode) return; + // Foreground recovery: a remote load that failed while the app + // was backgrounded (or during a transient blip) stays in + // failedRemoteIds until something clears it. The offline→online + // toggle didn't fire if offlineMode never flipped, so clear here. + clearFailedRemoteIds('appstate-active'); await repairIncompleteImages('appstate-active'); })(), 'imageCache.appStateActive', @@ -481,20 +512,8 @@ offlineModeStore.subscribe((state, prev) => { if (state.offlineMode) return; // Coming back online: drop every remote-failed marker so CachedImage // instances get a fresh shot at the server URL while the repair pass - // works in the background. We notify the listener set per id so any - // mounted CachedImages re-derive immediately rather than waiting for - // the next render. - if (failedRemoteIds.size > 0) { - const ids = Array.from(failedRemoteIds); - failedRemoteIds.clear(); - for (const id of ids) { - const listeners = cacheUpdateListeners.get(id); - if (!listeners) continue; - for (const listener of listeners) { - try { listener(); } catch { /* swallow */ } - } - } - } + // works in the background. + clearFailedRemoteIds('offline-online'); if (imageCacheStore.getState().incompleteCount <= 0) return; // _layout.tsx restarts connectivity monitoring on offline→online; wait // for the first post-resume ping so the repair pass acts on confirmed @@ -509,6 +528,18 @@ offlineModeStore.subscribe((state, prev) => { ); }); +// In-foreground transient blips: the connectivity layer flips +// `isServerReachable` without `offlineMode` ever changing (a brief server +// outage while the app stays foregrounded and online). When the server comes +// back, drop remote-failed markers so covers recover without a restart or an +// offline toggle — the user's "should recover when the server is available +// again" requirement. +connectivityStore.subscribe((state, prev) => { + if (!state.isServerReachable || prev.isServerReachable) return; + if (offlineModeStore.getState().offlineMode) return; + clearFailedRemoteIds('server-reachable'); +}); + /** * Heal drift between the `cached_images` table and the on-disk layout. * @@ -1677,16 +1708,22 @@ export async function clearImageCache( } /** - * Proactively cache cover art for a list of entities (songs, albums, etc.). - * Deduplicates by coverArt ID and skips entries already in cache. + * Proactively cache cover art for a list of entities (songs, albums, + * artists, playlists). Keys off the canonical entity ID via + * `coverArtIdForEntity` (NOT the server `coverArt` field) so the warmed + * file matches what the render side reads. Deduplicates by resolved ID + * and skips entries already in cache. */ -export function prefetchCoverArt(entities: Array<{ coverArt?: string }>): void { +export function prefetchCoverArt( + entities: Array, +): void { const seen = new Set(); for (const entity of entities) { - if (entity.coverArt && !seen.has(entity.coverArt)) { - seen.add(entity.coverArt); - if (!getCachedImageUri(entity.coverArt, 300)) { - cacheAllSizes(entity.coverArt).catch(() => { /* non-critical */ }); + const id = coverArtIdForEntity(entity); + if (id && !seen.has(id)) { + seen.add(id); + if (!getCachedImageUri(id, 300)) { + cacheAllSizes(id).catch(() => { /* non-critical */ }); } } } diff --git a/src/services/musicCacheService.ts b/src/services/musicCacheService.ts index 0ec0247..0224cde 100644 --- a/src/services/musicCacheService.ts +++ b/src/services/musicCacheService.ts @@ -58,6 +58,11 @@ import { ensureCached, prefetchCoverArt, } from './imageCacheService'; +import { + coverArtIdForAlbum, + coverArtIdForPlaylist, + coverArtIdForSong, +} from '../utils/coverArtId'; /* ------------------------------------------------------------------ */ /* Constants */ @@ -755,9 +760,10 @@ export async function enqueueAlbumDownload(albumId: string): Promise { }); } - if (album.coverArt) { - ensureCached(album.coverArt).catch(() => { /* non-critical */ }); - } + // Cover art keys off the album ID, never the server `coverArt` field + // (see src/utils/coverArtId.ts) — so the warmed/stored key matches what + // the grid renders. The raw `coverArt` is retained in the song envelopes. + ensureCached(albumId).catch(() => { /* non-critical */ }); cacheTrackCoverArt(missingSongs); musicCacheStore.getState().enqueueTopUp({ @@ -765,7 +771,7 @@ export async function enqueueAlbumDownload(albumId: string): Promise { type: 'album', name: album.name, artist: album.artist ?? album.displayArtist, - coverArtId: album.coverArt, + coverArtId: albumId, totalSongs: missingSongs.length, songsJson: JSON.stringify(missingSongs), }); @@ -774,9 +780,7 @@ export async function enqueueAlbumDownload(albumId: string): Promise { return; } - if (album.coverArt) { - ensureCached(album.coverArt).catch(() => { /* non-critical */ }); - } + ensureCached(albumId).catch(() => { /* non-critical */ }); cacheTrackCoverArt(album.song); musicCacheStore.getState().enqueue({ @@ -784,7 +788,7 @@ export async function enqueueAlbumDownload(albumId: string): Promise { type: 'album', name: album.name, artist: album.artist ?? album.displayArtist, - coverArtId: album.coverArt, + coverArtId: albumId, totalSongs: album.song.length, songsJson: JSON.stringify(album.song), }); @@ -802,16 +806,15 @@ export async function enqueuePlaylistDownload(playlistId: string): Promise const playlist = await playlistDetailStore.getState().fetchPlaylist(playlistId); if (!playlist?.entry?.length) return; - if (playlist.coverArt) { - ensureCached(playlist.coverArt).catch(() => { /* non-critical */ }); - } + // Cover art keys off the playlist ID (see src/utils/coverArtId.ts). + ensureCached(playlistId).catch(() => { /* non-critical */ }); cacheTrackCoverArt(playlist.entry); musicCacheStore.getState().enqueue({ itemId: playlistId, type: 'playlist', name: playlist.name, - coverArtId: playlist.coverArt, + coverArtId: playlistId, totalSongs: playlist.entry.length, songsJson: JSON.stringify(playlist.entry), }); @@ -854,7 +857,7 @@ export async function enqueueSongDownload(song: Child): Promise { type: 'song', name: song.title ?? existing.title, artist: song.artist ?? existing.artist, - coverArtId: song.coverArt ?? existing.coverArt, + coverArtId: coverArtIdForSong(song), expectedSongCount: 1, parentAlbumId: song.albumId ?? existing.albumId, lastSyncAt: Date.now(), @@ -875,7 +878,7 @@ export async function enqueueSongDownload(song: Child): Promise { type: 'song', name: song.title ?? 'Unknown', artist: song.artist, - coverArtId: song.coverArt, + coverArtId: coverArtIdForSong(song), totalSongs: 1, songsJson: JSON.stringify([song]), }); @@ -1053,7 +1056,8 @@ async function ensurePartialAlbumEdge( type: 'album', name: song.album ?? cachedAlbum?.album?.name ?? 'Unknown', artist: song.artist ?? cachedAlbum?.album?.artist, - coverArtId: song.coverArt ?? cachedAlbum?.album?.coverArt, + // Album item — cover art keys off the album ID (see coverArtId.ts). + coverArtId: albumId, expectedSongCount, parentAlbumId: undefined, lastSyncAt: now, diff --git a/src/services/playerHelpers.ts b/src/services/playerHelpers.ts index 4ad3c8d..c00aa7a 100644 --- a/src/services/playerHelpers.ts +++ b/src/services/playerHelpers.ts @@ -14,6 +14,7 @@ import { offlineModeStore } from '../store/offlineModeStore'; import { playbackSettingsStore, type RepeatModeSetting } from '../store/playbackSettingsStore'; import { type PlaybackStatus } from '../store/playerStore'; import { resolveEffectiveFormat } from '../utils/effectiveFormat'; +import { coverArtIdForSong } from '../utils/coverArtId'; import { getCachedImageUri } from './imageCacheService'; import { getLocalTrackUri } from './musicCacheService'; import { getCoverArtUrl, getStreamUrl, type Child } from './subsonicService'; @@ -116,9 +117,10 @@ export function childToTrack(child: Child): Track | null { // Cover-art lookup keys off the parent album's ID (see // src/utils/coverArtId.ts) so every track in an album shares one - // cached file — fixes the MiniPlayer / lock-screen placeholder + // cached file — fixes the mini player / lock-screen placeholder // problem caused by Navidrome-style per-track coverArt variants. - const coverArtId = child.albumId ?? child.id; + // `child.id` is always present, so the result is a defined string. + const coverArtId = coverArtIdForSong(child) ?? child.id; const cachedArt = getCachedImageUri(coverArtId, 600); const contentType = localUri ? mimeFromUri(localUri) : undefined; // In offline mode drop any server-only artwork so RNTP's lock-screen diff --git a/src/services/playerService.ts b/src/services/playerService.ts index 2a8ca44..9b87be5 100644 --- a/src/services/playerService.ts +++ b/src/services/playerService.ts @@ -526,7 +526,7 @@ export async function initPlayer(): Promise { // finishes (polling cadence), so without this write the store can be // left at e.g. 150/200 when the track ends — a visible ~75% progress // bar instead of a full one. Using the Subsonic metadata duration - // (authoritative) keeps MiniPlayer and PlayerProgressBar in lockstep. + // (authoritative) keeps mini player and PlayerProgressBar in lockstep. const endDuration = trackThatEnded.duration ?? 0; if (endDuration > 0) { playerStore.getState().setProgress(endDuration, endDuration, endDuration); @@ -669,7 +669,7 @@ export async function initPlayer(): Promise { // --- Restore persisted queue from previous session --- // // restorePersistedQueue() populates the Zustand store synchronously so the - // MiniPlayer can render immediately. If it returns true, a previously + // mini player can render immediately. If it returns true, a previously // active queue exists; kick off the async hydration sequence which loads // tracks into RNTP in a muted, paused, seek-positioned state so the first // user tap plays without negotiating any native-layer uncertainty. @@ -735,7 +735,7 @@ async function syncStoreFromNative(): Promise { * Restore the persisted queue from a previous session. * * Called once during initPlayer(). Populates the Zustand store and - * module-level currentChildQueue so the MiniPlayer renders immediately. + * module-level currentChildQueue so the mini player renders immediately. * Does NOT touch RNTP — that's the job of the async hydrateRestoredQueue() * that initPlayer() kicks off next. * @@ -829,7 +829,7 @@ async function hydrateRestoredQueue(): Promise { if (rnTracks.length === 0) { // Nothing in the restored queue is playable right now (offline + no // cached files). Surface a toast and clear the stale queue so the - // MiniPlayer doesn't linger on an unplayable track. Call the + // mini player doesn't linger on an unplayable track. Call the // internal helper — clearQueue() would deadlock awaiting us. playbackToastStore.getState().fail(i18n.t('noOfflineTracksInQueue')); await clearPlayerStateInternal(); @@ -1257,7 +1257,7 @@ async function clearPlayerStateInternal(): Promise { * Stop playback, clear the queue, and reset all player state to defaults. * * Resets both the native RNTP player and the Zustand store so the UI - * returns to its idle state (MiniPlayer hidden, no current track). + * returns to its idle state (mini player hidden, no current track). */ export async function clearQueue(): Promise { // Wait for any in-flight cold-start hydration so its mid-sequence diff --git a/src/store/__tests__/moreOptionsStore.test.ts b/src/store/__tests__/moreOptionsStore.test.ts index b6a2af2..4b747fc 100644 --- a/src/store/__tests__/moreOptionsStore.test.ts +++ b/src/store/__tests__/moreOptionsStore.test.ts @@ -19,22 +19,22 @@ describe('moreOptionsStore', () => { }); it('show with explicit source sets source', () => { - moreOptionsStore.getState().show({ type: 'album', item: mockAlbum }, 'player'); - expect(moreOptionsStore.getState().source).toBe('player'); + moreOptionsStore.getState().show({ type: 'album', item: mockAlbum }, 'player-phone-portrait'); + expect(moreOptionsStore.getState().source).toBe('player-phone-portrait'); }); it('show with playerpanel source sets source', () => { - moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'playerpanel'); - expect(moreOptionsStore.getState().source).toBe('playerpanel'); + moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'player-tablet-splitview'); + expect(moreOptionsStore.getState().source).toBe('player-tablet-splitview'); }); it('show with playerexpanded source sets source', () => { - moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'playerexpanded'); - expect(moreOptionsStore.getState().source).toBe('playerexpanded'); + moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'player-tablet-landscape'); + expect(moreOptionsStore.getState().source).toBe('player-tablet-landscape'); }); it('hide resets all fields including source', () => { - moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'player'); + moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'player-phone-portrait'); moreOptionsStore.getState().hide(); const state = moreOptionsStore.getState(); expect(state.visible).toBe(false); @@ -48,7 +48,7 @@ describe('moreOptionsStore', () => { // chained modal mounts AFTER the sheet's native Modal is fully gone. describe('hideAndAwait + _signalCloseComplete', () => { it('hideAndAwait resets state and waits for the signal to resolve', async () => { - moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'player'); + moreOptionsStore.getState().show({ type: 'song', item: mockSong }, 'player-phone-portrait'); const promise = moreOptionsStore.getState().hideAndAwait(); // State is already cleared synchronously diff --git a/src/store/albumDetailStore.ts b/src/store/albumDetailStore.ts index 47682c2..44bc153 100644 --- a/src/store/albumDetailStore.ts +++ b/src/store/albumDetailStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { ensureCached, prefetchCoverArt } from '../services/imageCacheService'; +import { coverArtIdForAlbum } from '../utils/coverArtId'; import { ensureCoverArtAuth, getAlbum, @@ -86,7 +87,8 @@ export const albumDetailStore = create()((set, get) => ({ // Proactively cache cover art for new IDs so they survive offline. // Skipped during bulk sync — see prefetchCovers contract above. if (prefetchCovers) { - if (data.coverArt) ensureCached(data.coverArt).catch(() => { /* non-critical */ }); + const albumArtId = coverArtIdForAlbum(data); + if (albumArtId) ensureCached(albumArtId).catch(() => { /* non-critical */ }); if (data.song?.length) prefetchCoverArt(data.song); } } diff --git a/src/store/favoritesStore.ts b/src/store/favoritesStore.ts index c710fb1..66d4b53 100644 --- a/src/store/favoritesStore.ts +++ b/src/store/favoritesStore.ts @@ -6,6 +6,7 @@ import i18n from '../i18n/i18n'; import { kvStorage } from './persistence'; import { ensureCached, prefetchCoverArt } from '../services/imageCacheService'; +import { coverArtIdForAlbum, coverArtIdForArtist } from '../utils/coverArtId'; import { ensureCoverArtAuth, getStarred2, @@ -94,10 +95,12 @@ export const favoritesStore = create()( if (prefetchCovers) { prefetchCoverArt(songs); for (const a of albums) { - if (a.coverArt) ensureCached(a.coverArt).catch(() => { /* non-critical */ }); + const albumArtId = coverArtIdForAlbum(a); + if (albumArtId) ensureCached(albumArtId).catch(() => { /* non-critical */ }); } for (const a of artists) { - if (a.coverArt) ensureCached(a.coverArt).catch(() => { /* non-critical */ }); + const artistArtId = coverArtIdForArtist(a); + if (artistArtId) ensureCached(artistArtId).catch(() => { /* non-critical */ }); } } } catch (e) { diff --git a/src/store/moreOptionsStore.ts b/src/store/moreOptionsStore.ts index 9162c62..13810f4 100644 --- a/src/store/moreOptionsStore.ts +++ b/src/store/moreOptionsStore.ts @@ -24,7 +24,12 @@ export type MoreOptionsEntity = /* Store */ /* ------------------------------------------------------------------ */ -export type MoreOptionsSource = 'default' | 'player' | 'playerpanel' | 'playerexpanded'; +export type MoreOptionsSource = + | 'default' + | 'player-phone-portrait' + | 'player-tablet-portrait' + | 'player-tablet-splitview' + | 'player-tablet-landscape'; export interface MoreOptionsState { visible: boolean; diff --git a/src/store/playlistDetailStore.ts b/src/store/playlistDetailStore.ts index ff42d99..68d13b0 100644 --- a/src/store/playlistDetailStore.ts +++ b/src/store/playlistDetailStore.ts @@ -4,6 +4,7 @@ import { createJSONStorage, persist } from 'zustand/middleware'; import { kvStorage } from './persistence'; import { ensureCached, prefetchCoverArt } from '../services/imageCacheService'; +import { coverArtIdForPlaylist } from '../utils/coverArtId'; import { ensureCoverArtAuth, getPlaylist, @@ -67,7 +68,8 @@ export const playlistDetailStore = create()( // Proactively cache cover art for new IDs so they survive offline. // Skipped during bulk sync — see prefetchCovers contract above. if (prefetchCovers) { - if (data.coverArt) ensureCached(data.coverArt).catch(() => { /* non-critical */ }); + const playlistArtId = coverArtIdForPlaylist(data); + if (playlistArtId) ensureCached(playlistArtId).catch(() => { /* non-critical */ }); if (data.entry?.length) prefetchCoverArt(data.entry); } } diff --git a/src/utils/__tests__/coverArtId.test.ts b/src/utils/__tests__/coverArtId.test.ts new file mode 100644 index 0000000..99742ad --- /dev/null +++ b/src/utils/__tests__/coverArtId.test.ts @@ -0,0 +1,66 @@ +import { + coverArtIdForAlbum, + coverArtIdForArtist, + coverArtIdForEntity, + coverArtIdForPlaylist, + coverArtIdForSong, +} from '../coverArtId'; +import { + type AlbumID3, + type ArtistID3, + type Child, + type Playlist, +} from '../../services/subsonicService'; + +/** + * The single rule: cover-art keys off the entity ID, NEVER the server + * `coverArt` field. These tests pin that — every helper must ignore a + * (deliberately different) `coverArt` value and return the ID. + */ +describe('coverArtId helpers', () => { + it('coverArtIdForAlbum returns the album id, ignoring coverArt', () => { + const album = { id: 'al-1', coverArt: 'cover-xyz' } as AlbumID3; + expect(coverArtIdForAlbum(album)).toBe('al-1'); + }); + + it('coverArtIdForArtist returns the artist id, ignoring coverArt', () => { + const artist = { id: 'ar-1', coverArt: 'cover-xyz' } as ArtistID3; + expect(coverArtIdForArtist(artist)).toBe('ar-1'); + }); + + it('coverArtIdForPlaylist returns the playlist id, ignoring coverArt', () => { + const playlist = { id: 'pl-1', coverArt: 'cover-xyz' } as Playlist; + expect(coverArtIdForPlaylist(playlist)).toBe('pl-1'); + }); + + it('coverArtIdForSong returns the parent albumId, ignoring coverArt', () => { + const song = { id: 's-1', albumId: 'al-1', coverArt: 'mf-9' } as Child; + expect(coverArtIdForSong(song)).toBe('al-1'); + }); + + it('coverArtIdForSong falls back to the song id when no albumId (orphan)', () => { + const song = { id: 's-1', coverArt: 'mf-9' } as Child; + expect(coverArtIdForSong(song)).toBe('s-1'); + }); + + it('returns undefined when the entity has no usable id', () => { + expect(coverArtIdForAlbum({ coverArt: 'c' } as AlbumID3)).toBeUndefined(); + expect(coverArtIdForSong({ coverArt: 'c' } as unknown as Child)).toBeUndefined(); + }); + + describe('coverArtIdForEntity dispatch', () => { + it('treats an entity with albumId as a song (albumId wins)', () => { + const song = { id: 's-1', albumId: 'al-1', coverArt: 'mf-9' } as Child; + expect(coverArtIdForEntity(song)).toBe('al-1'); + }); + + it('treats an entity without albumId as id-keyed (album/artist/playlist)', () => { + const album = { id: 'al-2', coverArt: 'al-cover' } as AlbumID3; + const artist = { id: 'ar-2', coverArt: 'ar-cover' } as ArtistID3; + const playlist = { id: 'pl-2', coverArt: 'pl-cover' } as Playlist; + expect(coverArtIdForEntity(album)).toBe('al-2'); + expect(coverArtIdForEntity(artist)).toBe('ar-2'); + expect(coverArtIdForEntity(playlist)).toBe('pl-2'); + }); + }); +}); diff --git a/src/utils/colors.ts b/src/utils/colors.ts index e86f0f4..90c8171 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -4,7 +4,7 @@ * Palette extraction itself now lives in the native `expo-image-colors` * module (see `useImagePalette`). These helpers remain for gradient-stop * alpha composition and theme-mix blending in `GradientBackground`, - * `ExpandedPlayerView`, and friends. + * `PlayerTabletLandscape`, and friends. */ /** From 6339adb9799466cc945fc0e072ad0e859fdc54a2 Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Tue, 2 Jun 2026 11:53:50 +1000 Subject: [PATCH 15/18] chore(deps): bump Expo SDK 56 patch releases Patch-level updates within SDK 56 (expo, expo-router, expo-crypto, expo-notifications, expo-sharing, expo-location, expo-linking, expo-dev-client, expo-build-properties). --- package-lock.json | 168 ++++++++++++++++++++++++---------------------- package.json | 18 ++--- 2 files changed, 98 insertions(+), 88 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0fc840f..909c0ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,15 +21,15 @@ "@react-native-vector-icons/ionicons": "^13.1.1", "@react-native-vector-icons/material-design-icons": "^13.1.1", "@shopify/flash-list": "^2.3.1", - "expo": "~56.0.5", + "expo": "~56.0.8", "expo-async-fs": "file:./modules/expo-async-fs", "expo-backup-exclusions": "file:./modules/expo-backup-exclusions", "expo-battery": "~56.0.4", "expo-blur": "~56.0.3", - "expo-build-properties": "~56.0.15", + "expo-build-properties": "~56.0.16", "expo-clipboard": "~56.0.3", "expo-constants": "~56.0.15", - "expo-crypto": "56.0.3", + "expo-crypto": "~56.0.4", "expo-device": "~56.0.4", "expo-file-system": "~56.0.7", "expo-font": "~56.0.5", @@ -39,14 +39,14 @@ "expo-image-resize": "file:./modules/expo-image-resize", "expo-intent-launcher": "~56.0.4", "expo-linear-gradient": "~56.0.4", - "expo-linking": "~56.0.12", + "expo-linking": "~56.0.13", "expo-localization": "~56.0.6", - "expo-location": "~56.0.14", + "expo-location": "~56.0.15", "expo-move-to-back": "file:./modules/expo-move-to-back", - "expo-notifications": "~56.0.14", - "expo-router": "~56.2.7", + "expo-notifications": "~56.0.15", + "expo-router": "~56.2.8", "expo-screen-orientation": "~56.0.5", - "expo-sharing": "~56.0.14", + "expo-sharing": "~56.0.15", "expo-sqlite": "~56.0.4", "expo-ssl-trust": "file:./modules/expo-ssl-trust", "expo-status-bar": "~56.0.4", @@ -76,7 +76,7 @@ "@types/jest": "~29.5.14", "@types/react": "^19.2.15", "babel-plugin-transform-remove-console": "^6.9.4", - "expo-dev-client": "~56.0.16", + "expo-dev-client": "~56.0.18", "jest": "~29.7.0", "jest-expo": "~56.0.4", "patch-package": "^8.0.1", @@ -1410,9 +1410,9 @@ "license": "MIT AND Apache-2.0" }, "node_modules/@expo/cli": { - "version": "56.1.12", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.12.tgz", - "integrity": "sha512-Ya/13E1yDx1oAuPw5MDmqzIGyzwSs7KSr1EjgSObOF0VO0GD9jqJjvjOiwurjScLUfxcGZQgq23UzMlBVHwdvA==", + "version": "56.1.13", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.13.tgz", + "integrity": "sha512-7n5VzlBr7TKW0BgWgpEopWy+v8buPhMvbSEsuXD+bI1YIJBopkfWAub0qTvlc357E8wWOvV5MJXYyoeRvoOjoQ==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", @@ -1428,9 +1428,9 @@ "@expo/metro-config": "~56.0.13", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", - "@expo/package-manager": "^1.12.0", + "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", - "@expo/prebuild-config": "^56.0.13", + "@expo/prebuild-config": "^56.0.14", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.12", "@expo/schema-utils": "^56.0.0", @@ -1857,9 +1857,9 @@ } }, "node_modules/@expo/package-manager": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.12.0.tgz", - "integrity": "sha512-SWr6093nwBjn94cvElsYZNUnhvs+XtUatUz3h0vAn0IbaWG0B6l/V5ZfOBptX/xq6rMpFG5ibIf/eckLSXw8Gg==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.12.1.tgz", + "integrity": "sha512-fQLiFAcFRWF53mtuLK32SUJQ1ahhrTcBZPZPedYTiUT5ha5FF+UO6bPtCc0Y/hgj0/m3HCGBAuSHjbg2kI9oPQ==", "license": "MIT", "dependencies": { "@expo/json-file": "^10.2.0", @@ -1882,9 +1882,9 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "56.0.13", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.13.tgz", - "integrity": "sha512-caR1karpDasbNmM+LrcHKZrSnyEYdmxm7kedq+WjiuZg+9XAW5sbEjojo2i9Dq6cfbDJPyr7I0yEprLabnvmpA==", + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.14.tgz", + "integrity": "sha512-JHdMqR7Mf5ApLC50ZwTL0Z86ezrHOMYwoSHcWT6Pha/+1TcC+/J+i7vjhP06wGXQ2Kvjt74p/3mKg2Pd12KjhQ==", "license": "MIT", "dependencies": { "@expo/config": "~56.0.9", @@ -1894,7 +1894,7 @@ "@expo/json-file": "^10.2.0", "@react-native/normalize-colors": "0.85.3", "debug": "^4.3.1", - "expo-modules-autolinking": "~56.0.13", + "expo-modules-autolinking": "~56.0.14", "resolve-from": "^5.0.0", "semver": "^7.6.0" } @@ -1981,9 +1981,9 @@ "license": "MIT" }, "node_modules/@expo/ui": { - "version": "56.0.14", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-56.0.14.tgz", - "integrity": "sha512-0Wr8nsvk2C+BmhmZDQzYr/hxxddHK+ajuJ7ahacUvxt+gQnEXwbueTm0S/hk/54YGASEgplrPGDuR5zzcY+IZg==", + "version": "56.0.15", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-56.0.15.tgz", + "integrity": "sha512-PFZBzztQGCp2bRFP8wIOb5ntP2ORH2GdQkJMSJcDOd4NldoWMe1pFqv7PdthjNlaaTHHTTHK+RsQrz+M6z6isw==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -2040,9 +2040,19 @@ "license": "Python-2.0" }, "node_modules/@expo/xcpretty/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4581,9 +4591,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "56.0.13", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.13.tgz", - "integrity": "sha512-+CxxAQrN95N+/dF4AUJXNxEh5cEv4yhxb4CM5ijdc2OeIIw+hxzYh2OM1X7QHIm6hkT66H4vJCTT636yjJ8MnQ==", + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.14.tgz", + "integrity": "sha512-+JKVMYf3HajO3tPRA9DlKd/VhZOPTHyTzUo2yZajfMAoQ3l5VEdGVxm2MzX4DXMNKXwsC8GOeTRx7CrO/5dBDA==", "license": "MIT", "dependencies": { "@babel/generator": "^7.20.5", @@ -4632,7 +4642,7 @@ "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", - "expo-widgets": "^56.0.15", + "expo-widgets": "^56.0.16", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -5926,13 +5936,13 @@ } }, "node_modules/expo": { - "version": "56.0.5", - "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.5.tgz", - "integrity": "sha512-5rTo664JOpRIx41CGW6gbnQJyU5JCu6P1JXMgMzISViIIyevmaThBM3yxoTDKutqGAopFuZNhqHPUCWUg6WhEA==", + "version": "56.0.8", + "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.8.tgz", + "integrity": "sha512-GzQi5450yrCk5JRSlm0epsmtURBErh0wS77uWLZImFdnPICuX912MaRWooR+Q1Sw/7aQjp9F+KXH+dvrqGxpeQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^56.1.12", + "@expo/cli": "^56.1.13", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/devtools": "~56.0.2", @@ -5943,14 +5953,14 @@ "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.13", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~56.0.13", + "babel-preset-expo": "~56.0.14", "expo-asset": "~56.0.15", "expo-constants": "~56.0.16", "expo-file-system": "~56.0.7", "expo-font": "~56.0.5", "expo-keep-awake": "~56.0.3", - "expo-modules-autolinking": "~56.0.13", - "expo-modules-core": "~56.0.13", + "expo-modules-autolinking": "~56.0.14", + "expo-modules-core": "~56.0.14", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -6041,9 +6051,9 @@ } }, "node_modules/expo-build-properties": { - "version": "56.0.15", - "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.15.tgz", - "integrity": "sha512-3OlfTnBE6BIFxchjXzb0OlgDcWw19fxhIzpIZqgcgzZUVjyn4gCrQuNcsfazVVddBypwkEzOVfwArPROIk4J7g==", + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.16.tgz", + "integrity": "sha512-C3avazYP2fR8efJBBmhx8yITjIRDaIe3ULPk0YfACP61QfnWC9u3LxaDNNaiIvYfZ+CLne30W+nS5F6pdgO/8g==", "license": "MIT", "dependencies": { "@expo/schema-utils": "^56.0.0", @@ -6079,23 +6089,23 @@ } }, "node_modules/expo-crypto": { - "version": "56.0.3", - "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.3.tgz", - "integrity": "sha512-Ehiub29JVhN69RbMfaBoZbrrT55o9zU5YojHg48W63aCSN7lGyFz5g8JdUN3mXMaZCAUoExdk24NJPvMgbFZ+w==", + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz", + "integrity": "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-dev-client": { - "version": "56.0.16", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-56.0.16.tgz", - "integrity": "sha512-mxmGA6YSP4KiMB4bREpriQ4K6EaS4tcm0eh1+LtAzgFCytq+Y4WxMfIvFe3B5kXlSpA0ohMLdAN0AUzU0xHGQg==", + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-56.0.18.tgz", + "integrity": "sha512-pTfDcYTOvrs4vCgAaM+vP2OEO93oGkczgGpTAzCY7ZTIvtPhpekJURHBxfOnKvfn97IF3Hk+8J9tMozsNDj0Gw==", "dev": true, "license": "MIT", "dependencies": { - "expo-dev-launcher": "~56.0.16", - "expo-dev-menu": "~56.0.15", + "expo-dev-launcher": "~56.0.18", + "expo-dev-menu": "~56.0.16", "expo-dev-menu-interface": "~56.0.0", "expo-manifests": "~56.0.4", "expo-updates-interface": "~56.0.1" @@ -6105,14 +6115,14 @@ } }, "node_modules/expo-dev-launcher": { - "version": "56.0.16", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-56.0.16.tgz", - "integrity": "sha512-3t2PCX2lCKetKL8EgRRo2tzSlGh1zcuaWuwp3V0k4/3nuM7pztyImaR6Sm3HUyarDOofAIPX1hIIxnuAfk5cnw==", + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-56.0.18.tgz", + "integrity": "sha512-7acFJlkAbp3cMz7Uy787todMR/3A/Row2EOPD21RRoetvzJe4DTm9s7RwJ8PDtyNyued9rooD4+Q6rD8ijpTgw==", "dev": true, "license": "MIT", "dependencies": { "@expo/schema-utils": "^56.0.0", - "expo-dev-menu": "~56.0.15", + "expo-dev-menu": "~56.0.16", "expo-manifests": "~56.0.4" }, "peerDependencies": { @@ -6121,9 +6131,9 @@ } }, "node_modules/expo-dev-menu": { - "version": "56.0.15", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-56.0.15.tgz", - "integrity": "sha512-FY6Y5sZkNXxPBGDgC51ZArOi8N7Y8wpXwanTClFO36IVMoVf7BBqhjW13KpDecvJONtEtaUeNIAt9C25PO8MOQ==", + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-56.0.16.tgz", + "integrity": "sha512-aVgoe+YGhrQnpwiB5BRI7G+uQnGHMUij32bBnEVdc6eJrVZCStxQlV9NeFbbXxrDhLJt6OSqbCHbLR+XToWUUA==", "dev": true, "license": "MIT", "dependencies": { @@ -6250,9 +6260,9 @@ } }, "node_modules/expo-linking": { - "version": "56.0.12", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.12.tgz", - "integrity": "sha512-EJ+YoazVqlrUXMAARo1iTExpqEGjuKJDGiE/P1K+A3m5hs+2Uf8F9ucqpq9k5dizeiaV2D8B9+uLvqMHFzGGsQ==", + "version": "56.0.13", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.13.tgz", + "integrity": "sha512-38YrpTh6xdiDxmYSDIUffDqev1hIcEggw2fZ3IZhNp2DVLF1xvqsbO6hJD1fuBKN8P34B3Ggc9Yy26fkqdfCOA==", "license": "MIT", "dependencies": { "expo-constants": "~56.0.16", @@ -6277,9 +6287,9 @@ } }, "node_modules/expo-location": { - "version": "56.0.14", - "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.14.tgz", - "integrity": "sha512-k9p6mR11o5S0R4yUs3uWLJfnSk6XIB9UIgSYiNu2goGLWb2f0sazuZ0iYhuc2p2wIsdidhpL/51ZXjtZl5JCOg==", + "version": "56.0.15", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.15.tgz", + "integrity": "sha512-CM5+1untDxsuN0NIgsBS9cRel5xh8UXstQS6KtQw/run5PiArqCl51cnTuG+aqjYgE+9gweSG70PI6A1Ax1XTA==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.10.1" @@ -6302,9 +6312,9 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "56.0.13", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.13.tgz", - "integrity": "sha512-6JwVGX+geAikA4zNyZPX3H+PWINZdjjUMV0VLDmfR2gToA0Gu8AdbONP0+2yNuwBYFgQr0MrBtxCDU3YE6avRQ==", + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.14.tgz", + "integrity": "sha512-9ugtZkheNPYDkW4DZopY1rH2BCbUICaafUEPxRgbLDR5UNRF5K3cdHMIMEt8pxZPq2+eX4wCm+6pbSvdY/DPHg==", "license": "MIT", "dependencies": { "@expo/require-utils": "^56.1.3", @@ -6317,9 +6327,9 @@ } }, "node_modules/expo-modules-core": { - "version": "56.0.13", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.13.tgz", - "integrity": "sha512-3Hgpi9Q1O0XqoesQtgFY7qhfDsNA3bJtdCJotEqdE42+N8Zv/LJACbNgIyFN/XrnMDzfF5rozh0vNWaRT0/eXQ==", + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.14.tgz", + "integrity": "sha512-dl1TlYRm1k7xk9QeAyDoMfFE2p6rNyzHUcH5ArcGwUzO8YKku+Z2tQ8+kG7zLe3OhfMoJcFR/czrFy7vGSVI6w==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "~0.0.9", @@ -6351,9 +6361,9 @@ "link": true }, "node_modules/expo-notifications": { - "version": "56.0.14", - "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.14.tgz", - "integrity": "sha512-A+BDJYyBIkC17Bfqlrbf9A80npjOyoTbaSCydP2agfhVv+Ld7DuOYOJSApBmtzBZM0LvdUVX/pdrwjEp1ixmaw==", + "version": "56.0.15", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.15.tgz", + "integrity": "sha512-F+OasAePiVnHaPNKI9JAYV8fg8bdBwo7Mh9R3ydBp8S21fRQyxKOSgJvj8fX/HoPFFIC6V2B+y1LJbG5Ovh/Fg==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.10.1", @@ -6369,15 +6379,15 @@ } }, "node_modules/expo-router": { - "version": "56.2.7", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.7.tgz", - "integrity": "sha512-T7MSugHfj6XDrVJG8dCkP5EEAWeCkPrkkxqKCqCRokXmBKTAiRGXsmPsgHzOXhr/5MxGDJXhj5ON19uWoCevDA==", + "version": "56.2.8", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.8.tgz", + "integrity": "sha512-l387I/ddPY/5SS+Rfpp1SrRV9gBKevxtPuZod7igMjR6L674QrxEwGiAILRq6AKCSbrP2I0ufKj7e5xz8JqA4Q==", "license": "MIT", "dependencies": { "@expo/log-box": "^56.0.12", "@expo/metro-runtime": "^56.0.13", "@expo/schema-utils": "^56.0.0", - "@expo/ui": "^56.0.14", + "@expo/ui": "^56.0.15", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", @@ -6409,7 +6419,7 @@ "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^56.0.16", - "expo-linking": "^56.0.12", + "expo-linking": "^56.0.13", "react": "*", "react-dom": "*", "react-native": "*", @@ -6548,9 +6558,9 @@ } }, "node_modules/expo-sharing": { - "version": "56.0.14", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.14.tgz", - "integrity": "sha512-Hu7pm3U9vn9NFGBe5EUM6ct6wBhAc7Zgl5koOYpJnMvL6n85bkIA8sLvvxB6V+p4JRoh3TD6xXpOIr23qwsV2w==", + "version": "56.0.15", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.15.tgz", + "integrity": "sha512-6Hy1+Mjy4UYXkFiDK3Ea934NUmA71i8dmZkDe+rrUHRzZAv4FR+q/VyiT7LzNFEqpT4wn4wcI66lc2QY526RsA==", "license": "MIT", "dependencies": { "@expo/config-plugins": "^56.0.8", diff --git a/package.json b/package.json index e7985fd..4135325 100644 --- a/package.json +++ b/package.json @@ -41,15 +41,15 @@ "@react-native-vector-icons/ionicons": "^13.1.1", "@react-native-vector-icons/material-design-icons": "^13.1.1", "@shopify/flash-list": "^2.3.1", - "expo": "~56.0.5", + "expo": "~56.0.8", "expo-async-fs": "file:./modules/expo-async-fs", "expo-backup-exclusions": "file:./modules/expo-backup-exclusions", "expo-battery": "~56.0.4", "expo-blur": "~56.0.3", - "expo-build-properties": "~56.0.15", + "expo-build-properties": "~56.0.16", "expo-clipboard": "~56.0.3", "expo-constants": "~56.0.15", - "expo-crypto": "56.0.3", + "expo-crypto": "~56.0.4", "expo-device": "~56.0.4", "expo-file-system": "~56.0.7", "expo-font": "~56.0.5", @@ -59,14 +59,14 @@ "expo-image-resize": "file:./modules/expo-image-resize", "expo-intent-launcher": "~56.0.4", "expo-linear-gradient": "~56.0.4", - "expo-linking": "~56.0.12", + "expo-linking": "~56.0.13", "expo-localization": "~56.0.6", - "expo-location": "~56.0.14", + "expo-location": "~56.0.15", "expo-move-to-back": "file:./modules/expo-move-to-back", - "expo-notifications": "~56.0.14", - "expo-router": "~56.2.7", + "expo-notifications": "~56.0.15", + "expo-router": "~56.2.8", "expo-screen-orientation": "~56.0.5", - "expo-sharing": "~56.0.14", + "expo-sharing": "~56.0.15", "expo-sqlite": "~56.0.4", "expo-ssl-trust": "file:./modules/expo-ssl-trust", "expo-status-bar": "~56.0.4", @@ -96,7 +96,7 @@ "@types/jest": "~29.5.14", "@types/react": "^19.2.15", "babel-plugin-transform-remove-console": "^6.9.4", - "expo-dev-client": "~56.0.16", + "expo-dev-client": "~56.0.18", "jest": "~29.7.0", "jest-expo": "~56.0.4", "patch-package": "^8.0.1", From 2b891f049787dd5ec15d8e6beca38db89f55bffc Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Tue, 2 Jun 2026 11:54:06 +1000 Subject: [PATCH 16/18] feat(tuned-in): tablet layouts + multi-decade builder; fix Intl timezone - For You re-flows into a responsive bento grid on tablets (lead mix spans 2 cells); phone keeps the hero/medium/compact stack - Build a Mix: multi-select decades (50s/60s + Earlier/Recent shortcuts), cross-product genre x decade fetch; embedded two-column panel on tablet with wrapping chip/decade clouds, sheet retained on phone - shared business logic extracted to useMixBuilder hook; sheet and panel are presentation-only - Jump Back In uses a 150px cover grid on tablet, scroller on phone - larger Tuned In section titles + more spacing - fix: set the @formatjs/intl-datetimeformat default timezone from the device (was defaulting to UTC), correcting the My Listening hour labels / peak-hour and all Intl-formatted times --- src/components/SectionTitle.tsx | 24 +- src/hooks/useMixBuilder.ts | 137 ++++ src/i18n/i18n.ts | 20 +- src/i18n/locales/en.json | 2 + src/screens/tuned-in.tsx | 607 +++++++++++------- src/services/__tests__/tunedInService.test.ts | 94 ++- src/services/tunedInService.ts | 124 ++-- 7 files changed, 726 insertions(+), 282 deletions(-) create mode 100644 src/hooks/useMixBuilder.ts diff --git a/src/components/SectionTitle.tsx b/src/components/SectionTitle.tsx index 766346e..5d3b59d 100644 --- a/src/components/SectionTitle.tsx +++ b/src/components/SectionTitle.tsx @@ -7,8 +7,21 @@ import { StyleSheet, Text } from 'react-native'; -export function SectionTitle({ title, color }: { title: string; color: string }) { - return {title}; +export function SectionTitle({ + title, + color, + large = false, +}: { + title: string; + color: string; + /** Larger, title-case heading for prominent feature pages (e.g. Tuned In). */ + large?: boolean; +}) { + return ( + + {title} + + ); } const styles = StyleSheet.create({ @@ -20,4 +33,11 @@ const styles = StyleSheet.create({ marginBottom: 10, marginLeft: 4, }, + sectionTitleLarge: { + fontSize: 22, + fontWeight: '700', + letterSpacing: 0.2, + marginBottom: 18, + marginLeft: 4, + }, }); diff --git a/src/hooks/useMixBuilder.ts b/src/hooks/useMixBuilder.ts new file mode 100644 index 0000000..7d5e344 --- /dev/null +++ b/src/hooks/useMixBuilder.ts @@ -0,0 +1,137 @@ +/** + * Stateful business logic for the "Build a Mix" feature, shared by both the + * phone bottom-sheet builder and the embedded tablet panel so the two stay + * presentation-only. Owns genre/decade selection, the genre search, and the + * play action; the pure era/mix helpers live in `tunedInService`. + */ + +import { useCallback, useMemo, useState } from 'react'; + +import { connectivityStore } from '../store/connectivityStore'; +import { genreStore } from '../store/genreStore'; +import { layoutPreferencesStore } from '../store/layoutPreferencesStore'; +import { offlineModeStore } from '../store/offlineModeStore'; +import { playTrack } from '../services/playerService'; +import { + decadeRangesForLabels, + fetchCustomMix, + fetchMixSongs, +} from '../services/tunedInService'; +import { selectionAsync } from '../utils/haptics'; + +/** Max genres a mix can combine. */ +export const MAX_SELECTED_GENRES = 3; + +export interface MixBuilder { + selectedGenres: string[]; + selectedDecades: string[]; + loading: boolean; + searchQuery: string; + setSearchQuery: (q: string) => void; + /** History genres + genres added via search, deduped. */ + displayGenres: string[]; + /** Server genres matching the current search query (excludes already-shown). */ + searchResults: string[]; + toggleGenre: (genre: string) => void; + selectSearchResult: (genre: string) => void; + toggleDecade: (label: string) => void; + /** Resolve the selection to songs and start playback. */ + play: () => Promise; +} + +export function useMixBuilder(availableGenres: string[]): MixBuilder { + const [selectedGenres, setSelectedGenres] = useState([]); + const [selectedDecades, setSelectedDecades] = useState([]); + const [loading, setLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [addedGenres, setAddedGenres] = useState([]); + + const serverGenres = genreStore((s) => s.genres); + + const displayGenres = useMemo(() => { + const availableSet = new Set(availableGenres.map((g) => g.toLowerCase())); + const extra = addedGenres.filter((g) => !availableSet.has(g.toLowerCase())); + return [...extra, ...availableGenres]; + }, [availableGenres, addedGenres]); + + const searchResults = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (query.length === 0) return []; + const displaySet = new Set(displayGenres.map((g) => g.toLowerCase())); + return serverGenres + .filter((g) => { + const name = g.value.toLowerCase(); + return name.includes(query) && !displaySet.has(name); + }) + .slice(0, 8) + .map((g) => g.value); + }, [searchQuery, serverGenres, displayGenres]); + + const toggleGenre = useCallback((genre: string) => { + setSelectedGenres((prev) => { + if (prev.includes(genre)) return prev.filter((g) => g !== genre); + if (prev.length >= MAX_SELECTED_GENRES) return prev; + return [...prev, genre]; + }); + }, []); + + const selectSearchResult = useCallback((genre: string) => { + selectionAsync(); + setAddedGenres((prev) => [genre, ...prev.filter((g) => g !== genre)]); + setSelectedGenres((prev) => { + if (prev.includes(genre)) return prev; + if (prev.length >= MAX_SELECTED_GENRES) return prev; + return [genre, ...prev]; + }); + setSearchQuery(''); + }, []); + + const toggleDecade = useCallback((label: string) => { + selectionAsync(); + setSelectedDecades((prev) => + prev.includes(label) ? prev.filter((d) => d !== label) : [...prev, label], + ); + }, []); + + const play = useCallback(async () => { + if (loading) return; + selectionAsync(); + setLoading(true); + try { + const online = + !offlineModeStore.getState().offlineMode && + connectivityStore.getState().isServerReachable; + const ll = layoutPreferencesStore.getState().listLength; + const decadeRanges = decadeRangesForLabels(selectedDecades); + + let songs; + if (selectedGenres.length === 0 && decadeRanges.length === 0) { + // Nothing picked — fully random "Mix It Up". + songs = await fetchMixSongs( + online ? { type: 'random', size: ll } : { type: 'offline' }, + ll, + ); + } else { + // Genre-only, era-only, or both (incl. multiple non-contiguous decades). + songs = await fetchCustomMix(selectedGenres, decadeRanges, online, ll); + } + if (songs.length > 0) await playTrack(songs[0], songs); + } finally { + setLoading(false); + } + }, [loading, selectedGenres, selectedDecades]); + + return { + selectedGenres, + selectedDecades, + loading, + searchQuery, + setSearchQuery, + displayGenres, + searchResults, + toggleGenre, + selectSearchResult, + toggleDecade, + play, + }; +} diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts index 8600959..8f0463d 100644 --- a/src/i18n/i18n.ts +++ b/src/i18n/i18n.ts @@ -45,7 +45,25 @@ import '@formatjs/intl-datetimeformat/locale-data/zh.js'; import i18next from 'i18next'; import { initReactI18next } from 'react-i18next'; -import { getLocales } from 'expo-localization'; +import { getCalendars, getLocales } from 'expo-localization'; + +// `@formatjs/intl-datetimeformat` (force-polyfilled above) defaults its +// timezone to UTC — without this, EVERY Intl-formatted time renders in UTC. +// The hour bucketing uses native `Date.getHours()` (already local), but the +// chart labels / "peak hour" text go through Intl, so they appeared shifted by +// the device's UTC offset (e.g. a 1 PM peak labelled "1 AM" for +12 users). +// Point the polyfill at the device timezone; `add-all-tz` is already loaded. +try { + const deviceTimeZone = getCalendars()[0]?.timeZone; + const DTF = Intl.DateTimeFormat as unknown as { + __setDefaultTimeZone?: (tz: string) => void; + }; + if (deviceTimeZone && typeof DTF.__setDefaultTimeZone === 'function') { + DTF.__setDefaultTimeZone(deviceTimeZone); + } +} catch { + // Native timezone unavailable (e.g. tests) — fall back to the UTC default. +} // Locale JSON imports — add new imports here when enabling a language import en from './locales/en.json'; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 72fc3a6..9b4d3cc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -965,6 +965,8 @@ "heavyRotation": "Heavy Rotation", "mostPlayedThisWeek": "Your most played this week", "decadeAny": "Any", + "decadeEarlier": "Earlier", + "decadeRecent": "Recent", "theDecade": "The {{decade}}", "monthJan": "Jan", "monthFeb": "Feb", diff --git a/src/screens/tuned-in.tsx b/src/screens/tuned-in.tsx index 441d33a..9d0732c 100644 --- a/src/screens/tuned-in.tsx +++ b/src/screens/tuned-in.tsx @@ -11,6 +11,7 @@ import { Text, TextInput, View, + useWindowDimensions, } from 'react-native'; import { useTranslation } from 'react-i18next'; import Animated, { @@ -29,15 +30,14 @@ import { GradientBackground } from '../components/GradientBackground'; import { BottomChrome } from '../components/BottomChrome'; import { SectionTitle } from '../components/SectionTitle'; import { - DECADES, - fetchCustomMix, + SELECTABLE_DECADES, fetchMixSongs, generateMixes, type MixDefinition, } from '../services/tunedInService'; import { getOfflineSongsByGenre } from '../services/searchService'; import { playTrack } from '../services/playerService'; -import { getAlbum, type Child } from '../services/subsonicService'; +import { getAlbum } from '../services/subsonicService'; import { albumListsStore } from '../store/albumListsStore'; import { completedScrobbleStore } from '../store/completedScrobbleStore'; import { connectivityStore } from '../store/connectivityStore'; @@ -45,6 +45,8 @@ import { favoritesStore } from '../store/favoritesStore'; import { genreStore } from '../store/genreStore'; import { layoutPreferencesStore } from '../store/layoutPreferencesStore'; import { offlineModeStore } from '../store/offlineModeStore'; +import { getGridColumns } from '../hooks/useGridColumns'; +import { MAX_SELECTED_GENRES, useMixBuilder, type MixBuilder } from '../hooks/useMixBuilder'; import { useRefreshControlKey } from '../hooks/useRefreshControlKey'; import { useTheme } from '../hooks/useTheme'; import { useTransitionComplete } from '../hooks/useTransitionComplete'; @@ -54,22 +56,26 @@ import { selectionAsync } from '../utils/haptics'; import { minDelay } from '../utils/stringHelpers'; import { absoluteFill } from '../utils/styles'; -const MAX_SELECTED_GENRES = 3; const MAX_BUILDER_GENRES = 30; const JUMP_BACK_IN_SIZE = 150; const JUMP_BACK_IN_IMAGE = 80; +/** Tablet Jump Back In artwork — matches the home screen's album cover width. */ +const JUMP_BACK_IN_TABLET_IMAGE = 150; + +/* Tablet "For You" bento grid. Phone keeps the hero / medium-row / compact-list + stack; tablets (min dimension >= 600) re-flow mixes into a responsive grid so + cards stop stretching into thin full-width bars. */ +const SCREEN_H_PADDING = 16; +const BENTO_GAP = 12; +const BENTO_TILE_H = 150; +const TABLET_MIN_DIMENSION = 600; +/** Embedded builder goes two-column above this content width; single below. */ +const BUILDER_TWO_COL_MIN_WIDTH = 700; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ -function isOnline(): boolean { - const { offlineMode } = offlineModeStore.getState(); - if (offlineMode) return false; - const { isServerReachable } = connectivityStore.getState(); - return isServerReachable; -} - function genreColor(genre: string): string { let hash = 0; for (let i = 0; i < genre.length; i++) { @@ -166,27 +172,30 @@ function useMixCardPlayback(mix: MixDefinition, index: number) { const HeroMixCard = memo(function HeroMixCard({ mix, index, + fillHeight = false, }: { mix: MixDefinition; index: number; + /** Fill a fixed-height grid cell (tablet bento) instead of using minHeight. */ + fillHeight?: boolean; }) { const { loading, error, handlePress, handlePressIn, handlePressOut, animatedStyle, gradientAnimatedStyle } = useMixCardPlayback(mix, index); return ( - + - + {/* Decorative circles */} @@ -222,9 +231,12 @@ const HeroMixCard = memo(function HeroMixCard({ const MediumMixCard = memo(function MediumMixCard({ mix, index, + fillHeight = false, }: { mix: MixDefinition; index: number; + /** Fill a fixed-height grid cell (tablet bento) instead of using minHeight. */ + fillHeight?: boolean; }) { const { loading, error, handlePress, handlePressIn, handlePressOut, animatedStyle, gradientAnimatedStyle } = useMixCardPlayback(mix, index); @@ -242,7 +254,7 @@ const MediumMixCard = memo(function MediumMixCard({ colors={mix.gradientColors} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} - style={styles.mediumGradient} + style={[styles.mediumGradient, fillHeight && styles.fillFlex]} > {/* Small play button top-right */} @@ -379,12 +391,18 @@ const BuildMixButton = memo(function BuildMixButton({ const JumpBackInItem = memo(function JumpBackInItem({ album, colors, + size = JUMP_BACK_IN_IMAGE, }: { album: { id: string; name?: string; coverArt?: string }; colors: ThemeColors; + /** Artwork edge length. Phone uses the compact default; the tablet grid + passes a larger value for a more visual layout. */ + size?: number; }) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); + // Fetch a sharper variant for the larger tablet tiles. + const coverFetchSize = size > 120 ? 300 : JUMP_BACK_IN_SIZE; const handlePress = useCallback(async () => { if (loading) return; @@ -402,20 +420,20 @@ const JumpBackInItem = memo(function JumpBackInItem({ }, [album.id, loading]); return ( - + {loading && ( - + )} 120 && styles.jumpTitleLarge]} numberOfLines={1} > {album.name ?? t('unknownAlbum')} @@ -553,149 +571,174 @@ const GenreSearchResult = memo(function GenreSearchResult({ }); /* ------------------------------------------------------------------ */ -/* BuildMixSheetContent */ +/* Build-a-Mix presentation (shared logic in useMixBuilder) */ /* ------------------------------------------------------------------ */ -const BuildMixSheetContent = memo(function BuildMixSheetContent({ +/** Genre search input — identical in the sheet and the panel. */ +const GenreSearchField = memo(function GenreSearchField({ colors, - availableGenres, + value, + onChange, }: { colors: ThemeColors; - availableGenres: string[]; + value: string; + onChange: (q: string) => void; }) { const { t } = useTranslation(); - const [selectedGenres, setSelectedGenres] = useState([]); - const [selectedDecadeIndex, setSelectedDecadeIndex] = useState(0); - const [loading, setLoading] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [addedGenres, setAddedGenres] = useState([]); - const chipScrollRef = useRef(null); - const online = isOnline(); - - const serverGenres = genreStore((s) => s.genres); + return ( + + + + {value.length > 0 && ( + onChange('')} hitSlop={8}> + + + )} + + ); +}); - // Merge added genres (from search) to the front of the chip list - const displayGenres = useMemo(() => { - const availableSet = new Set(availableGenres.map((g) => g.toLowerCase())); - const extraGenres = addedGenres.filter((g) => !availableSet.has(g.toLowerCase())); - return [...extraGenres, ...availableGenres]; - }, [availableGenres, addedGenres]); - - // Filter full server genre list for search - const searchResults = useMemo(() => { - const query = searchQuery.trim().toLowerCase(); - if (query.length === 0) return []; - - const displaySet = new Set(displayGenres.map((g) => g.toLowerCase())); - - return serverGenres - .filter((g) => { - const name = g.value.toLowerCase(); - return name.includes(query) && !displaySet.has(name); - }) - .slice(0, 8) - .map((g) => g.value); - }, [searchQuery, serverGenres, displayGenres]); - - const handleToggleGenre = useCallback((genre: string) => { - setSelectedGenres((prev) => { - if (prev.includes(genre)) return prev.filter((g) => g !== genre); - if (prev.length >= MAX_SELECTED_GENRES) return prev; - return [...prev, genre]; - }); - }, []); +/** Genre search-results dropdown (renders nothing when empty). */ +const GenreSearchResults = memo(function GenreSearchResults({ + colors, + results, + onSelect, +}: { + colors: ThemeColors; + results: string[]; + onSelect: (genre: string) => void; +}) { + if (results.length === 0) return null; + return ( + + {results.map((genre) => ( + + ))} + + ); +}); - const handleSelectSearchResult = useCallback((genre: string) => { - selectionAsync(); - setAddedGenres((prev) => [genre, ...prev.filter((g) => g !== genre)]); - setSelectedGenres((prev) => { - if (prev.includes(genre)) return prev; - if (prev.length >= MAX_SELECTED_GENRES) return prev; - return [genre, ...prev]; - }); - setSearchQuery(''); - chipScrollRef.current?.scrollTo({ x: 0, animated: true }); - }, []); +/** Primary "Play Mix" action button. */ +const PlayMixButton = memo(function PlayMixButton({ + colors, + loading, + onPress, +}: { + colors: ThemeColors; + loading: boolean; + onPress: () => void; +}) { + const { t } = useTranslation(); + return ( + + {loading ? ( + + ) : ( + <> + + {t('playMix')} + + )} + + ); +}); - const handleDecadePress = useCallback((index: number) => { - selectionAsync(); - setSelectedDecadeIndex(index); - }, []); +/* Shared chip / pill renderers so the sheet and panel build identical controls + from the shared builder state. */ +function renderGenreChips(builder: MixBuilder, colors: ThemeColors) { + return builder.displayGenres.map((genre) => ( + + )); +} - const handlePlay = useCallback(async () => { - if (loading) return; - selectionAsync(); - setLoading(true); - try { - let songs: Child[]; - const ll = layoutPreferencesStore.getState().listLength; - const decade = DECADES[selectedDecadeIndex]; - const hasDecade = decade.fromYear !== undefined && decade.toYear !== undefined; - if (selectedGenres.length === 0 && !hasDecade) { - // No selection at all — fully random "Mix It Up" - const strategy = online - ? { type: 'random' as const, size: ll } - : { type: 'offline' as const }; - songs = await fetchMixSongs(strategy, ll); - } else { - // Era-only, genre-only, or both — fetchCustomMix handles all three. - songs = await fetchCustomMix( - selectedGenres, - decade.fromYear, - decade.toYear, - online, - ll, - ); - } - if (songs.length > 0) { - await playTrack(songs[0], songs); - } - } finally { - setLoading(false); - } - }, [selectedGenres, selectedDecadeIndex, loading, online]); +function renderDecadePills( + builder: MixBuilder, + colors: ThemeColors, + t: (key: string) => string, +) { + return SELECTABLE_DECADES.map((decade) => ( + builder.toggleDecade(decade.label)} + colors={colors} + /> + )); +} +/** "Genres" heading + selected-count, sized small (sheet) or large (panel). */ +const GenresHeading = memo(function GenresHeading({ + builder, + colors, + large = false, +}: { + builder: MixBuilder; + colors: ThemeColors; + large?: boolean; +}) { + const { t } = useTranslation(); return ( - - {/* Genre chips */} - - {selectedGenres.length > 0 ? t('genresWithCount', { selected: selectedGenres.length, max: MAX_SELECTED_GENRES }) : t('genres')} - + + {builder.selectedGenres.length > 0 + ? t('genresWithCount', { selected: builder.selectedGenres.length, max: MAX_SELECTED_GENRES }) + : t('genres')} + + ); +}); - {/* Genre search input */} - - - - {searchQuery.length > 0 && ( - setSearchQuery('')} hitSlop={8}> - - - )} - - - {/* Search results dropdown */} - {searchResults.length > 0 && ( - - {searchResults.map((genre) => ( - - ))} - - )} +/* ------------------------------------------------------------------ */ +/* MixBuilderSheet — phone (bottom sheet) presentation */ +/* ------------------------------------------------------------------ */ + +const MixBuilderSheet = memo(function MixBuilderSheet({ + colors, + availableGenres, +}: { + colors: ThemeColors; + availableGenres: string[]; +}) { + const { t } = useTranslation(); + const builder = useMixBuilder(availableGenres); + const chipScrollRef = useRef(null); + const onSelectResult = useCallback( + (genre: string) => { + builder.selectSearchResult(genre); + chipScrollRef.current?.scrollTo({ x: 0, animated: true }); + }, + [builder], + ); + + return ( + + + + - {displayGenres.map((genre) => ( - - ))} + {renderGenreChips(builder, colors)} - {/* Decade selector */} {t('decade')} - - {DECADES.map((decade, i) => ( - handleDecadePress(i)} - colors={colors} - /> - ))} + + {renderDecadePills(builder, colors, t)} - {/* Play button */} - + + + ); +}); + +/* ------------------------------------------------------------------ */ +/* MixBuilderPanel — embedded tablet presentation */ +/* ------------------------------------------------------------------ */ + +const MixBuilderPanel = memo(function MixBuilderPanel({ + colors, + availableGenres, +}: { + colors: ThemeColors; + availableGenres: string[]; +}) { + const { t } = useTranslation(); + const builder = useMixBuilder(availableGenres); + const { width } = useWindowDimensions(); + // Genres | Decades side-by-side once there's room; stacked below that. + const twoColumn = width - SCREEN_H_PADDING * 2 >= BUILDER_TWO_COL_MIN_WIDTH; + + const genresSection = ( + <> + + + + {renderGenreChips(builder, colors)} + + ); + + const decadesSection = ( + <> + - {loading ? ( - - ) : ( - <> - - {t('playMix')} - - )} - + {t('decade')} + + {renderDecadePills(builder, colors, t)} + + ); - - + return ( + + {twoColumn ? ( + + {genresSection} + {decadesSection} + + ) : ( + <> + {genresSection} + {decadesSection} + + )} + + ); }); @@ -768,6 +832,15 @@ export function TunedInScreen() { const transitionComplete = useTransitionComplete(); const headerHeight = useContext(HeaderHeightContext) ?? 0; const refreshControlKey = useRefreshControlKey(); + const { width, height: screenHeight } = useWindowDimensions(); + + // Tablet "For You" bento: responsive columns with the lead mix spanning 2. + const isTablet = Math.min(width, screenHeight) >= TABLET_MIN_DIMENSION; + const bentoColumns = getGridColumns(width); + const bentoCellW = Math.floor( + (width - SCREEN_H_PADDING * 2 - (bentoColumns - 1) * BENTO_GAP) / bentoColumns, + ); + const bentoHeroW = bentoCellW * 2 + BENTO_GAP; const aggregates = completedScrobbleStore((s) => s.aggregates); const completedScrobbles = completedScrobbleStore((s) => s.completedScrobbles); @@ -893,49 +966,86 @@ export function TunedInScreen() { {/* For You section */} {mixes.length > 0 && ( - - - {/* Hero card */} - {heroMix && } - - {/* Medium cards side by side */} - {mediumMixes.length > 0 && ( - - {mediumMixes.map((mix, i) => ( - - ))} - - )} + + {isTablet ? ( + /* Tablet: responsive bento — lead mix spans 2 cells, the rest are + equal gradient tiles, so cards fill the width instead of + stretching into thin full-width bars. */ + + {mixes.map((mix, i) => ( + + {i === 0 ? ( + + ) : ( + + )} + + ))} + + ) : ( + /* Phone: unchanged hero / medium-row / compact-list stack. */ + + {heroMix && } + + {mediumMixes.length > 0 && ( + + {mediumMixes.map((mix, i) => ( + + ))} + + )} - {/* Compact cards */} - {compactMixes.map((mix, i) => ( - - ))} - + {compactMixes.map((mix, i) => ( + + ))} + + )} )} - {/* Create section */} + {/* Create section — embedded builder on tablet, sheet trigger on phone */} {hasBuilder && ( - - + + {isTablet ? ( + + ) : ( + + )} )} {/* Jump back in section */} {showJumpBackIn && ( - - - {recentlyPlayed.map((album) => ( - - ))} - + + {isTablet ? ( + /* Tablet: a grid of larger artwork at the home-screen cover size. */ + + {recentlyPlayed.map((album) => ( + + ))} + + ) : ( + /* Phone: compact horizontal scroller. */ + + {recentlyPlayed.map((album) => ( + + ))} + + )} )} @@ -945,7 +1055,7 @@ export function TunedInScreen() { {/* Build a Mix bottom sheet */} {t('buildAMix')} - + @@ -969,11 +1079,52 @@ const styles = StyleSheet.create({ alignItems: 'center', }, section: { - marginBottom: 24, + marginBottom: 36, }, mixList: { gap: 12, }, + /* Tablet bento grid — equal-gap wrap; lead tile spans 2 cells (see render). */ + bentoGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: BENTO_GAP, + alignItems: 'flex-start', + }, + /** Fills a fixed-height bento cell instead of relying on the card's minHeight. */ + fillFlex: { + flex: 1, + }, + /* Embedded (tablet) builder layout */ + builderColumns: { + flexDirection: 'row', + gap: 28, + }, + builderColGenres: { + flex: 3, + }, + builderColDecades: { + flex: 2, + }, + builderLabelLarge: { + fontSize: 17, + fontWeight: '700', + marginBottom: 12, + }, + builderLabelStacked: { + marginTop: 20, + }, + builderChipCloud: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginTop: 2, + }, + decadeCloud: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, /* Hero card */ heroOuter: { @@ -1217,6 +1368,16 @@ const styles = StyleSheet.create({ textAlign: 'center', marginTop: 6, }, + jumpTitleLarge: { + fontSize: 14, + marginTop: 8, + }, + /* Tablet: larger artwork laid out as a grid, aligned to the For You columns. */ + jumpGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: BENTO_GAP, + }, /* Sheet */ sheetTitle: { diff --git a/src/services/__tests__/tunedInService.test.ts b/src/services/__tests__/tunedInService.test.ts index 518136d..7fe3883 100644 --- a/src/services/__tests__/tunedInService.test.ts +++ b/src/services/__tests__/tunedInService.test.ts @@ -712,8 +712,8 @@ describe('fetchCustomMix', () => { it('fetches a single genre with decade filter', async () => { mockGetRandomSongsFiltered.mockResolvedValue(songs); - const result = await fetchCustomMix(['Rock'], 1990, 1999, true); - expect(result).toEqual(songs); + const result = await fetchCustomMix(['Rock'], [{ fromYear: 1990, toYear: 1999 }], true); + expect(result).toEqual(expect.arrayContaining(songs)); expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith({ size: 20, genre: 'Rock', @@ -724,8 +724,8 @@ describe('fetchCustomMix', () => { it('fetches a single genre without decade filter', async () => { mockGetRandomSongsFiltered.mockResolvedValue(songs); - const result = await fetchCustomMix(['Rock'], undefined, undefined, true); - expect(result).toEqual(songs); + const result = await fetchCustomMix(['Rock'], [], true); + expect(result).toEqual(expect.arrayContaining(songs)); expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith({ size: 20, genre: 'Rock', @@ -739,8 +739,8 @@ describe('fetchCustomMix', () => { // getRandomSongsFiltered with the year window and no genre. it('fetches with era filter only when no genres are selected', async () => { mockGetRandomSongsFiltered.mockResolvedValue(songs); - const result = await fetchCustomMix([], 2000, 2009, true); - expect(result).toEqual(songs); + const result = await fetchCustomMix([], [{ fromYear: 2000, toYear: 2009 }], true); + expect(result).toEqual(expect.arrayContaining(songs)); expect(mockGetRandomSongsFiltered).toHaveBeenCalledTimes(1); expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith({ size: 20, @@ -754,27 +754,27 @@ describe('fetchCustomMix', () => { .mockResolvedValueOnce([makeSong({ id: 'a' })]) .mockResolvedValueOnce([makeSong({ id: 'b' })]); - const result = await fetchCustomMix(['Rock', 'Jazz'], undefined, undefined, true); + const result = await fetchCustomMix(['Rock', 'Jazz'], [], true); expect(result.length).toBe(2); expect(mockGetRandomSongsFiltered).toHaveBeenCalledTimes(2); }); it('uses offline songs when not online', async () => { mockGetOfflineSongsByGenre.mockReturnValue(songs); - const result = await fetchCustomMix(['Rock'], undefined, undefined, false); + const result = await fetchCustomMix(['Rock'], [], false); expect(result.length).toBeLessThanOrEqual(20); expect(mockGetOfflineSongsByGenre).toHaveBeenCalledWith('Rock'); }); it('handles null API response gracefully', async () => { mockGetRandomSongsFiltered.mockResolvedValue(null); - const result = await fetchCustomMix(['Rock'], undefined, undefined, true); + const result = await fetchCustomMix(['Rock'], [], true); expect(result).toEqual([]); }); it('uses custom listLength for single genre', async () => { mockGetRandomSongsFiltered.mockResolvedValue(songs); - await fetchCustomMix(['Rock'], 1990, 1999, true, 50); + await fetchCustomMix(['Rock'], [{ fromYear: 1990, toYear: 1999 }], true, 50); expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith({ size: 50, genre: 'Rock', @@ -785,7 +785,7 @@ describe('fetchCustomMix', () => { it('splits custom listLength across multiple genres', async () => { mockGetRandomSongsFiltered.mockResolvedValue([makeSong()]); - await fetchCustomMix(['Rock', 'Jazz', 'Pop'], undefined, undefined, true, 50); + await fetchCustomMix(['Rock', 'Jazz', 'Pop'], [], true, 50); // Math.ceil(50 / 3) = 17 expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith( expect.objectContaining({ size: 17, genre: 'Rock' }), @@ -795,7 +795,7 @@ describe('fetchCustomMix', () => { it('uses custom listLength for offline slice', async () => { const manySongs = Array.from({ length: 100 }, (_, i) => makeSong({ id: `s${i}` })); mockGetOfflineSongsByGenre.mockReturnValue(manySongs); - const result = await fetchCustomMix(['Rock'], undefined, undefined, false, 50); + const result = await fetchCustomMix(['Rock'], [], false, 50); expect(result.length).toBe(50); }); @@ -805,9 +805,52 @@ describe('fetchCustomMix', () => { mockGetRandomSongsFiltered .mockResolvedValueOnce(genreA) .mockResolvedValueOnce(genreB); - const result = await fetchCustomMix(['Rock', 'Jazz'], undefined, undefined, true, 15); + const result = await fetchCustomMix(['Rock', 'Jazz'], [], true, 15); expect(result.length).toBe(15); }); + + it('queries each selected decade separately (non-contiguous eras)', async () => { + mockGetRandomSongsFiltered.mockResolvedValue([makeSong()]); + await fetchCustomMix( + ['Rock'], + [{ fromYear: 1970, toYear: 1979 }, { fromYear: 1990, toYear: 1999 }], + true, + ); + // 1 genre × 2 decades = 2 separate queries (a single year window can't + // express 70s + 90s without also pulling in the 80s). + expect(mockGetRandomSongsFiltered).toHaveBeenCalledTimes(2); + expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith( + expect.objectContaining({ genre: 'Rock', fromYear: 1970, toYear: 1979 }), + ); + expect(mockGetRandomSongsFiltered).toHaveBeenCalledWith( + expect.objectContaining({ genre: 'Rock', fromYear: 1990, toYear: 1999 }), + ); + }); + + it('fans out across the genre × decade cross-product', async () => { + mockGetRandomSongsFiltered.mockResolvedValue([makeSong()]); + await fetchCustomMix( + ['Rock', 'Jazz'], + [{ fromYear: 1980, toYear: 1989 }, { fromYear: 2000, toYear: 2009 }], + true, + ); + // 2 genres × 2 decades = 4 combos + expect(mockGetRandomSongsFiltered).toHaveBeenCalledTimes(4); + }); + + it('filters offline songs by selected decades client-side', async () => { + mockGetOfflineSongsByGenre.mockReturnValue([ + makeSong({ id: 'old', year: 1975 }), + makeSong({ id: 'mid', year: 1985 }), + makeSong({ id: 'new', year: 1995 }), + ]); + const result = await fetchCustomMix( + ['Rock'], + [{ fromYear: 1970, toYear: 1979 }, { fromYear: 1990, toYear: 1999 }], + false, + ); + expect(result.map((s) => s.id).sort()).toEqual(['new', 'old']); // 1985 excluded + }); }); /* ------------------------------------------------------------------ */ @@ -893,16 +936,33 @@ describe('getTimeGradient', () => { /* ------------------------------------------------------------------ */ describe('DECADES', () => { - it('has 7 entries starting with "Any"', () => { - expect(DECADES).toHaveLength(7); + it('starts with "Any" (no era filter)', () => { expect(DECADES[0].label).toBe('Any'); expect(DECADES[0].fromYear).toBeUndefined(); expect(DECADES[0].toYear).toBeUndefined(); }); - it('each decade has a 10-year range', () => { - for (const decade of DECADES.slice(1)) { + it('named decades (50s–20s) span exactly 10 years', () => { + for (const decade of DECADES.filter((d) => /^\d0s$/.test(d.label))) { expect(decade.toYear! - decade.fromYear!).toBe(9); } }); + + it('includes 50s and 60s', () => { + const labels = DECADES.map((d) => d.label); + expect(labels).toContain('50s'); + expect(labels).toContain('60s'); + }); + + it('has an "Earlier" shortcut for everything before the 50s', () => { + const earlier = DECADES.find((d) => d.label === 'Earlier'); + expect(earlier).toBeDefined(); + expect(earlier!.toYear).toBe(1949); + }); + + it('has a "Recent" shortcut covering the last several years', () => { + const recent = DECADES.find((d) => d.label === 'Recent'); + expect(recent).toBeDefined(); + expect(recent!.toYear! - recent!.fromYear!).toBeGreaterThanOrEqual(4); + }); }); diff --git a/src/services/tunedInService.ts b/src/services/tunedInService.ts index 23eb637..e882c66 100644 --- a/src/services/tunedInService.ts +++ b/src/services/tunedInService.ts @@ -467,55 +467,69 @@ export async function fetchMixSongs(strategy: FetchStrategy, listLength = 20): P /* Custom mix builder */ /* ------------------------------------------------------------------ */ +/** A selected era. Both bounds undefined means "any era". */ +export interface DecadeRange { + fromYear?: number; + toYear?: number; +} + export async function fetchCustomMix( genres: string[], - fromYear?: number, - toYear?: number, + decades: DecadeRange[], isOnline = true, listLength = 20, ): Promise { + // Only ranges with real bounds constrain the era; ignore "any" entries. + const ranges = decades.filter( + (d) => d.fromYear !== undefined && d.toYear !== undefined, + ); + if (!isOnline) { - const results: Child[] = []; - for (const genre of genres) { - const songs = getOfflineSongsByGenre(genre); - results.push(...songs); + let pool: Child[] = []; + if (genres.length === 0) { + pool = [...getOfflineSongsAll()]; + } else { + for (const genre of genres) pool.push(...getOfflineSongsByGenre(genre)); + } + // Decades can be non-contiguous (e.g. 70s + 90s), so filter the offline + // pool client-side against any selected range rather than a single window. + if (ranges.length > 0) { + pool = pool.filter( + (s) => + s.year != null && + ranges.some((r) => s.year! >= r.fromYear! && s.year! <= r.toYear!), + ); } - return shuffleArray(results).slice(0, listLength); + return shuffleArray(pool).slice(0, listLength); } - // Era-only (no genre): one Subsonic call with the year window. - if (genres.length === 0) { - const songs = await getRandomSongsFiltered({ - size: listLength, - fromYear, - toYear, - }); - return songs ?? []; - } + // Online: fan out across the genre × era cross-product. Each axis falls + // back to a single "any" slot so genre-only, era-only, and both work the + // same way. Non-contiguous decades each get their own server query. + const genreSlots: (string | undefined)[] = genres.length > 0 ? genres : [undefined]; + const eraSlots: DecadeRange[] = ranges.length > 0 ? ranges : [{}]; - if (genres.length === 1) { - const songs = await getRandomSongsFiltered({ - size: listLength, - genre: genres[0], - fromYear, - toYear, - }); - return songs ?? []; + const combos: Array<{ genre?: string } & DecadeRange> = []; + for (const genre of genreSlots) { + for (const era of eraSlots) { + combos.push({ genre, fromYear: era.fromYear, toYear: era.toYear }); + } } - // Multiple genres: split evenly - const perGenre = Math.ceil(listLength / genres.length); - const results: Child[] = []; - for (const genre of genres) { - const songs = await getRandomSongsFiltered({ - size: perGenre, - genre, - fromYear, - toYear, - }); - if (songs) results.push(...songs); - } - return shuffleArray(results).slice(0, listLength); + const perCombo = Math.max(1, Math.ceil(listLength / combos.length)); + const batches = await Promise.all( + combos.map((c) => + getRandomSongsFiltered({ + size: perCombo, + genre: c.genre, + fromYear: c.fromYear, + toYear: c.toYear, + }) + .then((songs) => songs ?? []) + .catch(() => []), + ), + ); + return shuffleArray(batches.flat()).slice(0, listLength); } /* ------------------------------------------------------------------ */ @@ -543,12 +557,44 @@ export function getTimeGradient(hour: number): [string, string] { /* Decade definitions for the builder */ /* ------------------------------------------------------------------ */ -export const DECADES = [ +export interface BuilderDecade { + /** Stable identity + default display string. */ + label: string; + /** i18n key for word labels (Earlier/Recent); numeric decades render `label`. */ + i18nKey?: string; + /** Both undefined means "any era". */ + fromYear?: number; + toYear?: number; +} + +// "Recent" tracks the rolling last ~5 years, so its range is resolved at module +// load from the current year rather than hard-coded. +const CURRENT_YEAR = new Date().getFullYear(); + +export const DECADES: BuilderDecade[] = [ { label: 'Any', fromYear: undefined, toYear: undefined }, + { label: 'Earlier', i18nKey: 'decadeEarlier', fromYear: 0, toYear: 1949 }, + { label: '50s', fromYear: 1950, toYear: 1959 }, + { label: '60s', fromYear: 1960, toYear: 1969 }, { label: '70s', fromYear: 1970, toYear: 1979 }, { label: '80s', fromYear: 1980, toYear: 1989 }, { label: '90s', fromYear: 1990, toYear: 1999 }, { label: '00s', fromYear: 2000, toYear: 2009 }, { label: '10s', fromYear: 2010, toYear: 2019 }, { label: '20s', fromYear: 2020, toYear: 2029 }, -] as const; + { label: 'Recent', i18nKey: 'decadeRecent', fromYear: CURRENT_YEAR - 4, toYear: CURRENT_YEAR + 1 }, +]; + +/** Decades the builder offers as multi-select pills — drops the "Any" sentinel + (no decades selected already means "any era", mirroring genres). */ +export const SELECTABLE_DECADES: BuilderDecade[] = DECADES.filter( + (d) => d.fromYear !== undefined, +); + +/** Map selected decade labels to the year ranges `fetchCustomMix` expects. */ +export function decadeRangesForLabels(labels: string[]): DecadeRange[] { + return SELECTABLE_DECADES.filter((d) => labels.includes(d.label)).map((d) => ({ + fromYear: d.fromYear, + toYear: d.toYear, + })); +} From 2913d0695e6f30a8f2ca41c6c88e5dc8ae97a81f Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Tue, 2 Jun 2026 15:10:38 +1000 Subject: [PATCH 17/18] feat(downloads): add Download Full Library (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-tap download of the entire server library from Settings → Storage → Downloaded Music, reusing the standard download queue. - refresh album/playlist lists, then enqueue every album followed by every playlist (sequential awaits; dedup + already-cached skipping handled by the existing enqueue path) - shared logic in fullLibraryDownloadService + a small fullLibraryDownloadStore for live progress - require an empty download queue before starting; if anything is queued, prompt the user to clear it first - Cancel stops adding more AND clears the download queue (existing clearDownloadQueue) - error feedback (alert) if preparing fails or items can't be queued - card button uses the shared settingsStyles to match other settings actions --- .../settings/DownloadedMusicCard.tsx | 117 ++++++++++++++++- src/i18n/locales/en.json | 12 ++ .../fullLibraryDownloadService.test.ts | 123 ++++++++++++++++++ src/services/fullLibraryDownloadService.ts | 87 +++++++++++++ src/store/fullLibraryDownloadStore.ts | 60 +++++++++ 5 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 src/services/__tests__/fullLibraryDownloadService.test.ts create mode 100644 src/services/fullLibraryDownloadService.ts create mode 100644 src/store/fullLibraryDownloadStore.ts diff --git a/src/components/settings/DownloadedMusicCard.tsx b/src/components/settings/DownloadedMusicCard.tsx index bae6c23..88bd4f6 100644 --- a/src/components/settings/DownloadedMusicCard.tsx +++ b/src/components/settings/DownloadedMusicCard.tsx @@ -1,16 +1,28 @@ import Ionicons from '@react-native-vector-icons/ionicons/static'; import { useRouter } from 'expo-router'; -import { useCallback, useState } from 'react'; -import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useCallback, useEffect, useState } from 'react'; +import { ActivityIndicator, Modal, Pressable, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../../hooks/useTheme'; +import { useThemedAlert } from '../../hooks/useThemedAlert'; import { settingsStyles } from '../../styles/settingsStyles'; +import { albumLibraryStore } from '../../store/albumLibraryStore'; +import { connectivityStore } from '../../store/connectivityStore'; +import { fullLibraryDownloadStore } from '../../store/fullLibraryDownloadStore'; import { musicCacheStore, type MaxConcurrentDownloads, } from '../../store/musicCacheStore'; +import { offlineModeStore } from '../../store/offlineModeStore'; +import { playlistLibraryStore } from '../../store/playlistLibraryStore'; +import { + canDownloadFullLibrary, + enqueueFullLibraryDownload, +} from '../../services/fullLibraryDownloadService'; +import { clearDownloadQueue } from '../../services/musicCacheService'; +import { fireAndForget } from '../../utils/fireAndForget'; import { formatBytes } from '../../utils/formatters'; import { SettingsSectionTitle } from './SettingsSectionTitle'; @@ -19,6 +31,7 @@ const CONCURRENT_OPTIONS: MaxConcurrentDownloads[] = [1, 3, 5]; export function DownloadedMusicCard() { const { t } = useTranslation(); const { colors } = useTheme(); + const { alert } = useThemedAlert(); const router = useRouter(); const insets = useSafeAreaInsets(); const [sheetVisible, setSheetVisible] = useState(false); @@ -29,11 +42,68 @@ export function DownloadedMusicCard() { const musicQueueCount = musicCacheStore((s) => s.downloadQueue.length); const maxConcurrentDownloads = musicCacheStore((s) => s.maxConcurrentDownloads); + // Full-library download progress / availability. + const fullLib = fullLibraryDownloadStore(); + const online = + !offlineModeStore((s) => s.offlineMode) && connectivityStore((s) => s.isServerReachable); + const handleSelect = useCallback((value: MaxConcurrentDownloads) => { musicCacheStore.getState().setMaxConcurrentDownloads(value); setSheetVisible(false); }, []); + const handleDownloadFullLibrary = useCallback(() => { + if (!canDownloadFullLibrary()) { + alert(t('downloadFullLibrary'), t('downloadFullLibraryOffline')); + return; + } + // The full-library download needs a clean queue to track its own progress. + // If anything is queued (in-progress, errored, or waiting), tell the user to + // clear it themselves first and stop here. + if (musicQueueCount > 0) { + alert( + t('downloadFullLibraryQueueNotEmptyTitle'), + t('downloadFullLibraryQueueNotEmptyBody'), + ); + return; + } + + const albums = albumLibraryStore.getState().albums.length; + const playlists = playlistLibraryStore.getState().playlists.length; + alert( + t('downloadFullLibraryConfirmTitle'), + t('downloadFullLibraryConfirmBody', { albums, playlists }), + [ + { text: t('cancel'), style: 'cancel' }, + { + text: t('downloadFullLibraryConfirm'), + style: 'default', + onPress: () => fireAndForget(enqueueFullLibraryDownload(), 'fullLibraryDownload'), + }, + ], + ); + }, [alert, t, musicQueueCount]); + + // Cancelling stops adding more AND clears whatever was queued so far. + const handleCancelFullLibrary = useCallback(() => { + fullLibraryDownloadStore.getState().cancel(); + clearDownloadQueue(); + }, []); + + // Surface preparing/queueing failures to the user, then clear the flag. + useEffect(() => { + if (fullLib.error) { + alert(t('downloadFullLibrary'), fullLib.error); + fullLibraryDownloadStore.getState().clearError(); + } + }, [fullLib.error, alert, t]); + + const fullLibProgressLabel = fullLib.phase === 'preparing' + ? t('preparingFullLibrary') + : fullLib.albumsQueued < fullLib.albumsTotal + ? t('addingAlbumsToQueue', { queued: fullLib.albumsQueued, total: fullLib.albumsTotal }) + : t('addingPlaylistsToQueue', { queued: fullLib.playlistsQueued, total: fullLib.playlistsTotal }); + return ( <> @@ -100,6 +170,49 @@ export function DownloadedMusicCard() { + + {/* Download Full Library — one-shot that queues every album + playlist */} + {fullLib.active ? ( + <> + + + + {fullLibProgressLabel} + + + + [ + settingsStyles.actionRowButton, + { borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth }, + pressed && settingsStyles.pressed, + ]} + > + + {t('cancel')} + + + + + ) : ( + + [ + settingsStyles.actionRowButton, + { backgroundColor: colors.primary }, + (!online || pressed) && settingsStyles.pressed, + ]} + > + + + {t('downloadFullLibrary')} + + + + )} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9b4d3cc..378e6fc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -415,6 +415,18 @@ "browseDownloadedMusic": "Browse Downloaded Music", "downloadQueue": "Download Queue", "downloadQueueWithCount": "Download Queue ({{count}})", + "downloadFullLibrary": "Download Full Library", + "downloadFullLibraryConfirmTitle": "Download entire library?", + "downloadFullLibraryConfirmBody": "This queues all {{albums}} albums and {{playlists}} playlists for download. It can take a while and use significant storage.", + "downloadFullLibraryConfirm": "Download", + "downloadFullLibraryOffline": "Connect to your server to download the full library.", + "downloadFullLibraryQueueNotEmptyTitle": "Download queue not empty", + "downloadFullLibraryQueueNotEmptyBody": "Please clear the download queue before downloading your full library.", + "downloadFullLibraryFailed": "Couldn't prepare the full-library download. Check your connection and try again.", + "downloadFullLibraryPartial": "Couldn't queue {{failed}} of {{total}} items. Try again to pick up what's missing.", + "addingAlbumsToQueue": "Adding albums… {{queued}} / {{total}}", + "addingPlaylistsToQueue": "Adding playlists… {{queued}} / {{total}}", + "preparingFullLibrary": "Preparing library…", "cachedAlbums": "Cached albums", "cachedArtists": "Cached artists", "cachedPlaylists": "Cached playlists", diff --git a/src/services/__tests__/fullLibraryDownloadService.test.ts b/src/services/__tests__/fullLibraryDownloadService.test.ts new file mode 100644 index 0000000..a92e509 --- /dev/null +++ b/src/services/__tests__/fullLibraryDownloadService.test.ts @@ -0,0 +1,123 @@ +let mockAlbumsState: Array<{ id: string }> = []; +let mockPlaylistsState: Array<{ id: string }> = []; +let mockOffline = false; +let mockReachable = true; + +const mockFetchAllAlbums = jest.fn().mockResolvedValue(undefined); +const mockFetchAllPlaylists = jest.fn().mockResolvedValue(undefined); + +jest.mock('../../store/albumLibraryStore', () => ({ + albumLibraryStore: { + getState: () => ({ albums: mockAlbumsState, fetchAllAlbums: mockFetchAllAlbums }), + }, +})); +jest.mock('../../store/playlistLibraryStore', () => ({ + playlistLibraryStore: { + getState: () => ({ playlists: mockPlaylistsState, fetchAllPlaylists: mockFetchAllPlaylists }), + }, +})); +jest.mock('../../store/offlineModeStore', () => ({ + offlineModeStore: { getState: () => ({ offlineMode: mockOffline }) }, +})); +jest.mock('../../store/connectivityStore', () => ({ + connectivityStore: { getState: () => ({ isServerReachable: mockReachable }) }, +})); + +const mockEnqueueAlbum = jest.fn(); +const mockEnqueuePlaylist = jest.fn(); +jest.mock('../musicCacheService', () => ({ + enqueueAlbumDownload: (id: string) => mockEnqueueAlbum(id), + enqueuePlaylistDownload: (id: string) => mockEnqueuePlaylist(id), +})); + +import { enqueueFullLibraryDownload } from '../fullLibraryDownloadService'; +import { fullLibraryDownloadStore } from '../../store/fullLibraryDownloadStore'; + +const calls: string[] = []; + +beforeEach(() => { + mockAlbumsState = [{ id: 'a1' }, { id: 'a2' }]; + mockPlaylistsState = [{ id: 'p1' }]; + mockOffline = false; + mockReachable = true; + calls.length = 0; + mockEnqueueAlbum.mockReset(); + mockEnqueuePlaylist.mockReset(); + mockEnqueueAlbum.mockImplementation((id: string) => { + calls.push(`a:${id}`); + return Promise.resolve(); + }); + mockEnqueuePlaylist.mockImplementation((id: string) => { + calls.push(`p:${id}`); + return Promise.resolve(); + }); + mockFetchAllAlbums.mockClear(); + mockFetchAllPlaylists.mockClear(); + fullLibraryDownloadStore.getState().finish(); +}); + +describe('enqueueFullLibraryDownload', () => { + it('enqueues every album then every playlist (albums first)', async () => { + await enqueueFullLibraryDownload(); + expect(mockFetchAllAlbums).toHaveBeenCalledTimes(1); + expect(mockFetchAllPlaylists).toHaveBeenCalledTimes(1); + expect(calls).toEqual(['a:a1', 'a:a2', 'p:p1']); + expect(fullLibraryDownloadStore.getState().active).toBe(false); + }); + + it('bails out when offline (no fetch, no enqueue)', async () => { + mockOffline = true; + await enqueueFullLibraryDownload(); + expect(mockFetchAllAlbums).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + }); + + it('bails out when the server is unreachable', async () => { + mockReachable = false; + await enqueueFullLibraryDownload(); + expect(calls).toEqual([]); + }); + + it('does nothing if a run is already active', async () => { + fullLibraryDownloadStore.getState().start(); + await enqueueFullLibraryDownload(); + expect(mockFetchAllAlbums).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + }); + + it('continues past a rejected album enqueue and reports the failure', async () => { + mockEnqueueAlbum.mockImplementation((id: string) => { + calls.push(`a:${id}`); + return id === 'a1' ? Promise.reject(new Error('boom')) : Promise.resolve(); + }); + await enqueueFullLibraryDownload(); + expect(calls).toEqual(['a:a1', 'a:a2', 'p:p1']); + // One album couldn't be queued — surfaced for the card, run still idle. + expect(fullLibraryDownloadStore.getState().error).toBeTruthy(); + expect(fullLibraryDownloadStore.getState().active).toBe(false); + }); + + it('sets an error and does not queue when preparing fails', async () => { + mockFetchAllAlbums.mockRejectedValueOnce(new Error('offline mid-prepare')); + await enqueueFullLibraryDownload(); + expect(calls).toEqual([]); + expect(fullLibraryDownloadStore.getState().error).toBeTruthy(); + expect(fullLibraryDownloadStore.getState().active).toBe(false); + }); + + it('leaves no error on a clean run', async () => { + await enqueueFullLibraryDownload(); + expect(fullLibraryDownloadStore.getState().error).toBeNull(); + }); + + it('stops adding once cancelled mid-run', async () => { + mockEnqueueAlbum.mockImplementation((id: string) => { + calls.push(`a:${id}`); + fullLibraryDownloadStore.getState().cancel(); + return Promise.resolve(); + }); + await enqueueFullLibraryDownload(); + // First album enqueued; cancel halts the loop before a2 / playlists. + expect(calls).toEqual(['a:a1']); + }); +}); diff --git a/src/services/fullLibraryDownloadService.ts b/src/services/fullLibraryDownloadService.ts new file mode 100644 index 0000000..54713a2 --- /dev/null +++ b/src/services/fullLibraryDownloadService.ts @@ -0,0 +1,87 @@ +/** + * "Download Full Library" — a one-shot that queues every album then every + * playlist for offline download, reusing the standard download queue (which + * already handles concurrency, dedup, status, storage limits, retries, resume). + * + * Light by design: refresh the album/playlist lists, then loop-enqueue. Each + * `enqueueAlbumDownload` fetches the album's song list and dedups, so per-album + * metadata freshens inline and already-cached albums are skipped with no + * transfer. Playlists go last — their songs are usually already on disk from the + * albums, so they complete quickly. Re-running is safe (idempotent). + */ + +import i18n from '../i18n/i18n'; +import { albumLibraryStore } from '../store/albumLibraryStore'; +import { connectivityStore } from '../store/connectivityStore'; +import { fullLibraryDownloadStore } from '../store/fullLibraryDownloadStore'; +import { offlineModeStore } from '../store/offlineModeStore'; +import { playlistLibraryStore } from '../store/playlistLibraryStore'; +import { enqueueAlbumDownload, enqueuePlaylistDownload } from './musicCacheService'; + +/** True when the server is reachable and the user isn't in offline mode. */ +export function canDownloadFullLibrary(): boolean { + return ( + !offlineModeStore.getState().offlineMode && + connectivityStore.getState().isServerReachable + ); +} + +/** + * Refresh the library lists, then enqueue every album followed by every + * playlist. Fire-and-forget from the UI — it awaits internally and reports + * progress through `fullLibraryDownloadStore`. No-op if already running or + * offline. + */ +export async function enqueueFullLibraryDownload(): Promise { + const store = fullLibraryDownloadStore.getState(); + if (store.active) return; + if (!canDownloadFullLibrary()) return; + + store.start(); + let failed = 0; + let total = 0; + try { + // Phase 1 — make sure we have the complete, current library lists so we + // don't miss anything added since the last sync. A failure here (e.g. the + // connection drops) aborts before queueing and is surfaced to the user. + fullLibraryDownloadStore.getState().setPhase('preparing'); + await albumLibraryStore.getState().fetchAllAlbums(); + await playlistLibraryStore.getState().fetchAllPlaylists(); + + const albums = albumLibraryStore.getState().albums; + const playlists = playlistLibraryStore.getState().playlists; + total = albums.length + playlists.length; + fullLibraryDownloadStore.getState().setTotals(albums.length, playlists.length); + + // Phase 2 — enqueue. Sequential awaits keep a single album-detail fetch in + // flight at a time (avoids hundreds of concurrent getAlbum calls) and yield + // to keep the UI responsive. The queue starts draining after the first item. + // A single failed item is tolerated and counted; we report the tally at the + // end so a partial outage doesn't silently drop part of the library. + fullLibraryDownloadStore.getState().setPhase('queueing'); + + for (const album of albums) { + if (!fullLibraryDownloadStore.getState().active) return; // cancelled + await enqueueAlbumDownload(album.id).catch(() => { failed += 1; }); + fullLibraryDownloadStore.getState().incAlbum(); + } + + // Playlists last — their songs are mostly already cached from the albums. + for (const playlist of playlists) { + if (!fullLibraryDownloadStore.getState().active) return; // cancelled + await enqueuePlaylistDownload(playlist.id).catch(() => { failed += 1; }); + fullLibraryDownloadStore.getState().incPlaylist(); + } + + if (failed > 0) { + fullLibraryDownloadStore.getState().fail( + i18n.t('downloadFullLibraryPartial', { failed, total }), + ); + } + } catch { + // Preparing failed (or an unexpected error) — couldn't queue the library. + fullLibraryDownloadStore.getState().fail(i18n.t('downloadFullLibraryFailed')); + } finally { + fullLibraryDownloadStore.getState().finish(); + } +} diff --git a/src/store/fullLibraryDownloadStore.ts b/src/store/fullLibraryDownloadStore.ts new file mode 100644 index 0000000..ad4ea8a --- /dev/null +++ b/src/store/fullLibraryDownloadStore.ts @@ -0,0 +1,60 @@ +import { create } from 'zustand'; + +/** + * Transient progress state for the "Download Full Library" one-shot. Tracks the + * prepare → queueing phases and how many albums/playlists have been added to the + * download queue so the settings card can show live progress + a Stop control. + * + * Deliberately NOT persisted: the action is idempotent (enqueue dedups), so a + * restart mid-run just means the user taps it again; the already-queued items + * keep downloading via the normal queue regardless. + */ +export type FullLibraryDownloadPhase = 'preparing' | 'queueing' | null; + +interface FullLibraryDownloadState { + active: boolean; + phase: FullLibraryDownloadPhase; + albumsTotal: number; + albumsQueued: number; + playlistsTotal: number; + playlistsQueued: number; + /** User-facing failure message from the last run, surfaced by the card. */ + error: string | null; + + start: () => void; + setPhase: (phase: FullLibraryDownloadPhase) => void; + setTotals: (albumsTotal: number, playlistsTotal: number) => void; + incAlbum: () => void; + incPlaylist: () => void; + /** Record a failure for the card to surface; doesn't stop the run. */ + fail: (error: string) => void; + clearError: () => void; + /** Request the run stop adding more items (in-flight downloads continue). */ + cancel: () => void; + /** Clear run progress (preserves `error` for the card to surface). */ + finish: () => void; +} + +const RUN_IDLE = { + active: false, + phase: null as FullLibraryDownloadPhase, + albumsTotal: 0, + albumsQueued: 0, + playlistsTotal: 0, + playlistsQueued: 0, +}; + +export const fullLibraryDownloadStore = create()((set) => ({ + ...RUN_IDLE, + error: null, + + start: () => set({ ...RUN_IDLE, error: null, active: true, phase: 'preparing' }), + setPhase: (phase) => set({ phase }), + setTotals: (albumsTotal, playlistsTotal) => set({ albumsTotal, playlistsTotal }), + incAlbum: () => set((s) => ({ albumsQueued: s.albumsQueued + 1 })), + incPlaylist: () => set((s) => ({ playlistsQueued: s.playlistsQueued + 1 })), + fail: (error) => set({ error }), + clearError: () => set({ error: null }), + cancel: () => set({ active: false }), + finish: () => set({ ...RUN_IDLE }), +})); From a4805bb757fec41fcc57f89566ddea70317453b8 Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Tue, 2 Jun 2026 21:17:43 +1000 Subject: [PATCH 18/18] feat(player): tablet-portrait mini player Taller floating now-playing card for tablets in portrait: cover art + favorite (left), centered title/artist over an inline draggable seek bar, and prev/play/next transport. Symmetric side columns keep the centre column centred on the card. Branched in via useIsTabletPortrait; phone and tablet-landscape unchanged. --- src/components/BottomChrome.tsx | 7 +- .../__tests__/BottomChrome.test.tsx | 20 + .../player/PlayerTabletPortraitMini.tsx | 374 ++++++++++++++++++ 3 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 src/components/player/PlayerTabletPortraitMini.tsx diff --git a/src/components/BottomChrome.tsx b/src/components/BottomChrome.tsx index 528ab2b..3e74f1e 100644 --- a/src/components/BottomChrome.tsx +++ b/src/components/BottomChrome.tsx @@ -3,6 +3,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { DownloadBanner } from './DownloadBanner'; import { PlayerPhoneMini } from './player/PlayerPhoneMini'; +import { PlayerTabletPortraitMini } from './player/PlayerTabletPortraitMini'; +import { useIsTabletPortrait } from '../hooks/useIsTabletPortrait'; import { useLayoutMode } from '../hooks/useLayoutMode'; import { authStore } from '../store/authStore'; import { musicCacheStore } from '../store/musicCacheStore'; @@ -39,6 +41,7 @@ interface BottomChromeProps { export function BottomChrome({ withSafeAreaPadding = false }: BottomChromeProps = {}) { const isWide = useLayoutMode() === 'wide'; + const isTabletPortrait = useIsTabletPortrait(); const isLoggedIn = authStore((s) => s.isLoggedIn); const hasCurrentTrack = playerStore((s) => s.currentTrack !== null); // Mirrors `DownloadBanner`'s own filter so the two can't drift. Counts @@ -66,7 +69,9 @@ export function BottomChrome({ withSafeAreaPadding = false }: BottomChromeProps ]} > {hasDownloads && } - {!isWide && hasCurrentTrack && } + {!isWide && hasCurrentTrack && ( + isTabletPortrait ? : + )} ); } diff --git a/src/components/__tests__/BottomChrome.test.tsx b/src/components/__tests__/BottomChrome.test.tsx index 66bdda9..ef2ed45 100644 --- a/src/components/__tests__/BottomChrome.test.tsx +++ b/src/components/__tests__/BottomChrome.test.tsx @@ -5,6 +5,15 @@ jest.mock('../player/PlayerPhoneMini', () => { return { PlayerPhoneMini: () => }; }); +jest.mock('../player/PlayerTabletPortraitMini', () => { + const { View } = require('react-native'); + return { PlayerTabletPortraitMini: () => }; +}); + +jest.mock('../../hooks/useIsTabletPortrait', () => ({ + useIsTabletPortrait: jest.fn(() => false), +})); + jest.mock('../DownloadBanner', () => { const { View } = require('react-native'); return { @@ -24,6 +33,7 @@ jest.mock('react-native-safe-area-context', () => ({ import React from 'react'; import { render } from '@testing-library/react-native'; +import { useIsTabletPortrait } from '../../hooks/useIsTabletPortrait'; import { useLayoutMode } from '../../hooks/useLayoutMode'; import { authStore } from '../../store/authStore'; import { musicCacheStore } from '../../store/musicCacheStore'; @@ -32,6 +42,7 @@ import type { DownloadQueueItem } from '../../store/musicCacheStore'; import { BottomChrome } from '../BottomChrome'; const mockUseLayoutMode = useLayoutMode as jest.Mock; +const mockUseIsTabletPortrait = useIsTabletPortrait as jest.Mock; const TRACK = { id: 't1', @@ -59,6 +70,7 @@ function makeQueueItem(overrides: Partial = {}): DownloadQueu beforeEach(() => { mockUseLayoutMode.mockReturnValue('compact'); + mockUseIsTabletPortrait.mockReturnValue(false); authStore.setState({ isLoggedIn: true }); playerStore.setState({ currentTrack: null }); musicCacheStore.setState({ downloadQueue: [] }); @@ -76,6 +88,14 @@ describe('BottomChrome', () => { expect(queryByTestId('download-banner')).toBeNull(); }); + it('compact + tablet-portrait + has-track → tablet mini player, phone mini absent', () => { + mockUseIsTabletPortrait.mockReturnValue(true); + playerStore.setState({ currentTrack: TRACK }); + const { getByTestId, queryByTestId } = render(); + expect(getByTestId('tablet-mini-player')).toBeTruthy(); + expect(queryByTestId('mini-player')).toBeNull(); + }); + it('compact + no-track + has-downloads → banner only, mini player absent', () => { musicCacheStore.setState({ downloadQueue: [makeQueueItem({ status: 'downloading' })], diff --git a/src/components/player/PlayerTabletPortraitMini.tsx b/src/components/player/PlayerTabletPortraitMini.tsx new file mode 100644 index 0000000..7c4892c --- /dev/null +++ b/src/components/player/PlayerTabletPortraitMini.tsx @@ -0,0 +1,374 @@ +import Ionicons from "@react-native-vector-icons/ionicons/static"; +import { LinearGradient } from 'expo-linear-gradient'; +import { useRouter } from 'expo-router'; +import { memo, useCallback, useMemo, useState } from 'react'; +import { ActivityIndicator, type LayoutChangeEvent, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import Animated, { useAnimatedStyle } from 'react-native-reanimated'; + +import { CachedImage } from '@/components/CachedImage'; +import { FavoriteButton } from '@/components/FavoriteButton'; +import { PlayerProgressBar } from '@/components/PlayerProgressBar'; +import WaveformLogo from '@/components/WaveformLogo'; +import { type ThemeColors } from '@/constants/theme'; +import { useCanSkip } from '@/hooks/useCanSkip'; +import { useImagePalette } from '@/hooks/useImagePalette'; +import { useTheme } from '@/hooks/useTheme'; +import { retryPlayback, seekTo, skipToNext, skipToPrevious, togglePlayPause } from '@/services/playerService'; +import { playerStore } from '@/store/playerStore'; +import { absoluteFill } from '@/utils/styles'; + +const COVER_SIZE = 76; +/** Matches the placeholder cover art background (rgb 150,150,150). */ +const PLACEHOLDER_BG = '#969696'; + +/** Append alpha hex to a colour string (supports #RGB, #RRGGBB). */ +const withAlpha = (hex: string, alpha: number) => { + const a = Math.round(alpha * 255).toString(16).padStart(2, '0'); + return `${hex}${a}`; +}; + +/** + * Tablet-portrait mini player — the persistent now-playing footer on tablets + * held in portrait. A taller floating card than the phone bar: a 3-column split + * of cover art (left), centered title / artist·album·year over an inline + * draggable seek bar (center), and favorite / play-pause / next controls + * (right). Tapping the art or text opens the full /player screen. + * + * Phone (PlayerPhoneMini) and tablet landscape (split-view, no mini) are + * unaffected. Branched in by BottomChrome via useIsTabletPortrait. + */ +export function PlayerTabletPortraitMini() { + const { colors } = useTheme(); + const { t } = useTranslation(); + const currentTrack = playerStore((s) => s.currentTrack); + const queueLoading = playerStore((s) => s.queueLoading); + + const router = useRouter(); + const openPlayer = useCallback(() => router.push('/player'), [router]); + + // The controls cluster is wider than the cover, which would shove the + // centered text/progress off to the left. Measure the controls' width and + // mirror it onto the cover side so the centre column is symmetric about the + // card's centre (robust across tablet widths and themes). + const [sideWidth, setSideWidth] = useState(0); + const onControlsLayout = useCallback((e: LayoutChangeEvent) => { + const w = e.nativeEvent.layout.width; + setSideWidth((prev) => (Math.abs(prev - w) > 0.5 ? w : prev)); + }, []); + + // Colour extraction (theme-aware; secondary preferred for a calmer top hue). + const { primary, secondary, gradientOpacity } = useImagePalette( + currentTrack ? (currentTrack.albumId ?? currentTrack.id) : undefined, + ); + const gradientAnimatedStyle = useAnimatedStyle(() => ({ + opacity: gradientOpacity.value, + })); + + // The default track colour (colors.border) is only 6–8% opacity and gets + // lost over the album-art gradient, so use a much stronger unplayed-track + // colour for this surface only — textPrimary at 28% stays theme-aware + // (light track in dark mode, dark track in light mode). + const progressColors = useMemo( + () => ({ ...colors, border: withAlpha(colors.textPrimary, 0.28) }), + [colors], + ); + + if (!currentTrack) return null; + + const extractedTop = secondary ?? primary ?? colors.card; + const topColor = queueLoading ? PLACEHOLDER_BG : extractedTop; + const gradientColors: readonly [string, string, ...string[]] = [ + withAlpha(topColor, 0.65), + withAlpha(colors.background, 0.65), + ]; + + return ( + + {/* Album-art accent gradient over the solid card */} + + + + + + {/* Column 1 — cover art (tap to expand). Sized to match the controls + column so the centre column stays centred on the card. */} + + [styles.coverWrap, pressed && styles.pressed]} + > + {queueLoading ? ( + + + + ) : ( + + )} + + + + + {/* Column 2 — centered title + artist + progress bar */} + + pressed && styles.pressed} + > + + {queueLoading ? t('loading') : currentTrack.title} + + + + {!queueLoading && ( + pressed && styles.pressed} + > + + {currentTrack.artist ?? t('unknownArtist')} + + + )} + + + + + + + + + {/* Column 3 — transport controls */} + + + + + + + + ); +} + +/* ------------------------------------------------------------------ */ +/* Progress bar — isolated so high-frequency position ticks don't */ +/* re-render the whole card. Mirrors player-tablet-portrait.tsx. */ +/* ------------------------------------------------------------------ */ + +const ProgressBar = memo(function ProgressBar({ colors }: { colors: ThemeColors }) { + const position = playerStore((s) => s.position); + const duration = playerStore((s) => s.duration); + const bufferedPosition = playerStore((s) => s.bufferedPosition); + const playbackState = playerStore((s) => s.playbackState); + const error = playerStore((s) => s.error); + const retrying = playerStore((s) => s.retrying); + const isBuffering = playbackState === 'buffering' || playbackState === 'loading'; + + return ( + + ); +}); + +/* ------------------------------------------------------------------ */ +/* Play / pause — filled circle, mirrors the tablet full player. */ +/* ------------------------------------------------------------------ */ + +const PlayPauseButton = memo(function PlayPauseButton({ colors }: { colors: ThemeColors }) { + const playbackState = playerStore((s) => s.playbackState); + const queueLoading = playerStore((s) => s.queueLoading); + const error = playerStore((s) => s.error); + const isPlaying = playbackState === 'playing' || playbackState === 'buffering'; + const isBuffering = playbackState === 'buffering' || playbackState === 'loading'; + + return ( + [ + styles.playPauseButton, + { backgroundColor: colors.textPrimary }, + pressed && styles.pressed, + ]} + > + {(isBuffering || queueLoading) ? ( + + ) : ( + + )} + + ); +}); + +/* ------------------------------------------------------------------ */ +/* Skip to previous. */ +/* ------------------------------------------------------------------ */ + +const PrevButton = memo(function PrevButton({ colors }: { colors: ThemeColors }) { + const { canSkipPrevious } = useCanSkip(); + return ( + [styles.controlButton, pressed && canSkipPrevious && styles.pressed]} + > + + + ); +}); + +/* ------------------------------------------------------------------ */ +/* Skip to next. */ +/* ------------------------------------------------------------------ */ + +const NextButton = memo(function NextButton({ colors }: { colors: ThemeColors }) { + const { canSkipNext } = useCanSkip(); + const handlePress = useCallback(() => { + if (canSkipNext) skipToNext(); + }, [canSkipNext]); + + return ( + [styles.controlButton, pressed && canSkipNext && styles.pressed]} + > + + + ); +}); + +const styles = StyleSheet.create({ + card: { + marginHorizontal: 12, + marginBottom: 8, + borderRadius: 16, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.22, + shadowRadius: 12, + elevation: 8, + }, + band: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 14, + paddingVertical: 12, + }, + sideLeft: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + }, + coverFavorite: { + marginLeft: 28, + padding: 4, + }, + coverWrap: { + borderRadius: 10, + }, + cover: { + width: COVER_SIZE, + height: COVER_SIZE, + borderRadius: 10, + backgroundColor: 'rgba(0,0,0,0.06)', + }, + coverPlaceholder: { + alignItems: 'center', + justifyContent: 'center', + }, + center: { + flex: 1, + paddingHorizontal: 16, + justifyContent: 'center', + }, + title: { + fontSize: 16, + fontWeight: '600', + lineHeight: 20, + textAlign: 'center', + }, + subtitle: { + fontSize: 13, + lineHeight: 17, + marginTop: 2, + textAlign: 'center', + }, + progressSection: { + width: '100%', + marginTop: 6, + alignItems: 'center', + }, + progressInner: { + width: '80%', + }, + controls: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + controlButton: { + padding: 4, + }, + playPauseButton: { + width: 52, + height: 52, + borderRadius: 26, + alignItems: 'center', + justifyContent: 'center', + }, + playIcon: { + marginLeft: 2, + }, + pressed: { + opacity: 0.6, + }, + disabled: { + opacity: 0.35, + }, +});