mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 20:49:47 +02:00
update nowplaying UI/UX
This commit is contained in:
+263
-166
@@ -42,13 +42,20 @@ const NARROW_CONTENT_SIDE_PADDING = spacing.md;
|
|||||||
const MEDIA_AREA_MIN = 220;
|
const MEDIA_AREA_MIN = 220;
|
||||||
const MEDIA_AREA_MAX = 360;
|
const MEDIA_AREA_MAX = 360;
|
||||||
const ART_SIZE_MAX = 340;
|
const ART_SIZE_MAX = 340;
|
||||||
const SCOPE_HEIGHT_MIN = 150;
|
const VISUALIZER_ART_SCALE = 0.8;
|
||||||
const SCOPE_HEIGHT_MAX = 220;
|
const VISUALIZER_ART_SIZE_MIN = 176;
|
||||||
const SCOPE_HEIGHT_RATIO = 9 / 16;
|
const VISUALIZER_WIDTH_MAX = 448;
|
||||||
|
const VISUALIZER_SIDE_PADDING = spacing.md;
|
||||||
|
const VISUALIZER_TOP_GAP = spacing.lg;
|
||||||
|
const VISUALIZER_BOTTOM_GAP = spacing.sm;
|
||||||
|
const VISUALIZER_HEIGHT_MIN = 84;
|
||||||
|
const VISUALIZER_HEIGHT_MAX = 108;
|
||||||
|
const VISUALIZER_HEIGHT_RATIO = 0.28;
|
||||||
const HEADER_HEIGHT = 32;
|
const HEADER_HEIGHT = 32;
|
||||||
const CONTENT_TOP_PADDING = spacing.sm;
|
const CONTENT_TOP_PADDING = spacing.sm;
|
||||||
const CONTENT_BOTTOM_PADDING = spacing.lg;
|
const CONTENT_BOTTOM_PADDING = spacing.lg;
|
||||||
const MEDIA_TOP_MARGIN = spacing.lg;
|
const MEDIA_TOP_MARGIN = spacing.lg;
|
||||||
|
const VISUALIZER_MEDIA_BOTTOM_GAP = spacing.sm;
|
||||||
const MEDIA_BOTTOM_GAP = spacing.xl;
|
const MEDIA_BOTTOM_GAP = spacing.xl;
|
||||||
const TRACK_INFO_ESTIMATE = 96;
|
const TRACK_INFO_ESTIMATE = 96;
|
||||||
const WAVEFORM_HEIGHT = 58;
|
const WAVEFORM_HEIGHT = 58;
|
||||||
@@ -57,40 +64,52 @@ const WAVEFORM_BLOCK_ESTIMATE = WAVEFORM_HEIGHT + WAVEFORM_TOUCH_PADDING * 2 + 2
|
|||||||
const PLAY_BUTTON_SIZE = 68;
|
const PLAY_BUTTON_SIZE = 68;
|
||||||
const SKIP_ICON_SIZE = 32;
|
const SKIP_ICON_SIZE = 32;
|
||||||
const PLAY_ICON_SIZE = 34;
|
const PLAY_ICON_SIZE = 34;
|
||||||
const TRANSPORT_GAP = spacing.xxl;
|
|
||||||
const TRANSPORT_TOP_MARGIN = spacing.lg;
|
const TRANSPORT_TOP_MARGIN = spacing.lg;
|
||||||
const SUB_BUTTON_SIZE = 40;
|
const SUB_BUTTON_SIZE = 40;
|
||||||
const SUB_ICON_SIZE = 20;
|
const SUB_ICON_SIZE = 20;
|
||||||
const SUB_TOP_MARGIN = spacing.lg;
|
const SUB_TOP_MARGIN = spacing.lg;
|
||||||
const MIN_FLOATING_SPACE = spacing.sm;
|
const MIN_FLOATING_SPACE = spacing.sm;
|
||||||
const SECONDARY_CONTROLS_MIN_HEIGHT = 660;
|
|
||||||
|
|
||||||
interface NowPlayingLayout {
|
interface NowPlayingLayout {
|
||||||
contentPadding: number;
|
contentPadding: number;
|
||||||
contentWidth: number;
|
contentWidth: number;
|
||||||
mediaHeight: number;
|
|
||||||
artSize: number;
|
artSize: number;
|
||||||
scopeWidth: number;
|
scopeWidth: number;
|
||||||
scopeHeight: number;
|
scopeHeight: number;
|
||||||
|
visualizerTopGap: number;
|
||||||
|
visualizerBottomGap: number;
|
||||||
mediaTopMargin: number;
|
mediaTopMargin: number;
|
||||||
mediaBottomGap: number;
|
mediaBottomGap: number;
|
||||||
showSecondaryControls: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNowPlayingLayout(windowWidth: number, availableHeight: number): NowPlayingLayout {
|
function getScopeHeight(scopeWidth: number): number {
|
||||||
|
return Math.round(
|
||||||
|
clamp(scopeWidth * VISUALIZER_HEIGHT_RATIO, VISUALIZER_HEIGHT_MIN, VISUALIZER_HEIGHT_MAX)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNowPlayingLayout(
|
||||||
|
windowWidth: number,
|
||||||
|
availableHeight: number,
|
||||||
|
showVisualizer: boolean
|
||||||
|
): NowPlayingLayout {
|
||||||
const contentPadding =
|
const contentPadding =
|
||||||
windowWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING;
|
windowWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING;
|
||||||
const contentWidth = Math.max(0, Math.min(windowWidth - contentPadding * 2, MAX_CONTENT_WIDTH));
|
const contentWidth = Math.max(0, Math.min(windowWidth - contentPadding * 2, MAX_CONTENT_WIDTH));
|
||||||
|
const scopeWidth = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(windowWidth - VISUALIZER_SIDE_PADDING * 2, VISUALIZER_WIDTH_MAX)
|
||||||
|
);
|
||||||
|
const scopeHeight = getScopeHeight(scopeWidth);
|
||||||
const mediaMax = Math.min(contentWidth, MEDIA_AREA_MAX);
|
const mediaMax = Math.min(contentWidth, MEDIA_AREA_MAX);
|
||||||
const mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN);
|
const mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN);
|
||||||
const showSecondaryControls = availableHeight >= SECONDARY_CONTROLS_MIN_HEIGHT;
|
|
||||||
const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN;
|
const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN;
|
||||||
const mediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP;
|
const defaultMediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP;
|
||||||
const secondaryHeight = showSecondaryControls ? SUB_TOP_MARGIN + SUB_BUTTON_SIZE : 0;
|
const mediaBottomGap = showVisualizer ? VISUALIZER_MEDIA_BOTTOM_GAP : defaultMediaBottomGap;
|
||||||
const fixedHeight =
|
const fixedHeight =
|
||||||
CONTENT_TOP_PADDING +
|
CONTENT_TOP_PADDING +
|
||||||
CONTENT_BOTTOM_PADDING +
|
CONTENT_BOTTOM_PADDING +
|
||||||
@@ -101,27 +120,37 @@ function getNowPlayingLayout(windowWidth: number, availableHeight: number): NowP
|
|||||||
WAVEFORM_BLOCK_ESTIMATE +
|
WAVEFORM_BLOCK_ESTIMATE +
|
||||||
TRANSPORT_TOP_MARGIN +
|
TRANSPORT_TOP_MARGIN +
|
||||||
PLAY_BUTTON_SIZE +
|
PLAY_BUTTON_SIZE +
|
||||||
secondaryHeight +
|
SUB_TOP_MARGIN +
|
||||||
|
SUB_BUTTON_SIZE +
|
||||||
MIN_FLOATING_SPACE;
|
MIN_FLOATING_SPACE;
|
||||||
const heightBoundMedia = availableHeight - fixedHeight;
|
const heightBoundMedia = availableHeight - fixedHeight;
|
||||||
const mediaHeight = Math.round(clamp(heightBoundMedia, mediaMin, mediaMax));
|
const baseArtSize = Math.min(
|
||||||
const artSize = Math.min(mediaHeight, ART_SIZE_MAX);
|
Math.round(clamp(heightBoundMedia, mediaMin, mediaMax)),
|
||||||
const scopeWidth = contentWidth;
|
ART_SIZE_MAX
|
||||||
const scopeHeight = Math.min(
|
|
||||||
mediaHeight,
|
|
||||||
Math.round(clamp(scopeWidth * SCOPE_HEIGHT_RATIO, SCOPE_HEIGHT_MIN, SCOPE_HEIGHT_MAX))
|
|
||||||
);
|
);
|
||||||
|
const artSize = showVisualizer
|
||||||
|
? Math.round(
|
||||||
|
clamp(
|
||||||
|
baseArtSize * VISUALIZER_ART_SCALE,
|
||||||
|
Math.min(VISUALIZER_ART_SIZE_MIN, mediaMax),
|
||||||
|
mediaMax
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: baseArtSize;
|
||||||
|
|
||||||
|
const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0;
|
||||||
|
const visualizerBottomGap = showVisualizer ? VISUALIZER_BOTTOM_GAP : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contentPadding,
|
contentPadding,
|
||||||
contentWidth,
|
contentWidth,
|
||||||
mediaHeight,
|
|
||||||
artSize,
|
artSize,
|
||||||
scopeWidth,
|
scopeWidth,
|
||||||
scopeHeight,
|
scopeHeight,
|
||||||
|
visualizerTopGap,
|
||||||
|
visualizerBottomGap,
|
||||||
mediaTopMargin,
|
mediaTopMargin,
|
||||||
mediaBottomGap,
|
mediaBottomGap,
|
||||||
showSecondaryControls,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,9 +158,10 @@ export default function NowPlayingScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||||
const [showScopeStage, setShowScopeStage] = useState(false);
|
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
||||||
|
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
|
||||||
|
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
|
||||||
const track = usePlayerStore((s) => s.currentTrack);
|
const track = usePlayerStore((s) => s.currentTrack);
|
||||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||||
@@ -144,7 +174,7 @@ export default function NowPlayingScreen() {
|
|||||||
const isPlaying = playbackState === 'playing';
|
const isPlaying = playbackState === 'playing';
|
||||||
const isLoading = playbackState === 'loading';
|
const isLoading = playbackState === 'loading';
|
||||||
const availableHeight = windowHeight - insets.top - insets.bottom;
|
const availableHeight = windowHeight - insets.top - insets.bottom;
|
||||||
const layout = getNowPlayingLayout(windowWidth, availableHeight);
|
const layout = getNowPlayingLayout(windowWidth, availableHeight, scopeStageVisible);
|
||||||
const source = track?.album?.trim() ? track.album : 'Library';
|
const source = track?.album?.trim() ? track.album : 'Library';
|
||||||
|
|
||||||
// Swipe down to minimize. The stack transition is disabled for this route, so
|
// Swipe down to minimize. The stack transition is disabled for this route, so
|
||||||
@@ -233,60 +263,24 @@ export default function NowPlayingScreen() {
|
|||||||
|
|
||||||
{track ? (
|
{track ? (
|
||||||
<View style={styles.player}>
|
<View style={styles.player}>
|
||||||
<Pressable
|
<View
|
||||||
onPress={() => setShowScopeStage((visible) => !visible)}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={showScopeStage ? 'Show artwork' : 'Show visualizer'}
|
|
||||||
style={[
|
style={[
|
||||||
styles.mediaArea,
|
styles.middleStack,
|
||||||
{
|
{
|
||||||
height: layout.mediaHeight,
|
|
||||||
marginTop: layout.mediaTopMargin,
|
marginTop: layout.mediaTopMargin,
|
||||||
marginBottom: layout.mediaBottomGap,
|
marginBottom: layout.mediaBottomGap,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{showScopeStage ? (
|
<View
|
||||||
<View
|
style={[
|
||||||
style={[
|
styles.artButton,
|
||||||
styles.scopeSurface,
|
{
|
||||||
{
|
width: layout.artSize,
|
||||||
width: layout.scopeWidth,
|
height: layout.artSize,
|
||||||
height: layout.scopeHeight,
|
},
|
||||||
},
|
]}
|
||||||
]}
|
>
|
||||||
>
|
|
||||||
<Visualizer
|
|
||||||
width={layout.scopeWidth}
|
|
||||||
height={layout.scopeHeight}
|
|
||||||
interactive={false}
|
|
||||||
showChrome={false}
|
|
||||||
mode={scopeMode}
|
|
||||||
edgeFade
|
|
||||||
/>
|
|
||||||
{/* Nested Pressable: RN grants the responder to this
|
|
||||||
button, so a swap tap does not also collapse the
|
|
||||||
stage (the outer Pressable's onPress won't fire). */}
|
|
||||||
<Pressable
|
|
||||||
onPress={() =>
|
|
||||||
useSettingsStore
|
|
||||||
.getState()
|
|
||||||
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum')
|
|
||||||
}
|
|
||||||
hitSlop={12}
|
|
||||||
style={styles.scopeSwap}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`Showing ${
|
|
||||||
scopeMode === 'spectrum' ? 'spectrum' : 'oscilloscope'
|
|
||||||
}. Tap to switch.`}
|
|
||||||
>
|
|
||||||
<Text variant="caption" style={styles.scopeSwapLabel}>
|
|
||||||
{scopeMode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
|
|
||||||
</Text>
|
|
||||||
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.artCard,
|
styles.artCard,
|
||||||
@@ -307,38 +301,111 @@ export default function NowPlayingScreen() {
|
|||||||
<AstraLogo size={Math.round(layout.artSize * 0.4)} />
|
<AstraLogo size={Math.round(layout.artSize * 0.4)} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<View style={styles.trackInfo}>
|
|
||||||
<Text variant="heading" numberOfLines={2} style={styles.centered}>
|
|
||||||
{track.title}
|
|
||||||
</Text>
|
|
||||||
<Text variant="body" numberOfLines={1} style={[styles.centered, styles.artist]}>
|
|
||||||
{track.artist}
|
|
||||||
</Text>
|
|
||||||
<View style={styles.badges}>
|
|
||||||
<FormatBadges track={track} />
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{scopeStageVisible && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.scopeRail,
|
||||||
|
{
|
||||||
|
width: layout.scopeWidth,
|
||||||
|
height: layout.scopeHeight,
|
||||||
|
marginTop: layout.visualizerTopGap,
|
||||||
|
marginBottom: layout.visualizerBottomGap,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Visualizer
|
||||||
|
width={layout.scopeWidth}
|
||||||
|
height={layout.scopeHeight}
|
||||||
|
interactive={false}
|
||||||
|
showChrome={false}
|
||||||
|
mode={scopeMode}
|
||||||
|
edgeFade
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={() =>
|
||||||
|
useSettingsStore
|
||||||
|
.getState()
|
||||||
|
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum')
|
||||||
|
}
|
||||||
|
hitSlop={12}
|
||||||
|
style={styles.scopeSwap}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Showing ${
|
||||||
|
scopeMode === 'spectrum' ? 'spectrum' : 'oscilloscope'
|
||||||
|
}. Tap to switch.`}
|
||||||
|
>
|
||||||
|
<Text variant="caption" style={styles.scopeSwapLabel}>
|
||||||
|
{scopeMode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.spacer} />
|
<View style={styles.spacer} />
|
||||||
|
|
||||||
<View style={styles.playerControls}>
|
<View style={styles.playerControls}>
|
||||||
<View style={styles.progressBlock}>
|
<View style={styles.trackInfo}>
|
||||||
<WaveformSeekBar
|
<View style={styles.trackTextStack}>
|
||||||
currentTime={currentTime}
|
<Text variant="heading" numberOfLines={1} style={styles.trackTitle}>
|
||||||
duration={duration}
|
{track.title}
|
||||||
height={WAVEFORM_HEIGHT}
|
</Text>
|
||||||
touchPadding={WAVEFORM_TOUCH_PADDING}
|
<View style={styles.trackMetaRow}>
|
||||||
trackKey={track.id}
|
<Text variant="body" numberOfLines={1} style={styles.artist}>
|
||||||
trackPath={track.path}
|
{track.artist}
|
||||||
onSeek={(seconds) => void seekTo(seconds)}
|
</Text>
|
||||||
/>
|
<View style={styles.badges}>
|
||||||
|
<FormatBadges track={track} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.inlineActionBtn}
|
||||||
|
onPress={() => void toggleFavorite(track)}
|
||||||
|
accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
accessibilityState={{ selected: isFavorite }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isFavorite ? 'heart' : 'heart-outline'}
|
||||||
|
size={SUB_ICON_SIZE + 4}
|
||||||
|
color={isFavorite ? colors.accent : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<WaveformSeekBar
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
height={WAVEFORM_HEIGHT}
|
||||||
|
touchPadding={WAVEFORM_TOUCH_PADDING}
|
||||||
|
trackKey={track.id}
|
||||||
|
trackPath={track.path}
|
||||||
|
onSeek={(seconds) => void seekTo(seconds)}
|
||||||
|
/>
|
||||||
|
|
||||||
<View style={styles.transport}>
|
<View style={styles.transport}>
|
||||||
<Pressable onPress={skipToPrevious} hitSlop={12}>
|
<Pressable
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.transportSideBtn}
|
||||||
|
onPress={() => void toggleShuffle()}
|
||||||
|
accessibilityLabel="Shuffle"
|
||||||
|
accessibilityState={{ selected: shuffle }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="shuffle"
|
||||||
|
size={SUB_ICON_SIZE + 2}
|
||||||
|
color={shuffle ? colors.accent : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={skipToPrevious}
|
||||||
|
hitSlop={12}
|
||||||
|
style={styles.transportMainBtn}
|
||||||
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="play-skip-back"
|
name="play-skip-back"
|
||||||
size={SKIP_ICON_SIZE}
|
size={SKIP_ICON_SIZE}
|
||||||
@@ -352,78 +419,67 @@ export default function NowPlayingScreen() {
|
|||||||
color={colors.bgPrimary}
|
color={colors.bgPrimary}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={skipToNext} hitSlop={12}>
|
<Pressable
|
||||||
|
onPress={skipToNext}
|
||||||
|
hitSlop={12}
|
||||||
|
style={styles.transportMainBtn}
|
||||||
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="play-skip-forward"
|
name="play-skip-forward"
|
||||||
size={SKIP_ICON_SIZE}
|
size={SKIP_ICON_SIZE}
|
||||||
color={colors.textPrimary}
|
color={colors.textPrimary}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.transportSideBtn}
|
||||||
|
onPress={() => void cycleRepeat()}
|
||||||
|
accessibilityLabel="Repeat"
|
||||||
|
accessibilityState={{ selected: repeat !== 'none' }}
|
||||||
|
>
|
||||||
|
{repeat === 'one' ? (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="repeat-once"
|
||||||
|
size={SUB_ICON_SIZE + 2}
|
||||||
|
color={colors.accent}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name="repeat"
|
||||||
|
size={SUB_ICON_SIZE + 2}
|
||||||
|
color={repeat === 'all' ? colors.accent : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{layout.showSecondaryControls && (
|
<View style={styles.subRow}>
|
||||||
<View style={styles.subRow}>
|
<Pressable
|
||||||
<Pressable
|
hitSlop={10}
|
||||||
hitSlop={10}
|
style={styles.subBtn}
|
||||||
style={styles.subBtn}
|
onPress={() => void setScopeStageVisible(!scopeStageVisible)}
|
||||||
onPress={() => void toggleShuffle()}
|
accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'}
|
||||||
accessibilityLabel="Shuffle"
|
accessibilityState={{ selected: scopeStageVisible }}
|
||||||
accessibilityState={{ selected: shuffle }}
|
>
|
||||||
>
|
<MaterialCommunityIcons
|
||||||
<Ionicons
|
name="sine-wave"
|
||||||
name="shuffle"
|
size={SUB_ICON_SIZE + 2}
|
||||||
size={SUB_ICON_SIZE}
|
color={scopeStageVisible ? colors.accent : colors.textTertiary}
|
||||||
color={shuffle ? colors.accent : colors.textTertiary}
|
/>
|
||||||
/>
|
</Pressable>
|
||||||
</Pressable>
|
<Pressable
|
||||||
<Pressable
|
hitSlop={10}
|
||||||
hitSlop={10}
|
style={styles.subBtn}
|
||||||
style={styles.subBtn}
|
onPress={() => setQueueOpen(true)}
|
||||||
onPress={() => void toggleFavorite(track)}
|
accessibilityLabel="Queue"
|
||||||
accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
>
|
||||||
accessibilityState={{ selected: isFavorite }}
|
<Ionicons
|
||||||
>
|
name="list-outline"
|
||||||
<Ionicons
|
size={SUB_ICON_SIZE + 2}
|
||||||
name={isFavorite ? 'heart' : 'heart-outline'}
|
color={colors.textTertiary}
|
||||||
size={SUB_ICON_SIZE}
|
/>
|
||||||
color={isFavorite ? colors.accent : colors.textTertiary}
|
</Pressable>
|
||||||
/>
|
</View>
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
hitSlop={10}
|
|
||||||
style={styles.subBtn}
|
|
||||||
onPress={() => setQueueOpen(true)}
|
|
||||||
accessibilityLabel="Queue"
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="list-outline"
|
|
||||||
size={SUB_ICON_SIZE}
|
|
||||||
color={colors.textTertiary}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
hitSlop={10}
|
|
||||||
style={styles.subBtn}
|
|
||||||
onPress={() => void cycleRepeat()}
|
|
||||||
accessibilityLabel="Repeat"
|
|
||||||
accessibilityState={{ selected: repeat !== 'none' }}
|
|
||||||
>
|
|
||||||
{repeat === 'one' ? (
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="repeat-once"
|
|
||||||
size={SUB_ICON_SIZE}
|
|
||||||
color={colors.accent}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Ionicons
|
|
||||||
name="repeat"
|
|
||||||
size={SUB_ICON_SIZE}
|
|
||||||
color={repeat === 'all' ? colors.accent : colors.textTertiary}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -483,9 +539,12 @@ const styles = StyleSheet.create({
|
|||||||
player: {
|
player: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
mediaArea: {
|
middleStack: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
artButton: {
|
||||||
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
artCard: {
|
artCard: {
|
||||||
@@ -495,7 +554,8 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
scopeSurface: {
|
scopeRail: {
|
||||||
|
alignSelf: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
@@ -517,20 +577,45 @@ const styles = StyleSheet.create({
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
trackInfo: {
|
trackInfo: {
|
||||||
alignItems: 'center',
|
|
||||||
alignSelf: 'stretch',
|
alignSelf: 'stretch',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.md,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
trackTextStack: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
trackTitle: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
textAlign: 'left',
|
||||||
|
},
|
||||||
|
inlineActionBtn: {
|
||||||
|
width: SUB_BUTTON_SIZE,
|
||||||
|
height: SUB_BUTTON_SIZE,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
trackMetaRow: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
centered: {
|
centered: {
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
artist: {
|
artist: {
|
||||||
color: colors.accentText,
|
color: colors.accentText,
|
||||||
marginTop: spacing.xs,
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
badges: {
|
badges: {
|
||||||
marginTop: spacing.md,
|
flexShrink: 0,
|
||||||
},
|
|
||||||
progressBlock: {
|
|
||||||
},
|
},
|
||||||
spacer: {
|
spacer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -542,10 +627,21 @@ const styles = StyleSheet.create({
|
|||||||
transport: {
|
transport: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'space-between',
|
||||||
gap: TRANSPORT_GAP,
|
|
||||||
marginTop: TRANSPORT_TOP_MARGIN,
|
marginTop: TRANSPORT_TOP_MARGIN,
|
||||||
},
|
},
|
||||||
|
transportMainBtn: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
transportSideBtn: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
playButton: {
|
playButton: {
|
||||||
width: PLAY_BUTTON_SIZE,
|
width: PLAY_BUTTON_SIZE,
|
||||||
height: PLAY_BUTTON_SIZE,
|
height: PLAY_BUTTON_SIZE,
|
||||||
@@ -557,7 +653,8 @@ const styles = StyleSheet.create({
|
|||||||
subRow: {
|
subRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'flex-end',
|
||||||
|
gap: spacing.lg,
|
||||||
marginTop: SUB_TOP_MARGIN,
|
marginTop: SUB_TOP_MARGIN,
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { ArtistGroupingMode } from '@/library/artistGrouping';
|
|||||||
*/
|
*/
|
||||||
const ARTIST_GROUPING_KEY = 'artist_grouping_mode';
|
const ARTIST_GROUPING_KEY = 'artist_grouping_mode';
|
||||||
const SCOPE_MODE_KEY = 'scope_mode';
|
const SCOPE_MODE_KEY = 'scope_mode';
|
||||||
|
const SCOPE_STAGE_VISIBLE_KEY = 'scope_stage_visible';
|
||||||
|
|
||||||
/** Which visualizer the now-playing scope stage shows. */
|
/** Which visualizer the now-playing scope stage shows. */
|
||||||
export type ScopeMode = 'spectrum' | 'scope';
|
export type ScopeMode = 'spectrum' | 'scope';
|
||||||
@@ -22,30 +23,39 @@ function parseScopeMode(value: string | null): ScopeMode {
|
|||||||
return value === 'scope' ? 'scope' : 'spectrum';
|
return value === 'scope' ? 'scope' : 'spectrum';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBoolean(value: string | null): boolean {
|
||||||
|
return value === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
interface SettingsStore {
|
interface SettingsStore {
|
||||||
artistGroupingMode: ArtistGroupingMode;
|
artistGroupingMode: ArtistGroupingMode;
|
||||||
scopeMode: ScopeMode;
|
scopeMode: ScopeMode;
|
||||||
|
scopeStageVisible: boolean;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
load: () => Promise<void>;
|
load: () => Promise<void>;
|
||||||
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
|
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
|
||||||
setScopeMode: (mode: ScopeMode) => Promise<void>;
|
setScopeMode: (mode: ScopeMode) => Promise<void>;
|
||||||
|
setScopeStageVisible: (visible: boolean) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||||
artistGroupingMode: 'astra',
|
artistGroupingMode: 'astra',
|
||||||
scopeMode: 'spectrum',
|
scopeMode: 'spectrum',
|
||||||
|
scopeStageVisible: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
|
||||||
load: async () => {
|
load: async () => {
|
||||||
if (get().loaded) return;
|
if (get().loaded) return;
|
||||||
const db = await openLibraryDb();
|
const db = await openLibraryDb();
|
||||||
const [grouping, scope] = await Promise.all([
|
const [grouping, scope, scopeStageVisible] = await Promise.all([
|
||||||
getSetting(db, ARTIST_GROUPING_KEY),
|
getSetting(db, ARTIST_GROUPING_KEY),
|
||||||
getSetting(db, SCOPE_MODE_KEY),
|
getSetting(db, SCOPE_MODE_KEY),
|
||||||
|
getSetting(db, SCOPE_STAGE_VISIBLE_KEY),
|
||||||
]);
|
]);
|
||||||
set({
|
set({
|
||||||
artistGroupingMode: parseGroupingMode(grouping),
|
artistGroupingMode: parseGroupingMode(grouping),
|
||||||
scopeMode: parseScopeMode(scope),
|
scopeMode: parseScopeMode(scope),
|
||||||
|
scopeStageVisible: parseBoolean(scopeStageVisible),
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -63,4 +73,11 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|||||||
const db = await openLibraryDb();
|
const db = await openLibraryDb();
|
||||||
await setSetting(db, SCOPE_MODE_KEY, mode);
|
await setSetting(db, SCOPE_MODE_KEY, mode);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setScopeStageVisible: async (visible) => {
|
||||||
|
if (get().scopeStageVisible === visible) return;
|
||||||
|
set({ scopeStageVisible: visible });
|
||||||
|
const db = await openLibraryDb();
|
||||||
|
await setSetting(db, SCOPE_STAGE_VISIBLE_KEY, visible ? 'true' : 'false');
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user