mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
improve home page
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user