mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
lyrics support + lookup
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
// The scrolling synced-lyrics list for lyrics mode. Fills its parent (flex:1),
|
||||
// left-aligned, self-measuring. The active line advances off the same smooth
|
||||
// playback clock the waveform uses (RNTP stays authoritative) and auto-scrolls to
|
||||
// a comfortable reading anchor; manual scroll pauses the follow and surfaces a
|
||||
// Recenter pill. Top/bottom gradient overlays soften the edges into the chrome
|
||||
// (react-native-svg — no native rebuild). Plain (unsynced) hits render as a
|
||||
// static scroll; loading/not-found/error states get a centered message.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Pressable, ScrollView, View, type LayoutChangeEvent } from 'react-native';
|
||||
import { Text } from '@/components/Text';
|
||||
import { useColors } from '@/theme/themed';
|
||||
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
|
||||
import { useLyricsStore } from '@/stores/lyricsStore';
|
||||
import {
|
||||
getLyricsLineSeekTimeSeconds,
|
||||
getSyncedLyricsDisplayLines,
|
||||
getSyncedLyricsGapProgress,
|
||||
hasRenderableSyncedLines,
|
||||
resolveSyncedLyricsTiming,
|
||||
} from '@/lyrics/presentation';
|
||||
import { LyricsLine, type LyricsLineTier } from './LyricsLine';
|
||||
import type { Track } from '@/types/audio';
|
||||
|
||||
const TRANSLATION_PRIORITY = ['en', 'ja-Latn'];
|
||||
const ANCHOR_RATIO = 0.4;
|
||||
const H_PADDING = 22;
|
||||
// The displayed active line lags the audio by a fixed pipeline delay (RNTP
|
||||
// position reporting + poll/smoothing) that the desktop doesn't have, so advance
|
||||
// the lyrics clock by this much. Tune to taste — bigger = earlier highlight.
|
||||
const LYRICS_LEAD_MS = 350;
|
||||
|
||||
interface LyricsBandProps {
|
||||
track: Track;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
onSeek: (seconds: number) => void;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: LyricsBandProps) {
|
||||
const colors = useColors();
|
||||
const entry = useLyricsStore((s) => s.byPath[track.path]);
|
||||
const loadForTrack = useLyricsStore((s) => s.loadForTrack);
|
||||
|
||||
useEffect(() => {
|
||||
void loadForTrack(track);
|
||||
}, [track, loadForTrack]);
|
||||
|
||||
const [size, setSize] = useState({ w: 0, h: 0 });
|
||||
const onContainerLayout = (event: LayoutChangeEvent) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setSize((prev) => (prev.w === width && prev.h === height ? prev : { w: width, h: height }));
|
||||
};
|
||||
|
||||
const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying);
|
||||
// Lead the audio to counter display-pipeline lag (see LYRICS_LEAD_MS).
|
||||
const lyricsTime = smoothTime + LYRICS_LEAD_MS / 1000;
|
||||
const result = entry?.result ?? null;
|
||||
const isLoading = entry?.loading ?? !entry;
|
||||
|
||||
const syncedLines = useMemo(
|
||||
() => (result?.status === 'hit' ? result.lyrics.syncedLines : []),
|
||||
[result]
|
||||
);
|
||||
const displayLines = useMemo(
|
||||
() => getSyncedLyricsDisplayLines(syncedLines, { durationSeconds: duration }),
|
||||
[syncedLines, duration]
|
||||
);
|
||||
const hasSynced = hasRenderableSyncedLines(syncedLines);
|
||||
const timing = resolveSyncedLyricsTiming(syncedLines, lyricsTime, { durationSeconds: duration });
|
||||
const focusIndex = timing.focusLineIndex;
|
||||
|
||||
// Uniform size for every line (LyricsLine no longer scales font per tier), so pick
|
||||
// a comfortable reading size rather than the old oversized active value.
|
||||
const baseSize = size.w > 0 ? Math.round(clamp(size.w * 0.058, 18, 24)) : 22;
|
||||
|
||||
// --- auto-scroll centering ---
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const offsets = useRef<number[]>([]);
|
||||
const heights = useRef<number[]>([]);
|
||||
const [followPaused, setFollowPaused] = useState(false);
|
||||
|
||||
const centerOn = useCallback(
|
||||
(displayIndex: number, animated: boolean) => {
|
||||
if (displayIndex < 0 || size.h <= 0) return;
|
||||
const y = offsets.current[displayIndex];
|
||||
const h = heights.current[displayIndex];
|
||||
if (y == null || h == null) return;
|
||||
const target = Math.max(0, y + h / 2 - size.h * ANCHOR_RATIO);
|
||||
scrollRef.current?.scrollTo({ y: target, animated });
|
||||
},
|
||||
[size.h]
|
||||
);
|
||||
|
||||
// Reset follow + jump to the focus line when the track (or its lyrics) changes.
|
||||
useEffect(() => {
|
||||
offsets.current = [];
|
||||
heights.current = [];
|
||||
const timer = setTimeout(() => {
|
||||
setFollowPaused(false);
|
||||
centerOn(timing.focusLineIndex, false);
|
||||
}, 90);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [track.path, displayLines.length]);
|
||||
|
||||
// Follow the active/focus line as playback advances.
|
||||
useEffect(() => {
|
||||
if (followPaused) return;
|
||||
centerOn(focusIndex, true);
|
||||
}, [focusIndex, followPaused, centerOn]);
|
||||
|
||||
const recenter = useCallback(() => {
|
||||
setFollowPaused(false);
|
||||
centerOn(focusIndex, true);
|
||||
}, [centerOn, focusIndex]);
|
||||
|
||||
const message = !hasSynced
|
||||
? result?.status === 'transient_error'
|
||||
? 'Lyrics lookup ran into a problem. A retry may work.'
|
||||
: result?.status === 'not_found'
|
||||
? result.reason === 'online-disabled'
|
||||
? 'Online lyrics lookup is off.'
|
||||
: result.reason === 'provider-unavailable'
|
||||
? "Lyrics providers didn't respond in time."
|
||||
: 'No lyrics found for this track.'
|
||||
: isLoading
|
||||
? 'Finding lyrics…'
|
||||
: 'Lyrics are ready when a track is playing.'
|
||||
: null;
|
||||
|
||||
// --- plain (unsynced) hit ---
|
||||
if (result?.status === 'hit' && !hasSynced) {
|
||||
return (
|
||||
<View style={{ flex: 1 }} onLayout={onContainerLayout}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingVertical: 24, paddingHorizontal: H_PADDING }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text variant="body" color={colors.textSecondary} style={{ lineHeight: 28 }}>
|
||||
{result.lyrics.plainLyrics ?? ''}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- loading / not-found / error ---
|
||||
if (!hasSynced) {
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28 }}>
|
||||
<Text variant="body" color={colors.textTertiary} style={{ textAlign: 'center' }}>
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- synced view ---
|
||||
return (
|
||||
<View style={{ flex: 1 }} onLayout={onContainerLayout}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
showsVerticalScrollIndicator={false}
|
||||
scrollEventThrottle={16}
|
||||
onScrollBeginDrag={() => setFollowPaused(true)}
|
||||
contentContainerStyle={{
|
||||
paddingVertical: size.h > 0 ? Math.round(size.h * ANCHOR_RATIO) : 120,
|
||||
paddingHorizontal: H_PADDING,
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
{displayLines.map((displayLine) => {
|
||||
const onLayout = (event: LayoutChangeEvent) => {
|
||||
offsets.current[displayLine.displayIndex] = event.nativeEvent.layout.y;
|
||||
heights.current[displayLine.displayIndex] = event.nativeEvent.layout.height;
|
||||
};
|
||||
|
||||
if (displayLine.kind === 'gap') {
|
||||
const progress = getSyncedLyricsGapProgress(displayLine, lyricsTime) ?? 0;
|
||||
const isCurrentGap = displayLine.displayIndex === focusIndex && timing.isNeutral;
|
||||
return (
|
||||
<View
|
||||
key={displayLine.key}
|
||||
onLayout={onLayout}
|
||||
style={{ paddingVertical: 16, opacity: isCurrentGap ? 0.9 : 0.25 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: clamp(size.w * 0.3, 110, 220),
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.glassBorder,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<View style={{ width: `${Math.round(progress * 100)}%`, height: 3, backgroundColor: colors.textSecondary }} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const distance = displayLine.displayIndex - focusIndex;
|
||||
const isActive = !timing.isNeutral && displayLine.displayIndex === timing.activeLineIndex;
|
||||
const absDistance = Math.abs(distance);
|
||||
const tier: LyricsLineTier = isActive ? 'active' : absDistance <= 1 ? 'near' : absDistance === 2 ? 'far' : 'distant';
|
||||
const seekSeconds = getLyricsLineSeekTimeSeconds(displayLine.timestampMs, duration, 0);
|
||||
|
||||
return (
|
||||
<LyricsLine
|
||||
key={displayLine.key}
|
||||
line={displayLine.line}
|
||||
tier={tier}
|
||||
baseSize={baseSize}
|
||||
translationPriority={TRANSLATION_PRIORITY}
|
||||
onSeek={() => {
|
||||
if (seekSeconds != null) onSeek(seekSeconds);
|
||||
}}
|
||||
onLayout={onLayout}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{followPaused ? (
|
||||
<Pressable
|
||||
onPress={recenter}
|
||||
hitSlop={10}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
alignSelf: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.glassBg,
|
||||
}}
|
||||
>
|
||||
<Text variant="mono" color={colors.accentText} style={{ fontSize: 9, letterSpacing: 1, textTransform: 'uppercase' }}>
|
||||
Recenter
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// One synced lyric line for the now-playing lyrics view. Left-aligned (mobile /
|
||||
// Apple-Music reading style), with furigana ruby columns when present and an
|
||||
// optional translation line beneath. Tapping seeks to the line.
|
||||
//
|
||||
// Every line uses the SAME font size, weight, and metrics, so text wrapping and
|
||||
// line heights are identical across tiers — the layout never reflows as the active
|
||||
// line moves, which is what caused the surrounding lines to shift/flicker. The
|
||||
// active line is emphasized only by opacity, a small scale, and an accent glow —
|
||||
// all paint/transform, never layout — and both animate so tier changes ease
|
||||
// instead of snapping.
|
||||
//
|
||||
// RN has no <ruby>, so ruby is a stacked column: a small reading Text over the
|
||||
// base Text. Every segment is the identical box (a View reserving `readingHeight`
|
||||
// of top space with the base below) so all bases land on one line; the reading is
|
||||
// absolutely positioned in that top zone, base-width and shrunk to fit, so a wide
|
||||
// reading never widens the column and spreads the sentence apart.
|
||||
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
import { Pressable, View, type LayoutChangeEvent } from 'react-native';
|
||||
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import { Text } from '@/components/Text';
|
||||
import { useColors } from '@/theme/themed';
|
||||
import { getPreferredLyricsTranslation } from '@/lyrics/presentation';
|
||||
import type { LyricsFurigana, LyricsLine as LyricsLineData } from '@/lyrics/types';
|
||||
|
||||
export type LyricsLineTier = 'active' | 'near' | 'far' | 'distant';
|
||||
|
||||
interface LyricsLineProps {
|
||||
line: LyricsLineData;
|
||||
tier: LyricsLineTier;
|
||||
baseSize: number;
|
||||
translationPriority: string[];
|
||||
onSeek: () => void;
|
||||
onLayout?: (event: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
// scale/opacity only — never anything that reflows layout. Active scale is kept
|
||||
// small enough that the grow overflows into the horizontal padding, not off-screen.
|
||||
const TIER: Record<LyricsLineTier, { scale: number; opacity: number }> = {
|
||||
active: { scale: 1.06, opacity: 1 },
|
||||
near: { scale: 1.0, opacity: 0.52 },
|
||||
far: { scale: 0.965, opacity: 0.27 },
|
||||
distant: { scale: 0.93, opacity: 0.13 },
|
||||
};
|
||||
|
||||
const EASE = Easing.bezier(0.22, 1, 0.36, 1);
|
||||
const DURATION = 220;
|
||||
|
||||
interface Segment {
|
||||
text: string;
|
||||
reading?: string;
|
||||
}
|
||||
|
||||
function buildSegments(text: string, furigana: LyricsFurigana[] | undefined): Segment[] {
|
||||
if (!furigana || furigana.length === 0) return [{ text }];
|
||||
const sorted = [...furigana].sort((a, b) => a.start - b.start);
|
||||
const segments: Segment[] = [];
|
||||
let cursor = 0;
|
||||
for (const entry of sorted) {
|
||||
if (entry.start < cursor || entry.end > text.length) continue;
|
||||
if (entry.start > cursor) segments.push({ text: text.slice(cursor, entry.start) });
|
||||
segments.push({ text: text.slice(entry.start, entry.end), reading: entry.reading });
|
||||
cursor = entry.end;
|
||||
}
|
||||
if (cursor < text.length) segments.push({ text: text.slice(cursor) });
|
||||
return segments;
|
||||
}
|
||||
|
||||
function LyricsLineComponent({ line, tier, baseSize, translationPriority, onSeek, onLayout }: LyricsLineProps) {
|
||||
const colors = useColors();
|
||||
const target = TIER[tier];
|
||||
// Uniform metrics for every line (the whole point — no wrap/reflow between tiers).
|
||||
const size = baseSize;
|
||||
const lineHeight = Math.round(size * 1.2);
|
||||
const readingSize = Math.max(9, Math.round(size * 0.5));
|
||||
const readingHeight = Math.round(readingSize * 1.25);
|
||||
const translation = useMemo(
|
||||
() => getPreferredLyricsTranslation(line, translationPriority),
|
||||
[line, translationPriority]
|
||||
);
|
||||
const hasFurigana = Boolean(line.furigana && line.furigana.length > 0);
|
||||
const segments = useMemo(() => buildSegments(line.text, line.furigana), [line.text, line.furigana]);
|
||||
|
||||
const opacity = useSharedValue(target.opacity);
|
||||
const scale = useSharedValue(target.scale);
|
||||
useEffect(() => {
|
||||
opacity.value = withTiming(target.opacity, { duration: DURATION, easing: EASE });
|
||||
scale.value = withTiming(target.scale, { duration: DURATION, easing: EASE });
|
||||
}, [target.opacity, target.scale, opacity, scale]);
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const textColor = colors.textPrimary;
|
||||
const mainShadow =
|
||||
tier === 'active'
|
||||
? { textShadowColor: colors.accentGlow, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 16 }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Animated.View onLayout={onLayout} style={[{ width: '100%', transformOrigin: 'left center' }, animatedStyle]}>
|
||||
<Pressable onPress={onSeek} style={{ paddingVertical: 7 }}>
|
||||
<View style={{ alignItems: 'flex-start' }}>
|
||||
{hasFurigana ? (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{segments.map((segment, index) => (
|
||||
<View key={index} style={{ paddingTop: readingHeight }}>
|
||||
<Text variant="heading" color={textColor} style={{ fontSize: size, lineHeight, ...mainShadow }}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
{segment.reading ? (
|
||||
<Text
|
||||
variant="caption"
|
||||
color={colors.textSecondary}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: readingHeight,
|
||||
fontSize: readingSize,
|
||||
lineHeight: readingHeight,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{segment.reading}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text variant="heading" color={textColor} style={{ fontSize: size, lineHeight, textAlign: 'left', ...mainShadow }}>
|
||||
{line.text}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{translation ? (
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.textSecondary}
|
||||
style={{
|
||||
fontSize: Math.max(11, Math.round(size * 0.52)),
|
||||
lineHeight: Math.round(size * 0.66),
|
||||
textAlign: 'left',
|
||||
marginTop: 3,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
>
|
||||
{translation.text}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
export const LyricsLine = memo(LyricsLineComponent);
|
||||
@@ -0,0 +1,193 @@
|
||||
// Lyrics mode for now-playing — a lyrics-first takeover that replaces the whole
|
||||
// art/controls body (and the standard header). Lyrics fill the screen over the
|
||||
// blurred-art wash; a slim strip on top (dismiss · track · favorite · exit) and a
|
||||
// minimal control bar below (progress + prev/play/next) stay permanently visible
|
||||
// so you never lose control to go fully immersive. The ♫ toggle exits back to the
|
||||
// art view; swipe-down still closes the player.
|
||||
|
||||
import { Image } from 'expo-image';
|
||||
import { Pressable, View } from 'react-native';
|
||||
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Text } from '@/components/Text';
|
||||
import { MarqueeText } from '@/components/MarqueeText';
|
||||
import { AstraLogo } from '@/components/AstraLogo';
|
||||
import { SeekBar } from '@/components/SeekBar';
|
||||
import { LyricsBand } from './LyricsBand';
|
||||
import { spacing, radius } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import type { Track } from '@/types/audio';
|
||||
|
||||
interface LyricsViewProps {
|
||||
track: Track;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
isLoading: boolean;
|
||||
isFavorite: boolean;
|
||||
onSeek: (seconds: number) => void;
|
||||
onPlayPause: () => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
onExitLyrics: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function LyricsView({
|
||||
track,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
isLoading,
|
||||
isFavorite,
|
||||
onSeek,
|
||||
onPlayPause,
|
||||
onNext,
|
||||
onPrev,
|
||||
onToggleFavorite,
|
||||
onExitLyrics,
|
||||
onDismiss,
|
||||
}: LyricsViewProps) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.strip}>
|
||||
<Pressable onPress={onDismiss} hitSlop={12} style={styles.stripBtn} accessibilityLabel="Close player">
|
||||
<Ionicons name="chevron-down" size={24} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.thumb}>
|
||||
{track.artworkData ? (
|
||||
<Image source={{ uri: track.artworkData }} style={styles.thumbImage} contentFit="cover" />
|
||||
) : (
|
||||
<AstraLogo size={18} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.stripText}>
|
||||
<MarqueeText variant="label" style={styles.stripTitle}>
|
||||
{track.title}
|
||||
</MarqueeText>
|
||||
<Text variant="caption" numberOfLines={1} color={colors.textTertiary}>
|
||||
{track.artist}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={onToggleFavorite}
|
||||
hitSlop={10}
|
||||
style={styles.stripBtn}
|
||||
accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
accessibilityState={{ selected: isFavorite }}
|
||||
>
|
||||
<Ionicons
|
||||
name={isFavorite ? 'heart' : 'heart-outline'}
|
||||
size={20}
|
||||
color={isFavorite ? colors.accent : colors.textTertiary}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={onExitLyrics}
|
||||
hitSlop={10}
|
||||
style={styles.stripBtn}
|
||||
accessibilityLabel="Hide lyrics"
|
||||
accessibilityState={{ selected: true }}
|
||||
>
|
||||
<MaterialCommunityIcons name="script-text-outline" size={20} color={colors.accent} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<LyricsBand
|
||||
track={track}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isPlaying={isPlaying}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
|
||||
<View style={styles.controls}>
|
||||
<SeekBar currentTime={currentTime} duration={duration} trackKey={track.id} onSeek={onSeek} />
|
||||
<View style={styles.transport}>
|
||||
<Pressable onPress={onPrev} hitSlop={12} style={styles.transportBtn} accessibilityLabel="Previous">
|
||||
<Ionicons name="play-skip-back" size={28} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Pressable onPress={onPlayPause} hitSlop={12} style={styles.playButton} accessibilityLabel={isPlaying ? 'Pause' : 'Play'}>
|
||||
<Ionicons
|
||||
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||
size={28}
|
||||
color={colors.bgPrimary}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={onNext} hitSlop={12} style={styles.transportBtn} accessibilityLabel="Next">
|
||||
<Ionicons name="play-skip-forward" size={28} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
strip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
stripBtn: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
thumb: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
thumbImage: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
stripText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
stripTitle: {
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
controls: {
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
transport: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xl,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
transportBtn: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
playButton: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: colors.accent,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user