mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 12:40:15 +02:00
initial home screen
This commit is contained in:
+727
-44
@@ -1,50 +1,561 @@
|
|||||||
import { View, Pressable, StyleSheet } from 'react-native';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Pressable, ScrollView, StyleSheet, View, type LayoutChangeEvent } from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
import { AstraLogo } from '@/components/AstraLogo';
|
import { AstraLogo } from '@/components/AstraLogo';
|
||||||
import { FormatBadges } from '@/components/FormatBadge';
|
import { SpectrumCurve } from '@/components/SpectrumCurve';
|
||||||
|
import { TrackRow } from '@/components/library/TrackRow';
|
||||||
|
import { PlaylistRow } from '@/components/library/PlaylistRow';
|
||||||
|
import { ScanProgress } from '@/components/library/ScanProgress';
|
||||||
import { colors, fonts, radius, spacing } from '@/theme';
|
import { colors, fonts, radius, spacing } from '@/theme';
|
||||||
import { playSample } from '@/audio/playbackController';
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
import { SAMPLE_TRACKS } from '@/audio/sampleTracks';
|
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import {
|
||||||
|
playTracks,
|
||||||
|
shuffleTracks,
|
||||||
|
skipToNext,
|
||||||
|
skipToPrevious,
|
||||||
|
togglePlay,
|
||||||
|
} from '@/audio/playbackController';
|
||||||
|
import { dbTrackToTrack } from '@/library/trackAdapter';
|
||||||
|
import { artworkUri } from '@/library/artwork';
|
||||||
|
import { formatDuration } from '@/lib/format';
|
||||||
|
import { useScopeActive } from '@/scope/scopeStore';
|
||||||
|
import type { PlaybackState, Track } from '@/types/audio';
|
||||||
|
import type { Album, DbTrack } from '@/types/library';
|
||||||
|
|
||||||
export default function HomeScreen() {
|
const RECENT_ALBUM_LIMIT = 8;
|
||||||
|
const RECENT_TRACK_LIMIT = 3;
|
||||||
|
const PLAYLIST_LIMIT = 4;
|
||||||
|
const PLAYER_CARD_MIN_HEIGHT = 174;
|
||||||
|
const CURVE_POINTS = 64;
|
||||||
|
|
||||||
|
function chooseRandomAlbum(albums: Album[], currentKey?: string | null): string | null {
|
||||||
|
if (albums.length === 0) return null;
|
||||||
|
if (albums.length === 1) return albums[0].identity_key;
|
||||||
|
|
||||||
|
let next: string | null = currentKey ?? null;
|
||||||
|
while (next === currentKey) {
|
||||||
|
next = albums[Math.floor(Math.random() * albums.length)].identity_key;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function albumMeta(album: Album, tracks: DbTrack[]): string {
|
||||||
|
const duration = tracks.reduce((sum, track) => sum + track.duration, 0);
|
||||||
|
return [
|
||||||
|
album.year ? String(album.year) : null,
|
||||||
|
`${album.track_count} ${album.track_count === 1 ? 'track' : 'tracks'}`,
|
||||||
|
formatDuration(duration),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(count: number, noun: string): string {
|
||||||
|
return `${count} ${count === 1 ? noun : `${noun}s`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({
|
||||||
|
title,
|
||||||
|
trailing,
|
||||||
|
actionLabel,
|
||||||
|
onActionPress,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
trailing?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onActionPress?: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<View style={styles.sectionHeader}>
|
||||||
<View style={styles.header}>
|
<View style={styles.sectionTitleGroup}>
|
||||||
<AstraLogo size={36} />
|
<Text variant="heading" style={styles.sectionTitle}>
|
||||||
<Text style={styles.wordmark}>ASTRA</Text>
|
{title}
|
||||||
</View>
|
|
||||||
<Text variant="label" style={styles.tagline}>
|
|
||||||
Audiophile player
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text variant="heading">Quick start</Text>
|
|
||||||
<Text variant="body" color={colors.textSecondary} style={styles.cardBody}>
|
|
||||||
On-device library scanning lands next. For now, stream a test track to
|
|
||||||
verify playback, background audio, and lock-screen controls.
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{trailing ? (
|
||||||
|
<Text variant="label" numberOfLines={1}>
|
||||||
|
{trailing}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{onActionPress && actionLabel ? (
|
||||||
|
<Pressable style={styles.seeAllButton} onPress={onActionPress} accessibilityRole="button">
|
||||||
|
<Text variant="label" color={colors.accentText}>
|
||||||
|
{actionLabel}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={14} color={colors.accentText} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<View style={styles.badges}>
|
function AlbumCover({ album, size }: { album: Album; size: number }) {
|
||||||
<FormatBadges track={SAMPLE_TRACKS[0]} />
|
return (
|
||||||
|
<View style={[styles.albumArt, { width: size, height: size }]}>
|
||||||
|
{album.artwork_hash ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: artworkUri(album.artwork_hash) }}
|
||||||
|
style={styles.image}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={120}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AstraLogo size={Math.round(size * 0.36)} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NowPlayingCard({
|
||||||
|
track,
|
||||||
|
playbackState,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
track: Track;
|
||||||
|
playbackState: PlaybackState;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
onOpen: () => void;
|
||||||
|
}) {
|
||||||
|
const isPlaying = playbackState === 'playing';
|
||||||
|
const isLoading = playbackState === 'loading';
|
||||||
|
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
|
||||||
|
const scopeActive = useScopeActive();
|
||||||
|
const [cardSize, setCardSize] = useState({ width: 0, height: PLAYER_CARD_MIN_HEIGHT });
|
||||||
|
|
||||||
|
const onCardLayout = (event: LayoutChangeEvent) => {
|
||||||
|
const { width, height } = event.nativeEvent.layout;
|
||||||
|
setCardSize((prev) =>
|
||||||
|
prev.width === width && prev.height === height ? prev : { width, height }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.playerCard} onLayout={onCardLayout}>
|
||||||
|
{scopeActive && cardSize.width > 0 ? (
|
||||||
|
<View pointerEvents="none" style={styles.playerSpectrum}>
|
||||||
|
<SpectrumCurve
|
||||||
|
active={scopeActive}
|
||||||
|
pointCount={CURVE_POINTS}
|
||||||
|
analysisFrameMs={0}
|
||||||
|
dbMin={-84}
|
||||||
|
dbMax={-20}
|
||||||
|
width={cardSize.width}
|
||||||
|
height={cardSize.height}
|
||||||
|
lineWidth={1.4}
|
||||||
|
lineOpacity={0.38}
|
||||||
|
fillOpacity={0.28}
|
||||||
|
glow
|
||||||
|
glowOpacity={0.07}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
) : null}
|
||||||
|
{scopeActive && cardSize.width > 0 ? (
|
||||||
|
<View pointerEvents="none" style={styles.playerSpectrumVeil} />
|
||||||
|
) : null}
|
||||||
|
<Pressable style={styles.playerArt} onPress={onOpen} accessibilityRole="button">
|
||||||
|
{track.artworkData ? (
|
||||||
|
<Image source={{ uri: track.artworkData }} style={styles.image} contentFit="cover" />
|
||||||
|
) : (
|
||||||
|
<AstraLogo size={42} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
<View style={styles.playerMeta}>
|
||||||
|
<Text variant="label" color={colors.textTertiary}>
|
||||||
|
NOW PLAYING
|
||||||
|
</Text>
|
||||||
|
<Text variant="heading" numberOfLines={1}>
|
||||||
|
{track.title}
|
||||||
|
</Text>
|
||||||
|
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
|
||||||
|
{track.album ? `${track.artist} / ${track.album}` : track.artist}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.seekTrack}>
|
||||||
|
<View style={[styles.seekFill, { width: `${progress * 100}%` }]} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.placeholderControls}>
|
||||||
|
<Pressable hitSlop={10} onPress={() => void skipToPrevious()}>
|
||||||
|
<Ionicons name="play-skip-back" size={22} color={colors.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable hitSlop={10} onPress={() => void togglePlay()} style={styles.playCircle}>
|
||||||
|
<Ionicons
|
||||||
|
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||||
|
size={18}
|
||||||
|
color={colors.bgPrimary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable hitSlop={10} onPress={() => void skipToNext()}>
|
||||||
|
<Ionicons name="play-skip-forward" size={22} color={colors.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable hitSlop={10} onPress={onOpen}>
|
||||||
|
<Ionicons name="expand-outline" size={21} color={colors.textTertiary} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentlyAddedAlbum({
|
||||||
|
album,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
album: Album;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable style={styles.recentAlbum} onPress={onPress} accessibilityRole="button">
|
||||||
|
<AlbumCover album={album} size={112} />
|
||||||
|
<Text variant="body" numberOfLines={1} style={styles.recentAlbumTitle}>
|
||||||
|
{album.album}
|
||||||
|
</Text>
|
||||||
|
<Text variant="label" numberOfLines={1}>
|
||||||
|
{album.artist}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RandomAlbumCard({
|
||||||
|
album,
|
||||||
|
tracks,
|
||||||
|
onPlay,
|
||||||
|
onShuffle,
|
||||||
|
onReroll,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
album: Album;
|
||||||
|
tracks: DbTrack[];
|
||||||
|
onPlay: () => void;
|
||||||
|
onShuffle: () => void;
|
||||||
|
onReroll: () => void;
|
||||||
|
onOpen: () => void;
|
||||||
|
}) {
|
||||||
|
const disabled = tracks.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.randomCard}>
|
||||||
|
<Pressable style={styles.randomMain} onPress={onOpen} accessibilityRole="button">
|
||||||
|
<AlbumCover album={album} size={96} />
|
||||||
|
<View style={styles.randomMeta}>
|
||||||
|
<Text variant="label" color={colors.textTertiary}>
|
||||||
|
RANDOM ALBUM
|
||||||
|
</Text>
|
||||||
|
<Text variant="heading" numberOfLines={2}>
|
||||||
|
{album.album}
|
||||||
|
</Text>
|
||||||
|
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
|
||||||
|
{album.artist}
|
||||||
|
</Text>
|
||||||
|
<Text variant="label" numberOfLines={1}>
|
||||||
|
{albumMeta(album, tracks)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={({ pressed }) => [styles.cta, pressed && styles.ctaPressed]}
|
style={styles.reroll}
|
||||||
onPress={() => {
|
onPress={onReroll}
|
||||||
void playSample();
|
hitSlop={8}
|
||||||
}}
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Pick another random album"
|
||||||
>
|
>
|
||||||
<Ionicons name="play" size={20} color={colors.bgPrimary} />
|
<Ionicons name="shuffle" size={19} color={colors.textSecondary} />
|
||||||
<Text style={styles.ctaText}>Play sample track</Text>
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={styles.randomActions}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.primaryButton, disabled && styles.buttonDisabled]}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPlay}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
<Ionicons name="play" size={16} color={colors.bgPrimary} />
|
||||||
|
<Text variant="body" style={styles.primaryButtonText}>
|
||||||
|
Play
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.secondaryButton, disabled && styles.buttonDisabled]}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onShuffle}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
<Ionicons name="shuffle" size={16} color={colors.accent} />
|
||||||
|
<Text variant="body" color={colors.accent}>
|
||||||
|
Shuffle
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHomeCard({
|
||||||
|
isScanning,
|
||||||
|
scanError,
|
||||||
|
onAddFolder,
|
||||||
|
}: {
|
||||||
|
isScanning: boolean;
|
||||||
|
scanError: string | null;
|
||||||
|
onAddFolder: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyCard}>
|
||||||
|
<Ionicons name="folder-open-outline" size={34} color={colors.textTertiary} />
|
||||||
|
<View style={styles.emptyCopy}>
|
||||||
|
<Text variant="heading">No music yet</Text>
|
||||||
|
<Text variant="body" color={colors.textSecondary}>
|
||||||
|
Add a local folder to fill Home with albums, history, favorites, and playlists.
|
||||||
|
</Text>
|
||||||
|
{scanError ? (
|
||||||
|
<Text variant="caption" color={colors.warning} numberOfLines={2}>
|
||||||
|
Scan problem: {scanError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.primaryButton, isScanning && styles.buttonDisabled]}
|
||||||
|
disabled={isScanning}
|
||||||
|
onPress={onAddFolder}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={18} color={colors.bgPrimary} />
|
||||||
|
<Text variant="body" style={styles.primaryButtonText}>
|
||||||
|
Add folder
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const tracks = useLibraryStore((s) => s.tracks);
|
||||||
|
const albums = useLibraryStore((s) => s.albums);
|
||||||
|
const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks);
|
||||||
|
const isScanning = useLibraryStore((s) => s.isScanning);
|
||||||
|
const scanError = useLibraryStore((s) => s.scanError);
|
||||||
|
const addFolder = useLibraryStore((s) => s.addFolder);
|
||||||
|
const playlists = usePlaylistStore((s) => s.playlists);
|
||||||
|
const favoriteTracks = usePlaylistStore((s) => s.favoriteTracks);
|
||||||
|
const currentTrack = usePlayerStore((s) => s.currentTrack);
|
||||||
|
const currentPath = currentTrack?.path;
|
||||||
|
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||||
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||||
|
const duration = usePlayerStore((s) => s.duration);
|
||||||
|
|
||||||
|
const [randomAlbumKey, setRandomAlbumKey] = useState<string | null>(null);
|
||||||
|
const [randomSeed] = useState(() => Math.random());
|
||||||
|
|
||||||
|
const tracksByAlbum = useMemo(() => {
|
||||||
|
const map = new Map<string, DbTrack[]>();
|
||||||
|
for (const track of tracks) {
|
||||||
|
const list = map.get(track.album_identity_key) ?? [];
|
||||||
|
list.push(track);
|
||||||
|
map.set(track.album_identity_key, list);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const recentlyAddedAlbums = useMemo(
|
||||||
|
() => [...albums].sort((a, b) => b.latest_added_at - a.latest_added_at).slice(0, RECENT_ALBUM_LIMIT),
|
||||||
|
[albums]
|
||||||
|
);
|
||||||
|
|
||||||
|
const homePlaylists = useMemo(
|
||||||
|
() =>
|
||||||
|
[...playlists]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.last_played_at ?? b.updated_at ?? b.created_at) -
|
||||||
|
(a.last_played_at ?? a.updated_at ?? a.created_at)
|
||||||
|
)
|
||||||
|
.slice(0, PLAYLIST_LIMIT),
|
||||||
|
[playlists]
|
||||||
|
);
|
||||||
|
|
||||||
|
const randomAlbum = useMemo(() => {
|
||||||
|
if (!albums.length) return null;
|
||||||
|
const selected = randomAlbumKey
|
||||||
|
? albums.find((album) => album.identity_key === randomAlbumKey)
|
||||||
|
: null;
|
||||||
|
if (selected) return selected;
|
||||||
|
return albums[Math.floor(randomSeed * albums.length) % albums.length];
|
||||||
|
}, [albums, randomAlbumKey, randomSeed]);
|
||||||
|
const randomTracks = randomAlbum ? (tracksByAlbum.get(randomAlbum.identity_key) ?? []) : [];
|
||||||
|
const recentTracks = recentlyPlayedTracks.slice(0, RECENT_TRACK_LIMIT);
|
||||||
|
const canExpandRecentTracks = recentlyPlayedTracks.length > RECENT_TRACK_LIMIT;
|
||||||
|
const hasLibrary = tracks.length > 0;
|
||||||
|
|
||||||
|
const openAlbum = (album: Album) => {
|
||||||
|
router.push({
|
||||||
|
pathname: '/library/album/[key]',
|
||||||
|
params: { key: album.identity_key },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const playTrackList = (list: DbTrack[], index = 0) => {
|
||||||
|
if (list.length === 0) return;
|
||||||
|
void playTracks(list.map(dbTrackToTrack), index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const playAlbum = (album: Album, shuffled = false) => {
|
||||||
|
const albumTracks = tracksByAlbum.get(album.identity_key) ?? [];
|
||||||
|
if (albumTracks.length === 0) return;
|
||||||
|
if (shuffled) {
|
||||||
|
void shuffleTracks(albumTracks.map(dbTrackToTrack));
|
||||||
|
} else {
|
||||||
|
void playTracks(albumTracks.map(dbTrackToTrack), 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<AstraLogo size={36} />
|
||||||
|
<Text style={styles.wordmark}>ASTRA</Text>
|
||||||
|
</View>
|
||||||
|
<Text variant="label" style={styles.tagline}>
|
||||||
|
Audiophile player
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ScanProgress />
|
||||||
|
|
||||||
|
{!hasLibrary ? (
|
||||||
|
<>
|
||||||
|
{currentTrack ? (
|
||||||
|
<View style={styles.topFeature}>
|
||||||
|
<NowPlayingCard
|
||||||
|
track={currentTrack}
|
||||||
|
playbackState={playbackState}
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
onOpen={() => router.push('/now-playing')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<EmptyHomeCard
|
||||||
|
isScanning={isScanning}
|
||||||
|
scanError={scanError}
|
||||||
|
onAddFolder={() => void addFolder()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={styles.topFeature}>
|
||||||
|
{currentTrack ? (
|
||||||
|
<NowPlayingCard
|
||||||
|
track={currentTrack}
|
||||||
|
playbackState={playbackState}
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
onOpen={() => router.push('/now-playing')}
|
||||||
|
/>
|
||||||
|
) : randomAlbum ? (
|
||||||
|
<RandomAlbumCard
|
||||||
|
album={randomAlbum}
|
||||||
|
tracks={randomTracks}
|
||||||
|
onOpen={() => openAlbum(randomAlbum)}
|
||||||
|
onPlay={() => playAlbum(randomAlbum)}
|
||||||
|
onShuffle={() => playAlbum(randomAlbum, true)}
|
||||||
|
onReroll={() => setRandomAlbumKey(chooseRandomAlbum(albums, randomAlbum.identity_key))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<SectionHeader
|
||||||
|
title="Recently Played"
|
||||||
|
trailing={
|
||||||
|
recentlyPlayedTracks.length > 0
|
||||||
|
? formatCount(recentlyPlayedTracks.length, 'track')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
actionLabel={
|
||||||
|
canExpandRecentTracks ? 'See all' : undefined
|
||||||
|
}
|
||||||
|
onActionPress={
|
||||||
|
canExpandRecentTracks ? () => router.push('/recently-played') : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{recentTracks.length > 0 ? (
|
||||||
|
<View style={styles.listBlock}>
|
||||||
|
{recentTracks.map((track, index) => (
|
||||||
|
<TrackRow
|
||||||
|
key={track.path}
|
||||||
|
track={track}
|
||||||
|
active={track.path === currentPath}
|
||||||
|
swipeToQueue={false}
|
||||||
|
onPress={() => playTrackList(recentTracks, index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text variant="body" color={colors.textSecondary} style={styles.emptyLine}>
|
||||||
|
No recent plays yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<SectionHeader title="Recently Added" />
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.albumRail}
|
||||||
|
>
|
||||||
|
{recentlyAddedAlbums.map((album) => (
|
||||||
|
<RecentlyAddedAlbum
|
||||||
|
key={album.identity_key}
|
||||||
|
album={album}
|
||||||
|
onPress={() => openAlbum(album)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<SectionHeader title="Favorites & Playlists" />
|
||||||
|
<View style={styles.listBlock}>
|
||||||
|
<PlaylistRow
|
||||||
|
name="Favorites"
|
||||||
|
trackCount={favoriteTracks.length}
|
||||||
|
coverHash={favoriteTracks[0]?.artwork_hash ?? null}
|
||||||
|
pinned
|
||||||
|
onPress={() => router.push('/library/playlist/favorites')}
|
||||||
|
/>
|
||||||
|
{homePlaylists.map((playlist) => (
|
||||||
|
<PlaylistRow
|
||||||
|
key={playlist.id}
|
||||||
|
name={playlist.name}
|
||||||
|
trackCount={playlist.track_count}
|
||||||
|
missingCount={playlist.missing_track_count}
|
||||||
|
coverHash={playlist.auto_cover_hash}
|
||||||
|
onPress={() => router.push(`/library/playlist/${playlist.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
content: {
|
||||||
|
paddingBottom: spacing.xxl,
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -61,37 +572,209 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
},
|
},
|
||||||
card: {
|
topFeature: {
|
||||||
marginTop: spacing.xxl,
|
marginTop: spacing.xxl,
|
||||||
|
},
|
||||||
|
playerCard: {
|
||||||
|
minHeight: PLAYER_CARD_MIN_HEIGHT,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
gap: spacing.lg,
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
borderRadius: radius.lg,
|
borderRadius: radius.md,
|
||||||
backgroundColor: colors.glassBg,
|
backgroundColor: colors.glassBg,
|
||||||
borderColor: colors.glassBorder,
|
borderColor: colors.glassBorder,
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
cardBody: {
|
playerSpectrum: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
playerSpectrumVeil: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(8, 10, 15, 0.28)',
|
||||||
|
},
|
||||||
|
playerArt: {
|
||||||
|
width: 112,
|
||||||
|
aspectRatio: 1,
|
||||||
|
alignSelf: 'center',
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.bgTertiary,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
playerMeta: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
seekTrack: {
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: colors.glassBorder,
|
||||||
|
overflow: 'hidden',
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
lineHeight: 20,
|
|
||||||
},
|
},
|
||||||
badges: {
|
seekFill: {
|
||||||
marginTop: spacing.md,
|
width: '38%',
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: colors.accent,
|
||||||
},
|
},
|
||||||
cta: {
|
placeholderControls: {
|
||||||
marginTop: spacing.lg,
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
maxWidth: 220,
|
||||||
|
},
|
||||||
|
playCircle: {
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 19,
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginTop: spacing.xl,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
minHeight: 32,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: spacing.md,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
sectionTitleGroup: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
seeAllButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
paddingLeft: spacing.sm,
|
||||||
|
},
|
||||||
|
albumRail: {
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingRight: spacing.lg,
|
||||||
|
},
|
||||||
|
recentAlbum: {
|
||||||
|
width: 112,
|
||||||
|
},
|
||||||
|
recentAlbumTitle: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
albumArt: {
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.bgTertiary,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
randomCard: {
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.glassBg,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
randomMain: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
randomMeta: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
reroll: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.glassHighlight,
|
||||||
|
},
|
||||||
|
randomActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
minHeight: 40,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: spacing.sm,
|
gap: spacing.xs,
|
||||||
backgroundColor: colors.accent,
|
backgroundColor: colors.accent,
|
||||||
paddingVertical: spacing.md,
|
borderRadius: radius.pill,
|
||||||
borderRadius: radius.md,
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
},
|
},
|
||||||
ctaPressed: {
|
primaryButtonText: {
|
||||||
backgroundColor: colors.accentHover,
|
|
||||||
},
|
|
||||||
ctaText: {
|
|
||||||
fontFamily: fonts.sans.semibold,
|
|
||||||
fontSize: 15,
|
|
||||||
color: colors.bgPrimary,
|
color: colors.bgPrimary,
|
||||||
|
fontFamily: fonts.sans.semibold,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
minHeight: 40,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
borderColor: colors.accent,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.45,
|
||||||
|
},
|
||||||
|
listBlock: {
|
||||||
|
backgroundColor: colors.bgPrimary,
|
||||||
|
},
|
||||||
|
emptyLine: {
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
},
|
||||||
|
emptyCard: {
|
||||||
|
marginTop: spacing.xl,
|
||||||
|
padding: spacing.lg,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.glassBg,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
emptyCopy: {
|
||||||
|
gap: spacing.xs,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Pressable, StyleSheet, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { FlashList } from '@shopify/flash-list';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Screen } from '@/components/Screen';
|
||||||
|
import { Text } from '@/components/Text';
|
||||||
|
import { TrackRow } from '@/components/library/TrackRow';
|
||||||
|
import { colors, spacing } from '@/theme';
|
||||||
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { playTracks } from '@/audio/playbackController';
|
||||||
|
import { dbTrackToTrack } from '@/library/trackAdapter';
|
||||||
|
|
||||||
|
function formatCount(count: number, noun: string): string {
|
||||||
|
return `${count} ${count === 1 ? noun : `${noun}s`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyList() {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Ionicons name="time-outline" size={24} color={colors.textTertiary} />
|
||||||
|
<Text variant="body" color={colors.textSecondary} style={styles.emptyText}>
|
||||||
|
No recent plays yet.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecentlyPlayedScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const tracks = useLibraryStore((s) => s.recentlyPlayedTracks);
|
||||||
|
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
|
||||||
|
|
||||||
|
const playFrom = (index: number) => {
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
void playTracks(tracks.map(dbTrackToTrack), index);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
|
||||||
|
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
|
||||||
|
<Text variant="body" color={colors.textSecondary}>
|
||||||
|
Home
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={styles.heading}>
|
||||||
|
<Text variant="title" numberOfLines={1}>
|
||||||
|
Recently Played
|
||||||
|
</Text>
|
||||||
|
<Text variant="label">{formatCount(tracks.length, 'track')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlashList
|
||||||
|
data={tracks}
|
||||||
|
keyExtractor={(track) => track.path}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TrackRow
|
||||||
|
track={item}
|
||||||
|
active={item.path === currentPath}
|
||||||
|
swipeToQueue={false}
|
||||||
|
onPress={() => playFrom(index)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={<EmptyList />}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
back: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
gap: spacing.xs,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingBottom: spacing.xxl,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingVertical: spacing.xxl,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
State,
|
State,
|
||||||
useActiveTrack,
|
useActiveTrack,
|
||||||
@@ -6,9 +6,19 @@ import {
|
|||||||
useProgress,
|
useProgress,
|
||||||
} from 'react-native-track-player';
|
} from 'react-native-track-player';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
import type { PlaybackState } from '@/types/audio';
|
import type { PlaybackState } from '@/types/audio';
|
||||||
import { rntpToTrack } from './sampleTracks';
|
import { rntpToTrack } from './sampleTracks';
|
||||||
|
|
||||||
|
const RECENT_PLAY_THRESHOLD_MS = 15_000;
|
||||||
|
|
||||||
|
interface RecentPlayCandidate {
|
||||||
|
path: string | null;
|
||||||
|
accumulatedMs: number;
|
||||||
|
playingSinceMs: number | null;
|
||||||
|
recorded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function mapState(state?: State): PlaybackState {
|
function mapState(state?: State): PlaybackState {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case State.Playing:
|
case State.Playing:
|
||||||
@@ -32,10 +42,18 @@ export function usePlaybackSync(): void {
|
|||||||
const activeTrack = useActiveTrack();
|
const activeTrack = useActiveTrack();
|
||||||
const progress = useProgress(500);
|
const progress = useProgress(500);
|
||||||
const playbackState = usePlaybackState();
|
const playbackState = usePlaybackState();
|
||||||
|
const mappedPlaybackState = mapState(playbackState.state);
|
||||||
|
const recentPlayCandidate = useRef<RecentPlayCandidate>({
|
||||||
|
path: null,
|
||||||
|
accumulatedMs: 0,
|
||||||
|
playingSinceMs: null,
|
||||||
|
recorded: false,
|
||||||
|
});
|
||||||
|
|
||||||
const setCurrentTrack = usePlayerStore((s) => s.setCurrentTrack);
|
const setCurrentTrack = usePlayerStore((s) => s.setCurrentTrack);
|
||||||
const setProgress = usePlayerStore((s) => s.setProgress);
|
const setProgress = usePlayerStore((s) => s.setProgress);
|
||||||
const setPlaybackState = usePlayerStore((s) => s.setPlaybackState);
|
const setPlaybackState = usePlayerStore((s) => s.setPlaybackState);
|
||||||
|
const recordTrackPlayed = useLibraryStore((s) => s.recordTrackPlayed);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentTrack(activeTrack ? rntpToTrack(activeTrack) : null);
|
setCurrentTrack(activeTrack ? rntpToTrack(activeTrack) : null);
|
||||||
@@ -46,6 +64,49 @@ export function usePlaybackSync(): void {
|
|||||||
}, [progress.position, progress.duration, setProgress]);
|
}, [progress.position, progress.duration, setProgress]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPlaybackState(mapState(playbackState.state));
|
setPlaybackState(mappedPlaybackState);
|
||||||
}, [playbackState.state, setPlaybackState]);
|
}, [mappedPlaybackState, setPlaybackState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const path = activeTrack?.url ? String(activeTrack.url) : null;
|
||||||
|
const now = Date.now();
|
||||||
|
const candidate = recentPlayCandidate.current;
|
||||||
|
|
||||||
|
if (!path || mappedPlaybackState === 'stopped') {
|
||||||
|
recentPlayCandidate.current = {
|
||||||
|
path: null,
|
||||||
|
accumulatedMs: 0,
|
||||||
|
playingSinceMs: null,
|
||||||
|
recorded: false,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.path !== path) {
|
||||||
|
candidate.path = path;
|
||||||
|
candidate.accumulatedMs = 0;
|
||||||
|
candidate.playingSinceMs = null;
|
||||||
|
candidate.recorded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedPlaybackState !== 'playing') {
|
||||||
|
if (candidate.playingSinceMs != null) {
|
||||||
|
candidate.accumulatedMs += now - candidate.playingSinceMs;
|
||||||
|
candidate.playingSinceMs = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.playingSinceMs == null) {
|
||||||
|
candidate.playingSinceMs = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedMs = candidate.accumulatedMs + (now - candidate.playingSinceMs);
|
||||||
|
if (candidate.recorded || elapsedMs < RECENT_PLAY_THRESHOLD_MS) return;
|
||||||
|
|
||||||
|
candidate.recorded = true;
|
||||||
|
void recordTrackPlayed(path).catch((err) => {
|
||||||
|
console.warn('[library] playback history update failed', err);
|
||||||
|
});
|
||||||
|
}, [activeTrack?.url, mappedPlaybackState, progress.position, recordTrackPlayed]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ interface TabBarProps {
|
|||||||
*/
|
*/
|
||||||
export function TabBar({ items, onPress }: TabBarProps) {
|
export function TabBar({ items, onPress }: TabBarProps) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const homeFocused = items.some((item) => item.name === 'index' && item.focused);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrap}>
|
<View style={styles.wrap}>
|
||||||
<MiniPlayer />
|
{!homeFocused ? <MiniPlayer /> : null}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.bar,
|
styles.bar,
|
||||||
|
|||||||
+33
-1
@@ -105,7 +105,8 @@ export function getAlbums(db: LibraryDatabase): Promise<Album[]> {
|
|||||||
MAX(COALESCE(album_artist, artist)) AS artist,
|
MAX(COALESCE(album_artist, artist)) AS artist,
|
||||||
MAX(year) AS year,
|
MAX(year) AS year,
|
||||||
MAX(artwork_hash) AS artwork_hash,
|
MAX(artwork_hash) AS artwork_hash,
|
||||||
COUNT(*) AS track_count
|
COUNT(*) AS track_count,
|
||||||
|
MAX(added_at) AS latest_added_at
|
||||||
FROM tracks
|
FROM tracks
|
||||||
GROUP BY album_identity_key
|
GROUP BY album_identity_key
|
||||||
ORDER BY 3 COLLATE NOCASE, 2 COLLATE NOCASE
|
ORDER BY 3 COLLATE NOCASE, 2 COLLATE NOCASE
|
||||||
@@ -141,6 +142,37 @@ export async function getTrackCount(db: LibraryDatabase): Promise<number> {
|
|||||||
return row?.count ?? 0;
|
return row?.count ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Playback history --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a local library play. The INSERT is sourced from `tracks`, so streamed
|
||||||
|
* samples / external paths are ignored unless they are actually in the library.
|
||||||
|
*/
|
||||||
|
export async function markTrackPlayed(db: LibraryDatabase, path: string): Promise<boolean> {
|
||||||
|
const result = await db.run(
|
||||||
|
`INSERT INTO playback_history (track_path, last_played_at, play_count)
|
||||||
|
SELECT path, ?, 1 FROM tracks WHERE path = ?
|
||||||
|
ON CONFLICT(track_path) DO UPDATE SET
|
||||||
|
last_played_at = excluded.last_played_at,
|
||||||
|
play_count = playback_history.play_count + 1`,
|
||||||
|
[Date.now(), path]
|
||||||
|
);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentlyPlayedTracks(
|
||||||
|
db: LibraryDatabase,
|
||||||
|
limit = 24
|
||||||
|
): Promise<DbTrack[]> {
|
||||||
|
return db.all<DbTrack>(
|
||||||
|
`SELECT t.* FROM playback_history h
|
||||||
|
JOIN tracks t ON t.path = h.track_path
|
||||||
|
ORDER BY h.last_played_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Loudness (M4 normalization facts) ---------------------------------------
|
// --- Loudness (M4 normalization facts) ---------------------------------------
|
||||||
|
|
||||||
export interface TrackLoudness {
|
export interface TrackLoudness {
|
||||||
|
|||||||
+13
-2
@@ -9,11 +9,12 @@
|
|||||||
// tags) measured for normalization; v8 clears any loudness measured by the earlier
|
// tags) measured for normalization; v8 clears any loudness measured by the earlier
|
||||||
// ungated whole-file method so it re-measures with the fast gated subset method;
|
// ungated whole-file method so it re-measures with the fast gated subset method;
|
||||||
// v9 adds ReplayGain peak columns + an `rg_scanned` sentinel so tag reading runs
|
// v9 adds ReplayGain peak columns + an `rg_scanned` sentinel so tag reading runs
|
||||||
// once per track (and is retried if it ever failed), independent of loudness.
|
// once per track (and is retried if it ever failed), independent of loudness;
|
||||||
|
// v10 adds lightweight local playback history for Home.
|
||||||
|
|
||||||
import type { LibraryDatabase } from './database';
|
import type { LibraryDatabase } from './database';
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 9;
|
export const SCHEMA_VERSION = 10;
|
||||||
|
|
||||||
// One statement per entry — op-sqlite executes single statements.
|
// One statement per entry — op-sqlite executes single statements.
|
||||||
const MIGRATIONS: readonly (readonly string[])[] = [
|
const MIGRATIONS: readonly (readonly string[])[] = [
|
||||||
@@ -141,6 +142,16 @@ const MIGRATIONS: readonly (readonly string[])[] = [
|
|||||||
`ALTER TABLE tracks ADD COLUMN replay_gain_album_peak REAL`,
|
`ALTER TABLE tracks ADD COLUMN replay_gain_album_peak REAL`,
|
||||||
`ALTER TABLE tracks ADD COLUMN rg_scanned INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE tracks ADD COLUMN rg_scanned INTEGER NOT NULL DEFAULT 0`,
|
||||||
],
|
],
|
||||||
|
// v9 -> v10 — recently played facts. No FK so rows can survive temporary
|
||||||
|
// folder removal; Home joins against tracks so missing files stay hidden.
|
||||||
|
[
|
||||||
|
`CREATE TABLE IF NOT EXISTS playback_history (
|
||||||
|
track_path TEXT PRIMARY KEY NOT NULL,
|
||||||
|
last_played_at INTEGER NOT NULL,
|
||||||
|
play_count INTEGER NOT NULL DEFAULT 1
|
||||||
|
)`,
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_playback_history_last_played ON playback_history(last_played_at DESC)',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function migrate(db: LibraryDatabase): Promise<void> {
|
export async function migrate(db: LibraryDatabase): Promise<void> {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface ArtistAlbum {
|
|||||||
year: number | null;
|
year: number | null;
|
||||||
artwork_hash: string | null;
|
artwork_hash: string | null;
|
||||||
track_count: number;
|
track_count: number;
|
||||||
|
latest_added_at: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ function buildArtistAlbums(tracks: readonly DbTrack[]): ArtistAlbum[] {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
existing.track_count += 1;
|
existing.track_count += 1;
|
||||||
existing.duration += track.duration;
|
existing.duration += track.duration;
|
||||||
|
existing.latest_added_at = Math.max(existing.latest_added_at, track.added_at);
|
||||||
if (existing.year == null && track.year != null) existing.year = track.year;
|
if (existing.year == null && track.year != null) existing.year = track.year;
|
||||||
if (!existing.artwork_hash && track.artwork_hash) existing.artwork_hash = track.artwork_hash;
|
if (!existing.artwork_hash && track.artwork_hash) existing.artwork_hash = track.artwork_hash;
|
||||||
continue;
|
continue;
|
||||||
@@ -89,6 +91,7 @@ function buildArtistAlbums(tracks: readonly DbTrack[]): ArtistAlbum[] {
|
|||||||
year: track.year,
|
year: track.year,
|
||||||
artwork_hash: track.artwork_hash,
|
artwork_hash: track.artwork_hash,
|
||||||
track_count: 1,
|
track_count: 1,
|
||||||
|
latest_added_at: track.added_at,
|
||||||
duration: track.duration,
|
duration: track.duration,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library';
|
import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library';
|
||||||
import { openLibraryDb } from '@/db/database';
|
import { openLibraryDb } from '@/db/database';
|
||||||
import { getAlbums, getAllTracks, getTrackCount } from '@/db/queries';
|
import {
|
||||||
|
getAlbums,
|
||||||
|
getAllTracks,
|
||||||
|
getRecentlyPlayedTracks,
|
||||||
|
getTrackCount,
|
||||||
|
markTrackPlayed,
|
||||||
|
} from '@/db/queries';
|
||||||
import { ensureArtworkThumbnails } from '@/library/artwork';
|
import { ensureArtworkThumbnails } from '@/library/artwork';
|
||||||
import { buildArtistList } from '@/library/artistGrouping';
|
import { buildArtistList } from '@/library/artistGrouping';
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +42,7 @@ const IDLE_PROGRESS: ScanProgressState = { phase: 'idle', processed: 0, total: 0
|
|||||||
interface LibraryStore {
|
interface LibraryStore {
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
tracks: DbTrack[];
|
tracks: DbTrack[];
|
||||||
|
recentlyPlayedTracks: DbTrack[];
|
||||||
albums: Album[];
|
albums: Album[];
|
||||||
artists: Artist[];
|
artists: Artist[];
|
||||||
folders: FolderWithCount[];
|
folders: FolderWithCount[];
|
||||||
@@ -48,6 +55,7 @@ interface LibraryStore {
|
|||||||
|
|
||||||
initialize: () => Promise<void>;
|
initialize: () => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
|
recordTrackPlayed: (path: string) => Promise<void>;
|
||||||
recomputeArtists: () => void;
|
recomputeArtists: () => void;
|
||||||
setViewMode: (mode: ViewMode) => void;
|
setViewMode: (mode: ViewMode) => void;
|
||||||
setTrackSort: (sort: TrackSort) => void;
|
setTrackSort: (sort: TrackSort) => void;
|
||||||
@@ -78,6 +86,7 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
|||||||
return {
|
return {
|
||||||
initialized: false,
|
initialized: false,
|
||||||
tracks: [],
|
tracks: [],
|
||||||
|
recentlyPlayedTracks: [],
|
||||||
albums: [],
|
albums: [],
|
||||||
artists: [],
|
artists: [],
|
||||||
folders: [],
|
folders: [],
|
||||||
@@ -118,11 +127,12 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
|||||||
|
|
||||||
refresh: async () => {
|
refresh: async () => {
|
||||||
const db = await openLibraryDb();
|
const db = await openLibraryDb();
|
||||||
const [tracks, albums, folders, totalTrackCount] = await Promise.all([
|
const [tracks, albums, folders, totalTrackCount, recentlyPlayedTracks] = await Promise.all([
|
||||||
getAllTracks(db),
|
getAllTracks(db),
|
||||||
getAlbums(db),
|
getAlbums(db),
|
||||||
loadFolders(),
|
loadFolders(),
|
||||||
getTrackCount(db),
|
getTrackCount(db),
|
||||||
|
getRecentlyPlayedTracks(db),
|
||||||
]);
|
]);
|
||||||
try {
|
try {
|
||||||
await ensureArtworkThumbnails(tracks.map((track) => track.artwork_hash));
|
await ensureArtworkThumbnails(tracks.map((track) => track.artwork_hash));
|
||||||
@@ -131,11 +141,19 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
|||||||
}
|
}
|
||||||
// The artist list is derived in JS so it can honor the grouping mode.
|
// The artist list is derived in JS so it can honor the grouping mode.
|
||||||
const artists = buildArtistList(tracks, useSettingsStore.getState().artistGroupingMode);
|
const artists = buildArtistList(tracks, useSettingsStore.getState().artistGroupingMode);
|
||||||
set({ tracks, albums, artists, folders, totalTrackCount });
|
set({ tracks, recentlyPlayedTracks, albums, artists, folders, totalTrackCount });
|
||||||
// Playlist counts/missing states depend on tracks — keep them in step.
|
// Playlist counts/missing states depend on tracks — keep them in step.
|
||||||
await usePlaylistStore.getState().refresh();
|
await usePlaylistStore.getState().refresh();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
recordTrackPlayed: async (path) => {
|
||||||
|
const db = await openLibraryDb();
|
||||||
|
const recorded = await markTrackPlayed(db, path);
|
||||||
|
if (!recorded) return;
|
||||||
|
const recentlyPlayedTracks = await getRecentlyPlayedTracks(db);
|
||||||
|
set({ recentlyPlayedTracks });
|
||||||
|
},
|
||||||
|
|
||||||
// Rebuild the artist list from in-memory tracks (e.g. on grouping-mode change),
|
// Rebuild the artist list from in-memory tracks (e.g. on grouping-mode change),
|
||||||
// without re-querying SQLite.
|
// without re-querying SQLite.
|
||||||
recomputeArtists: () =>
|
recomputeArtists: () =>
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface Album {
|
|||||||
year: number | null;
|
year: number | null;
|
||||||
artwork_hash: string | null;
|
artwork_hash: string | null;
|
||||||
track_count: number;
|
track_count: number;
|
||||||
|
/** Newest track import timestamp in this album, used by Home recently-added. */
|
||||||
|
latest_added_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Artist {
|
export interface Artist {
|
||||||
|
|||||||
Reference in New Issue
Block a user