From 96702ba7f020abd1db61dd735fec86892ed56a5d Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:21:35 -0400 Subject: [PATCH] initial home screen --- src/app/(tabs)/index.tsx | 771 +++++++++++++++++++++++++++++++++-- src/app/recently-played.tsx | 99 +++++ src/audio/usePlaybackSync.ts | 67 ++- src/components/TabBar.tsx | 4 +- src/db/queries.ts | 34 +- src/db/schema.ts | 15 +- src/library/artistDetail.ts | 3 + src/stores/libraryStore.ts | 24 +- src/types/library.ts | 2 + 9 files changed, 965 insertions(+), 54 deletions(-) create mode 100644 src/app/recently-played.tsx diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index bd3b794..fa3217b 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -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 { useRouter } from 'expo-router'; import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; 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 { playSample } from '@/audio/playbackController'; -import { SAMPLE_TRACKS } from '@/audio/sampleTracks'; +import { useLibraryStore } from '@/stores/libraryStore'; +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 ( - - - - ASTRA - - - Audiophile player - - - - Quick start - - On-device library scanning lands next. For now, stream a test track to - verify playback, background audio, and lock-screen controls. + + + + {title} + {trailing ? ( + + {trailing} + + ) : null} + + {onActionPress && actionLabel ? ( + + + {actionLabel} + + + + ) : null} + + ); +} - - +function AlbumCover({ album, size }: { album: Album; size: number }) { + return ( + + {album.artwork_hash ? ( + + ) : ( + + )} + + ); +} + +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 ( + + {scopeActive && cardSize.width > 0 ? ( + + + ) : null} + {scopeActive && cardSize.width > 0 ? ( + + ) : null} + + {track.artworkData ? ( + + ) : ( + + )} + + + + NOW PLAYING + + + {track.title} + + + {track.album ? `${track.artist} / ${track.album}` : track.artist} + + + + + + void skipToPrevious()}> + + + void togglePlay()} style={styles.playCircle}> + + + void skipToNext()}> + + + + + + + + + ); +} +function RecentlyAddedAlbum({ + album, + onPress, +}: { + album: Album; + onPress: () => void; +}) { + return ( + + + + {album.album} + + + {album.artist} + + + ); +} + +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 ( + + + + + + RANDOM ALBUM + + + {album.album} + + + {album.artist} + + + {albumMeta(album, tracks)} + + [styles.cta, pressed && styles.ctaPressed]} - onPress={() => { - void playSample(); - }} + style={styles.reroll} + onPress={onReroll} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Pick another random album" > - - Play sample track + + + + + + + + + Play + + + + + + Shuffle + + + ); +} + +function EmptyHomeCard({ + isScanning, + scanError, + onAddFolder, +}: { + isScanning: boolean; + scanError: string | null; + onAddFolder: () => void; +}) { + return ( + + + + No music yet + + Add a local folder to fill Home with albums, history, favorites, and playlists. + + {scanError ? ( + + Scan problem: {scanError} + + ) : null} + + + + + Add folder + + + + ); +} + +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(null); + const [randomSeed] = useState(() => Math.random()); + + const tracksByAlbum = useMemo(() => { + const map = new Map(); + 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 ( + + + + + ASTRA + + + Audiophile player + + + + + {!hasLibrary ? ( + <> + {currentTrack ? ( + + router.push('/now-playing')} + /> + + ) : null} + void addFolder()} + /> + + ) : ( + <> + + {currentTrack ? ( + router.push('/now-playing')} + /> + ) : randomAlbum ? ( + openAlbum(randomAlbum)} + onPlay={() => playAlbum(randomAlbum)} + onShuffle={() => playAlbum(randomAlbum, true)} + onReroll={() => setRandomAlbumKey(chooseRandomAlbum(albums, randomAlbum.identity_key))} + /> + ) : null} + + + + 0 + ? formatCount(recentlyPlayedTracks.length, 'track') + : undefined + } + actionLabel={ + canExpandRecentTracks ? 'See all' : undefined + } + onActionPress={ + canExpandRecentTracks ? () => router.push('/recently-played') : undefined + } + /> + {recentTracks.length > 0 ? ( + + {recentTracks.map((track, index) => ( + playTrackList(recentTracks, index)} + /> + ))} + + ) : ( + + No recent plays yet. + + )} + + + + + + {recentlyAddedAlbums.map((album) => ( + openAlbum(album)} + /> + ))} + + + + + + + router.push('/library/playlist/favorites')} + /> + {homePlaylists.map((playlist) => ( + router.push(`/library/playlist/${playlist.id}`)} + /> + ))} + + + + )} + ); } const styles = StyleSheet.create({ + content: { + paddingBottom: spacing.xxl, + }, header: { flexDirection: 'row', alignItems: 'center', @@ -61,37 +572,209 @@ const styles = StyleSheet.create({ marginTop: spacing.xs, letterSpacing: 1, }, - card: { + topFeature: { marginTop: spacing.xxl, + }, + playerCard: { + minHeight: PLAYER_CARD_MIN_HEIGHT, + flexDirection: 'row', + alignItems: 'stretch', + gap: spacing.lg, padding: spacing.lg, - borderRadius: radius.lg, + borderRadius: radius.md, backgroundColor: colors.glassBg, borderColor: colors.glassBorder, 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, - lineHeight: 20, }, - badges: { - marginTop: spacing.md, + seekFill: { + width: '38%', + height: 3, + backgroundColor: colors.accent, }, - cta: { - marginTop: spacing.lg, + placeholderControls: { + 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', alignItems: 'center', justifyContent: 'center', - gap: spacing.sm, + gap: spacing.xs, backgroundColor: colors.accent, - paddingVertical: spacing.md, - borderRadius: radius.md, + borderRadius: radius.pill, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, }, - ctaPressed: { - backgroundColor: colors.accentHover, - }, - ctaText: { - fontFamily: fonts.sans.semibold, - fontSize: 15, + primaryButtonText: { 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, }, }); diff --git a/src/app/recently-played.tsx b/src/app/recently-played.tsx new file mode 100644 index 0000000..82ec4ad --- /dev/null +++ b/src/app/recently-played.tsx @@ -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 ( + + + + No recent plays yet. + + + ); +} + +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 ( + + router.back()} hitSlop={8}> + + + Home + + + + + + Recently Played + + {formatCount(tracks.length, 'track')} + + + track.path} + showsVerticalScrollIndicator={false} + renderItem={({ item, index }) => ( + playFrom(index)} + /> + )} + ListEmptyComponent={} + contentContainerStyle={styles.listContent} + /> + + ); +} + +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', + }, +}); diff --git a/src/audio/usePlaybackSync.ts b/src/audio/usePlaybackSync.ts index d4db79c..e934292 100644 --- a/src/audio/usePlaybackSync.ts +++ b/src/audio/usePlaybackSync.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { State, useActiveTrack, @@ -6,9 +6,19 @@ import { useProgress, } from 'react-native-track-player'; import { usePlayerStore } from '@/stores/playerStore'; +import { useLibraryStore } from '@/stores/libraryStore'; import type { PlaybackState } from '@/types/audio'; 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 { switch (state) { case State.Playing: @@ -32,10 +42,18 @@ export function usePlaybackSync(): void { const activeTrack = useActiveTrack(); const progress = useProgress(500); const playbackState = usePlaybackState(); + const mappedPlaybackState = mapState(playbackState.state); + const recentPlayCandidate = useRef({ + path: null, + accumulatedMs: 0, + playingSinceMs: null, + recorded: false, + }); const setCurrentTrack = usePlayerStore((s) => s.setCurrentTrack); const setProgress = usePlayerStore((s) => s.setProgress); const setPlaybackState = usePlayerStore((s) => s.setPlaybackState); + const recordTrackPlayed = useLibraryStore((s) => s.recordTrackPlayed); useEffect(() => { setCurrentTrack(activeTrack ? rntpToTrack(activeTrack) : null); @@ -46,6 +64,49 @@ export function usePlaybackSync(): void { }, [progress.position, progress.duration, setProgress]); useEffect(() => { - setPlaybackState(mapState(playbackState.state)); - }, [playbackState.state, setPlaybackState]); + setPlaybackState(mappedPlaybackState); + }, [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]); } diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 2660df7..8a99e65 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -32,9 +32,11 @@ interface TabBarProps { */ export function TabBar({ items, onPress }: TabBarProps) { const insets = useSafeAreaInsets(); + const homeFocused = items.some((item) => item.name === 'index' && item.focused); + return ( - + {!homeFocused ? : null} { MAX(COALESCE(album_artist, artist)) AS artist, MAX(year) AS year, MAX(artwork_hash) AS artwork_hash, - COUNT(*) AS track_count + COUNT(*) AS track_count, + MAX(added_at) AS latest_added_at FROM tracks GROUP BY album_identity_key ORDER BY 3 COLLATE NOCASE, 2 COLLATE NOCASE @@ -141,6 +142,37 @@ export async function getTrackCount(db: LibraryDatabase): Promise { 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 { + 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 { + return db.all( + `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) --------------------------------------- export interface TrackLoudness { diff --git a/src/db/schema.ts b/src/db/schema.ts index 12eec69..7b37d6d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -9,11 +9,12 @@ // 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; // 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'; -export const SCHEMA_VERSION = 9; +export const SCHEMA_VERSION = 10; // One statement per entry — op-sqlite executes single statements. 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 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 { diff --git a/src/library/artistDetail.ts b/src/library/artistDetail.ts index bc93fc9..c67f7e6 100644 --- a/src/library/artistDetail.ts +++ b/src/library/artistDetail.ts @@ -14,6 +14,7 @@ export interface ArtistAlbum { year: number | null; artwork_hash: string | null; track_count: number; + latest_added_at: number; duration: number; } @@ -77,6 +78,7 @@ function buildArtistAlbums(tracks: readonly DbTrack[]): ArtistAlbum[] { if (existing) { existing.track_count += 1; 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.artwork_hash && track.artwork_hash) existing.artwork_hash = track.artwork_hash; continue; @@ -89,6 +91,7 @@ function buildArtistAlbums(tracks: readonly DbTrack[]): ArtistAlbum[] { year: track.year, artwork_hash: track.artwork_hash, track_count: 1, + latest_added_at: track.added_at, duration: track.duration, }); } diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index df3e9c2..62233f5 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -1,7 +1,13 @@ import { create } from 'zustand'; import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library'; 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 { buildArtistList } from '@/library/artistGrouping'; import { @@ -36,6 +42,7 @@ const IDLE_PROGRESS: ScanProgressState = { phase: 'idle', processed: 0, total: 0 interface LibraryStore { initialized: boolean; tracks: DbTrack[]; + recentlyPlayedTracks: DbTrack[]; albums: Album[]; artists: Artist[]; folders: FolderWithCount[]; @@ -48,6 +55,7 @@ interface LibraryStore { initialize: () => Promise; refresh: () => Promise; + recordTrackPlayed: (path: string) => Promise; recomputeArtists: () => void; setViewMode: (mode: ViewMode) => void; setTrackSort: (sort: TrackSort) => void; @@ -78,6 +86,7 @@ export const useLibraryStore = create((set, get) => { return { initialized: false, tracks: [], + recentlyPlayedTracks: [], albums: [], artists: [], folders: [], @@ -118,11 +127,12 @@ export const useLibraryStore = create((set, get) => { refresh: async () => { const db = await openLibraryDb(); - const [tracks, albums, folders, totalTrackCount] = await Promise.all([ + const [tracks, albums, folders, totalTrackCount, recentlyPlayedTracks] = await Promise.all([ getAllTracks(db), getAlbums(db), loadFolders(), getTrackCount(db), + getRecentlyPlayedTracks(db), ]); try { await ensureArtworkThumbnails(tracks.map((track) => track.artwork_hash)); @@ -131,11 +141,19 @@ export const useLibraryStore = create((set, get) => { } // The artist list is derived in JS so it can honor the grouping mode. 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. 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), // without re-querying SQLite. recomputeArtists: () => diff --git a/src/types/library.ts b/src/types/library.ts index 03dd6d8..ed584df 100644 --- a/src/types/library.ts +++ b/src/types/library.ts @@ -55,6 +55,8 @@ export interface Album { year: number | null; artwork_hash: string | null; track_count: number; + /** Newest track import timestamp in this album, used by Home recently-added. */ + latest_added_at: number; } export interface Artist {