App.tsx
import { useEffect, useMemo, useState } from 'react';
import { StatusBar, useWindowDimensions } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Carousel } from '@plocks/carousel';
import { useAudioPlaylist, useAudioPlaylistStatus } from 'expo-audio';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import {
ScrollArea,
SafeArea,
Block,
Button,
Card,
Column,
Flex,
Icon,
IconButton,
Input,
PlocksProvider,
Row,
Slider,
Text,
Title,
} from '@plocks/ui';
import { COLLECTIONS, TRACKS, formatTime, type ArtworkStyle, type Collection, type Track } from './catalog';
const COLORS = {
background: '#000000',
sidebar: '#000000',
surface: '#181818',
surfaceHover: '#282828',
line: '#292929',
green: '#1ED760',
white: '#FFFFFF',
muted: '#B3B3B3',
};
const THEME = { colorScheme: 'dark' as const };
const ALL_INDICES = TRACKS.map((_, index) => index);
const SOURCES = TRACKS.map((track) => track.source);
const LIKES_KEY = 'plocks-music-app:likes';
type Section = 'home' | 'search' | 'library';
function Artwork({ art, size, label }: { art: ArtworkStyle; size: number; label: string }) {
return (
<Card
padding={0}
bg={art.background}
radius="md"
accessibilityLabel={`${label} artwork`}
w={size}
h={size}
clip
shrink={0}
>
<Block
direction="row"
position="absolute"
w={size * 0.86}
h={size * 0.86}
radius={size}
bg={art.accent}
top={-size * 0.28}
right={-size * 0.24}
opacity={0.86}
/>
<Block
direction="row"
position="absolute"
w={size * 0.7}
h={size * 0.7}
radius={size}
borderWidth={Math.max(2, size * 0.025)}
borderColor={art.detail}
bottom={-size * 0.18}
left={-size * 0.14}
opacity={0.75}
/>
<Text
c={art.detail}
size={Math.max(24, size * 0.38)}
fw="bold"
position="absolute"
bottom={size * 0.1}
right={size * 0.13}
lh={size * 0.48}
>
{art.glyph}
</Text>
</Card>
);
}
function NavItem({
icon,
label,
active,
onPress,
compact = false,
}: {
icon: string;
label: string;
active: boolean;
onPress: () => void;
compact?: boolean;
}) {
return (
<Card
onPress={onPress}
accessibilityLabel={label}
padding={compact ? 6 : 'sm'}
bg={active ? COLORS.surfaceHover : 'transparent'}
radius="md"
flex={compact ? 1 : undefined}
>
<Flex
direction={compact ? 'column' : 'row'}
align="center"
justify={compact ? 'center' : 'flex-start'}
gap={compact ? 2 : 'sm'}
>
<Icon name={icon} size={compact ? 22 : 20} color={active ? COLORS.green : COLORS.muted} />
<Text c={active ? COLORS.white : COLORS.muted} fw={active ? 'bold' : 'medium'} size={compact ? 11 : 14}>
{label}
</Text>
</Flex>
</Card>
);
}
function TrackRow({
track,
index,
active,
liked,
onPlay,
onLike,
}: {
track: Track;
index: number;
active: boolean;
liked: boolean;
onPlay: () => void;
onLike: () => void;
}) {
return (
<Block direction="row" align="center" gap="xs" w="100%">
<Card
onPress={onPlay}
accessibilityLabel={`Play ${track.title} by ${track.artist}`}
padding="sm"
bg={active ? COLORS.surfaceHover : 'transparent'}
radius="md"
flex={1}
miw={0}
>
<Block align="center" gap="sm" direction="row" w="100%">
<Text c={active ? COLORS.green : COLORS.muted} size={13} ta="center" w={18}>
{active ? '♪' : String(index + 1)}
</Text>
<Artwork art={track.art} size={45} label={track.title} />
<Block gap={0} direction="column" flex={1} miw={0}>
<Text c={active ? COLORS.green : COLORS.white} fw="semibold" size={14} numberOfLines={1}>
{track.title}
</Text>
<Text c={COLORS.muted} size={12} numberOfLines={1}>
{track.artist} · {track.album}
</Text>
</Block>
<Text c={COLORS.muted} size={12}>
{formatTime(track.seconds)}
</Text>
</Block>
</Card>
<IconButton
icon="heart"
iconVariant={liked ? 'filled' : 'outlined'}
iconColor={liked ? COLORS.green : COLORS.muted}
variant="ghost"
size="sm"
accessibilityLabel={liked ? `Remove ${track.title} from liked songs` : `Like ${track.title}`}
onPress={onLike}
/>
</Block>
);
}
function CollectionTile({
collection,
onPress,
artSize,
}: {
collection: Collection;
onPress: () => void;
artSize: number;
}) {
return (
<Block
gap={0}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={`Open ${collection.title} playlist`}
w={artSize}
>
<Column gap="xs">
<Artwork art={collection.art} size={artSize} label={collection.title} />
<Column gap={2}>
<Text c={COLORS.white} fw="bold" size={15} numberOfLines={1}>
{collection.title}
</Text>
<Text c={COLORS.muted} size={12} numberOfLines={2}>
{collection.subtitle}
</Text>
</Column>
</Column>
</Block>
);
}
function MusicScreen() {
const { width } = useWindowDimensions();
const desktop = width >= 800;
const compact = width < 560;
const carouselItemsPerPage = width >= 1080 ? 4 : compact ? 2 : 3;
const contentWidth = desktop ? Math.min(width - 235 - 68, 1040) : width - (compact ? 36 : 68);
const carouselArtSize = Math.min(
220,
Math.floor((contentWidth - 16 * (carouselItemsPerPage - 1)) / carouselItemsPerPage),
);
const playlist = useAudioPlaylist({ sources: SOURCES, updateInterval: 350, loop: 'all' });
const status = useAudioPlaylistStatus(playlist);
const [queue, setQueue] = useState<number[]>(ALL_INDICES);
const [section, setSection] = useState<Section>('home');
const [collectionId, setCollectionId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [liked, setLiked] = useState<Set<string>>(() => new Set(['golden-hour', 'blue-apartment']));
const [likesReady, setLikesReady] = useState(false);
const [expanded, setExpanded] = useState(false);
const [seekPreview, setSeekPreview] = useState<number | null>(null);
useEffect(() => {
AsyncStorage.getItem(LIKES_KEY)
.then((saved) => {
if (saved) {
const ids: unknown = JSON.parse(saved);
if (Array.isArray(ids)) setLiked(new Set(ids.filter((id): id is string => typeof id === 'string')));
}
})
.catch(() => {})
.finally(() => setLikesReady(true));
}, []);
useEffect(() => {
if (likesReady) AsyncStorage.setItem(LIKES_KEY, JSON.stringify([...liked])).catch(() => {});
}, [liked, likesReady]);
const activeIndex = queue[status.currentIndex] ?? 0;
const activeTrack = TRACKS[activeIndex];
const collection = COLLECTIONS.find((item) => item.id === collectionId) ?? null;
const duration = status.duration > 0 ? status.duration : activeTrack.seconds;
const currentTime = Math.min(status.currentTime, duration);
const progress = Math.max(0, Math.min(100, (currentTime / Math.max(duration, 1)) * 100));
const searchResults = useMemo(() => {
const term = query.trim().toLowerCase();
return term
? TRACKS.filter((track) =>
[track.title, track.artist, track.album, track.genre].some((value) => value.toLowerCase().includes(term)),
)
: TRACKS;
}, [query]);
function selectSection(next: Section) {
setSection(next);
setCollectionId(null);
setExpanded(false);
}
function playTrack(index: number, context: number[] = ALL_INDICES) {
const sameQueue = context.length === queue.length && context.every((item, position) => item === queue[position]);
if (!sameQueue) {
playlist.clear();
context.forEach((item) => playlist.add(TRACKS[item].source));
setQueue(context);
}
const nextIndex = context.indexOf(index);
if (nextIndex >= 0) {
if (sameQueue && nextIndex === status.currentIndex) void playlist.seekTo(0);
else playlist.skipTo(nextIndex);
playlist.play();
setSeekPreview(null);
}
}
function togglePlay() {
if (status.playing) playlist.pause();
else playlist.play();
}
function nextTrack() {
playlist.next();
playlist.play();
setSeekPreview(null);
}
function previousTrack() {
if (status.currentTime > 3) void playlist.seekTo(0);
else playlist.previous();
playlist.play();
setSeekPreview(null);
}
function toggleLike(id: string) {
setLiked((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function renderTracks(tracks: Track[], context: number[] = ALL_INDICES) {
return tracks.map((track, index) => {
const globalIndex = TRACKS.findIndex((item) => item.id === track.id);
return (
<TrackRow
key={track.id}
track={track}
index={index}
active={activeIndex === globalIndex && status.playing}
liked={liked.has(track.id)}
onPlay={() => playTrack(globalIndex, context)}
onLike={() => toggleLike(track.id)}
/>
);
});
}
function renderContent() {
if (collection) {
const tracks = collection.trackIds.map((id) => TRACKS.find((track) => track.id === id)!);
const indices = tracks.map((track) => TRACKS.indexOf(track));
return (
<Column gap="xl">
<Button
title="← Back"
variant="ghost"
color={COLORS.muted}
size="sm"
onPress={() => setCollectionId(null)}
/>
<Flex direction="row" wrap="wrap" align="flex-end" gap="xl">
<Artwork art={collection.art} size={compact ? 180 : 222} label={collection.title} />
<Block gap="sm" direction="column" flex={1} miw={220}>
<Text c={COLORS.green} fw="bold" size={11} lts={2}>
FREQUENCY PLAYLIST
</Text>
<Title order={1} c={COLORS.white} size={compact ? 34 : 54}>
{collection.title}
</Title>
<Text c={COLORS.muted}>{collection.description}</Text>
<Text c={COLORS.muted} size={12}>
{tracks.length} songs · original demo loops
</Text>
<Button
title="Play playlist"
variant="filled"
color={COLORS.green}
textColor={COLORS.background}
onPress={() => playTrack(indices[0], indices)}
/>
</Block>
</Flex>
<Column gap="xs">{renderTracks(tracks, indices)}</Column>
</Column>
);
}
if (section === 'search') {
return (
<Column gap="xl">
<Column gap="xs">
<Text c={COLORS.green} fw="bold" size={11} lts={2}>
EXPLORE
</Text>
<Title order={1} c={COLORS.white}>
Search
</Title>
<Text c={COLORS.muted}>Find a song, artist, album, or mood.</Text>
</Column>
<Input
value={query}
onChangeText={setQuery}
placeholder="What do you want to hear?"
startSection={<Icon name="search" size={18} color={COLORS.muted} />}
variant="filled"
accessibilityLabel="Search songs and artists"
/>
{!query.trim() && (
<Column gap="md">
<Text c={COLORS.white} fw="bold" size={19}>
Browse moods
</Text>
<Flex direction="row" wrap="wrap" gap="md">
{COLLECTIONS.map((item) => (
<CollectionTile
key={item.id}
collection={item}
artSize={carouselArtSize}
onPress={() => setCollectionId(item.id)}
/>
))}
</Flex>
</Column>
)}
<Column gap="sm">
<Text c={COLORS.white} fw="bold" size={19}>
{query.trim() ? 'Results' : 'All songs'}
</Text>
{searchResults.length ? (
renderTracks(searchResults)
) : (
<Card bg={COLORS.surface} padding="xl">
<Text c={COLORS.muted}>No matches yet. Try another title or mood.</Text>
</Card>
)}
</Column>
</Column>
);
}
if (section === 'library') {
const tracks = TRACKS.filter((track) => liked.has(track.id));
const indices = tracks.map((track) => TRACKS.indexOf(track));
return (
<Column gap="xl">
<Column gap="xs">
<Text c={COLORS.green} fw="bold" size={11} lts={2}>
YOUR COLLECTION
</Text>
<Title order={1} c={COLORS.white}>
Your Library
</Title>
<Text c={COLORS.muted}>The tracks you want to keep close.</Text>
</Column>
<Card bg={COLORS.surfaceHover} padding="lg" radius="lg">
<Row align="center" gap="lg">
<Artwork
art={{ background: '#7767AB', accent: '#C5B9F4', detail: '#343057', glyph: '♥' }}
size={compact ? 78 : 110}
label="Liked Songs"
/>
<Block gap="xs" direction="column" flex={1}>
<Text c={COLORS.white} fw="bold" size={compact ? 19 : 26}>
Liked Songs
</Text>
<Text c={COLORS.muted} size={13}>
{tracks.length} saved {tracks.length === 1 ? 'track' : 'tracks'}
</Text>
</Block>
{tracks.length > 0 && (
<IconButton
icon="play"
iconVariant="filled"
variant="filled"
color={COLORS.green}
radius="full"
accessibilityLabel="Play liked songs"
onPress={() => playTrack(indices[0], indices)}
/>
)}
</Row>
</Card>
{tracks.length ? (
<Column gap="xs">{renderTracks(tracks, indices)}</Column>
) : (
<Card bg={COLORS.surface} padding="xl">
<Column gap="sm">
<Text c={COLORS.white} fw="bold">
Start your collection
</Text>
<Text c={COLORS.muted}>Tap a heart beside any song to save it here.</Text>
<Button
title="Explore music"
variant="outline"
color={COLORS.green}
onPress={() => selectSection('search')}
/>
</Column>
</Card>
)}
</Column>
);
}
const featured = COLLECTIONS[0];
return (
<Column gap="xl">
<Column gap="xs">
<Text c={COLORS.green} fw="bold" size={11} lts={2}>
GOOD TO HAVE YOU HERE
</Text>
<Title order={1} c={COLORS.white} size={compact ? 32 : 43}>
Find your frequency.
</Title>
<Text c={COLORS.muted}>A little music for wherever you are.</Text>
</Column>
<Card bg={COLORS.surface} padding={compact ? 'md' : 'xl'} radius="xl" clip>
<Flex direction="row" wrap="wrap" align="center" justify="space-between" gap="lg">
<Block gap="md" direction="column" flex={1} miw={210}>
<Text c={COLORS.green} fw="bold" size={11} lts={2}>
FEATURED PLAYLIST
</Text>
<Title order={2} c={COLORS.white} size={compact ? 28 : 38}>
Slow Mornings
</Title>
<Text c={COLORS.muted} size={14}>
Warm chords, open windows, and nowhere else to be.
</Text>
<Flex direction="row" wrap="wrap" gap="sm">
<Button
title="Play now"
variant="filled"
color={COLORS.green}
textColor={COLORS.background}
onPress={() =>
playTrack(
0,
featured.trackIds.map((id) => TRACKS.findIndex((track) => track.id === id)),
)
}
/>
<Button
title="View playlist"
variant="outline"
color={COLORS.green}
onPress={() => setCollectionId(featured.id)}
/>
</Flex>
</Block>
<Artwork art={featured.art} size={compact ? 130 : 226} label={featured.title} />
</Flex>
</Card>
<Column gap="md">
<Text c={COLORS.white} fw="bold" size={20}>
Made for this moment
</Text>
<Carousel
h={carouselArtSize + 72}
itemsPerPage={carouselItemsPerPage}
slidesToScroll={1}
slideGap={16}
loop={false}
showArrows={!compact}
showDots={compact}
accessibilityLabel="Made for this moment playlists"
>
{COLLECTIONS.map((item) => (
<CollectionTile
key={item.id}
collection={item}
artSize={carouselArtSize}
onPress={() => setCollectionId(item.id)}
/>
))}
</Carousel>
</Column>
<Column gap="sm">
<Text c={COLORS.white} fw="bold" size={20}>
Fresh on rotation
</Text>
{renderTracks(TRACKS)}
</Column>
</Column>
);
}
if (expanded) {
const artSize = Math.min(width - 80, 360);
return (
<SafeArea gap={0} flex={1} bg={COLORS.background}>
<StatusBar barStyle="light-content" />
<ScrollArea contentProps={{ align: 'center', p: 24, pb: 54 }}>
<Block gap="xl" direction="column" w="100%" maw={480}>
<Row align="center" justify="space-between">
<IconButton
icon="chevron-down"
variant="ghost"
iconColor={COLORS.white}
accessibilityLabel="Close now playing"
onPress={() => setExpanded(false)}
/>
<Text c={COLORS.white} fw="bold" size={12} lts={2}>
NOW PLAYING
</Text>
<Block direction="row" w={40} />
</Row>
<Flex justify="center">
<Artwork art={activeTrack.art} size={artSize} label={activeTrack.title} />
</Flex>
<Row align="center" justify="space-between" gap="sm">
<Block gap={2} direction="column" flex={1}>
<Text c={COLORS.white} fw="bold" size={28} numberOfLines={1}>
{activeTrack.title}
</Text>
<Text c={COLORS.muted} size={16}>
{activeTrack.artist}
</Text>
</Block>
<IconButton
icon="heart"
iconVariant={liked.has(activeTrack.id) ? 'filled' : 'outlined'}
iconColor={liked.has(activeTrack.id) ? COLORS.green : COLORS.white}
variant="ghost"
accessibilityLabel={liked.has(activeTrack.id) ? 'Unlike current song' : 'Like current song'}
onPress={() => toggleLike(activeTrack.id)}
/>
</Row>
<Column gap="xs">
<Slider
min={0}
max={duration}
step={0.1}
value={seekPreview ?? currentTime}
onChange={setSeekPreview}
onChangeEnd={(value) => {
void playlist.seekTo(value);
setSeekPreview(null);
}}
color={COLORS.green}
tooltip="never"
valueLabel={null}
accessibilityLabel="Playback position"
/>
<Row fullWidth justify="space-between">
<Text c={COLORS.white} size={12}>
{formatTime(seekPreview ?? currentTime)}
</Text>
<Text c={COLORS.white} size={12}>
{formatTime(duration)}
</Text>
</Row>
</Column>
<Row fullWidth align="center" justify="center" gap="xl">
<IconButton
icon="chevron-left"
iconColor={COLORS.white}
variant="ghost"
size="lg"
accessibilityLabel="Previous track"
onPress={previousTrack}
/>
<IconButton
icon={status.playing ? 'pause' : 'play'}
iconVariant="filled"
variant="filled"
color={COLORS.green}
radius="full"
size={72}
iconSize={34}
accessibilityLabel={status.playing ? 'Pause' : 'Play'}
onPress={togglePlay}
/>
<IconButton
icon="chevron-right"
iconColor={COLORS.white}
variant="ghost"
size="lg"
accessibilityLabel="Next track"
onPress={nextTrack}
/>
</Row>
</Block>
</ScrollArea>
</SafeArea>
);
}
return (
<SafeArea gap={0} flex={1} bg={COLORS.background}>
<StatusBar barStyle="light-content" />
<Block direction="row" flex={1} mih={0}>
{desktop && (
<Block
gap="xl"
direction="column"
w={235}
p={18}
bg={COLORS.sidebar}
borderRightWidth={1}
borderRightColor={COLORS.line}
>
<Row align="center" gap="sm">
<Card bg={COLORS.green} padding={6} radius="md">
<Icon name="music" size={21} color={COLORS.background} />
</Card>
<Text c={COLORS.white} fw="bold" size={22}>
frequency
</Text>
</Row>
<Column gap="xs">
<NavItem icon="home" label="Home" active={section === 'home'} onPress={() => selectSection('home')} />
<NavItem
icon="search"
label="Search"
active={section === 'search'}
onPress={() => selectSection('search')}
/>
<NavItem
icon="music"
label="Your Library"
active={section === 'library'}
onPress={() => selectSection('library')}
/>
</Column>
<Card bg={COLORS.surface} padding="md" radius="lg">
<Column gap="sm">
<Text c={COLORS.white} fw="bold" size={14}>
Made for listening
</Text>
<Text c={COLORS.muted} size={12}>
Six original little loops, ready whenever you are.
</Text>
<Text c={COLORS.green} size={12}>
NO ACCOUNT NEEDED ↗
</Text>
</Column>
</Card>
</Block>
)}
<ScrollArea flex={1} contentProps={{ p: compact ? 18 : 34, pb: 44, align: 'center' }}>
<Block gap="xl" direction="column" w="100%" maw={1040}>
{!desktop && (
<Row align="center" gap="sm">
<Card bg={COLORS.green} padding={5} radius="md">
<Icon name="music" size={18} color={COLORS.background} />
</Card>
<Text c={COLORS.white} fw="bold" size={19}>
frequency
</Text>
</Row>
)}
{renderContent()}
</Block>
</ScrollArea>
</Block>
<Card
bg={COLORS.surface}
padding={compact ? 'sm' : 'md'}
radius="sm"
borderTopWidth={1}
borderTopColor={COLORS.line}
w="100%"
>
<Column gap="xs">
<Block align="center" gap="md" direction="row" w="100%">
<Block
gap={0}
onPress={() => setExpanded(true)}
flex={1}
miw={0}
accessibilityRole="button"
accessibilityLabel={`Open now playing: ${activeTrack.title} by ${activeTrack.artist}`}
>
<Row align="center" gap="sm">
<Artwork art={activeTrack.art} size={compact ? 43 : 52} label={activeTrack.title} />
<Block gap={0} direction="column" flex={1} miw={0}>
<Text c={COLORS.white} fw="bold" size={13} numberOfLines={1}>
{activeTrack.title}
</Text>
<Text c={COLORS.muted} size={11} numberOfLines={1}>
{activeTrack.artist}
</Text>
</Block>
</Row>
</Block>
{!compact && (
<IconButton
icon="chevron-left"
variant="ghost"
iconColor={COLORS.white}
accessibilityLabel="Previous track"
onPress={previousTrack}
/>
)}
<IconButton
icon={status.playing ? 'pause' : 'play'}
iconVariant="filled"
variant="filled"
color={COLORS.green}
radius="full"
accessibilityLabel={status.playing ? 'Pause' : 'Play'}
onPress={togglePlay}
/>
<IconButton
icon="chevron-right"
variant="ghost"
iconColor={COLORS.white}
accessibilityLabel="Next track"
onPress={nextTrack}
/>
{!compact && (
<Text c={COLORS.muted} size={12}>
{formatTime(currentTime)} / {formatTime(duration)}
</Text>
)}
</Block>
<Block direction="row" h={2} w="100%" bg={COLORS.line} radius={2}>
<Block direction="row" h={2} w={`${progress}%` as `${number}%`} bg={COLORS.green} radius={2} />
</Block>
</Column>
</Card>
{!desktop && (
<Block direction="row" bg={COLORS.sidebar} borderTopWidth={1} borderTopColor={COLORS.line} px={8} py={3}>
<NavItem icon="home" label="Home" active={section === 'home'} compact onPress={() => selectSection('home')} />
<NavItem
icon="search"
label="Search"
active={section === 'search'}
compact
onPress={() => selectSection('search')}
/>
<NavItem
icon="music"
label="Library"
active={section === 'library'}
compact
onPress={() => selectSection('library')}
/>
</Block>
)}
</SafeArea>
);
}
export default function App() {
return (
<SafeAreaProvider>
<PlocksProvider theme={THEME}>
<MusicScreen />
</PlocksProvider>
</SafeAreaProvider>
);
}
catalog.ts
export type ArtworkStyle = {
background: string;
accent: string;
detail: string;
glyph: string;
};
export type Track = {
id: string;
title: string;
artist: string;
album: string;
genre: string;
seconds: number;
source: number;
art: ArtworkStyle;
};
export const TRACKS: Track[] = [
{
id: 'golden-hour', title: 'Golden Hour', artist: 'Mira Sol', album: 'Daylight Studies',
genre: 'Chill', seconds: 20, source: require('./assets/audio/golden-hour.mp3'),
art: { background: '#DD9C59', accent: '#F9DF9E', detail: '#864E4B', glyph: '✳' },
},
{
id: 'afterglow', title: 'Afterglow', artist: 'Luna Vale', album: 'Blue Hours',
genre: 'Chill', seconds: 21, source: require('./assets/audio/afterglow.mp3'),
art: { background: '#8A84BA', accent: '#EBC5CF', detail: '#413E72', glyph: '✦' },
},
{
id: 'night-drive', title: 'Night Drive', artist: 'Lowlight', album: 'City Lines',
genre: 'Electronic', seconds: 17, source: require('./assets/audio/night-drive.mp3'),
art: { background: '#284B64', accent: '#80D8CE', detail: '#ED806E', glyph: '◒' },
},
{
id: 'blue-apartment', title: 'Blue Apartment', artist: 'June Ellis', album: 'Rooms',
genre: 'Focus', seconds: 22, source: require('./assets/audio/blue-apartment.mp3'),
art: { background: '#7799AA', accent: '#E5D5B6', detail: '#284E68', glyph: '▣' },
},
{
id: 'soft-focus', title: 'Soft Focus', artist: 'Niko Bloom', album: 'Slow Motion',
genre: 'Focus', seconds: 25, source: require('./assets/audio/soft-focus.mp3'),
art: { background: '#7F9581', accent: '#D6E2A2', detail: '#405C53', glyph: '❋' },
},
{
id: 'side-streets', title: 'Side Streets', artist: 'The Nova Club', album: 'After Dark',
genre: 'Electronic', seconds: 16, source: require('./assets/audio/side-streets.mp3'),
art: { background: '#B35A5D', accent: '#FFD18A', detail: '#512F52', glyph: '✺' },
},
];
export type Collection = {
id: string;
title: string;
description: string;
subtitle: string;
trackIds: string[];
art: ArtworkStyle;
};
export const COLLECTIONS: Collection[] = [
{
id: 'slow-mornings', title: 'Slow Mornings', subtitle: 'Made for the softer hours',
description: 'Warm chords, open windows, and nowhere else to be.',
trackIds: ['golden-hour', 'afterglow', 'blue-apartment', 'soft-focus'],
art: { background: '#B18A70', accent: '#F3DFAE', detail: '#657D6A', glyph: '☼' },
},
{
id: 'night-shift', title: 'Night Shift', subtitle: 'After dark, on repeat',
description: 'A little pulse for the way home.',
trackIds: ['night-drive', 'side-streets', 'afterglow'],
art: { background: '#364D6A', accent: '#AFB8E9', detail: '#E77F82', glyph: '✦' },
},
{
id: 'deep-focus', title: 'Deep Focus', subtitle: 'Stay in your own lane',
description: 'Gentle instrumentals for the things that matter.',
trackIds: ['soft-focus', 'blue-apartment', 'golden-hour'],
art: { background: '#547868', accent: '#CDE7B4', detail: '#AAC9CF', glyph: '◌' },
},
{
id: 'easy-days', title: 'Easy Days', subtitle: 'Keep the good mood going',
description: 'Light, unhurried sounds for a day with room to breathe.',
trackIds: ['afterglow', 'golden-hour', 'soft-focus'],
art: { background: '#AA8B5A', accent: '#F5DFA0', detail: '#5E6951', glyph: '✳' },
},
{
id: 'headphones-on', title: 'Headphones On', subtitle: 'Get lost in the sound',
description: 'A little energy for wherever the night takes you.',
trackIds: ['side-streets', 'night-drive', 'blue-apartment'],
art: { background: '#69517A', accent: '#C597CF', detail: '#F0A89E', glyph: '✦' },
},
];
export function formatTime(seconds: number): string {
const safe = Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0));
return `${Math.floor(safe / 60)}:${String(safe % 60).padStart(2, '0')}`;
}