improve home page

This commit is contained in:
Boof2015
2026-07-13 21:51:26 -04:00
parent e496df9bf5
commit 03904d5df3
8 changed files with 797 additions and 447 deletions
+1
View File
@@ -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"
+452 -396
View File
File diff suppressed because it is too large Load Diff
+3 -11
View File
@@ -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 (
<>
<Pressable
pointerEvents={visible ? 'auto' : 'none'}
android_ripple={ripple.bounded}
style={[styles.pill, !visible && styles.hidden]}
style={styles.pill}
onPress={() => 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,
+2 -40
View File
@@ -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<string, { label: string; icon: IconName }> = {
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 (
<View style={styles.wrap}>
{miniPlayerPhase === 'visible' ? <MiniPlayer /> : null}
{miniPlayerPhase === 'reserved' ? <MiniPlayer visible={false} /> : null}
<MiniPlayer />
<View
style={[
styles.bar,
@@ -28,6 +28,7 @@ import { useThemeStore } from '@/stores/themeStore';
import type { LastFmStatus } from '@/types/lastFm';
import { Text } from '@/components/Text';
import { playHaptic } from '@/lib/haptics';
import type { HomeGreetingTextMode } from '@/home/homeGreeting';
export function lastFmScrobbleSubtitle(status: LastFmStatus | null): string {
const connected = status?.profiles.filter((p) => 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() {
</View>
) : null}
<SettingsSectionLabel spaced>HOME</SettingsSectionLabel>
<SettingsCard>
<Text variant="body" style={styles.settingTitle}>
Greeting
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
Show rotating Astra messages, a clock, or only the search action on Home.
</Text>
<SegmentedControl
segments={HOME_GREETING_SEGMENTS}
value={homeGreetingTextMode}
onChange={(key) => void setHomeGreetingTextMode(key as HomeGreetingTextMode)}
/>
</SettingsCard>
<SettingsSectionLabel spaced>NOW PLAYING SCOPES</SettingsSectionLabel>
<ScopeStyleCards
value={nowPlayingScopeStyle}
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
chooseHomeGreeting,
getDayAwareGreetings,
getHomeGreetingBucket,
getTimeAwareGreetings,
HOME_PLAYFUL_GREETINGS,
parseHomeGreetingTextMode,
} from './homeGreeting.ts';
function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date {
return new Date(year, month - 1, day, hour, minute, 0, 0);
}
test('defaults missing and invalid greeting modes to messages', () => {
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');
});
+195
View File
@@ -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) };
}
+19
View File
@@ -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<void>;
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
@@ -65,6 +71,7 @@ interface SettingsStore {
setNowPlayingScopeStyle: (style: NowPlayingScopeStyle) => Promise<void>;
setLyricsVisible: (visible: boolean) => Promise<void>;
setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise<void>;
setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise<void>;
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
@@ -75,6 +82,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
nowPlayingScopeStyle: 'rail',
lyricsVisible: false,
nowPlayingCompanion: 'queue',
homeGreetingTextMode: 'messages',
loaded: false,
load: async () => {
@@ -88,6 +96,7 @@ export const useSettingsStore = create<SettingsStore>((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<SettingsStore>((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<SettingsStore>((set, get) => ({
nowPlayingScopeStyle: parseScopeStyle(scopeStyle),
lyricsVisible: parseBoolean(lyricsVisible),
nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion),
homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode),
loaded: true,
});
},
@@ -157,4 +168,12 @@ export const useSettingsStore = create<SettingsStore>((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);
},
}));