diff --git a/frontend/tests/ui/fixtures.ts b/frontend/tests/ui/fixtures.ts index b56cde74..7713bcd5 100644 --- a/frontend/tests/ui/fixtures.ts +++ b/frontend/tests/ui/fixtures.ts @@ -560,7 +560,7 @@ export const adminUsers: AdminUser[] = [ ]; export const invites: Invite[] = [ - { id: "inv-1", token: "lmn_7QxV2pKc9RfT4wZb", target_role: "user", max_uses: 5, uses: 2, expires_at: "2026-11-01T00:00:00Z", created_at: "2026-10-01T12:00:00Z" }, + { id: "inv-1", token: "lmn_7QxV2pKc9RfT4wZb", target_role: "user", max_uses: 5, uses: 2, expires_at: "2099-11-01T00:00:00Z", created_at: "2026-10-01T12:00:00Z" }, { id: "inv-2", token: "lmn_Hd83LwQe1NvY6sAa", target_role: "admin", max_uses: 1, uses: 0, expires_at: "2026-10-18T00:00:00Z", created_at: "2026-10-09T08:30:00Z" }, { id: "inv-3", token: "lmn_Zp4mKt7BcXr2Ue9J", target_role: "user", max_uses: 10, uses: 10, expires_at: null, created_at: "2026-05-20T17:45:00Z" }, ]; diff --git a/mobile/app/(auth)/login.tsx b/mobile/app/(auth)/login.tsx index 411cb3e7..c600a4a6 100644 --- a/mobile/app/(auth)/login.tsx +++ b/mobile/app/(auth)/login.tsx @@ -141,8 +141,8 @@ export default function LoginScreen() { opacity: pressed ? 0.6 : 1, })} > - - Have an invite? Create an account + + I have an invite @@ -156,6 +156,7 @@ export default function LoginScreen() { > - This invite is invalid, expired, revoked, or has no uses remaining. + This invite is invalid, expired, revoked, or has no uses remaining ) : ( - + ) : null} {activeInviteState.status === "verification-error" ? ( <> - void verifyInvite(activeInviteState.token)} /> @@ -296,7 +296,7 @@ export default function RegisterScreen() { {activeInviteState.status === "valid" ? ( <> - + - + - - Choose a new password before continuing. + Choose a new password before continuing @@ -114,6 +114,7 @@ export default function ResetPasswordScreen() { (null); const host = serverHost(); + const headline: TextStyle = { + color: theme.color.fg, + fontSize: 36, + lineHeight: 44, + fontWeight: "500", + letterSpacing: -0.4, + }; + const recordSize = RECORD_SIZE * Math.min(fontScale, 1.6); + return ( // Scrolls only when the content outgrows the screen (large text sizes); - // otherwise the bubbles take whatever height is left. + // otherwise the scene takes whatever height is left. - - - + {wordsTop !== null ? ( + + ) : null} - + - setWordsTop(Math.round(event.nativeEvent.layout.y))} style={{ paddingHorizontal: theme.space.xl, paddingBottom: insets.bottom + theme.space.md, - gap: theme.space.xl, }} > - - Your music, + - {"Your music library,\nlit up."} - - - Sign in to your server, or join with an invite. - - + on your + + server + + - + Lumen plays your library from {host ?? "your server"} on your phone, in + the car and offline + + + { void Haptics.selectionAsync(); router.push("/(auth)/login"); }} /> - { - void Haptics.selectionAsync(); - router.push("/(auth)/register"); - }} - accessibilityRole="button" - accessibilityLabel="Create an account with an invite" - style={({ pressed }) => ({ - alignItems: "center", - paddingVertical: 8, - opacity: pressed ? 0.6 : 1, - })} - > - - Have an invite? Create an account - - - + - { + void Haptics.selectionAsync(); + router.push("/(auth)/register"); + }} + accessibilityRole="button" + accessibilityLabel="Create an account with an invite" + style={({ pressed }) => ({ + alignItems: "center", + marginTop: theme.space.xs, + paddingVertical: theme.space.md, + opacity: pressed ? 0.6 : 1, + })} > - {host ? ( - <> - Accounts on {host}{" "} - are invite-only. - - ) : ( - "Accounts are invite-only." - )} - - + + I have an invite + + + ); } -/** Host of the server this build talks to, for the footer; null if unset. */ +/** Host of the server this build talks to, for the subhead; null if unset. */ function serverHost(): string | null { try { return new URL(getBaseUrl()).hostname || null; diff --git a/mobile/app/(tabs)/(favorites)/_layout.tsx b/mobile/app/(tabs)/(favorites)/_layout.tsx index 32970aa9..9447ee9c 100644 --- a/mobile/app/(tabs)/(favorites)/_layout.tsx +++ b/mobile/app/(tabs)/(favorites)/_layout.tsx @@ -7,11 +7,11 @@ export default function FavoritesStackLayout() { ); diff --git a/mobile/app/(tabs)/(favorites)/index.tsx b/mobile/app/(tabs)/(favorites)/index.tsx index eac294b9..64ab45c4 100644 --- a/mobile/app/(tabs)/(favorites)/index.tsx +++ b/mobile/app/(tabs)/(favorites)/index.tsx @@ -10,7 +10,6 @@ import { useFavoritesListModel, } from "../../../components/favorites/favorites-list"; import { FavoritesNavBar } from "../../../components/favorites/favorites-nav-bar"; -import { ModeWash } from "../../../components/favorites/mode-wash"; import { useFavoritesQuery } from "../../../context/favorites"; import { DEFAULT_HEADER_GEOMETRY, @@ -184,12 +183,6 @@ export default function FavoritesScreen() { }} style={{ flex: 1, backgroundColor: theme.color.bg }} > - ); diff --git a/mobile/app/(tabs)/(library,browse)/albums/edit.tsx b/mobile/app/(tabs)/(library,browse)/albums/edit.tsx index 8fcd2781..09cbe5ea 100644 --- a/mobile/app/(tabs)/(library,browse)/albums/edit.tsx +++ b/mobile/app/(tabs)/(library,browse)/albums/edit.tsx @@ -66,7 +66,7 @@ export default function AlbumEditScreen() { ); @@ -123,7 +123,7 @@ function AlbumEditor({ id, album }: { id: string; album: Album }) { quality: 0.9, }); } catch { - setError("Couldn't open the photo library."); + setError("Couldn't open the photo library"); return; } if (result.canceled || result.assets.length === 0) return; @@ -205,7 +205,7 @@ function AlbumEditor({ id, album }: { id: string; album: Album }) { <> ( void onSave()} /> ), @@ -296,7 +296,7 @@ function AlbumEditor({ id, album }: { id: string; album: Album }) { ); diff --git a/mobile/app/(tabs)/(library,browse)/index.tsx b/mobile/app/(tabs)/(library,browse)/index.tsx index 3d93c511..8715a97f 100644 --- a/mobile/app/(tabs)/(library,browse)/index.tsx +++ b/mobile/app/(tabs)/(library,browse)/index.tsx @@ -346,7 +346,7 @@ export default function HomeScreen() { )} {hasReplay && topAlbums.length > 0 && ( -
+
{topAlbums.map((a) => ( 0 && (
diff --git a/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx b/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx index 7fb14240..d5ce8647 100644 --- a/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx +++ b/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx @@ -178,7 +178,7 @@ export default function TidalAlbumDetailScreen() { ); diff --git a/mobile/app/(tabs)/(library,browse)/tidal-artists/[id].tsx b/mobile/app/(tabs)/(library,browse)/tidal-artists/[id].tsx index 252ec5a8..2f7146bd 100644 --- a/mobile/app/(tabs)/(library,browse)/tidal-artists/[id].tsx +++ b/mobile/app/(tabs)/(library,browse)/tidal-artists/[id].tsx @@ -63,7 +63,7 @@ export default function TidalArtistScreen() { ); const onTrackPress = usePlayQueue(tracks); const warning = data?.warnings?.join(" "); - const problem = query.isError ? "Couldn't load artist." : warning; + const problem = query.isError ? "Couldn't load artist" : warning; const artistName = data?.artist?.name || name || "TIDAL artist"; // Artists without a TIDAL picture borrow their top track's cover. const imageUri = artistImageUrl( @@ -175,7 +175,7 @@ export default function TidalArtistScreen() { query.isLoading ? ( ) : !problem && releases.length === 0 ? ( - + ) : null } contentInsetAdjustmentBehavior="automatic" diff --git a/mobile/app/(tabs)/(library,browse)/track/[id].tsx b/mobile/app/(tabs)/(library,browse)/track/[id].tsx index 5ef52b18..d8a1d6ab 100644 --- a/mobile/app/(tabs)/(library,browse)/track/[id].tsx +++ b/mobile/app/(tabs)/(library,browse)/track/[id].tsx @@ -135,7 +135,7 @@ export default function TrackInfoScreen() { return ; } if (trackQuery.isError || !t || !item) { - return ; + return ; } const view = trackInfoRows( @@ -149,7 +149,7 @@ export default function TrackInfoScreen() { <> {t.unavailable ? ( - Removed from TIDAL, so it can't play. + Removed from TIDAL, so it can't play ) : null} {view.note ? {view.note} : null} diff --git a/mobile/app/(tabs)/(library,browse)/track/edit.tsx b/mobile/app/(tabs)/(library,browse)/track/edit.tsx index d92d3403..99b22141 100644 --- a/mobile/app/(tabs)/(library,browse)/track/edit.tsx +++ b/mobile/app/(tabs)/(library,browse)/track/edit.tsx @@ -30,7 +30,7 @@ import { EmptyState, retryAction } from "../../../../components/empty-state"; /** * Admin-only track metadata editor. Reached from the track context menu's - * "Edit Metadata" action. Loads the track, lets an admin rewrite the same + * "Edit metadata" action. Loads the track, lets an admin rewrite the same * fields the web edit dialog exposes, and PATCHes only what actually changed. */ export default function TrackEditScreen() { @@ -59,7 +59,7 @@ export default function TrackEditScreen() { ); @@ -67,7 +67,7 @@ export default function TrackEditScreen() { if (!canEdit) { return ( <> - + ( void onSave()} /> ), @@ -147,7 +147,7 @@ function TrackEditor({ id, track }: { id: string; track: TrackDetail }) { autoCapitalize="words" /> - + setField("albumTitle", value)} @@ -156,7 +156,7 @@ function TrackEditor({ id, track }: { id: string; track: TrackDetail }) { ; } if (playlistQuery.isError || tracksQuery.isError || !playlist) { - return { void playlistQuery.refetch(); void tracksQuery.refetch(); } }} />; } diff --git a/mobile/app/(tabs)/(playlists)/_layout.tsx b/mobile/app/(tabs)/(playlists)/_layout.tsx index 1ac7aa2c..e15db9a6 100644 --- a/mobile/app/(tabs)/(playlists)/_layout.tsx +++ b/mobile/app/(tabs)/(playlists)/_layout.tsx @@ -13,14 +13,14 @@ export default function PlaylistsStackLayout() { name="new" options={{ headerLargeTitle: false, - title: "New Playlist", + title: "New playlist", }} /> ); diff --git a/mobile/app/(tabs)/(playlists)/edit/[id].tsx b/mobile/app/(tabs)/(playlists)/edit/[id].tsx index 0445cc53..fda1ee50 100644 --- a/mobile/app/(tabs)/(playlists)/edit/[id].tsx +++ b/mobile/app/(tabs)/(playlists)/edit/[id].tsx @@ -133,7 +133,7 @@ function PlaylistEditor({ id, playlist }: { id: string; playlist: Playlist }) { {forbidden ? ( - + ) : ( <> {permissions.canChangeCover ? ( diff --git a/mobile/app/(tabs)/(playlists)/index.tsx b/mobile/app/(tabs)/(playlists)/index.tsx index 6b4495e1..037594dc 100644 --- a/mobile/app/(tabs)/(playlists)/index.tsx +++ b/mobile/app/(tabs)/(playlists)/index.tsx @@ -217,7 +217,7 @@ function InviteRow({ return ( ); diff --git a/mobile/app/(tabs)/(settings)/admin-invites.tsx b/mobile/app/(tabs)/(settings)/admin-invites.tsx index 0ae0c106..ebc803b6 100644 --- a/mobile/app/(tabs)/(settings)/admin-invites.tsx +++ b/mobile/app/(tabs)/(settings)/admin-invites.tsx @@ -124,7 +124,7 @@ export default function AdminInvitesScreen() { style={{ paddingVertical: 0 }} message={errorMessage( invitesQuery.error, - "Couldn't load invitations.", + "Couldn't load invitations", )} /> ) : ( - Scan all enabled roots for new or changed tracks. + Scan all enabled roots for new or changed tracks )} diff --git a/mobile/app/(tabs)/(settings)/admin-new-invite.tsx b/mobile/app/(tabs)/(settings)/admin-new-invite.tsx index 4eb8b515..21009c8e 100644 --- a/mobile/app/(tabs)/(settings)/admin-new-invite.tsx +++ b/mobile/app/(tabs)/(settings)/admin-new-invite.tsx @@ -209,7 +209,7 @@ export default function AdminNewInviteScreen() { > Invite ready @@ -224,7 +224,7 @@ export default function AdminNewInviteScreen() { @@ -278,7 +278,7 @@ export default function AdminNewInviteScreen() { diff --git a/mobile/app/(tabs)/(settings)/admin-tidal.tsx b/mobile/app/(tabs)/(settings)/admin-tidal.tsx index 4e5f4cb9..d88f31fd 100644 --- a/mobile/app/(tabs)/(settings)/admin-tidal.tsx +++ b/mobile/app/(tabs)/(settings)/admin-tidal.tsx @@ -204,12 +204,12 @@ export default function AdminTidalScreen() { - + Waiting for TIDAL - This page updates automatically after approval. + This page updates automatically after approval @@ -314,12 +314,12 @@ function StatusCard({ /> )} - + {PROXY_STATE_LABELS[state]} - TIDAL credentials stay on the server. + TIDAL credentials stay on the server @@ -401,10 +401,10 @@ function StatusValue({ borderRadius: theme.radius.sm, borderCurve: "continuous", padding: theme.space.sm, - gap: 3, + gap: 4, }} > - + {label} @@ -437,7 +437,7 @@ function AccountCard({ }} > - + {tidalAccountLabel(account)} diff --git a/mobile/app/(tabs)/(settings)/download-log.tsx b/mobile/app/(tabs)/(settings)/download-log.tsx index c7bbf479..61d795fc 100644 --- a/mobile/app/(tabs)/(settings)/download-log.tsx +++ b/mobile/app/(tabs)/(settings)/download-log.tsx @@ -383,7 +383,7 @@ function ActionRow({ alignItems: "center", gap: 12, paddingHorizontal: theme.space.md, - paddingVertical: 14, + paddingVertical: 12, borderTopWidth: border ? StyleSheet.hairlineWidth : 0, borderTopColor: theme.color.separator, backgroundColor: pressed ? theme.color.bgElev2 : "transparent", diff --git a/mobile/app/(tabs)/(settings)/downloads.tsx b/mobile/app/(tabs)/(settings)/downloads.tsx index 8fe11dbf..c094d90f 100644 --- a/mobile/app/(tabs)/(settings)/downloads.tsx +++ b/mobile/app/(tabs)/(settings)/downloads.tsx @@ -36,7 +36,7 @@ const HERO_COVER_MAX = 160; // "Custom" is the download order, newest first. No "Plays": the stored // snapshots carry no play counts. const SORT_OPTIONS: SortMenuOption[] = [ - { key: "custom", label: "Recently Downloaded", symbol: "arrow.down.circle" }, + { key: "custom", label: "Recently downloaded", symbol: "arrow.down.circle" }, { key: "title", label: "Title" }, { key: "duration", label: "Length" }, ]; @@ -184,7 +184,7 @@ const styles = StyleSheet.create({ }, title: { fontSize: 24, - fontWeight: "700", + fontWeight: "600", letterSpacing: -0.2, textAlign: "center", }, diff --git a/mobile/app/(tabs)/(settings)/replay.tsx b/mobile/app/(tabs)/(settings)/replay.tsx index 97f46073..3f581671 100644 --- a/mobile/app/(tabs)/(settings)/replay.tsx +++ b/mobile/app/(tabs)/(settings)/replay.tsx @@ -10,9 +10,11 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Directory, File, Paths } from "expo-file-system"; import { useRouter } from "expo-router"; import * as Haptics from "expo-haptics"; +import { startListPlayback } from "@music-library/core/player/play-list"; import { ApiError, api, + artistImageUrl, displayText, errorMessage, playsById as playsByIdFor, @@ -22,6 +24,8 @@ import { replayImageRequest, replayPlaylistRequest, replayRequest, + topArtistAction, + topArtistTracks, useAuth, useLocalDay, type ReplayAlbum, @@ -52,7 +56,8 @@ import { SummaryGrid } from "../../../components/replay/summary-grid"; import { TopTrackList } from "../../../components/replay/top-track-list"; import { qk } from "../../../lib/query-keys"; import { QUERY_STALE_TIME } from "../../../lib/query-policy"; -import { usePlayQueue } from "../../../lib/use-play-queue"; +import { listPlaybackQueue, usePlayQueue } from "../../../lib/use-play-queue"; +import { usePlayTrack } from "../../../context/player"; import { usePullToRefresh } from "../../../lib/use-pull-to-refresh"; import { useTheme } from "../../../theme/theme"; import { isShareDismissal } from "../../../lib/share-dismissal"; @@ -98,6 +103,13 @@ export default function ReplayScreen() { ); const playsById = useMemo(() => playsByIdFor(data?.top_tracks), [data?.top_tracks]); const onTrackPress = usePlayQueue(topTracks); + const play = usePlayTrack(); + // Their tracks among the top tracks: the artist tile's cover, and what it plays + // when they have no library page to open. + const artistTracks = useMemo( + () => topArtistTracks(data?.top_artists ?? [], topTracks), + [data?.top_artists, topTracks], + ); const onAlbumPress = useCallback( (album: ReplayAlbum) => { @@ -198,7 +210,7 @@ export default function ReplayScreen() { {replayQuery.isLoading && !data ? ( ) : replayQuery.isError ? ( - + ) : !hasData ? ( ) : data && summary ? ( @@ -211,7 +223,7 @@ export default function ReplayScreen() { /> {topTracks.length > 0 && ( -
+
0 && ( -
+
- {data.top_artists.map((a, i) => ( - - router.push({ - pathname: "/(tabs)/(settings)/artists/[id]", - params: { id: a.id }, - }) - } - /> - ))} + {data.top_artists.map((a, i) => { + const own = artistTracks.get(a.id) ?? []; + const action = topArtistAction(a, own); + const name = displayText(a.name); + return ( + + router.push({ + pathname: "/(tabs)/(settings)/artists/[id]", + params: { id: a.id }, + }) + : action === "play" + ? () => startListPlayback(play, listPlaybackQueue(own), false) + : undefined + } + /> + ); + })}
)} {data.top_albums.length > 0 && ( -
+
{data.top_albums.map((a, i) => ( 0 && ( -
+
0 && ( -
+
(null); const previousTrackIndexRef = useRef(index); // These refs hold the last committed track solely to choose the direction of @@ -402,7 +402,7 @@ export default function NowPlayingScreen() { alignItems: "center", }} > - No track loaded. + No track loaded ); } diff --git a/mobile/app/playlist-picker.tsx b/mobile/app/playlist-picker.tsx index 28152a49..a7a45049 100644 --- a/mobile/app/playlist-picker.tsx +++ b/mobile/app/playlist-picker.tsx @@ -205,13 +205,13 @@ export default function PlaylistPickerScreen() { ListEmptyComponent={ // Only a link can open the sheet without a track. !trackId ? ( - + ) : !playlists && playlistsQuery.isError ? ( - + ) : !playlists ? ( ) : ( - + ) } /> @@ -252,7 +252,7 @@ const styles = StyleSheet.create({ alignItems: "center", gap: 10, paddingTop: 24, - paddingHorizontal: 14, + paddingHorizontal: 16, }, close: { width: 44, diff --git a/mobile/app/share-track.tsx b/mobile/app/share-track.tsx index 06896745..eb09a88d 100644 --- a/mobile/app/share-track.tsx +++ b/mobile/app/share-track.tsx @@ -433,7 +433,7 @@ export default function ShareTrackScreen() { }, [getShareUrl, picked, trackQuery.data]); // Downloads the snippet's generated preview video (the MP4 chat apps embed) - // and hands it to the share sheet, whose "Save Video" puts it in Photos. + // and hands it to the share sheet, whose "Save video" puts it in Photos. const saveVideo = useCallback(async () => { if (!picked || videoBusy) return; let url: string; @@ -544,7 +544,7 @@ export default function ShareTrackScreen() { ) : trackQuery.isError || !trackQuery.data ? ( - Couldn't load track. + Couldn't load track ) : ( @@ -595,21 +595,21 @@ export default function ShareTrackScreen() { onPickCustom={() => void pickCustomBackground()} /> void shareLink()} /> void saveVideo()} /> void copyLink()} @@ -619,7 +619,7 @@ export default function ShareTrackScreen() { )} - Share Track + Share track - {confirmation.created ? "New Playlist" : "Added"} + {confirmation.created ? "New playlist" : "Added"} diff --git a/mobile/components/add-confirmation/store.ts b/mobile/components/add-confirmation/store.ts index f4aa53ac..82578c4c 100644 --- a/mobile/components/add-confirmation/store.ts +++ b/mobile/components/add-confirmation/store.ts @@ -32,7 +32,7 @@ export interface AddConfirmationRequest { playlistArt: { uri: string; placeholder: string | null } | null; /** Glyph for the empty tile. */ playlistGlyph: "music.note.list" | "person.2.fill"; - /** Made for this add: the HUD says "New Playlist" rather than "Added". */ + /** Made for this add: the HUD says "New playlist" rather than "Added". */ created: boolean; /** The add itself, already running. */ done: Promise; diff --git a/mobile/components/album-header.tsx b/mobile/components/album-header.tsx index bb17bb41..fb0ac232 100644 --- a/mobile/components/album-header.tsx +++ b/mobile/components/album-header.tsx @@ -76,7 +76,7 @@ export function AlbumHeader({ diff --git a/mobile/components/auth/bouncing-title.tsx b/mobile/components/auth/bouncing-title.tsx deleted file mode 100644 index 2b0e4464..00000000 --- a/mobile/components/auth/bouncing-title.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { View, type TextStyle } from "react-native"; -import Animated, { - FadeInDown, - useAnimatedStyle, - type SharedValue, -} from "react-native-reanimated"; - -/** Seconds between waves rippling through the letters. */ -const WAVE_EVERY = 4.2; -/** Seconds each letter spends in the air. */ -const HOP = 0.32; -/** Seconds between neighboring letters taking off. */ -const HOP_STAGGER = 0.07; - -/** - * A word whose letters drop in one by one on mount, then periodically hop in - * a left-to-right wave. Read as one header by VoiceOver. - */ -export function BouncingTitle({ - text, - clock, - style, -}: { - text: string; - clock: SharedValue; - style: TextStyle; -}) { - const letters = Array.from(text); - return ( - - {letters.map((letter, index) => ( - - - - ))} - - ); -} - -function HoppingLetter({ - letter, - index, - clock, - style, -}: { - letter: string; - index: number; - clock: SharedValue; - style: TextStyle; -}) { - const animatedStyle = useAnimatedStyle(() => { - // The first wave lands after the drop-in settles. - const local = ((clock.value + WAVE_EVERY - 1.6) % WAVE_EVERY) - index * HOP_STAGGER; - const hop = local > 0 && local < HOP ? Math.sin((Math.PI * local) / HOP) : 0; - return { transform: [{ translateY: -7 * hop }] }; - }); - return {letter}; -} diff --git a/mobile/components/auth/bubble-parade.tsx b/mobile/components/auth/bubble-parade.tsx deleted file mode 100644 index eb795868..00000000 --- a/mobile/components/auth/bubble-parade.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import { - Pressable, - StyleSheet, - useWindowDimensions, - View, - type StyleProp, - type ViewStyle, -} from "react-native"; -import * as Haptics from "expo-haptics"; -import { SymbolView, type SFSymbol } from "expo-symbols"; -import Animated, { - Easing, - FadeIn, - useAnimatedStyle, - useSharedValue, - withSequence, - withSpring, - withTiming, - type SharedValue, -} from "react-native-reanimated"; -import { useTheme, withAlpha, type ThemeTokens } from "../../theme/theme"; - -/** Points per second the parade slides left. */ -const SPEED = 14; -/** - * Off-screen run-up on each side. Wider than half the largest bubble, so a - * bubble wraps from the left edge to the right one where nobody can see it. - */ -const EDGE_PAD = 110; - -interface Bubble { - symbol: SFSymbol; - size: number; - /** Vertical offset from the parade's midline. */ - y: number; - phase: number; -} - -// Listed in paint order: smaller bubbles sit behind larger ones. -const BUBBLES: Bubble[] = [ - { symbol: "hifispeaker.fill", size: 112, y: 34, phase: 4.1 }, - { symbol: "opticaldisc.fill", size: 118, y: 24, phase: 1.3 }, - { symbol: "music.mic", size: 126, y: 12, phase: 3.2 }, - { symbol: "pianokeys", size: 142, y: -10, phase: 5.0 }, - { symbol: "headphones", size: 150, y: -22, phase: 0.2 }, - { symbol: "guitars.fill", size: 160, y: -34, phase: 2.4 }, -]; - -// Where each bubble sits along the loop, in `BUBBLES` order, as fractions of -// the loop. Interleaved so neighbors on screen alternate big and small. -const SLOTS = [0.08, 0.58, 0.25, 0.75, 0.42, 0.92]; - -/** - * The welcome screen's hero: glass bubbles, each holding a piece of music - * gear in the brand purple, drifting right to left in an endless loop while - * they bob. Tapping one squishes it and spins what's inside. - */ -export function BubbleParade({ - clock, - style, -}: { - clock: SharedValue; - style?: StyleProp; -}) { - const theme = useTheme(); - const { width } = useWindowDimensions(); - const loop = width + EDGE_PAD * 2; - const glass = glassStyle(theme); - return ( - - - {BUBBLES.map((bubble, index) => ( - - ))} - - ); -} - -/** Scheme-specific glass: a faint fill and rim, lit from above. */ -function glassStyle(theme: ThemeTokens) { - const dark = theme.scheme === "dark"; - return { - sphere: { - backgroundColor: dark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.02)", - borderColor: dark ? "rgba(255,255,255,0.14)" : "rgba(0,0,0,0.07)", - boxShadow: dark - ? `inset 0 2px 6px rgba(255,255,255,0.22), inset 0 -12px 24px ${withAlpha(theme.color.brand, 0.2)}, inset 0 0 20px rgba(255,255,255,0.06)` - : `inset 0 2px 6px rgba(255,255,255,0.9), inset 0 -12px 24px ${withAlpha(theme.color.brand, 0.08)}, inset 0 0 18px rgba(0,0,0,0.04)`, - } satisfies ViewStyle, - shadow: { - boxShadow: dark - ? "0 18px 36px rgba(0,0,0,0.6)" - : "0 14px 30px rgba(0,0,0,0.1)", - } satisfies ViewStyle, - innerGlow: withAlpha(theme.color.brand, dark ? 0.22 : 0.1), - specular: dark ? 0.45 : 0.9, - }; -} - -function GlassBubble({ - bubble, - start, - loop, - clock, - glass, - tint, -}: { - bubble: Bubble; - start: number; - loop: number; - clock: SharedValue; - glass: ReturnType; - tint: string; -}) { - const size = bubble.size; - const squish = useSharedValue(0); - const spin = useSharedValue(0); - - const travelStyle = useAnimatedStyle(() => { - const t = clock.value; - const along = (((start - t * SPEED) % loop) + loop) % loop; - return { - transform: [ - { translateX: along - EDGE_PAD - size / 2 }, - { translateY: bubble.y + 9 * Math.sin(t * 0.9 + bubble.phase) }, - ], - }; - }); - const sphereStyle = useAnimatedStyle(() => ({ - transform: [ - { scaleX: 1 + 0.1 * squish.value }, - { scaleY: 1 - 0.14 * squish.value }, - ], - })); - const objectStyle = useAnimatedStyle(() => { - const t = clock.value; - return { - transform: [ - { translateY: 3 * Math.sin(t * 1.3 + bubble.phase) }, - { rotate: `${8 * Math.sin(t * 0.8 + bubble.phase) + spin.value}deg` }, - ], - }; - }); - - const onPress = () => { - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - squish.set( - withSequence( - withTiming(1, { duration: 90 }), - withSpring(0, { damping: 5, stiffness: 180 }), - ), - ); - spin.set( - withTiming(spin.get() + 360, { - duration: 750, - easing: Easing.out(Easing.back(1.6)), - }), - ); - }; - - const round = { width: size, height: size, borderRadius: size / 2 }; - return ( - - - {/* Kept apart from the clipped sphere so the drop shadow isn't cut off. */} - - - - - - - - - - - - ); -} - -const styles = StyleSheet.create({ - glow: { - position: "absolute", - left: "-10%", - right: "-10%", - top: "15%", - bottom: "5%", - }, - slot: { - position: "absolute", - left: 0, - // A touch below center: the headline block below is taller than the - // wordmark above, so this balances the gaps. - top: "54%", - }, - sphere: { - overflow: "hidden", - alignItems: "center", - justifyContent: "center", - borderWidth: 1, - }, - specular: { - position: "absolute", - transform: [{ rotate: "-32deg" }], - }, -}); diff --git a/mobile/components/auth/motion.ts b/mobile/components/auth/motion.ts deleted file mode 100644 index dc8718ca..00000000 --- a/mobile/components/auth/motion.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useEffect } from "react"; -import { useIsFocused } from "expo-router"; -import { - useFrameCallback, - useReducedMotion, - useSharedValue, - type SharedValue, -} from "react-native-reanimated"; - -/** Largest frame step the clock accepts, so a resume never jumps the scene. */ -const MAX_FRAME_STEP = 1 / 15; - -/** - * Seconds of running animation time, advanced on the UI thread. Every ambient - * loop on the welcome screen (bubble parade, title hop) derives - * its phase from this one clock, so pausing is a single frame-callback toggle - * and resuming picks up where it left off. - */ -export function useMotionClock(active: boolean): SharedValue { - const clock = useSharedValue(0); - const frame = useFrameCallback((info) => { - const step = (info.timeSincePreviousFrame ?? 0) / 1000; - clock.value += Math.min(step, MAX_FRAME_STEP); - }, false); - useEffect(() => { - frame.setActive(active); - }, [active, frame]); - return clock; -} - -/** - * The clock for an auth screen: it only runs while the screen is on top and - * motion is allowed. With Reduce Motion the scene renders as a still frame. - */ -export function useAuthMotionClock(): SharedValue { - const reducedMotion = useReducedMotion(); - const focused = useIsFocused(); - return useMotionClock(focused && !reducedMotion); -} diff --git a/mobile/components/auth/welcome-art.ts b/mobile/components/auth/welcome-art.ts new file mode 100644 index 00000000..9f9960e8 --- /dev/null +++ b/mobile/components/auth/welcome-art.ts @@ -0,0 +1,4 @@ +/** The welcome scene's files, rendered by `scripts/render-welcome-art.py`. */ +export const WELCOME_NIGHT = require("../../assets/images/welcome-night.jpg"); +export const WELCOME_MORNING = require("../../assets/images/welcome-morning.jpg"); +export const WELCOME_RECORD = require("../../assets/images/welcome-record.png"); diff --git a/mobile/components/auth/welcome-hero.tsx b/mobile/components/auth/welcome-hero.tsx new file mode 100644 index 00000000..75a1266f --- /dev/null +++ b/mobile/components/auth/welcome-hero.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from "react"; +import { + Image as NativeImage, + PixelRatio, + StyleSheet, + useWindowDimensions, + View, +} from "react-native"; +import { Image } from "expo-image"; +import Animated, { FadeIn } from "react-native-reanimated"; +import { + Canvas, + Fill, + ImageShader, + Shader, + Skia, + type SkImage, + type SkRuntimeEffect, +} from "@shopify/react-native-skia"; +import { + WELCOME_MESH_SHADER, + loadWelcomeArt, + welcomeArtRect, + welcomeFadeHeight, + welcomeMesh, +} from "../../lib/welcome-mesh"; +import { useTheme, withAlpha } from "../../theme/theme"; +import { WELCOME_MORNING, WELCOME_NIGHT } from "./welcome-art"; + +const ENTER = FadeIn.duration(400); +/** How much light is left in the lines between cells, per scheme. */ +const LINE_LIGHT = { dark: 0.6, light: 0.88 } as const; + +let meshEffect: SkRuntimeEffect | null | undefined; + +/** The compiled mesh, or null if this device's Skia refuses it. */ +function getMeshEffect(): SkRuntimeEffect | null { + if (meshEffect === undefined) { + try { + meshEffect = Skia.RuntimeEffect.Make(WELCOME_MESH_SHADER); + } catch { + meshEffect = null; + } + } + return meshEffect; +} + +/** The decoded art for Skia: null while loading, and `failed` if it never will. */ +function useWelcomeArt(source: number): { art: SkImage | null; failed: boolean } { + const [loaded, setLoaded] = useState<{ source: number; art: SkImage | null } | null>( + null, + ); + useEffect(() => { + let live = true; + void loadWelcomeArt(NativeImage.resolveAssetSource(source).uri, Skia).then((art) => { + if (live) setLoaded({ source, art }); + }); + return () => { + live = false; + }; + }, [source]); + // After a switch between light and dark, the old scene belongs to the old + // scheme. Drawing nothing lets the new one fade in on its own. + if (loaded?.source !== source) return { art: null, failed: false }; + return { art: loaded.art, failed: loaded.art === null }; +} + +/** + * The welcome screen's scene: a record sliding out of its sleeve among the + * clouds, at night in dark mode and in the morning in light mode, drawn through + * a pixel mesh and fading into the page. Without the mesh it's the plain image. + */ +export function WelcomeHero({ height }: { height: number }) { + const theme = useTheme(); + const { width } = useWindowDimensions(); + const source = theme.scheme === "dark" ? WELCOME_NIGHT : WELCOME_MORNING; + const { art, failed } = useWelcomeArt(source); + const effect = getMeshEffect(); + const view = { width, height }; + const fadeColor = theme.color.bg; + + return ( + + {effect && !failed ? ( + art ? ( + + + + + + + + + + ) : null + ) : ( + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + hero: { + position: "absolute", + top: 0, + left: 0, + }, + fade: { + position: "absolute", + left: 0, + right: 0, + // One point past the edge, so no row of the mesh survives under the text. + bottom: -1, + }, +}); diff --git a/mobile/components/buttons.tsx b/mobile/components/buttons.tsx index 923b4a2a..ce5c4663 100644 --- a/mobile/components/buttons.tsx +++ b/mobile/components/buttons.tsx @@ -1,6 +1,8 @@ import { ActivityIndicator, Pressable, Text } from "react-native"; import { useTheme } from "../theme/theme"; +const BUTTON_HEIGHT = 48; + /** * Full-width filled accent button used for primary form actions (sign in, * create account, upload, save). Previously hand-rolled in six screens with @@ -14,15 +16,21 @@ export function PrimaryButton({ loading = false, disabled = false, accessibilityLabel, + tone = "accent", }: { label: string; onPress: () => void; loading?: boolean; disabled?: boolean; accessibilityLabel?: string; + /** `inverse` fills with the text color, for the signed-out screens. */ + tone?: "accent" | "inverse"; }) { const theme = useTheme(); const isDisabled = disabled || loading; + const inverse = tone === "inverse"; + const background = inverse ? theme.color.fg : theme.color.accent; + const foreground = inverse ? theme.color.bg : theme.color.onAccent; return ( ({ - backgroundColor: theme.color.accent, + backgroundColor: background, borderRadius: theme.radius.md, - paddingVertical: 14, + minHeight: BUTTON_HEIGHT, + paddingVertical: 12, alignItems: "center", justifyContent: "center", borderCurve: "continuous", @@ -41,11 +50,9 @@ export function PrimaryButton({ })} > {loading ? ( - + ) : ( - + {label} )} @@ -81,8 +88,10 @@ export function SecondaryButton({ backgroundColor: theme.color.bgElev1, borderRadius: theme.radius.md, borderCurve: "continuous", - paddingVertical: 11, + minHeight: BUTTON_HEIGHT, + paddingVertical: 12, alignItems: "center", + justifyContent: "center", opacity: disabled ? 0.5 : pressed ? 0.8 : 1, })} > diff --git a/mobile/components/dock/dock-mini-player.tsx b/mobile/components/dock/dock-mini-player.tsx index 3f0d8726..3a33877f 100644 --- a/mobile/components/dock/dock-mini-player.tsx +++ b/mobile/components/dock/dock-mini-player.tsx @@ -496,38 +496,38 @@ function PadTrackActionsButton({ track }: { track: TrackListItem }) { { label: "Play", onPress: actions.play }, { label: actions.favorite - ? "Remove from Favorites" - : "Add to Favorites", + ? "Remove from favorites" + : "Add to favorites", onPress: actions.toggleFavorite, }, - { label: "Track Info", onPress: actions.openInfo }, - ...(actions.canShare ? [{ label: "Share...", onPress: actions.openShare }] : []), - { label: "Add to Playlist...", onPress: actions.openPlaylistPicker }, + { label: "Track info", onPress: actions.openInfo }, + ...(actions.canShare ? [{ label: "Share…", onPress: actions.openShare }] : []), + { label: "Add to playlist…", onPress: actions.openPlaylistPicker }, { - label: actions.downloading ? "Downloading..." : "Download File...", + label: actions.downloading ? "Downloading…" : "Download file…", onPress: actions.download, disabled: actions.downloading, }, ]; if (actions.hasAlbum) { - items.push({ label: "View Album", onPress: actions.openAlbum }); + items.push({ label: "View album", onPress: actions.openAlbum }); } if (actions.canEditMetadata) { items.push({ - label: "Edit Metadata", + label: "Edit metadata", onPress: actions.openEditMetadata, }); } if (actions.hasEditableAlbum) { items.push({ - label: "Edit Album & Cover", + label: "Edit album & cover", onPress: actions.openEditAlbum, }); } if (actions.owned) { items.push({ - label: actions.deleting ? "Deleting..." : "Delete from My Library", + label: actions.deleting ? "Deleting…" : "Delete from my library", onPress: actions.deleteTrack, destructive: true, disabled: actions.deleting, @@ -622,7 +622,7 @@ const styles = StyleSheet.create({ }, padSideClusterWide: { width: 250, - gap: 14, + gap: 12, }, padSideClusterCompact: { width: 154, @@ -654,7 +654,7 @@ const styles = StyleSheet.create({ }, padTitle: { fontSize: 15, - fontWeight: "700", + fontWeight: "600", }, padArtist: { marginTop: 2, @@ -666,7 +666,7 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", justifyContent: "flex-end", - gap: 14, + gap: 12, }, padActionClusterCompact: { width: 72, diff --git a/mobile/components/eyebrow.tsx b/mobile/components/eyebrow.tsx index 3d653fda..42da6f27 100644 --- a/mobile/components/eyebrow.tsx +++ b/mobile/components/eyebrow.tsx @@ -2,11 +2,7 @@ import { type ReactNode } from "react"; import { Text, type StyleProp, type TextStyle } from "react-native"; import { useTheme } from "../theme/theme"; -/** - * Muted uppercase micro-label above section titles and feature cards (home - * shelves, replay sections). Smaller and wider-tracked than the form - * `SectionLabel`. - */ +/** Muted caption above section titles and feature cards (home shelves). */ export function Eyebrow({ style, children, @@ -20,9 +16,7 @@ export function Eyebrow({ style={[ { color: theme.color.fgMuted, - fontSize: 11, - letterSpacing: 0.6, - textTransform: "uppercase", + fontSize: 13, }, style, ]} diff --git a/mobile/components/favorites/favorites-list-header.tsx b/mobile/components/favorites/favorites-list-header.tsx index ac861ca4..8ea4d6d9 100644 --- a/mobile/components/favorites/favorites-list-header.tsx +++ b/mobile/components/favorites/favorites-list-header.tsx @@ -25,12 +25,12 @@ import type { ThemeTokens } from "../../theme/theme"; // order, newest first. const SORT_OPTIONS: Record = { favorites: [ - { key: "custom", label: "Recently Added", symbol: "heart" }, + { key: "custom", label: "Recently added", symbol: "heart" }, { key: "title", label: "Title" }, { key: "duration", label: "Length" }, ], recent: [ - { key: "custom", label: "Last Played", symbol: "clock" }, + { key: "custom", label: "Last played", symbol: "clock" }, { key: "title", label: "Title" }, { key: "duration", label: "Length" }, ], diff --git a/mobile/components/favorites/favorites-list.tsx b/mobile/components/favorites/favorites-list.tsx index 146324e7..7ae272f5 100644 --- a/mobile/components/favorites/favorites-list.tsx +++ b/mobile/components/favorites/favorites-list.tsx @@ -124,11 +124,11 @@ export function useFavoritesListModel( const EMPTY_MESSAGES: Record = { favorites: { - error: "Couldn't load favorites.", + error: "Couldn't load favorites", empty: "No favorites yet. Tap the heart on a track to save it here.", }, recent: { - error: "Couldn't load recent plays.", + error: "Couldn't load recent plays", empty: "No recent plays.", }, }; diff --git a/mobile/components/favorites/mode-wash.tsx b/mobile/components/favorites/mode-wash.tsx deleted file mode 100644 index 913b2a36..00000000 --- a/mobile/components/favorites/mode-wash.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect } from "react"; -import { StyleSheet } from "react-native"; -import Animated, { - Easing, - useAnimatedStyle, - useSharedValue, - withTiming, - type SharedValue, -} from "react-native-reanimated"; -import { modeColor } from "./mode-switch"; -import type { FavoritesMode } from "../../lib/favorites-header"; -import { withAlpha, type ThemeTokens } from "../../theme/theme"; - -const WASH_HEIGHT = 430; -const WASH_FADE = { duration: 350, easing: Easing.linear } as const; - -function washGradient(color: string, alpha: number): string { - return `radial-gradient(130% 75% at 20% 0%, ${withAlpha(color, alpha)} 0%, ${withAlpha(color, 0)} 72%)`; -} - -/** - * A faint wash of the list's colour behind the header. It sits behind both - * lists (they're transparent) so switching cross-fades purple and blue rather - * than snapping, and it scrolls with the active list. A pull to refresh leaves - * it pinned to the top instead of opening a gap above it. - */ -export function ModeWash({ - theme, - mode, - offset, - topInset, -}: { - theme: ThemeTokens; - mode: FavoritesMode; - /** The active list's raw content offset (rest is `-topInset`). */ - offset: SharedValue; - topInset: number; -}) { - const recent = useSharedValue(mode === "recent" ? 1 : 0); - useEffect(() => { - recent.set(withTiming(mode === "recent" ? 1 : 0, WASH_FADE)); - }, [mode, recent]); - - const followStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: -Math.max(0, offset.value + topInset) }], - })); - const favoritesStyle = useAnimatedStyle(() => ({ opacity: 1 - recent.value })); - const recentStyle = useAnimatedStyle(() => ({ opacity: recent.value })); - - // Lighter on white, where the same tint reads much stronger. - const dark = theme.scheme === "dark"; - return ( - - - - - ); -} - -const styles = StyleSheet.create({ - wash: { - position: "absolute", - top: 0, - left: 0, - right: 0, - height: WASH_HEIGHT, - }, -}); diff --git a/mobile/components/form-field.tsx b/mobile/components/form-field.tsx index f75cf741..4b42c468 100644 --- a/mobile/components/form-field.tsx +++ b/mobile/components/form-field.tsx @@ -81,7 +81,7 @@ export function FormTextInput( color: theme.color.fg, borderRadius: theme.radius.md, borderCurve: "continuous", - paddingHorizontal: 14, + paddingHorizontal: 16, paddingVertical: 12, fontSize: 17, }, diff --git a/mobile/components/library/browse-links-card.tsx b/mobile/components/library/browse-links-card.tsx index 7806b49d..f49603ed 100644 --- a/mobile/components/library/browse-links-card.tsx +++ b/mobile/components/library/browse-links-card.tsx @@ -44,7 +44,7 @@ export function BrowseLinksCard({ ); } -/** One disclosure row inside the card: accent icon, label, chevron, and an optional inset hairline above. */ +/** One disclosure row inside the card: icon, label, chevron, and an optional inset hairline above. */ function BrowseLinkRow({ icon, label, @@ -68,7 +68,7 @@ function BrowseLinkRow({ flexDirection: "row", alignItems: "center", paddingHorizontal: theme.space.md, - paddingVertical: 13, + paddingVertical: 12, gap: theme.space.md, backgroundColor: pressed ? theme.color.bgElev2 : "transparent", })} @@ -77,7 +77,7 @@ function BrowseLinkRow({ name={icon} size={20} weight="medium" - tintColor={theme.color.accent} + tintColor={theme.color.fg} /> {label} diff --git a/mobile/components/library/ranked-track-row.tsx b/mobile/components/library/ranked-track-row.tsx index 43165b08..4cc1a772 100644 --- a/mobile/components/library/ranked-track-row.tsx +++ b/mobile/components/library/ranked-track-row.tsx @@ -74,8 +74,7 @@ export function RankedTrackRow({ width: 30, color: rank === 1 ? theme.color.accent : theme.color.fgMuted, fontSize: 22, - fontWeight: "700", - letterSpacing: -0.5, + fontWeight: "600", fontVariant: ["tabular-nums"], textAlign: "center", }} diff --git a/mobile/components/library/search-results.tsx b/mobile/components/library/search-results.tsx index edb714cf..6ae95694 100644 --- a/mobile/components/library/search-results.tsx +++ b/mobile/components/library/search-results.tsx @@ -154,7 +154,7 @@ export function SearchResults({ search }: { search: string }) { } ListEmptyComponent={ !search ? ( - + ) : query.isLoading ? ( ) : query.isError || warnings.length === 0 ? ( diff --git a/mobile/components/library/welcome-card.tsx b/mobile/components/library/welcome-card.tsx index 661c1cfe..fb29af5a 100644 --- a/mobile/components/library/welcome-card.tsx +++ b/mobile/components/library/welcome-card.tsx @@ -1,17 +1,12 @@ -import { - Pressable, - Text, - View, - type StyleProp, - type ViewStyle, -} from "react-native"; +import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { SymbolView } from "expo-symbols"; +import { PrimaryButton, SecondaryButton } from "../buttons"; import { useTheme } from "../../theme/theme"; /** - * First-run card shown when the account has nothing personal yet (no plays, - * no favorites): a short pitch plus pill buttons into browse and upload, - * instead of a page of empty shelves. + * First-run block shown when the account has nothing personal yet (no plays, + * no favorites): one line plus buttons into browse and upload, instead of a + * page of empty shelves. */ export function WelcomeCard({ onBrowse, @@ -27,26 +22,23 @@ export function WelcomeCard({ - + - Make this place yours + Nothing played yet - Play a few songs and favorite the keepers — this page fills in with - your recents, heavy rotation, and daily rediscoveries. + Songs you play and favorite show up here - - - + + + + + + + ); } - -/** Compact pill CTA used only inside the welcome card. */ -function PillButton({ - label, - background, - color, - onPress, -}: { - label: string; - background: string; - color: string; - onPress: () => void; -}) { - return ( - ({ - backgroundColor: background, - borderRadius: 999, - paddingHorizontal: 18, - paddingVertical: 10, - opacity: pressed ? 0.7 : 1, - })} - > - {label} - - ); -} diff --git a/mobile/components/now-playing/hero-meta.tsx b/mobile/components/now-playing/hero-meta.tsx index 2e2a61b0..61c08ade 100644 --- a/mobile/components/now-playing/hero-meta.tsx +++ b/mobile/components/now-playing/hero-meta.tsx @@ -42,7 +42,7 @@ export function HeroMeta({ style={{ color: theme.color.fg, fontSize: 22, - fontWeight: "700", + fontWeight: "600", letterSpacing: -0.3, flexShrink: 1, }} diff --git a/mobile/components/now-playing/lyrics-language-selector.tsx b/mobile/components/now-playing/lyrics-language-selector.tsx index 2f0c7cc2..617feb02 100644 --- a/mobile/components/now-playing/lyrics-language-selector.tsx +++ b/mobile/components/now-playing/lyrics-language-selector.tsx @@ -45,7 +45,7 @@ const TRANSLATION_LANGUAGES: readonly TranslationLanguage[] = [ type ExpandedSelector = "source" | "target" | null; function languageLabel(code: string | null): string { - if (code === null) return "Auto Detect"; + if (code === null) return "Auto detect"; return ( TRANSLATION_LANGUAGES.find((language) => language.code === code)?.label ?? code @@ -121,7 +121,7 @@ export function LyricsLanguageSelector({ title="To" value={ targetLanguage === null - ? "Select Language" + ? "Select language" : languageLabel(targetLanguage) } expanded={expanded === "target"} @@ -213,7 +213,7 @@ function SelectorRow({ {title} @@ -245,7 +245,7 @@ function LanguageOptions({ const options: readonly ( TranslationLanguage | { code: null; label: string } )[] = includeAutoDetect - ? [{ code: null, label: "Auto Detect" }, ...TRANSLATION_LANGUAGES] + ? [{ code: null, label: "Auto detect" }, ...TRANSLATION_LANGUAGES] : TRANSLATION_LANGUAGES; return ( @@ -360,11 +360,11 @@ const styles = StyleSheet.create({ minHeight: 42, borderRadius: 21, marginTop: 10, - paddingHorizontal: 14, + paddingHorizontal: 16, flexDirection: "row", alignItems: "center", justifyContent: "center", - gap: 7, + gap: 8, }, actionLabel: { fontSize: 14, diff --git a/mobile/components/now-playing/lyrics-section.tsx b/mobile/components/now-playing/lyrics-section.tsx index 00dcde48..b135b770 100644 --- a/mobile/components/now-playing/lyrics-section.tsx +++ b/mobile/components/now-playing/lyrics-section.tsx @@ -428,11 +428,9 @@ export const LyricsSection = forwardRef< line.section ? { color: theme.color.accent, - fontSize: 12, - fontWeight: "700", - letterSpacing: 0.66, - lineHeight: 17, - textTransform: "uppercase", + fontSize: 13, + fontWeight: "600", + lineHeight: 18, } : { color: theme.color.fgSubtle, @@ -453,7 +451,7 @@ export const LyricsSection = forwardRef< color: theme.color.fgMuted, fontSize: 14, lineHeight: 20, - paddingTop: 3, + paddingTop: 4, }} > {activeTranslation.lines[index]} @@ -600,12 +598,11 @@ const SyncedLyricLine = memo(function SyncedLyricLine({ ); const baseLineText: TextStyle = { - fontSize: line.section ? 12 : state === "active" ? 19 : 18, - fontWeight: line.section ? "700" : state === "active" ? "500" : "400", + fontSize: line.section ? 13 : state === "active" ? 19 : 18, + fontWeight: line.section ? "600" : state === "active" ? "500" : "400", lineHeight: line.section ? 18 : 27, - // Frontend section letter-spacing 0.055em ≈ 0.66 at 12px; body -0.01em. - letterSpacing: line.section ? 0.66 : -0.19, - textTransform: line.section ? "uppercase" : "none", + // Body lines keep the frontend's -0.01em; section labels stay untracked. + letterSpacing: line.section ? 0 : -0.19, }; return ( @@ -645,9 +642,8 @@ const SyncedLyricLine = memo(function SyncedLyricLine({ color: theme.color.fgMuted, fontSize: 14, fontWeight: "400", - letterSpacing: -0.08, lineHeight: 20, - paddingTop: 3, + paddingTop: 4, }} > {translatedText} diff --git a/mobile/components/now-playing/queue-row.tsx b/mobile/components/now-playing/queue-row.tsx index 936fde15..856b1265 100644 --- a/mobile/components/now-playing/queue-row.tsx +++ b/mobile/components/now-playing/queue-row.tsx @@ -65,7 +65,7 @@ function QueueRowImpl({ transitionMs={120} priority="normal" /> - + - Nothing else queued. + Nothing else queued } diff --git a/mobile/components/ota-updates.tsx b/mobile/components/ota-updates.tsx index 82d6e38c..a08b6cd6 100644 --- a/mobile/components/ota-updates.tsx +++ b/mobile/components/ota-updates.tsx @@ -186,7 +186,7 @@ export function OtaUpdateRows() { styles.row, { paddingHorizontal: SETTINGS_INSET, - paddingVertical: 14, + paddingVertical: 12, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: theme.color.separator, backgroundColor: pressed ? theme.color.bgElev2 : "transparent", @@ -251,7 +251,7 @@ function StatusTrailing({ style={{ backgroundColor: theme.color.accent, borderRadius: 11, - paddingHorizontal: 9, + paddingHorizontal: 8, height: 22, justifyContent: "center", }} diff --git a/mobile/components/playback-device-button.tsx b/mobile/components/playback-device-button.tsx index 423f2fab..e816eef5 100644 --- a/mobile/components/playback-device-button.tsx +++ b/mobile/components/playback-device-button.tsx @@ -88,7 +88,7 @@ export function PlaybackDeviceButton({ swiftFrame({ width: size, height: size }), ]} > -
+
; + }, + Text: ({ children }: { children: ReactNode }) => {children}, + View: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("../theme/theme", () => ({ + useTheme: () => ({ color: { fg: "#fff", fgMuted: "#888" } }), +})); + +const render = (onPress?: () => void) => { + renderToStaticMarkup( + , + ); + if (!mock.pressable) throw new Error("no pressable"); + return mock.pressable; +}; + +beforeEach(() => { + mock.pressable = undefined; +}); + +it("is a button with press feedback when it does something", () => { + const tile = render(vi.fn()); + expect(tile.accessibilityRole).toBe("button"); + expect(tile.style({ pressed: true })[0].opacity).toBe(0.7); +}); + +it("is plain, not dimmed for VoiceOver, when it has nothing to do", () => { + const tile = render(); + expect(tile.accessibilityRole).toBeUndefined(); + expect(tile.disabled).toBeUndefined(); + expect(tile.accessibilityLabel).toBe("Clayco"); + expect(tile.style({ pressed: true })[0].opacity).toBe(1); +}); diff --git a/mobile/tests/swipe-actions.test.ts b/mobile/tests/swipe-actions.test.ts index 0f042293..a75c5c9e 100644 --- a/mobile/tests/swipe-actions.test.ts +++ b/mobile/tests/swipe-actions.test.ts @@ -51,7 +51,7 @@ describe("swipe release", () => { }); describe("action capsule", () => { - // "Play Next": a 20 pt icon, a 6 pt gap and the label. + // "Play next": a 20 pt icon, a 6 pt gap and the label. const content = 96; it("grows with the row, 8 pt off the screen edge and the row", () => { diff --git a/mobile/tests/tidal-album-versions.test.tsx b/mobile/tests/tidal-album-versions.test.tsx index 07d24812..5eb48c1a 100644 --- a/mobile/tests/tidal-album-versions.test.tsx +++ b/mobile/tests/tidal-album-versions.test.tsx @@ -225,14 +225,14 @@ it("keeps the previous version up, its rows hidden and buttons waiting, while th expect(mock.pending).toBe(true); expect(mock.segments?.value).toBe("2"); expect(html).toContain('aria-hidden="true">Powiedz To'); - expect(html).not.toContain("Couldn't load TIDAL album."); + expect(html).not.toContain("Couldn't load TIDAL album"); }); it("shows the error with the picker when the picked version can't load offline", () => { mock.params = { id: "2", artist: "77" }; Object.assign(mock.album, { isPlaceholderData: true, isPaused: true }); const html = markup(); - expect(html).toContain("Couldn't load TIDAL album."); + expect(html).toContain("Couldn't load TIDAL album"); expect(html).not.toContain("Powiedz To"); mock.segments?.onChange("1"); expect(mock.setParams).toHaveBeenCalledWith({ id: "1", artist: "77" }); @@ -242,7 +242,7 @@ it("shows the error with the picker when the picked version fails", () => { mock.params = { id: "2", artist: "77" }; mock.album = { data: undefined, isError: true }; const html = markup(); - expect(html).toContain("Couldn't load TIDAL album."); + expect(html).toContain("Couldn't load TIDAL album"); expect(mock.segments?.value).toBe("2"); }); @@ -250,7 +250,7 @@ it("stays on the error while a failed version is retried", () => { mock.params = { id: "2", artist: "77" }; Object.assign(mock.album, { isPlaceholderData: true, errorUpdateCount: 1 }); const html = markup(); - expect(html).toContain("Couldn't load TIDAL album."); + expect(html).toContain("Couldn't load TIDAL album"); expect(html).not.toContain("Powiedz To"); expect(mock.segments?.value).toBe("2"); }); diff --git a/mobile/tests/track-info-screen.test.tsx b/mobile/tests/track-info-screen.test.tsx index ebab430e..77be6011 100644 --- a/mobile/tests/track-info-screen.test.tsx +++ b/mobile/tests/track-info-screen.test.tsx @@ -355,7 +355,7 @@ it("offers no Play for a track dropped from TIDAL, and says why", () => { const out = html(); expect(out).not.toContain(""); expect(out).toContain(""); - expect(out).toContain("Removed from TIDAL, so it can't play."); + expect(out).toContain("Removed from TIDAL, so it can't play"); }); it("doesn't ask for the album after TIDAL failed, and never twice", () => { diff --git a/mobile/tests/welcome-hero.test.tsx b/mobile/tests/welcome-hero.test.tsx new file mode 100644 index 00000000..bfa37b9e --- /dev/null +++ b/mobile/tests/welcome-hero.test.tsx @@ -0,0 +1,129 @@ +import { type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, expect, it, vi } from "vitest"; + +const NIGHT = 1; +const MORNING = 2; + +const mock = vi.hoisted(() => ({ + make: vi.fn(), + // Stands in for the effect that decodes the art, which a server render skips. + loaded: null as null | { + source: number; + art: { width: () => number; height: () => number } | null; + }, + shader: undefined as undefined | Record, + image: undefined as undefined | Record, +})); + +// The hero's only state is the decoded art, so this is all of its state. A second +// useState in the hero would need this mock to tell them apart. +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useState: (initial: T) => + mock.loaded ? [mock.loaded, () => {}] : actual.useState(initial), + }; +}); + +vi.mock("react-native", () => ({ + Image: { resolveAssetSource: () => ({ uri: "file:///art.jpg" }) }, + PixelRatio: { get: () => 3 }, + StyleSheet: { absoluteFill: {}, create: (styles: T) => styles }, + useWindowDimensions: () => ({ width: 393, height: 852 }), + View: ({ children }: { children?: ReactNode }) =>
{children}
, +})); +vi.mock("expo-image", () => ({ Image: () => plain art })); +vi.mock("react-native-reanimated", () => { + const builder = { duration: () => builder }; + return { + default: { + View: ({ children }: { children: ReactNode }) =>
{children}
, + }, + FadeIn: builder, + }; +}); +vi.mock("@shopify/react-native-skia", () => ({ + Canvas: ({ children }: { children: ReactNode }) => {children}, + Fill: ({ children }: { children: ReactNode }) => <>{children}, + ImageShader: (props: Record) => { + mock.image = props; + return null; + }, + Shader: ({ children, ...props }: { children: ReactNode }) => { + mock.shader = props; + return <>{children}; + }, + Skia: { RuntimeEffect: { Make: mock.make } }, +})); +vi.mock("../components/auth/welcome-art", () => ({ + WELCOME_NIGHT: 1, + WELCOME_MORNING: 2, +})); +vi.mock("../theme/theme", () => ({ + useTheme: () => ({ scheme: "dark", color: { bg: "#000000" } }), + withAlpha: (color: string) => color, +})); + +beforeEach(() => { + vi.resetModules(); + mock.make.mockReset(); + mock.loaded = null; + mock.shader = undefined; + mock.image = undefined; +}); + +async function render() { + const { WelcomeHero } = await import("../components/auth/welcome-hero"); + return renderToStaticMarkup(); +} + +it("shows the plain art when this device's Skia refuses the mesh", async () => { + mock.make.mockImplementation(() => { + throw new Error("Error in SKSL"); + }); + expect(await render()).toContain('alt="plain art"'); +}); + +it("waits for the art without flashing the plain image", async () => { + mock.make.mockReturnValue({}); + const html = await render(); + expect(html).not.toContain("plain art"); + expect(html).not.toContain(" { + mock.make.mockReturnValue({}); + await render(); + await render(); + expect(mock.make).toHaveBeenCalledTimes(1); +}); + +it("draws the art through the mesh, clamped so cut-off edge cells stay opaque", async () => { + const effect = {}; + mock.make.mockReturnValue(effect); + const art = { width: () => 660, height: () => 960 }; + mock.loaded = { source: NIGHT, art }; + expect(await render()).toContain(" { + mock.make.mockReturnValue({}); + // The theme is dark, and what's decoded is still the morning file. + mock.loaded = { source: MORNING, art: { width: () => 660, height: () => 960 } }; + const html = await render(); + expect(html).not.toContain(" { + mock.make.mockReturnValue({}); + mock.loaded = { source: NIGHT, art: null }; + expect(await render()).toContain('alt="plain art"'); +}); diff --git a/mobile/tests/welcome-mesh.test.ts b/mobile/tests/welcome-mesh.test.ts new file mode 100644 index 00000000..2aee5aa0 --- /dev/null +++ b/mobile/tests/welcome-mesh.test.ts @@ -0,0 +1,142 @@ +import CanvasKitInit from "canvaskit-wasm"; +import { describe, expect, it, vi } from "vitest"; +import { + WELCOME_MESH_SHADER, + loadWelcomeArt, + welcomeArtRect, + welcomeFadeHeight, + welcomeMesh, +} from "../lib/welcome-mesh"; + +describe("welcomeMesh", () => { + it("lands on whole device pixels at every screen density", () => { + expect(welcomeMesh(3)).toEqual({ pitch: 9, gap: 2 }); + expect(welcomeMesh(2)).toEqual({ pitch: 6, gap: 1 }); + expect(welcomeMesh(2.625)).toEqual({ pitch: 8, gap: 2 }); + }); +}); + +describe("welcomeArtRect", () => { + const art = { width: 660, height: 960 }; + + it("fills the width and keeps the sky on a tall view", () => { + expect(welcomeArtRect({ width: 393, height: 540 }, art)).toEqual({ + x: 0, + y: 0, + width: 393, + height: (960 * 393) / 660, + }); + }); + + it("slides up on a short view, to keep the record out of the fade", () => { + const rect = welcomeArtRect({ width: 375, height: 380 }, art); + expect(rect.width).toBeCloseTo(375); + expect(rect.y).toBeLessThan(0); + // The record sits at 39.5% of the art. + expect(rect.y + rect.height * 0.395).toBeCloseTo(380 * 0.46); + }); + + it("fills the height and centers when the view is wider than the art", () => { + const rect = welcomeArtRect({ width: 300, height: 960 }, art); + expect(rect).toEqual({ x: -180, y: 0, width: 660, height: 960 }); + }); +}); + +describe("welcomeFadeHeight", () => { + it("scales with the hero within its limits", () => { + expect(welcomeFadeHeight(200)).toBe(96); + expect(welcomeFadeHeight(540)).toBe(162); + expect(welcomeFadeHeight(1200)).toBe(240); + }); +}); + +describe("loadWelcomeArt", () => { + const skia = (decoded: string | null) => ({ + Data: { fromURI: async (uri: string) => `bytes of ${uri}` }, + Image: { MakeImageFromEncoded: () => decoded }, + }); + + it("decodes the file at the URI", async () => { + const decode = vi.fn(() => "image"); + const art = await loadWelcomeArt("file:///night.jpg", { + Data: skia("").Data, + Image: { MakeImageFromEncoded: decode }, + }); + expect(art).toBe("image"); + expect(decode).toHaveBeenCalledWith("bytes of file:///night.jpg"); + }); + + it("reports a file it can't decode", async () => { + expect(await loadWelcomeArt("file:///night.jpg", skia(null))).toBeNull(); + }); + + it("reports a file it can't read, rather than rejecting", async () => { + const broken = { + Data: { fromURI: () => Promise.reject(new Error("no such file")) }, + Image: { MakeImageFromEncoded: () => "image" }, + }; + await expect(loadWelcomeArt("file:///night.jpg", broken)).resolves.toBeNull(); + }); +}); + +describe("the mesh shader, under real Skia", () => { + async function make() { + const CK = await CanvasKitInit(); + const errors: string[] = []; + const effect = CK.RuntimeEffect.Make(WELCOME_MESH_SHADER, (e) => errors.push(e)); + expect(errors).toEqual([]); + if (!effect) throw new Error("the shader didn't compile"); + return { CK, effect }; + } + + it("takes exactly the uniforms the screen passes", async () => { + const { effect } = await make(); + const names = Array.from({ length: effect.getUniformCount() }, (_, i) => + effect.getUniformName(i), + ); + // RN Skia throws on a missing uniform, by name. + expect(new Set(names)).toEqual( + new Set(["density", ...Object.keys(welcomeMesh(3)), "lineLight"]), + ); + }); + + it("dims a line along each cell's top and left, one mesh period apart", async () => { + const { CK, effect } = await make(); + const density = 3; + const { pitch, gap } = welcomeMesh(density); + const size = pitch * 2; + const surface = CK.MakeSurface(size, size); + if (!surface) throw new Error("no surface"); + const canvas = surface.getCanvas(); + canvas.scale(density, density); + const child = CK.Shader.MakeColor(CK.WHITE, CK.ColorSpace.SRGB); + const uniforms = [density, pitch, gap, 0.5]; + const order = Array.from({ length: effect.getUniformCount() }, (_, i) => + effect.getUniformName(i), + ); + expect(order).toEqual(["density", "pitch", "gap", "lineLight"]); + const paint = new CK.Paint(); + paint.setShader(effect.makeShaderWithChildren(uniforms, [child])); + canvas.drawPaint(paint); + surface.flush(); + + const pixels = canvas.readPixels(0, 0, { + width: size, + height: size, + colorType: CK.ColorType.RGBA_8888, + alphaType: CK.AlphaType.Premul, + colorSpace: CK.ColorSpace.SRGB, + }); + if (!pixels) throw new Error("no pixels"); + const red = (x: number, y: number) => pixels[(y * size + x) * 4]; + const row = Array.from({ length: size }, (_, x) => red(x, pitch - 1)); + const lit = 255; + // Across a cell's row: `gap` dim pixels, then lit ones, then it repeats. + expect(row.slice(0, gap).every((v) => v < 140)).toBe(true); + expect(row.slice(gap, pitch).every((v) => v === lit)).toBe(true); + expect(row.slice(pitch, pitch + gap).every((v) => v < 140)).toBe(true); + expect(row.slice(pitch + gap).every((v) => v === lit)).toBe(true); + // A line across the top of the cell stays dim the whole way. + expect(red(pitch - 1, 0)).toBeLessThan(140); + }); +}); diff --git a/mobile/tests/welcome-screen.test.tsx b/mobile/tests/welcome-screen.test.tsx new file mode 100644 index 00000000..16569eb8 --- /dev/null +++ b/mobile/tests/welcome-screen.test.tsx @@ -0,0 +1,101 @@ +import { type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, expect, it, vi } from "vitest"; +import WelcomeScreen from "../app/(auth)/welcome"; + +const mock = vi.hoisted(() => ({ + push: vi.fn(), + baseUrl: "https://musicfiles.example:8443", + presses: new Map void>(), +})); + +vi.mock("react-native", () => ({ + ActivityIndicator: () => null, + ScrollView: ({ children }: { children: ReactNode }) =>
{children}
, + View: ({ + children, + accessibilityLabel, + }: { + children: ReactNode; + accessibilityLabel?: string; + }) =>
{children}
, + Text: ({ children }: { children: ReactNode }) => {children}, + Pressable: ({ + children, + onPress, + accessibilityLabel, + }: { + children: ReactNode; + onPress: () => void; + accessibilityLabel: string; + }) => { + mock.presses.set(accessibilityLabel, onPress); + return ; + }, + useWindowDimensions: () => ({ width: 393, height: 852, fontScale: 1 }), +})); +vi.mock("expo-router", () => ({ useRouter: () => ({ push: mock.push }) })); +vi.mock("expo-image", () => ({ Image: () => })); +vi.mock("expo-haptics", () => ({ selectionAsync: vi.fn() })); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 59, bottom: 34 }), +})); +vi.mock("react-native-reanimated", () => { + const builder = { + delay: () => builder, + duration: () => builder, + withInitialValues: () => builder, + }; + return { + default: { + View: ({ children }: { children: ReactNode }) =>
{children}
, + }, + FadeInDown: builder, + }; +}); +vi.mock("@music-library/core", () => ({ getBaseUrl: () => mock.baseUrl })); +vi.mock("../components/auth/welcome-art", () => ({ WELCOME_RECORD: 1 })); +vi.mock("../components/auth/welcome-hero", () => ({ WelcomeHero: () => null })); +vi.mock("../theme/theme", () => ({ + useTheme: () => ({ + scheme: "dark", + color: { bg: "#000", fg: "#fff", fgMuted: "#888", fgSubtle: "#ccc", accent: "#00f", onAccent: "#fff" }, + radius: { md: 10 }, + space: { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 }, + }), +})); + +beforeEach(() => { + mock.push.mockClear(); + mock.presses.clear(); + mock.baseUrl = "https://musicfiles.example:8443"; +}); + +it("reads the headline as one sentence, whatever sits between its words", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('aria-label="Your music, on your server"'); +}); + +it("names the server this build talks to", () => { + // React splits the sentence around the value, so compare the text alone. + const text = renderToStaticMarkup().replace(/|<[^>]+>/g, ""); + expect(text).toContain("Lumen plays your library from musicfiles.example on your phone"); +}); + +it("still reads properly when the server isn't configured", () => { + mock.baseUrl = "not a url"; + const text = renderToStaticMarkup().replace(/|<[^>]+>/g, ""); + expect(text).toContain("Lumen plays your library from your server on your phone"); +}); + +it("offers one primary way in and one quiet one", () => { + renderToStaticMarkup(); + expect([...mock.presses.keys()]).toEqual([ + "Sign in", + "Create an account with an invite", + ]); + mock.presses.get("Sign in")?.(); + expect(mock.push).toHaveBeenLastCalledWith("/(auth)/login"); + mock.presses.get("Create an account with an invite")?.(); + expect(mock.push).toHaveBeenLastCalledWith("/(auth)/register"); +}); diff --git a/mobile/theme/theme.tsx b/mobile/theme/theme.tsx index 1e093f8e..10aa31b5 100644 --- a/mobile/theme/theme.tsx +++ b/mobile/theme/theme.tsx @@ -32,8 +32,8 @@ export interface ThemePalette { /** Foreground (text/icon/spinner) on top of `accent`. */ onAccent: string; /** - * The app icon's purple, for brand moments like the welcome screen's hero. - * Controls and links keep using `accent`. Lifted in dark so it reads on black. + * The app icon's purple, which in the interface means Favorites and nothing + * else. Controls and links use `accent`. Lifted in dark so it reads on black. */ brand: string; /** Destructive / error (iOS systemRed, per scheme). */