diff --git a/package.json b/package.json index e331bc2..d475eda 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts", "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts", "test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts", + "test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts", "test:session": "node --experimental-strip-types --test src/session/sessionState.test.mts src/session/playbackMaterialization.test.mts", "typecheck": "tsc --noEmit", "postinstall": "patch-package" diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index 2d1808c..b781da8 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -1,10 +1,11 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { + AppState, Pressable, ScrollView, StyleSheet, View, - type LayoutChangeEvent + type GestureResponderEvent, } from 'react-native'; import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; @@ -12,7 +13,6 @@ import { useRouter } from 'expo-router'; import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; import { AstraLogo } from '@/components/AstraLogo'; -import { SpectrumCurve } from '@/components/SpectrumCurve'; import { TrackRow } from '@/components/library/TrackRow'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { PlaylistRow } from '@/components/library/PlaylistRow'; @@ -29,55 +29,190 @@ import { } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; -import { rgbaFromHex } from '@/theme/colorUtils'; import { useLibraryStore } from '@/stores/libraryStore'; import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlayerStore } from '@/stores/playerStore'; -import { usePlayerUiStore } from '@/stores/playerUiStore'; import { useSearchStore } from '@/stores/searchStore'; -import { - playTracks, - shuffleTracks, - skipToNext, - skipToPrevious, - togglePlay -} from '@/audio/playbackController'; +import { useSettingsStore } from '@/stores/settingsStore'; +import { playTracks, shuffleTracks } from '@/audio/playbackController'; import { compareTracksByDiscTrackTitle } from '@/library/albumIdentity'; +import { buildArtistDetail } from '@/library/artistDetail'; +import { filterArtistBrowseList } from '@/library/artistGrouping'; import { dbTrackToTrack } from '@/library/trackAdapter'; -import { albumArtworkSource } 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'; +import { albumArtworkSource, artworkUri } from '@/library/artwork'; +import { + chooseHomeGreeting, + HOME_GREETING_ROTATION_MS, + type HomeGreetingTextMode, +} from '@/home/homeGreeting'; +import type { Album, Artist, DbTrack } from '@/types/library'; 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; +type RandomSpotlight = + | { kind: 'album'; key: string } + | { kind: 'artist'; name: string }; - let next: string | null = currentKey ?? null; - while (next === currentKey) { - next = albums[Math.floor(Math.random() * albums.length)].identity_key; +function chooseRandomSpotlight( + albums: Album[], + artists: Artist[], + current: RandomSpotlight | null = null, + random: () => number = Math.random +): RandomSpotlight | null { + const kinds: RandomSpotlight['kind'][] = []; + if (albums.length > 0) kinds.push('album'); + if (artists.length > 0) kinds.push('artist'); + if (kinds.length === 0) return null; + + let kind = kinds[Math.floor(random() * kinds.length)]; + const currentPoolSize = kind === 'album' ? albums.length : artists.length; + if (current?.kind === kind && currentPoolSize === 1 && kinds.length > 1) { + kind = kind === 'album' ? 'artist' : 'album'; } - return next; + + if (kind === 'album') { + const candidates = + current?.kind === 'album' && albums.length > 1 + ? albums.filter((album) => album.identity_key !== current.key) + : albums; + const album = candidates[Math.floor(random() * candidates.length)]; + return album ? { kind: 'album', key: album.identity_key } : null; + } + + const candidates = + current?.kind === 'artist' && artists.length > 1 + ? artists.filter((artist) => artist.artist !== current.name) + : artists; + const artist = candidates[Math.floor(random() * candidates.length)]; + return artist ? { kind: 'artist', name: artist.artist } : null; } -function albumMeta(album: Album, tracks: DbTrack[]): string { - const duration = tracks.reduce((sum, track) => sum + track.duration, 0); +function compactAlbumMeta(album: Album): string { return [ + album.artist, album.year ? String(album.year) : null, `${album.track_count} ${album.track_count === 1 ? 'track' : 'tracks'}`, - formatDuration(duration), ] .filter(Boolean) .join(' / '); } +function compactArtistMeta(artist: Artist): string { + return [ + `${artist.album_count} ${artist.album_count === 1 ? 'album' : 'albums'}`, + `${artist.track_count} ${artist.track_count === 1 ? 'track' : 'tracks'}`, + ].join(' / '); +} + +function formatHomeClockTime(date: Date): string { + return new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', + }).format(date); +} + +function formatHomeClockDate(date: Date): string { + return new Intl.DateTimeFormat(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric', + }).format(date); +} + +function HomeMasthead({ + mode, + onSearch, +}: { + mode: HomeGreetingTextMode; + onSearch: () => void; +}) { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + const [clockNow, setClockNow] = useState(() => new Date()); + const [greeting, setGreeting] = useState(() => chooseHomeGreeting(null, new Date())); + + useEffect(() => { + if (mode !== 'messages') return; + const rotateGreeting = () => { + setGreeting((current) => chooseHomeGreeting(current.id, new Date())); + }; + const interval = setInterval(rotateGreeting, HOME_GREETING_ROTATION_MS); + return () => clearInterval(interval); + }, [mode]); + + useEffect(() => { + if (mode !== 'clock') return; + + let interval: ReturnType | null = null; + const updateClock = () => setClockNow(new Date()); + updateClock(); + + const now = new Date(); + const delay = 60_000 - (now.getSeconds() * 1_000 + now.getMilliseconds()); + const timeout = setTimeout(() => { + updateClock(); + interval = setInterval(updateClock, 60_000); + }, Math.max(100, delay)); + + return () => { + clearTimeout(timeout); + if (interval) clearInterval(interval); + }; + }, [mode]); + + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + if (state !== 'active') return; + const now = new Date(); + setClockNow(now); + if (mode === 'messages') { + setGreeting((current) => chooseHomeGreeting(current.id, now)); + } + }); + return () => subscription.remove(); + }, [mode]); + + const searchButton = ( + + + + ); + + if (mode === 'off') { + return {searchButton}; + } + + const primary = mode === 'clock' ? formatHomeClockTime(clockNow) : greeting.primary; + const subline = mode === 'clock' ? formatHomeClockDate(clockNow) : greeting.subline; + + return ( + + + + {primary} + + {subline ? ( + + {subline} + + ) : null} + + {searchButton} + + ); +} + function formatCount(count: number, noun: string): string { return `${count} ${count === 1 ? noun : `${noun}s`}`; } @@ -139,102 +274,38 @@ function AlbumCover({ album, size }: { album: Album; size: number }) { ); } -/** Progress strip subscribes here so the 2Hz tick skips the card and screen. */ -function NowPlayingSeekStrip() { - const styles = useStyles(); - const currentTime = usePlayerStore((s) => s.currentTime); - const duration = usePlayerStore((s) => s.duration); - const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0; - return ( - - - - ); -} - -function NowPlayingCard({ - track, - playbackState, - onOpen, -}: { - track: Track; - playbackState: PlaybackState; - onOpen: () => void; -}) { +function ArtistCover({ artist, size }: { artist: Artist; size: number }) { const styles = useStyles(); const colors = useColors(); - const ripple = useRipple(); - const isPlaying = playbackState === 'playing'; - const isLoading = playbackState === 'loading'; - 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 } - ); - }; + const useMosaic = artist.artwork_hashes.length >= 4; + const hashes = useMosaic + ? artist.artwork_hashes.slice(0, 4) + : artist.artwork_hashes.slice(0, 1); return ( - - {scopeActive && cardSize.width > 0 ? ( - - + {hashes.length === 0 ? ( + + ) : useMosaic ? ( + hashes.map((hash) => ( + - - ) : 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} android_ripple={ripple.onAccent()} unstable_pressDelay={SCROLL_PRESS_DELAY}> - - - void skipToNext()}> - - - - - - - + )) + ) : ( + + )} ); } @@ -261,15 +332,15 @@ function RecentlyAddedAlbum({ ); } -function RandomAlbumCard({ - album, +function RandomSpotlightCard({ + spotlight, tracks, onPlay, onShuffle, onReroll, onOpen, }: { - album: Album; + spotlight: { kind: 'album'; album: Album } | { kind: 'artist'; artist: Artist }; tracks: DbTrack[]; onPlay: () => void; onShuffle: () => void; @@ -280,63 +351,81 @@ function RandomAlbumCard({ const colors = useColors(); const ripple = useRipple(); const disabled = tracks.length === 0; + const title = spotlight.kind === 'album' ? spotlight.album.album : spotlight.artist.artist; + const label = spotlight.kind === 'album' ? 'RANDOM ALBUM' : 'RANDOM ARTIST'; + const meta = spotlight.kind === 'album' + ? compactAlbumMeta(spotlight.album) + : compactArtistMeta(spotlight.artist); + const runAction = (event: GestureResponderEvent, action: () => void) => { + event.stopPropagation(); + action(); + }; return ( - - - + + + {spotlight.kind === 'album' ? ( + + ) : ( + + )} - RANDOM ALBUM + {label} - - {album.album} + + {title} - - {album.artist} - - - {albumMeta(album, tracks)} + + {meta} + + runAction(event, onPlay)} + hitSlop={{ top: 7, right: 4, bottom: 7, left: 4 }} + accessibilityRole="button" + accessibilityLabel={`Play ${title}`} + > + + + runAction(event, onShuffle)} + hitSlop={{ top: 7, right: 4, bottom: 7, left: 4 }} + accessibilityRole="button" + accessibilityLabel={`Shuffle ${title}`} + > + + + runAction(event, onReroll)} + hitSlop={{ top: 7, right: 4, bottom: 7, left: 4 }} + accessibilityRole="button" + accessibilityLabel="Pick another random album or artist" + > + + + - - - - - - - - - - Play - - - - - - Shuffle - - - + ); } @@ -381,21 +470,22 @@ function EmptyHomeCard({ export default function HomeScreen() { const styles = useStyles(); - const colors = useColors(); const router = useRouter(); const tracks = useLibraryStore((s) => s.tracks); const albums = useLibraryStore((s) => s.albums); + const artists = useLibraryStore((s) => s.artists); + const includeCollabArtists = useLibraryStore((s) => s.includeCollabArtists); const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks); const scanError = useLibraryStore((s) => s.scanError); 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 currentPath = usePlayerStore((s) => s.currentTrack?.path); const openQuickSearch = useSearchStore((s) => s.openQuickSearch); + const homeGreetingTextMode = useSettingsStore((s) => s.homeGreetingTextMode); + const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode); - const [randomAlbumKey, setRandomAlbumKey] = useState(null); - const [randomSeed] = useState(() => Math.random()); + const [spotlightOverride, setSpotlightOverride] = useState(null); + const [randomSeeds] = useState(() => [Math.random(), Math.random()] as const); const [actionTrack, setActionTrack] = useState(null); const scrollTop = useScrollTopGate(); const hasLibrary = tracks.length > 0; @@ -405,6 +495,11 @@ export default function HomeScreen() { [albums] ); + const visibleArtists = useMemo( + () => filterArtistBrowseList(artists, artistGroupingMode, includeCollabArtists), + [artistGroupingMode, artists, includeCollabArtists] + ); + const homePlaylists = useMemo( () => [...playlists] @@ -417,15 +512,36 @@ export default function HomeScreen() { [playlists] ); - const randomAlbum = useMemo(() => { - if (!albums.length) return null; - const selected = randomAlbumKey - ? albums.find((album) => album.identity_key === randomAlbumKey) + const randomSpotlight = useMemo(() => { + const overrideValid = spotlightOverride?.kind === 'album' + ? albums.some((album) => album.identity_key === spotlightOverride.key) + : spotlightOverride?.kind === 'artist' + ? visibleArtists.some((artist) => artist.artist === spotlightOverride.name) + : false; + if (spotlightOverride && overrideValid) return spotlightOverride; + + let seedIndex = 0; + return chooseRandomSpotlight( + albums, + visibleArtists, + null, + () => randomSeeds[seedIndex++] ?? randomSeeds[0] + ); + }, [albums, randomSeeds, spotlightOverride, visibleArtists]); + + const randomAlbum = randomSpotlight?.kind === 'album' + ? albums.find((album) => album.identity_key === randomSpotlight.key) ?? null + : null; + const randomArtist = randomSpotlight?.kind === 'artist' + ? visibleArtists.find((artist) => artist.artist === randomSpotlight.name) ?? null + : null; + const spotlightContent = randomAlbum + ? ({ kind: 'album', album: randomAlbum } as const) + : randomArtist + ? ({ kind: 'artist', artist: randomArtist } as const) : null; - if (selected) return selected; - return albums[Math.floor(randomSeed * albums.length) % albums.length]; - }, [albums, randomAlbumKey, randomSeed]); - const randomAlbumNeedsTracks = hasLibrary && !currentTrack && randomAlbum != null; + + const randomAlbumNeedsTracks = hasLibrary && randomAlbum != null; const tracksByAlbum = useMemo(() => { if (!randomAlbumNeedsTracks) return null; const map = new Map(); @@ -441,6 +557,13 @@ export default function HomeScreen() { }, [randomAlbumNeedsTracks, tracks]); const randomTracks = randomAlbum && tracksByAlbum ? (tracksByAlbum.get(randomAlbum.identity_key) ?? []) : []; + const randomArtistDetail = useMemo( + () => randomArtist + ? buildArtistDetail(tracks, randomArtist.artist, artistGroupingMode) + : null, + [artistGroupingMode, randomArtist, tracks] + ); + const spotlightTracks = randomAlbum ? randomTracks : randomArtistDetail?.playbackTracks ?? []; const recentTracks = recentlyPlayedTracks.slice(0, RECENT_TRACK_LIMIT); const canExpandRecentTracks = recentlyPlayedTracks.length > RECENT_TRACK_LIMIT; @@ -451,22 +574,31 @@ export default function HomeScreen() { }); }; + const openArtist = (artist: Artist) => { + router.push({ + pathname: '/library/artist/[name]', + params: { name: artist.artist }, + }); + }; + const playTrackList = (list: DbTrack[], index = 0) => { if (list.length === 0) return; void playTracks(list.map(dbTrackToTrack), index); }; - const playAlbum = (album: Album, shuffled = false) => { - if (!tracksByAlbum) return; - const albumTracks = tracksByAlbum.get(album.identity_key) ?? []; - if (albumTracks.length === 0) return; + const playSpotlight = (shuffled = false) => { + if (spotlightTracks.length === 0) return; if (shuffled) { - void shuffleTracks(albumTracks.map(dbTrackToTrack)); + void shuffleTracks(spotlightTracks.map(dbTrackToTrack)); } else { - void playTracks(albumTracks.map(dbTrackToTrack), 0); + void playTracks(spotlightTracks.map(dbTrackToTrack), 0); } }; + const rerollSpotlight = () => { + setSpotlightOverride(chooseRandomSpotlight(albums, visibleArtists, randomSpotlight)); + }; + const openSearch = () => openQuickSearch(); return ( @@ -479,69 +611,42 @@ export default function HomeScreen() { onScroll={scrollTop.onScroll} scrollEventThrottle={scrollTop.scrollEventThrottle} > - - - ASTRA - - - Audiophile player - + - + - {!hasLibrary ? ( - <> - {currentTrack ? ( - - usePlayerUiStore.getState().openPlayer()} - /> - - ) : null} + {!hasLibrary ? ( router.push('/settings')} /> - - ) : ( - <> - - {currentTrack ? ( - usePlayerUiStore.getState().openPlayer()} + ) : ( + <> + {spotlightContent ? ( + + spotlightContent.kind === 'album' + ? openAlbum(spotlightContent.album) + : openArtist(spotlightContent.artist)} + onPlay={() => playSpotlight()} + onShuffle={() => playSpotlight(true)} + onReroll={rerollSpotlight} /> - ) : randomAlbum ? ( - openAlbum(randomAlbum)} - onPlay={() => playAlbum(randomAlbum)} - onShuffle={() => playAlbum(randomAlbum, true)} - onReroll={() => setRandomAlbumKey(chooseRandomAlbum(albums, randomAlbum.identity_key))} - /> - ) : null} - + + ) : null} - - 0 - ? formatCount(recentlyPlayedTracks.length, 'track') - : undefined - } - actionLabel={ - canExpandRecentTracks ? 'See all' : undefined - } - onActionPress={ - canExpandRecentTracks ? () => router.push('/recently-played') : undefined - } - /> - {recentTracks.length > 0 ? ( + {recentTracks.length > 0 ? ( + + router.push('/recently-played') : undefined + } + /> {recentTracks.map((track, index) => ( ))} - ) : ( - - No recent plays yet. - - )} - - - - - - {recentlyAddedAlbums.map((album) => ( - openAlbum(album)} - /> - ))} - - - - - - - router.push('/library/playlist/favorites')} - /> - {homePlaylists.map((playlist) => ( - router.push(`/library/playlist/${playlist.id}`)} - /> - ))} - - - )} + ) : null} + + {recentlyAddedAlbums.length > 0 ? ( + + + + {recentlyAddedAlbums.map((album) => ( + openAlbum(album)} + /> + ))} + + + ) : null} + + {favoriteTracks.length > 0 || homePlaylists.length > 0 ? ( + + + + {favoriteTracks.length > 0 ? ( + router.push('/library/playlist/favorites')} + /> + ) : null} + {homePlaylists.map((playlist) => ( + router.push(`/library/playlist/${playlist.id}`)} + /> + ))} + + + ) : null} + + )} setActionTrack(null)} /> @@ -614,96 +721,42 @@ const useStyles = createThemedStyles((colors) => ({ content: { paddingBottom: spacing.xxl, }, - header: { + masthead: { + minHeight: 72, flexDirection: 'row', alignItems: 'center', - gap: spacing.sm, + gap: spacing.md, marginTop: spacing.xl, + paddingVertical: spacing.sm, }, - wordmark: { - fontFamily: fonts.sans.bold, - fontSize: 30, - letterSpacing: 6, - color: colors.textPrimary, - }, - tagline: { - marginTop: spacing.xs, - letterSpacing: 1, - }, - topFeature: { - marginTop: spacing.xxl, - }, - playerCard: { - minHeight: PLAYER_CARD_MIN_HEIGHT, - flexDirection: 'row', - alignItems: 'stretch', - gap: spacing.lg, - padding: spacing.lg, - borderRadius: radius.md, - backgroundColor: colors.glassBg, - borderColor: colors.glassBorder, - borderWidth: StyleSheet.hairlineWidth, - overflow: 'hidden', - }, - playerSpectrum: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - }, - playerSpectrumVeil: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: rgbaFromHex(colors.bgPrimary, 0.28), - }, - playerArt: { - width: 112, - aspectRatio: 1, - alignSelf: 'center', - borderRadius: radius.md, - backgroundColor: colors.bgTertiary, - borderColor: colors.glassBorder, - borderWidth: StyleSheet.hairlineWidth, - alignItems: 'center', + mastheadUtility: { + height: 44, + marginTop: spacing.xl, + alignItems: 'flex-end', justifyContent: 'center', - overflow: 'hidden', }, - playerMeta: { + mastheadCopy: { flex: 1, minWidth: 0, justifyContent: 'center', gap: spacing.xs, }, - seekTrack: { - height: 3, - borderRadius: 2, - backgroundColor: colors.glassBorder, - overflow: 'hidden', - marginTop: spacing.sm, + mastheadPrimary: { + fontSize: 28, + lineHeight: 32, }, - seekFill: { - width: '38%', - height: 3, - backgroundColor: colors.accent, - }, - placeholderControls: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginTop: spacing.sm, - maxWidth: 220, - }, - playCircle: { - width: 38, - height: 38, - borderRadius: 19, - backgroundColor: colors.accent, + mastheadSearch: { + width: 40, + height: 40, + borderRadius: 20, alignItems: 'center', justifyContent: 'center', + backgroundColor: colors.glassHighlight, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + }, + topFeature: { + marginTop: spacing.xl, }, section: { marginTop: spacing.xl, @@ -751,11 +804,20 @@ const useStyles = createThemedStyles((colors) => ({ justifyContent: 'center', overflow: 'hidden', }, + artistArt: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + artistMosaicTile: { + width: '50%', + height: '50%', + }, image: { width: '100%', height: '100%', }, randomCard: { + minHeight: 112, borderRadius: radius.md, backgroundColor: colors.glassBg, borderColor: colors.glassBorder, @@ -766,26 +828,35 @@ const useStyles = createThemedStyles((colors) => ({ flexDirection: 'row', alignItems: 'center', gap: spacing.md, - padding: spacing.lg, + padding: spacing.md, }, randomMeta: { flex: 1, minWidth: 0, - gap: 3, - }, - reroll: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.glassHighlight, + alignSelf: 'stretch', + justifyContent: 'space-between', + gap: 2, }, randomActions: { flexDirection: 'row', gap: spacing.sm, - paddingHorizontal: spacing.lg, - paddingBottom: spacing.lg, + alignItems: 'center', + }, + randomPrimaryAction: { + width: 36, + height: 30, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accent, + }, + randomAction: { + width: 36, + height: 30, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.glassHighlight, }, primaryButton: { minHeight: 40, @@ -802,27 +873,12 @@ const useStyles = createThemedStyles((colors) => ({ 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, diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index d684409..8abe2e7 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -35,10 +35,6 @@ const PILL_HEIGHT = 56; const ART = 42; const CURVE_POINTS = 64; -interface MiniPlayerProps { - visible?: boolean; -} - function MiniProgress({ currentTime, duration, @@ -70,7 +66,7 @@ function PhoneMiniProgress({ isPlaying }: { isPlaying: boolean }) { * bar with the live filled-line spectrum drifting behind the metadata. Tapping * opens the full now-playing screen. */ -export function MiniPlayer({ visible = true }: MiniPlayerProps) { +export function MiniPlayer() { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); @@ -110,7 +106,7 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) { const isLoading = presentation.playbackState === 'loading'; // The pill sits underneath the now-playing overlay; don't burn a second // live-scope frame loop while it's fully occluded. - const liveScopeActive = visible && scopeActive && !isDesktop && !playerOpen; + const liveScopeActive = scopeActive && !isDesktop && !playerOpen; const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width); const onTogglePlay = () => { @@ -135,9 +131,8 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) { return ( <> usePlayerUiStore.getState().openPlayer()} onLayout={onLayout} > @@ -238,9 +233,6 @@ const useStyles = createThemedStyles((colors) => ({ overflow: 'hidden', justifyContent: 'center', }, - hidden: { - opacity: 0, - }, spectrum: { position: 'absolute', top: 0, diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 0ea7473..61505f2 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { View, Pressable, @@ -22,14 +22,9 @@ import { import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import { motion } from '@/theme/motion'; -import { TAB_TRANSITION_SETTLE_MS } from '@/navigation/tabTransition'; -import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; -import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; -import { usePlayerStore } from '@/stores/playerStore'; import { playHaptic } from '@/lib/haptics'; type IconName = keyof typeof Ionicons.glyphMap; -type MiniPlayerPhase = 'hidden' | 'reserved' | 'visible'; export const TAB_META: Record = { index: { label: 'Home', icon: 'home' }, @@ -58,12 +53,6 @@ export function TabBar({ items, onPress }: TabBarProps) { const styles = useStyles(); const insets = useSafeAreaInsets(); const tabs = items.filter((item) => TAB_META[item.name]); - const homeFocused = items.some((item) => item.name === 'index' && item.focused); - const selectedTarget = usePlaybackTargetStore((s) => s.target); - const phoneTrack = usePlayerStore((s) => s.currentTrack); - const desktopConnection = useDesktopRemoteStore((s) => s.connection); - const desktopTrack = useDesktopRemoteStore((s) => s.snapshot?.currentTrack); - const [settledHomeFocused, setSettledHomeFocused] = useState(homeFocused); const count = tabs.length; const activeIndex = Math.max( 0, @@ -79,32 +68,6 @@ export function TabBar({ items, onPress }: TabBarProps) { position.value = withTiming(activeIndex, motion.snap); }, [activeIndex, position]); - useEffect(() => { - if (settledHomeFocused === homeFocused) { - return; - } - - const timer = setTimeout( - () => setSettledHomeFocused(homeFocused), - TAB_TRANSITION_SETTLE_MS - ); - return () => clearTimeout(timer); - }, [homeFocused, settledHomeFocused]); - - const remoteMiniVisibleOnHome = - (selectedTarget === 'desktop' && Boolean(desktopConnection || desktopTrack)) || - (!phoneTrack && Boolean(desktopTrack)); - const suppressMiniForHome = homeFocused && !remoteMiniVisibleOnHome; - const suppressMiniForSettledHome = settledHomeFocused && !remoteMiniVisibleOnHome; - - const miniPlayerPhase: MiniPlayerPhase = suppressMiniForHome - ? suppressMiniForSettledHome - ? 'hidden' - : 'reserved' - : suppressMiniForSettledHome - ? 'hidden' - : 'visible'; - const indicatorStyle = useAnimatedStyle(() => { const segment = count > 0 ? barWidth.value / count : 0; return { @@ -119,8 +82,7 @@ export function TabBar({ items, onPress }: TabBarProps) { return ( - {miniPlayerPhase === 'visible' ? : null} - {miniPlayerPhase === 'reserved' ? : null} + p.connected).length ?? 0; @@ -59,6 +60,12 @@ const DARK_STYLE_SEGMENTS = [ { key: 'amoled', label: 'AMOLED' }, ]; +const HOME_GREETING_SEGMENTS = [ + { key: 'messages', label: 'Messages' }, + { key: 'clock', label: 'Clock' }, + { key: 'off', label: 'Off' }, +]; + const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [ { mode: 'astra', @@ -101,6 +108,8 @@ export function AppearanceSettingsPanel() { const accentApplies = !resolvedId.startsWith('materialYou'); const nowPlayingScopeStyle = useSettingsStore((s) => s.nowPlayingScopeStyle); const setNowPlayingScopeStyle = useSettingsStore((s) => s.setNowPlayingScopeStyle); + const homeGreetingTextMode = useSettingsStore((s) => s.homeGreetingTextMode); + const setHomeGreetingTextMode = useSettingsStore((s) => s.setHomeGreetingTextMode); return ( <> @@ -156,6 +165,21 @@ export function AppearanceSettingsPanel() { ) : null} + HOME + + + Greeting + + + Show rotating Astra messages, a clock, or only the search action on Home. + + void setHomeGreetingTextMode(key as HomeGreetingTextMode)} + /> + + NOW PLAYING SCOPES { + assert.equal(parseHomeGreetingTextMode(null), 'messages'); + assert.equal(parseHomeGreetingTextMode(''), 'messages'); + assert.equal(parseHomeGreetingTextMode('weather'), 'messages'); + assert.equal(parseHomeGreetingTextMode('messages'), 'messages'); + assert.equal(parseHomeGreetingTextMode('clock'), 'clock'); + assert.equal(parseHomeGreetingTextMode('off'), 'off'); +}); + +test('uses exact time-window boundaries', () => { + const starts: Array<[number, string]> = [ + [0, 'time-midnight'], + [180, 'time-three-am'], + [300, 'time-early-morning'], + [420, 'time-morning'], + [660, 'time-late-morning'], + [720, 'time-early-afternoon'], + [840, 'time-afternoon'], + [1020, 'time-sunset'], + [1080, 'time-evening'], + [1380, 'time-late-night'], + ]; + + for (const [minute, id] of starts) { + const hour = Math.floor(minute / 60); + const minuteOfHour = minute % 60; + assert.equal(getTimeAwareGreetings(localDate(2026, 7, 14, hour, minuteOfHour))[0].id, id); + } +}); + +test('keeps the approved mobile playful copy intact', () => { + assert.equal(HOME_PLAYFUL_GREETINGS.length, 15); + assert.deepEqual(HOME_PLAYFUL_GREETINGS.at(-2), { + id: 'playful-pocket', + primary: 'I fit in your pocket now.', + subline: 'This was a mistake.', + }); + assert.deepEqual(HOME_PLAYFUL_GREETINGS.at(-1), { + id: 'playful-rectangle', + primary: 'This rectangle demands music.', + subline: 'Obey.', + }); + assert.equal( + HOME_PLAYFUL_GREETINGS.some((message) => message.primary === 'Pocket concert.'), + false + ); +}); + +test('only supplies day-aware greetings on the selected weekdays', () => { + assert.equal(getDayAwareGreetings(localDate(2026, 7, 13, 12))[0].id, 'day-monday'); + assert.equal(getDayAwareGreetings(localDate(2026, 7, 14, 12)).length, 0); + assert.equal(getDayAwareGreetings(localDate(2026, 7, 15, 12))[0].id, 'day-wednesday'); + assert.equal(getDayAwareGreetings(localDate(2026, 7, 17, 12))[0].id, 'day-friday'); + assert.equal(getDayAwareGreetings(localDate(2026, 7, 19, 12))[0].id, 'day-sunday'); +}); + +test('maps local time to the four adaptive tint buckets', () => { + assert.equal(getHomeGreetingBucket(localDate(2026, 7, 14, 4, 59)), 'late-night'); + assert.equal(getHomeGreetingBucket(localDate(2026, 7, 14, 5, 0)), 'morning'); + assert.equal(getHomeGreetingBucket(localDate(2026, 7, 14, 12, 0)), 'afternoon'); + assert.equal(getHomeGreetingBucket(localDate(2026, 7, 14, 18, 0)), 'evening'); + assert.equal(getHomeGreetingBucket(localDate(2026, 7, 14, 23, 0)), 'late-night'); +}); + +test('uses deterministic weighted selection for time, day, and playful pools', () => { + const mondayNoon = localDate(2026, 7, 13, 12); + assert.equal(chooseHomeGreeting(null, mondayNoon, () => 0).id, 'time-early-afternoon'); + assert.equal(chooseHomeGreeting(null, mondayNoon, () => 0.5).id, 'day-monday'); + + const values = [0.99, 0.999]; + assert.equal( + chooseHomeGreeting(null, mondayNoon, () => values.shift() ?? 0).id, + 'playful-rectangle' + ); +}); + +test('avoids immediately repeating the previous message', () => { + const mondayNoon = localDate(2026, 7, 13, 12); + const values = [0, 0, 0]; + const next = chooseHomeGreeting( + 'time-early-afternoon', + mondayNoon, + () => values.shift() ?? 0 + ); + assert.notEqual(next.id, 'time-early-afternoon'); +}); diff --git a/src/home/homeGreeting.ts b/src/home/homeGreeting.ts new file mode 100644 index 0000000..72510a7 --- /dev/null +++ b/src/home/homeGreeting.ts @@ -0,0 +1,195 @@ +export type HomeGreetingTextMode = 'messages' | 'clock' | 'off'; +export type HomeGreetingBucket = 'morning' | 'afternoon' | 'evening' | 'late-night'; + +export interface HomeGreetingCopy { + id: string; + primary: string; + subline: string; +} + +export interface HomeGreetingSelection extends HomeGreetingCopy { + bucket: HomeGreetingBucket; +} + +interface TimeGreetingWindow { + startMinute: number; + endMinute: number; + messages: readonly HomeGreetingCopy[]; +} + +interface WeightedGreetingPool { + messages: readonly HomeGreetingCopy[]; + weight: number; +} + +export const HOME_GREETING_ROTATION_MS = 30 * 60 * 1000; + +const GREETING_WEIGHT_TIME_AWARE = 0.4; +const GREETING_WEIGHT_DAY_AWARE = 0.28; +const GREETING_WEIGHT_PLAYFUL = 0.32; + +export const HOME_PLAYFUL_GREETINGS: readonly HomeGreetingCopy[] = [ + { id: 'playful-back-again', primary: 'Back again.', subline: 'Your music missed you.' }, + { id: 'playful-silence', primary: 'Silence?', subline: 'Nuh uh.' }, + { id: 'playful-aux', primary: 'The aux is yours.', subline: 'Don\'t mess this up.' }, + { id: 'playful-no-algorithm', primary: 'No algorithm.', subline: 'Just you.' }, + { id: 'playful-headphones', primary: 'Headphones on.', subline: 'World off.' }, + { id: 'playful-one-more', primary: 'One more song.', subline: 'Famous last words.' }, + { id: 'playful-tiny-screen', primary: 'Tiny screen.', subline: 'Big library.' }, + { id: 'playful-shuffle', primary: 'Shuffle responsibly.', subline: 'Or don\'t.' }, + { id: 'playful-queue', primary: 'Your queue called.', subline: 'It has concerns.' }, + { id: 'playful-local-files', primary: 'Local files.', subline: 'Radical concept, apparently.' }, + { id: 'playful-airplane', primary: 'Airplane mode?', subline: 'Still works.' }, + { id: 'playful-portable', primary: 'Now portable.', subline: 'Please don\'t drop me.' }, + { id: 'playful-hey', primary: 'hey…', subline: 'does anyone even read these?' }, + { id: 'playful-pocket', primary: 'I fit in your pocket now.', subline: 'This was a mistake.' }, + { id: 'playful-rectangle', primary: 'This rectangle demands music.', subline: 'Obey.' }, +]; + +const TIME_AWARE_GREETINGS: readonly TimeGreetingWindow[] = [ + { + startMinute: 0, + endMinute: 180, + messages: [{ id: 'time-midnight', primary: 'Still up?', subline: 'Go to sleep.' }], + }, + { + startMinute: 180, + endMinute: 300, + messages: [{ id: 'time-three-am', primary: '3 AM again, huh', subline: 'The void has music in it.' }], + }, + { + startMinute: 300, + endMinute: 420, + messages: [{ id: 'time-early-morning', primary: 'Morning.', subline: 'It\'s too early.' }], + }, + { + startMinute: 420, + endMinute: 660, + messages: [{ id: 'time-morning', primary: 'Good morning!', subline: 'Pick a soundtrack.' }], + }, + { + startMinute: 660, + endMinute: 720, + messages: [{ id: 'time-late-morning', primary: 'Late morning.', subline: 'Coffee acquired?' }], + }, + { + startMinute: 720, + endMinute: 840, + messages: [{ id: 'time-early-afternoon', primary: 'Good afternoon.', subline: 'Halfway there.' }], + }, + { + startMinute: 840, + endMinute: 1020, + messages: [{ id: 'time-afternoon', primary: 'Afternoon stretch.', subline: 'One more push.' }], + }, + { + startMinute: 1020, + endMinute: 1080, + messages: [{ id: 'time-sunset', primary: 'Sunset switch.', subline: 'Set the evening tone.' }], + }, + { + startMinute: 1080, + endMinute: 1380, + messages: [{ id: 'time-evening', primary: 'Good evening.', subline: 'The night is yours.' }], + }, + { + startMinute: 1380, + endMinute: 1440, + messages: [{ id: 'time-late-night', primary: 'Late night?', subline: 'Same.' }], + }, +]; + +export function parseHomeGreetingTextMode(value: string | null): HomeGreetingTextMode { + return value === 'clock' || value === 'off' ? value : 'messages'; +} + +export function getHomeGreetingBucket(date: Date): HomeGreetingBucket { + const hour = date.getHours(); + if (hour >= 5 && hour <= 11) return 'morning'; + if (hour >= 12 && hour <= 17) return 'afternoon'; + if (hour >= 18 && hour <= 22) return 'evening'; + return 'late-night'; +} + +export function getTimeAwareGreetings(date: Date): readonly HomeGreetingCopy[] { + const minuteOfDay = date.getHours() * 60 + date.getMinutes(); + return TIME_AWARE_GREETINGS.find( + (entry) => minuteOfDay >= entry.startMinute && minuteOfDay < entry.endMinute + )?.messages ?? []; +} + +export function getDayAwareGreetings(date: Date): readonly HomeGreetingCopy[] { + switch (date.getDay()) { + case 1: + return [{ id: 'day-monday', primary: 'Monday.', subline: 'Let\'s fix that.' }]; + case 3: + return [{ id: 'day-wednesday', primary: 'It\'s Wednesday somehow.', subline: '' }]; + case 5: + return [{ id: 'day-friday', primary: 'It\'s Friday.', subline: 'You made it.' }]; + case 0: + return [{ id: 'day-sunday', primary: 'Sunday already?', subline: 'Put something good on.' }]; + default: + return []; + } +} + +function boundedRandom(random: () => number): number { + const value = random(); + if (!Number.isFinite(value)) return 0; + return Math.min(0.999999999, Math.max(0, value)); +} + +function pickRandomGreeting( + messages: readonly HomeGreetingCopy[], + previousId: string | null, + random: () => number +): HomeGreetingCopy { + const candidates = previousId + ? messages.filter((message) => message.id !== previousId) + : messages; + const pool = candidates.length > 0 ? candidates : messages; + return pool[Math.floor(boundedRandom(random) * pool.length)] ?? HOME_PLAYFUL_GREETINGS[0]; +} + +function pickWeightedGreetingPool( + pools: readonly WeightedGreetingPool[], + random: () => number +): WeightedGreetingPool { + const totalWeight = pools.reduce((sum, pool) => sum + pool.weight, 0); + let threshold = boundedRandom(random) * totalWeight; + for (const pool of pools) { + threshold -= pool.weight; + if (threshold <= 0) return pool; + } + return pools[pools.length - 1]; +} + +export function chooseHomeGreeting( + previousId: string | null, + now: Date, + random: () => number = Math.random +): HomeGreetingSelection { + const timeAware = getTimeAwareGreetings(now); + const dayAware = getDayAwareGreetings(now); + const pools: WeightedGreetingPool[] = [ + { messages: timeAware, weight: GREETING_WEIGHT_TIME_AWARE }, + ...(dayAware.length > 0 + ? [{ messages: dayAware, weight: GREETING_WEIGHT_DAY_AWARE }] + : []), + { messages: HOME_PLAYFUL_GREETINGS, weight: GREETING_WEIGHT_PLAYFUL }, + ]; + + const selectedPool = pickWeightedGreetingPool(pools, random); + let greeting = pickRandomGreeting(selectedPool.messages, previousId, random); + + if (previousId && greeting.id === previousId) { + const alternatives = pools + .flatMap((pool) => pool.messages) + .filter((candidate) => candidate.id !== previousId); + if (alternatives.length > 0) { + greeting = pickRandomGreeting(alternatives, previousId, random); + } + } + + return { ...greeting, bucket: getHomeGreetingBucket(now) }; +} diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index a4f13a0..d506f78 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -6,6 +6,10 @@ import { parseNowPlayingCompanion, type NowPlayingCompanion, } from '@/components/player/nowPlayingPreferences'; +import { + parseHomeGreetingTextMode, + type HomeGreetingTextMode, +} from '@/home/homeGreeting'; /** * Persisted app preferences. SQLite (settings table) is the source of truth — this @@ -19,6 +23,7 @@ const SCOPE_STAGE_VISIBLE_KEY = 'scope_stage_visible'; const SCOPE_STYLE_KEY = 'now_playing_scope_style'; const LYRICS_VISIBLE_KEY = 'lyrics_visible'; const NOW_PLAYING_COMPANION_KEY = 'now_playing_companion'; +const HOME_GREETING_TEXT_MODE_KEY = 'home_greeting_text_mode'; /** Which visualizer the now-playing scope stage shows. */ export type ScopeMode = 'spectrum' | 'scope'; @@ -56,6 +61,7 @@ interface SettingsStore { /** Whether the now-playing top half shows lyrics instead of art/scope. */ lyricsVisible: boolean; nowPlayingCompanion: NowPlayingCompanion; + homeGreetingTextMode: HomeGreetingTextMode; loaded: boolean; load: () => Promise; setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise; @@ -65,6 +71,7 @@ interface SettingsStore { setNowPlayingScopeStyle: (style: NowPlayingScopeStyle) => Promise; setLyricsVisible: (visible: boolean) => Promise; setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise; + setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise; } export const useSettingsStore = create((set, get) => ({ @@ -75,6 +82,7 @@ export const useSettingsStore = create((set, get) => ({ nowPlayingScopeStyle: 'rail', lyricsVisible: false, nowPlayingCompanion: 'queue', + homeGreetingTextMode: 'messages', loaded: false, load: async () => { @@ -88,6 +96,7 @@ export const useSettingsStore = create((set, get) => ({ scopeStyle, lyricsVisible, nowPlayingCompanion, + homeGreetingTextMode, ] = await Promise.all([ getSetting(db, ARTIST_GROUPING_KEY), getSetting(db, INCLUDE_SINGLES_KEY), @@ -96,6 +105,7 @@ export const useSettingsStore = create((set, get) => ({ getSetting(db, SCOPE_STYLE_KEY), getSetting(db, LYRICS_VISIBLE_KEY), getSetting(db, NOW_PLAYING_COMPANION_KEY), + getSetting(db, HOME_GREETING_TEXT_MODE_KEY), ]); set({ artistGroupingMode: parseGroupingMode(grouping), @@ -105,6 +115,7 @@ export const useSettingsStore = create((set, get) => ({ nowPlayingScopeStyle: parseScopeStyle(scopeStyle), lyricsVisible: parseBoolean(lyricsVisible), nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion), + homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode), loaded: true, }); }, @@ -157,4 +168,12 @@ export const useSettingsStore = create((set, get) => ({ const db = await openLibraryDb(); await setSetting(db, NOW_PLAYING_COMPANION_KEY, companion); }, + + setHomeGreetingTextMode: async (mode) => { + const nextMode = parseHomeGreetingTextMode(mode); + if (get().homeGreetingTextMode === nextMode) return; + set({ homeGreetingTextMode: nextMode }); + const db = await openLibraryDb(); + await setSetting(db, HOME_GREETING_TEXT_MODE_KEY, nextMode); + }, }));