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 }),
]}
>
-
+
- AVAILABLE DEVICES
+ Available devices
{remoteDevices.length ? (
remoteDevices.map((device) => (
@@ -569,13 +569,11 @@ const styles = StyleSheet.create({
gap: 12,
},
eyebrow: {
- fontSize: 10,
- fontWeight: "600",
- letterSpacing: 1.2,
+ fontSize: 13,
},
title: {
fontSize: 18,
- fontWeight: "700",
+ fontWeight: "600",
},
connectionState: {
flexDirection: "row",
@@ -594,9 +592,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 10,
paddingTop: 16,
paddingBottom: 6,
- fontSize: 10,
- fontWeight: "600",
- letterSpacing: 1.1,
+ fontSize: 13,
},
deviceRow: {
minHeight: 62,
@@ -630,7 +626,7 @@ const styles = StyleSheet.create({
deviceCopy: {
flex: 1,
minWidth: 0,
- gap: 3,
+ gap: 2,
},
deviceName: {
fontSize: 15,
diff --git a/mobile/components/playlist-picker/picker-row.tsx b/mobile/components/playlist-picker/picker-row.tsx
index 8734bf07..ac364c0f 100644
--- a/mobile/components/playlist-picker/picker-row.tsx
+++ b/mobile/components/playlist-picker/picker-row.tsx
@@ -160,7 +160,7 @@ export const NewPlaylistRow = memo(function NewPlaylistRow({
maxFontSizeMultiplier={PICKER_MAX_FONT_SCALE}
style={[styles.subtitle, { color: theme.color.accent }]}
>
- New Playlist
+ New playlist
@@ -214,7 +214,7 @@ const styles = StyleSheet.create({
flexDirection: "row",
alignItems: "center",
gap: 12,
- paddingHorizontal: 14,
+ paddingHorizontal: 16,
},
body: {
flex: 1,
@@ -249,8 +249,8 @@ const styles = StyleSheet.create({
flexDirection: "row",
alignItems: "center",
gap: 4,
- paddingLeft: 7,
- paddingRight: 9,
+ paddingLeft: 8,
+ paddingRight: 8,
borderRadius: 12,
},
badgeText: {
diff --git a/mobile/components/playlist-picker/search-field.tsx b/mobile/components/playlist-picker/search-field.tsx
index c19c98ba..bcbcb1a7 100644
--- a/mobile/components/playlist-picker/search-field.tsx
+++ b/mobile/components/playlist-picker/search-field.tsx
@@ -7,7 +7,7 @@ const PLACEHOLDER = "Find or name a new playlist";
/**
* The picker's one field: it narrows the list, and a name no playlist has
- * becomes the "New Playlist" row. The placeholder says both. `busy` shows
+ * becomes the "New playlist" row. The placeholder says both. `busy` shows
* a spinner at its end while the list is still finding out which playlists
* already hold the track.
*/
@@ -65,7 +65,7 @@ const styles = StyleSheet.create({
field: {
height: 40,
marginTop: 12,
- marginHorizontal: 14,
+ marginHorizontal: 16,
marginBottom: 4,
paddingLeft: 12,
paddingRight: 6,
@@ -73,7 +73,7 @@ const styles = StyleSheet.create({
borderCurve: "continuous",
flexDirection: "row",
alignItems: "center",
- gap: 7,
+ gap: 8,
},
input: {
flex: 1,
diff --git a/mobile/components/playlist-picker/track-card.tsx b/mobile/components/playlist-picker/track-card.tsx
index 41cfe508..c8a25758 100644
--- a/mobile/components/playlist-picker/track-card.tsx
+++ b/mobile/components/playlist-picker/track-card.tsx
@@ -26,7 +26,7 @@ export function TrackCard({
lifted: boolean;
}) {
const theme = useTheme();
- const title = track ? displayText(track.title) : "Add to Playlist";
+ const title = track ? displayText(track.title) : "Add to playlist";
const names = track
? [displayText(track.artist), displayText(track.album_title)].filter(Boolean)
: [];
@@ -107,7 +107,7 @@ const styles = StyleSheet.create({
gap: 12,
paddingVertical: 10,
paddingLeft: 10,
- paddingRight: 14,
+ paddingRight: 16,
borderRadius: 18,
borderCurve: "continuous",
},
diff --git a/mobile/components/playlists/playlist-cover-field.tsx b/mobile/components/playlists/playlist-cover-field.tsx
index 182fc673..c4950e28 100644
--- a/mobile/components/playlists/playlist-cover-field.tsx
+++ b/mobile/components/playlists/playlist-cover-field.tsx
@@ -84,7 +84,7 @@ export function PlaylistCoverField({
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;
diff --git a/mobile/components/playlists/playlist-detail-header.tsx b/mobile/components/playlists/playlist-detail-header.tsx
index aa11978d..ee44b3dd 100644
--- a/mobile/components/playlists/playlist-detail-header.tsx
+++ b/mobile/components/playlists/playlist-detail-header.tsx
@@ -337,7 +337,7 @@ export function PlaylistStatusGlyphs({
{status === "downloading" ? (
<>
@@ -459,8 +459,8 @@ export function PlaylistMoreMenu({
const downloadLabel = isDownloading
? `Downloading ${downloaded}/${total}…`
: isDownloaded
- ? "Remove Download…"
- : "Download Playlist";
+ ? "Remove download…"
+ : "Download playlist";
const downloadDisabled =
!hasTracks || isDownloading || (offline && !isDownloaded);
const autoDisabled = !hasTracks || (offline && !autoEnabled);
@@ -474,12 +474,12 @@ export function PlaylistMoreMenu({
onPress={() =>
Alert.alert(playlistName, undefined, [
...(canEditDetails
- ? [{ text: "Edit Playlist", onPress: onEdit }]
+ ? [{ text: "Edit playlist", onPress: onEdit }]
: []),
...(canEdit
? [
{
- text: reorderActive ? "Done Reordering" : "Reorder Tracks",
+ text: reorderActive ? "Done reordering" : "Reorder tracks",
onPress: onToggleReorder,
},
]
@@ -491,8 +491,8 @@ export function PlaylistMoreMenu({
? [
{
text: autoEnabled
- ? "Turn Off Auto-Download"
- : "Auto-Download New Tracks",
+ ? "Turn off auto-download"
+ : "Auto-download new tracks",
onPress: onAutoPress,
},
]
@@ -504,8 +504,8 @@ export function PlaylistMoreMenu({
? [
{
text: serverSave.enabled
- ? "Stop Saving TIDAL to Server"
- : "Save TIDAL to Server",
+ ? "Stop saving TIDAL to server"
+ : "Save TIDAL to server",
onPress: serverSave.onToggle,
},
]
@@ -513,7 +513,7 @@ export function PlaylistMoreMenu({
...(canDelete
? [
{
- text: "Delete Playlist",
+ text: "Delete playlist",
style: "destructive" as const,
onPress: onDelete,
},
@@ -541,14 +541,14 @@ export function PlaylistMoreMenu({
>
{canEditDetails ? (
) : null}
{canEdit ? (
@@ -561,7 +561,7 @@ export function PlaylistMoreMenu({
onPress={onDownloadPress}
/>
activityChartScale(buckets), [buckets]);
return (
-
+
{buckets.map((b) => {
@@ -50,7 +51,10 @@ export function ActivityChart({
style={{
height: `${pct}%`,
minHeight: 2,
- backgroundColor: theme.color.accent,
+ backgroundColor:
+ max > 0 && b.plays === max
+ ? theme.color.accent
+ : withAlpha(theme.color.fgMuted, 0.45),
borderRadius: 2,
borderTopLeftRadius: 3,
borderTopRightRadius: 3,
@@ -63,8 +67,8 @@ export function ActivityChart({
{buckets.map((b, i) => {
@@ -78,7 +82,7 @@ export function ActivityChart({
diff --git a/mobile/components/replay/genre-list.tsx b/mobile/components/replay/genre-list.tsx
index 59f35f2e..9821d769 100644
--- a/mobile/components/replay/genre-list.tsx
+++ b/mobile/components/replay/genre-list.tsx
@@ -1,12 +1,12 @@
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { displayText, genreShares, type ReplayData } from "@music-library/core";
import { Card } from "../primitives";
-import { useTheme } from "../../theme/theme";
+import { useTheme, withAlpha } from "../../theme/theme";
import { HairlineSeparator } from "../hairline-separator";
/**
- * Card of genre rows with a proportional accent bar and percent-of-plays
- * figure per genre.
+ * Card of genre rows with a proportional bar and percent-of-plays figure per
+ * genre. Only the top genre's bar takes the accent.
*/
export function GenreList({
genres,
@@ -22,10 +22,10 @@ export function GenreList({
const pct = g.share;
return (
- {i > 0 && }
+ {i > 0 && }
diff --git a/mobile/components/replay/period-picker.tsx b/mobile/components/replay/period-picker.tsx
index a3e85943..2ec5d921 100644
--- a/mobile/components/replay/period-picker.tsx
+++ b/mobile/components/replay/period-picker.tsx
@@ -153,7 +153,7 @@ const styles = StyleSheet.create({
alignItems: "center",
},
pill: {
- paddingHorizontal: 14,
+ paddingHorizontal: 16,
paddingVertical: 8,
},
});
diff --git a/mobile/components/replay/ranked-shelf-tile.tsx b/mobile/components/replay/ranked-shelf-tile.tsx
index f4cf54a2..8b35f359 100644
--- a/mobile/components/replay/ranked-shelf-tile.tsx
+++ b/mobile/components/replay/ranked-shelf-tile.tsx
@@ -1,5 +1,4 @@
import {
- StyleSheet,
Text,
View,
type StyleProp,
@@ -7,6 +6,7 @@ import {
} from "react-native";
import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
+import { SymbolView } from "expo-symbols";
import { albumCoverUrl } from "@music-library/core";
import { ShelfTile } from "../shelf-tile";
import { useTheme } from "../../theme/theme";
@@ -14,20 +14,12 @@ import { useTheme } from "../../theme/theme";
const TILE_SIZE = 128;
/**
- * Artwork for a shelf tile: either a real album cover or a deterministic
- * two-tone gradient seeded by a string (for artists without cover art).
+ * Artwork for a shelf tile: an album's cover, or for an artist a cover from
+ * their top tracks, falling back to a person glyph.
*/
type ShelfTileArt =
| { kind: "album"; albumId: string }
- | { kind: "gradient"; seed: string };
-
-// Deterministic hue seeded by a string, used for artist tiles that have
-// no real cover art.
-function hueFor(seed: string): number {
- let hash = 0;
- for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0;
- return Math.abs(hash) % 360;
-}
+ | { kind: "artist"; imageUrl: string | null };
/**
* Ranked 128pt tile for the top-artists / top-albums shelves: artwork with
@@ -51,14 +43,15 @@ export function RankedShelfTile({
subtitleTabular?: boolean;
accessibilityLabel: string;
art: ShelfTileArt;
- onPress: () => void;
+ /** Omitted when the tile has nothing to open or play. */
+ onPress?: () => void;
style?: StyleProp;
}) {
return (
+ art.kind === "artist" ? (
+
) : (
)
@@ -68,18 +61,21 @@ export function RankedShelfTile({
subtitleTabular={subtitleTabular}
width={TILE_SIZE}
accessibilityLabel={accessibilityLabel}
- onPress={() => {
- void Haptics.selectionAsync();
- onPress();
- }}
+ onPress={
+ onPress
+ ? () => {
+ void Haptics.selectionAsync();
+ onPress();
+ }
+ : undefined
+ }
style={style}
/>
);
}
-function GradientArt({ seed, rank }: { seed: string; rank: number }) {
+function ArtistArt({ imageUrl, rank }: { imageUrl: string | null; rank: number }) {
const theme = useTheme();
- const hue = hueFor(seed);
return (
-
+ {imageUrl ? (
+
+ ) : (
+
+ )}
);
@@ -135,12 +137,12 @@ function RankBadge({ rank }: { rank: number }) {
) : (
-
+
)}
-
+
- No plays in this window yet
+ No plays in this period
- Listen to some music from the Library tab and your stats will show up
- here.
+ Music you play from the Library tab shows up here
);
diff --git a/mobile/components/replay/replay-hero.tsx b/mobile/components/replay/replay-hero.tsx
index 1d873714..cd0d2a60 100644
--- a/mobile/components/replay/replay-hero.tsx
+++ b/mobile/components/replay/replay-hero.tsx
@@ -4,8 +4,8 @@ import { useTheme } from "../../theme/theme";
import { formatListeningTime, pluralize } from "./format";
/**
- * Headline block for the Replay screen: eyebrow with the period title, a
- * huge total-plays figure, and the listening-time subline.
+ * Headline block for the Replay screen: a huge total-plays figure and the
+ * listening-time subline. The period is on the picker right above it.
*/
export function ReplayHero({
periodTitle,
@@ -29,23 +29,12 @@ export function ReplayHero({
accessibilityLabel={`Replay, ${periodTitle}: ${plays}, ${listened}`}
style={[{ paddingHorizontal: theme.space.lg, gap: 4 }, style]}
>
-
- Replay · {periodTitle}
-
diff --git a/mobile/components/replay/summary-grid.tsx b/mobile/components/replay/summary-grid.tsx
index 938bd1ce..75d3b25f 100644
--- a/mobile/components/replay/summary-grid.tsx
+++ b/mobile/components/replay/summary-grid.tsx
@@ -8,11 +8,10 @@ import {
import { displayText, type ReplayData } from "@music-library/core";
import { Card } from "../primitives";
import { useTheme } from "../../theme/theme";
-import { formatListeningTime } from "./format";
/**
- * 2×2 stat grid card (listening time, unique tracks/artists, top artist)
- * with hairline dividers between the cells.
+ * Stat card (unique tracks and artists side by side, the top artist below)
+ * with hairline dividers between the cells. Listening time is in the hero.
*/
export function SummaryGrid({
summary,
@@ -23,10 +22,6 @@ export function SummaryGrid({
}) {
const theme = useTheme();
const items: { label: string; value: string }[] = [
- {
- label: "Listening",
- value: formatListeningTime(summary.total_ms),
- },
{
label: "Tracks",
value: summary.unique_tracks.toLocaleString(),
@@ -52,14 +47,14 @@ export function SummaryGrid({
]}
>
{items.map((item, i) => {
- const isRight = i % 2 === 1;
- const isBottom = i >= 2;
+ const isRight = i === 1;
+ const isBottom = i === 2;
return (
{item.label}
diff --git a/mobile/components/section.tsx b/mobile/components/section.tsx
index 2286c4dc..59fa4f54 100644
--- a/mobile/components/section.tsx
+++ b/mobile/components/section.tsx
@@ -11,8 +11,8 @@ import { useTheme } from "../theme/theme";
import { Eyebrow } from "./eyebrow";
/**
- * Section shell for scrolling feature pages (home, replay): eyebrow + large
- * title on the left, optional accent action (label with an optional SF
+ * Section shell for scrolling feature pages (home, replay): optional eyebrow +
+ * large title on the left, optional accent action (label with an optional SF
* Symbol) on the right, then the section body. Horizontal padding applies to
* the header only, so full-bleed shelves can manage their own edge insets.
*/
@@ -67,7 +67,7 @@ export function Section({
style={({ pressed }) => ({
flexDirection: "row",
alignItems: "center",
- gap: 5,
+ gap: 4,
paddingBottom: 2,
opacity: pressed ? 0.6 : 1,
})}
diff --git a/mobile/components/settings/account-card.tsx b/mobile/components/settings/account-card.tsx
index 981bb457..a4cd0f89 100644
--- a/mobile/components/settings/account-card.tsx
+++ b/mobile/components/settings/account-card.tsx
@@ -73,12 +73,12 @@ export function AccountCard() {
- {accountInitials(me?.username)}
+
+ {accountInitials(me?.username)}
+
void onSignOut()}
@@ -125,7 +125,7 @@ export function AccountCard() {
title: username,
items: [
{
- label: "Sign Out",
+ label: "Sign out",
destructive: true,
onPress: () => void onSignOut(),
},
@@ -168,10 +168,8 @@ const styles = StyleSheet.create({
justifyContent: "center",
},
initials: {
- color: "#FFFFFF",
fontSize: 16,
fontWeight: "600",
- letterSpacing: 0.2,
},
moreTarget: {
width: MORE_TARGET,
diff --git a/mobile/components/settings/appearance-tile.tsx b/mobile/components/settings/appearance-tile.tsx
index eeff64cd..9521af27 100644
--- a/mobile/components/settings/appearance-tile.tsx
+++ b/mobile/components/settings/appearance-tile.tsx
@@ -256,7 +256,7 @@ const styles = StyleSheet.create({
label: {
flexDirection: "row",
alignItems: "center",
- gap: 5,
+ gap: 4,
},
radio: {
width: RADIO,
@@ -266,20 +266,20 @@ const styles = StyleSheet.create({
justifyContent: "center",
},
mini: {
- paddingTop: 14,
- paddingHorizontal: 9,
+ paddingTop: 12,
+ paddingHorizontal: 8,
},
miniTitle: {
height: 7,
width: "46%",
borderRadius: 3,
- marginBottom: 9,
+ marginBottom: 8,
},
miniLine: {
flexDirection: "row",
alignItems: "center",
- gap: 5,
- marginBottom: 7,
+ gap: 4,
+ marginBottom: 8,
},
miniCover: {
width: 15,
diff --git a/mobile/components/settings/device-card.tsx b/mobile/components/settings/device-card.tsx
index 22cec70c..81524e32 100644
--- a/mobile/components/settings/device-card.tsx
+++ b/mobile/components/settings/device-card.tsx
@@ -163,6 +163,6 @@ const styles = StyleSheet.create({
alignItems: "center",
gap: 12,
paddingHorizontal: SETTINGS_INSET,
- paddingVertical: 11,
+ paddingVertical: 12,
},
});
diff --git a/mobile/components/settings/lastfm-row.tsx b/mobile/components/settings/lastfm-row.tsx
index dd3f07d5..09e3c78b 100644
--- a/mobile/components/settings/lastfm-row.tsx
+++ b/mobile/components/settings/lastfm-row.tsx
@@ -127,7 +127,7 @@ const styles = StyleSheet.create({
alignItems: "center",
gap: 12,
paddingHorizontal: SETTINGS_INSET,
- paddingVertical: 14,
+ paddingVertical: 12,
},
lead: {
flexDirection: "row",
diff --git a/mobile/components/settings/replay-row.tsx b/mobile/components/settings/replay-row.tsx
index 3fac6db6..063d0270 100644
--- a/mobile/components/settings/replay-row.tsx
+++ b/mobile/components/settings/replay-row.tsx
@@ -95,7 +95,7 @@ export function ReplayRow({
/**
* Two-by-two top covers. One cover fills the tile; two or three repeat to
- * fill the grid (as the playlist hero does); none shows Replay's sparkles.
+ * fill the grid (as the playlist hero does); none shows a waveform.
*/
function Collage({ covers }: { covers: string[] }) {
const theme = useTheme();
@@ -109,7 +109,7 @@ function Collage({ covers }: { covers: string[] }) {
return (
{cells.length === 0 ? (
-
+
) : (
cells.map((uri, index) => (
{label}
@@ -74,9 +74,9 @@ export function ShareActionButton({
const styles = StyleSheet.create({
button: {
- minHeight: 52,
+ minHeight: 48,
width: "100%",
- borderRadius: 14,
+ borderRadius: 10,
borderCurve: "continuous",
borderWidth: StyleSheet.hairlineWidth,
flexDirection: "row",
diff --git a/mobile/components/share/share-panel.tsx b/mobile/components/share/share-panel.tsx
index 2ce9380e..c5da8d35 100644
--- a/mobile/components/share/share-panel.tsx
+++ b/mobile/components/share/share-panel.tsx
@@ -45,7 +45,7 @@ export function SharePanel({
style={{
color: theme.color.fg,
fontSize: 17,
- fontWeight: "700",
+ fontWeight: "600",
}}
>
{title}
diff --git a/mobile/components/share/snippet-panel.tsx b/mobile/components/share/snippet-panel.tsx
index 4cb0103f..d973c86b 100644
--- a/mobile/components/share/snippet-panel.tsx
+++ b/mobile/components/share/snippet-panel.tsx
@@ -72,7 +72,7 @@ export function SnippetPanel({
selectable
style={{
color: theme.color.fg,
- fontWeight: "700",
+ fontWeight: "600",
fontVariant: ["tabular-nums"],
}}
>
@@ -121,8 +121,8 @@ export function SnippetPanel({
weight="semibold"
tintColor={theme.color.fg}
/>
-
- {playing ? "Pause Preview" : "Preview Snippet"}
+
+ {playing ? "Pause preview" : "Preview snippet"}
diff --git a/mobile/components/share/story-background-crop-editor.tsx b/mobile/components/share/story-background-crop-editor.tsx
index 676d788b..ef1d8dcd 100644
--- a/mobile/components/share/story-background-crop-editor.tsx
+++ b/mobile/components/share/story-background-crop-editor.tsx
@@ -24,7 +24,7 @@ import {
const CROP_EDITOR_MAX_HEIGHT = 360;
/**
- * "Story Background" section: previews the user's picked photo and lets them
+ * "Story background" section: previews the user's picked photo and lets them
* drag the story-aspect crop window over it. Header offers Change (re-pick)
* and Clear (back to generated colors). Crop state is normalized (0..1) and
* owned by the screen.
@@ -114,7 +114,7 @@ export function StoryBackgroundCropEditor({
return (
- {label}
+ {label}
);
}
diff --git a/mobile/components/share/story-share-menu-button.tsx b/mobile/components/share/story-share-menu-button.tsx
index ba217720..a4b1d8f4 100644
--- a/mobile/components/share/story-share-menu-button.tsx
+++ b/mobile/components/share/story-share-menu-button.tsx
@@ -31,24 +31,24 @@ export function StoryShareMenuButton({
style?: StyleProp;
}) {
const theme = useTheme();
- const label = loading ? "Rendering Story..." : "Instagram Story";
+ const label = loading ? "Rendering story…" : "Instagram Story";
const openStoryOptions = useCallback(() => {
if (disabled || loading) return;
void Haptics.selectionAsync();
const customLabel = hasCustomBackground
- ? "Use Custom Image"
- : "Choose Custom Image";
+ ? "Use custom image"
+ : "Choose custom image";
showActionMenu({
// The Alert fallback needs a title; the iOS sheet reads fine without.
title: process.env.EXPO_OS === "ios" ? undefined : "Instagram Story Background",
items: [
- { label: "Use Generated Colors", onPress: onGenerated },
+ { label: "Use generated colors", onPress: onGenerated },
{ label: customLabel, onPress: onCustom },
...(hasCustomBackground
- ? [{ label: "Choose Different Image", onPress: onPickCustom }]
+ ? [{ label: "Choose different image", onPress: onPickCustom }]
: []),
],
tintColor: theme.color.accent,
diff --git a/mobile/components/share/track-header.tsx b/mobile/components/share/track-header.tsx
index 754625ed..a17131e1 100644
--- a/mobile/components/share/track-header.tsx
+++ b/mobile/components/share/track-header.tsx
@@ -31,7 +31,7 @@ export function TrackHeader({
{track.title}
diff --git a/mobile/components/shelf-tile.tsx b/mobile/components/shelf-tile.tsx
index 3e02df19..6fd9a2f4 100644
--- a/mobile/components/shelf-tile.tsx
+++ b/mobile/components/shelf-tile.tsx
@@ -34,16 +34,18 @@ export function ShelfTile({
subtitleAccessory?: ReactNode;
width: number;
accessibilityLabel: string;
- onPress: () => void;
+ /** Omitted when the tile has nothing to open or play. */
+ onPress?: () => void;
style?: StyleProp;
}) {
const theme = useTheme();
return (
[{ width, opacity: pressed ? 0.7 : 1 }, style]}
+ style={({ pressed }) => [{ width, opacity: pressed && onPress ? 0.7 : 1 }, style]}
>
{artwork}
(
() => [
- ...(canPlayNext ? [{ name: "playNext", label: "Play Next" }] : []),
- { name: "favorite", label: favorite ? "Remove from Favorites" : "Add to Favorites" },
+ ...(canPlayNext ? [{ name: "playNext", label: "Play next" }] : []),
+ { name: "favorite", label: favorite ? "Remove from favorites" : "Add to favorites" },
],
[canPlayNext, favorite],
);
diff --git a/mobile/components/track-actions-menu.tsx b/mobile/components/track-actions-menu.tsx
index 0747772c..489fe860 100644
--- a/mobile/components/track-actions-menu.tsx
+++ b/mobile/components/track-actions-menu.tsx
@@ -348,19 +348,19 @@ function TrackActionItems({
/>
{actions.canShare ? (
@@ -370,19 +370,19 @@ function TrackActionItems({
{actions.showPlayNext ? (
) : null}
@@ -406,14 +406,14 @@ function TrackActionItems({
{actions.canEditMetadata ? (
) : null}
{actions.hasEditableAlbum ? (
@@ -425,7 +425,7 @@ function TrackActionItems({
<>
{
Alert.alert(
- "Delete Track",
+ "Delete track",
deleteOwnUploadMessage(track),
[
{ text: "Cancel", style: "cancel" },
diff --git a/mobile/components/track-info/info-grid.tsx b/mobile/components/track-info/info-grid.tsx
index 2d2811ea..eb15623f 100644
--- a/mobile/components/track-info/info-grid.tsx
+++ b/mobile/components/track-info/info-grid.tsx
@@ -104,8 +104,8 @@ const styles = StyleSheet.create({
cell: {
flex: 1,
minWidth: 0,
- paddingHorizontal: 14,
+ paddingHorizontal: 16,
paddingTop: 10,
- paddingBottom: 11,
+ paddingBottom: 12,
},
});
diff --git a/mobile/components/track-info/player-hero.tsx b/mobile/components/track-info/player-hero.tsx
index 061d4360..c0a8d9bb 100644
--- a/mobile/components/track-info/player-hero.tsx
+++ b/mobile/components/track-info/player-hero.tsx
@@ -175,7 +175,7 @@ export function HeroMeta({
style={{
color: theme.color.fg,
fontSize: 22,
- fontWeight: "700",
+ fontWeight: "600",
letterSpacing: -0.3,
}}
>
@@ -301,7 +301,7 @@ export function CompactMeta({
style={{
color: theme.color.fg,
fontSize: 17,
- fontWeight: "700",
+ fontWeight: "600",
letterSpacing: -0.2,
}}
>
diff --git a/mobile/lib/welcome-mesh.ts b/mobile/lib/welcome-mesh.ts
new file mode 100644
index 00000000..292a75c9
--- /dev/null
+++ b/mobile/lib/welcome-mesh.ts
@@ -0,0 +1,107 @@
+/**
+ * The welcome screen's art is a smooth image drawn through a pixel mesh on the
+ * device. The mesh has to land on whole device pixels: baked into the file it
+ * would shimmer on any phone that scales the image.
+ */
+
+/** Spacing of the mesh, in points. */
+const CELL_POINTS = 3;
+/** The dark line between cells, as a share of a point. */
+const GAP_POINTS = 2 / 3;
+
+export interface WelcomeMesh {
+ /** Device pixels from one cell to the next. */
+ pitch: number;
+ /** Device pixels of dark line along each cell's top and left. */
+ gap: number;
+}
+
+export function welcomeMesh(pixelRatio: number): WelcomeMesh {
+ return {
+ pitch: Math.max(2, Math.round(CELL_POINTS * pixelRatio)),
+ gap: Math.max(1, Math.round(GAP_POINTS * pixelRatio)),
+ };
+}
+
+/** The share of the art's height at which the record sits. */
+const RECORD_IN_ART = 0.395;
+/** The highest share of the view the record may be pushed down to. */
+const RECORD_IN_VIEW = 0.46;
+
+export interface WelcomeArtRect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+/**
+ * Where the art goes: filling the view and centered sideways. It's pinned to the
+ * top unless the view is short, when it slides up to keep the record clear of
+ * the fade; the sky above the record is the part it can afford to lose.
+ */
+export function welcomeArtRect(
+ view: { width: number; height: number },
+ art: { width: number; height: number },
+): WelcomeArtRect {
+ const scale = Math.max(view.width / art.width, view.height / art.height);
+ const width = art.width * scale;
+ const height = art.height * scale;
+ return {
+ x: (view.width - width) / 2,
+ y: Math.min(0, view.height * RECORD_IN_VIEW - height * RECORD_IN_ART),
+ width,
+ height,
+ };
+}
+
+/** How tall the fade into the page is, for a hero of this height. */
+export function welcomeFadeHeight(heroHeight: number): number {
+ return Math.min(240, Math.max(96, Math.round(heroHeight * 0.3)));
+}
+
+interface WelcomeArtLoader {
+ Data: { fromURI: (uri: string) => Promise };
+ Image: { MakeImageFromEncoded: (data: Data) => Art | null };
+}
+
+/** Reads and decodes the art; null if either step fails, so the caller can fall back. */
+export async function loadWelcomeArt(
+ uri: string,
+ skia: WelcomeArtLoader,
+): Promise {
+ try {
+ return skia.Image.MakeImageFromEncoded(await skia.Data.fromURI(uri));
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Colors each cell with the average of four samples of the art, then darkens
+ * the lines between cells. `density` turns Skia's points back into device pixels.
+ */
+export const WELCOME_MESH_SHADER = `
+uniform shader art;
+uniform float density;
+uniform float pitch;
+uniform float gap;
+uniform float lineLight;
+
+half4 main(vec2 pos) {
+ vec2 px = pos * density;
+ vec2 cell = floor(px / pitch);
+ vec2 inCell = px - cell * pitch;
+ vec2 center = (cell + 0.5) * pitch / density;
+ float spread = pitch / density * 0.25;
+ half4 color = (
+ art.eval(center + vec2(-spread, -spread)) +
+ art.eval(center + vec2(spread, -spread)) +
+ art.eval(center + vec2(-spread, spread)) +
+ art.eval(center + vec2(spread, spread))
+ ) * 0.25;
+ float lit = step(gap, inCell.x) * step(gap, inCell.y);
+ color.rgb *= mix(lineLight, 1.0, lit);
+ return color;
+}
+`;
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
index 861beecd..2fbf01e6 100644
--- a/mobile/package-lock.json
+++ b/mobile/package-lock.json
@@ -65,6 +65,7 @@
"@types/node": "~25.6.0",
"@types/react": "~19.2.4",
"@types/react-dom": "~19.2.3",
+ "canvaskit-wasm": "0.41.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~57.0.0",
"patch-package": "^8.0.1",
diff --git a/mobile/package.json b/mobile/package.json
index 52878451..6647f5c6 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -72,6 +72,7 @@
"@types/node": "~25.6.0",
"@types/react": "~19.2.4",
"@types/react-dom": "~19.2.3",
+ "canvaskit-wasm": "0.41.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~57.0.0",
"patch-package": "^8.0.1",
diff --git a/mobile/scripts/render-welcome-art.py b/mobile/scripts/render-welcome-art.py
new file mode 100644
index 00000000..fe429b19
--- /dev/null
+++ b/mobile/scripts/render-welcome-art.py
@@ -0,0 +1,345 @@
+#!/usr/bin/env python3
+"""Render the welcome screen's art: a record sliding out of its sleeve, floating
+through clouds. Night for dark mode, early morning for light.
+
+The output is deliberately smooth. `components/auth/welcome-hero.tsx` turns it
+into a pixel mesh on the device, in exact device pixels, because a mesh baked
+into the file would shimmer on any phone that has to scale it.
+
+ python3 scripts/render-welcome-art.py [output dir, default assets/images]
+
+Needs numpy and Pillow. Rerun it only to change the art.
+"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+W, H = 660, 960
+SS = 2 # supersampling
+RNG = np.random.default_rng(7)
+
+PALETTES = {
+ "night": dict(
+ sky_stops=[(0.0, "#040716"), (0.22, "#081242"), (0.52, "#10267c"), (0.76, "#0a154e"), (1.0, "#02040f")],
+ cloud_shadow="#1c2565",
+ cloud_mid="#7c78b8",
+ cloud_lit="#f0e1ea",
+ cloud_alpha=1.0,
+ stars=0.9,
+ paper_lit="#f1e8d6",
+ paper_shade="#b7ac9c",
+ rim="#6d6558",
+ ambient=1.0,
+ ),
+ "morning": dict(
+ sky_stops=[(0.0, "#8fb2ea"), (0.3, "#a9c6f2"), (0.62, "#cfe0f8"), (1.0, "#ffffff")],
+ cloud_shadow="#b5c4e6",
+ cloud_mid="#e4e9f7",
+ cloud_lit="#ffffff",
+ cloud_alpha=1.0,
+ stars=0.0,
+ paper_lit="#fbf5e8",
+ paper_shade="#d3c8b6",
+ rim="#8f8676",
+ ambient=1.0,
+ ),
+}
+
+
+def rgb(value: str) -> np.ndarray:
+ value = value.lstrip("#")
+ return np.array([int(value[i : i + 2], 16) / 255 for i in (0, 2, 4)])
+
+
+def smooth(a, b, x):
+ t = np.clip((x - a) / (b - a), 0, 1)
+ return t * t * (3 - 2 * t)
+
+
+def mix(a, b, t):
+ t = np.asarray(t)
+ if t.ndim == 2:
+ t = t[..., None]
+ return a * (1 - t) + b * t
+
+
+def value_noise(w: int, h: int, cell: float, rng: np.random.Generator) -> np.ndarray:
+ gw, gh = int(w / cell) + 3, int(h / cell) + 3
+ grid = rng.random((gh, gw))
+ ys, xs = np.mgrid[0:h, 0:w] / cell
+ x0, y0 = xs.astype(int), ys.astype(int)
+ fx, fy = smooth(0, 1, xs - x0), smooth(0, 1, ys - y0)
+ top = grid[y0, x0] * (1 - fx) + grid[y0, x0 + 1] * fx
+ bottom = grid[y0 + 1, x0] * (1 - fx) + grid[y0 + 1, x0 + 1] * fx
+ return top * (1 - fy) + bottom * fy
+
+
+def fbm(w: int, h: int, cell: float, rng: np.random.Generator, octaves: int = 5) -> np.ndarray:
+ out, amp, total = np.zeros((h, w)), 1.0, 0.0
+ for _ in range(octaves):
+ out += value_noise(w, h, cell, rng) * amp
+ total += amp
+ amp *= 0.5
+ cell = max(cell / 2, 1.5)
+ return out / total
+
+
+def gradient(stops, h, w):
+ pos = np.array([s[0] for s in stops])
+ cols = np.stack([rgb(s[1]) for s in stops])
+ t = np.arange(h) / (h - 1)
+ channels = np.stack([np.interp(t, pos, cols[:, c]) for c in range(3)], -1)
+ # A wide box blur irons out the creases at each stop.
+ radius = h // 14
+ kernel = np.ones(radius * 2 + 1) / (radius * 2 + 1)
+ padded = np.pad(channels, ((radius, radius), (0, 0)), mode="edge")
+ channels = np.stack([np.convolve(padded[:, c], kernel, mode="valid") for c in range(3)], -1)
+ return np.repeat(channels[:, None, :], w, axis=1)
+
+
+def box_blur(a, radius):
+ """Two passes of a summed-area box blur, close enough to a gaussian."""
+ for _ in range(2):
+ for axis in (0, 1):
+ pad = [(radius + 1, radius) if i == axis else (0, 0) for i in range(2)]
+ total = np.cumsum(np.pad(a, pad, mode="edge"), axis=axis)
+ hi = np.take(total, range(2 * radius + 1, total.shape[axis]), axis=axis)
+ lo = np.take(total, range(0, total.shape[axis] - 2 * radius - 1), axis=axis)
+ a = (hi - lo) / (2 * radius + 1)
+ return a
+
+
+def cumulus(canvas, cx, base, width, height, pal, seed, puffs=70):
+ """A cloud built from many overlapping spheres on a soft, flat base, lit from
+ the upper left. The height map is blurred so the puffs melt into each other."""
+ h, w = canvas.shape[:2]
+ cx, base, width, height = cx * w, base * h, width * w, height * w
+ rng = np.random.default_rng(seed)
+ ys, xs = np.mgrid[0:h, 0:w].astype(float)
+ warp = height * 0.3
+ bx = xs + (fbm(w, h, 44 * SS, rng, 3) - 0.5) * warp
+ by = ys + (fbm(w, h, 44 * SS, rng, 3) - 0.5) * warp * 0.6
+ z = np.zeros((h, w))
+ for _ in range(puffs):
+ t = rng.uniform(-1, 1)
+ dome = max(1 - abs(t) ** 2.2, 0) ** 0.8
+ r = height * rng.uniform(0.09, 0.3) * (0.35 + 0.65 * dome)
+ px = cx + t * width
+ py = base - r * 0.4 - (height - r) * dome * rng.random() ** 1.3
+ z = np.maximum(z, np.sqrt(np.maximum(r * r - (bx - px) ** 2 - (by - py) ** 2, 0)))
+ wisp = (fbm(w, h, 26 * SS, rng, 3) - 0.5) * height * 0.28
+ fade = 1 - smooth(-height * 0.1, height * 0.1, by - base + wisp)
+ alpha = smooth(0.0, 2.5 * SS, z) * fade
+ soft = box_blur(z, 3 * SS)
+ gy, gx = np.gradient(soft)
+ nx, ny, nz = -gx, -gy, np.full_like(z, 0.9)
+ length = np.sqrt(nx * nx + ny * ny + nz * nz)
+ lit = (nx * -0.5 + ny * -0.72 + nz * 0.48) / length
+ rise = np.clip((base - by) / height, 0, 1)
+ fine = fbm(w, h, 11 * SS, rng, 3) - 0.5
+ tone = np.clip(lit * 0.7 + rise * 0.5 + fine * 0.26, 0, 1)
+ col = mix(rgb(pal["cloud_shadow"]), rgb(pal["cloud_mid"]), smooth(0.08, 0.55, tone))
+ col = mix(col, rgb(pal["cloud_lit"]), smooth(0.56, 0.98, tone))
+ return mix(canvas, col, alpha * pal["cloud_alpha"])
+
+
+def plane_coords(w, h, center, size, rot_deg, tilt_x, tilt_y):
+ """Screen pixels to a tilted plane's coordinates, where 1 unit is `size` px."""
+ ys, xs = np.mgrid[0:h, 0:w].astype(float)
+ x, y = xs - center[0], ys - center[1]
+ r = np.deg2rad(rot_deg)
+ ca, sa = np.cos(r), np.sin(r)
+ # Inverse perspective: plane (u, v) projects to (u, v) / (1 + tx u + ty v).
+ denom = 1 - tilt_x * x / size - tilt_y * y / size
+ px, py = x / denom, y / denom
+ u = (px * ca + py * sa) / size
+ v = (-px * sa + py * ca) / size
+ return u, v
+
+
+def rounded_box(u, v, half, radius):
+ qx, qy = np.abs(u) - half + radius, np.abs(v) - half + radius
+ return np.hypot(np.maximum(qx, 0), np.maximum(qy, 0)) + np.minimum(np.maximum(qx, qy), 0) - radius
+
+
+def record(u, v, cx, ambient, seed):
+ """A black disc whose grooves catch one bow tie of light, with an amber label."""
+ rng = np.random.default_rng(seed)
+ du, dv = u - cx, v
+ r = np.hypot(du, dv) / 0.92
+ th = np.arctan2(dv, du)
+ col = np.zeros(u.shape + (3,))
+ col[...] = rgb("#0b0b10")
+
+ # Bands of grooves, split by the smooth gaps between tracks.
+ grooves = 0.5 + 0.5 * np.sin(r * 300)
+ gaps = np.minimum.reduce([np.abs(r - g) for g in (0.5, 0.62, 0.76, 0.88)])
+ in_gap = 1 - smooth(0.004, 0.013, gaps)
+ base = 0.055 + grooves * 0.035
+ col += base[..., None] * np.array([0.9, 0.95, 1.1])
+
+ # The bow tie of light any record shows under one lamp, with a faint second
+ # lobe that picks up a little color.
+ lamp = -1.05
+ ray = np.power(np.abs(np.cos(th - lamp)), 26)
+ side = np.power(np.abs(np.cos(th - lamp - 0.62)), 48)
+ body = (0.7 + 0.3 * grooves) * (1 - in_gap * 0.75) * (1 - smooth(0.34, 0.5, 1 - r) * 0)
+ col += (ray * body * 0.92)[..., None] * np.array([0.86, 0.91, 1.0])
+ col += (side * body * 0.34)[..., None] * np.array([0.72, 0.62, 1.0])
+ col += (in_gap * 0.045)[..., None]
+
+ # Edge bevel.
+ bevel = smooth(0.965, 0.985, r) * (1 - smooth(0.992, 1.0, r))
+ light = np.power(np.abs(np.cos(th - lamp)), 3)
+ col += (bevel * (0.08 + 0.5 * light))[..., None] * np.array([0.85, 0.9, 1.0])
+
+ # Dead wax and label.
+ wax = smooth(0.37, 0.385, r) * (1 - smooth(0.43, 0.45, r))
+ col = mix(col, rgb("#101015"), wax * 0.75)
+ label = 1 - smooth(0.365, 0.374, r)
+ amber = mix(rgb("#ff9a2e"), rgb("#c6410b"), smooth(0.02, 0.38, r))
+ shine = np.power(np.abs(np.cos(th - lamp)), 8)
+ amber = mix(amber, rgb("#ffd98a"), shine * 0.62)
+ ring = (1 - smooth(0.003, 0.012, np.abs(r - 0.315))) * 0.6
+ amber = mix(amber, rgb("#7e2406"), ring)
+ speck = fbm(u.shape[1], u.shape[0], 5, rng, 2) - 0.5
+ amber = np.clip(amber + speck[..., None] * 0.08, 0, 1)
+ col = mix(col, amber, label)
+
+ # Spindle hole.
+ hole = 1 - smooth(0.034, 0.042, r)
+ col = mix(col, rgb("#120a05"), hole)
+
+ mask = 1 - smooth(0.996, 1.004, r)
+ return np.clip(col * ambient, 0, 1), mask, r
+
+
+def sleeve(u, v, pal, seed):
+ """A worn card sleeve: lit from the upper left, with the disc's ring pressed in."""
+ rng = np.random.default_rng(seed)
+ half = 1.0
+ d = rounded_box(u, v, half, 0.012)
+ mask = 1 - smooth(-0.002, 0.004, d)
+ light = np.clip(0.66 - (u * 0.34 + v * 0.42), 0, 1)
+ col = mix(rgb(pal["paper_shade"]), rgb(pal["paper_lit"]), smooth(0.0, 0.95, light))
+
+ h, w = u.shape
+ fiber = fbm(w, h, 8, rng, 3) - 0.5
+ col = np.clip(col + fiber[..., None] * 0.05, 0, 1)
+
+ # Ring wear from the disc inside.
+ rr = np.hypot(u + 0.02, v) / 0.92
+ ring = 1 - smooth(0.0, 0.03, np.abs(rr - 0.985))
+ arc = 0.5 + 0.5 * np.cos(np.arctan2(v, u) + 2.1)
+ col = mix(col, rgb(pal["paper_lit"]) * 1.03, ring * 0.4 * arc)
+ col = mix(col, rgb(pal["paper_shade"]) * 0.78, ring * 0.36 * (1 - arc))
+
+ # The app icon's waveform, printed on the front like a label's mark.
+ bars = (0.2, 0.46, 0.3, 0.74, 1.0, 0.56, 0.86, 0.38, 0.62, 0.24, 0.12)
+ ink = np.zeros_like(u)
+ pitch, half_width = 0.112, 0.04
+ for i, tall in enumerate(bars):
+ bx = (i - (len(bars) - 1) / 2) * pitch
+ bar_half = tall * 0.46
+ dx = np.abs(u - bx)
+ dy = np.maximum(np.abs(v) - bar_half, 0)
+ ink = np.maximum(ink, 1 - smooth(half_width - 0.006, half_width + 0.006, np.hypot(dx, dy)))
+ # A printed mark wears through a little, like everything else on the sleeve.
+ grain = smooth(0.34, 0.5, fbm(w, h, 7, rng, 2))
+ col = mix(col, rgb("#17204f"), ink * (0.8 + 0.2 * grain))
+
+ # The fold on the closed side, and the lip of the open one.
+ fold = 1 - smooth(0.0, 0.035, np.abs(u + 0.955))
+ col = mix(col, rgb(pal["paper_shade"]) * 0.8, fold * 0.42)
+ lip = 1 - smooth(0.0, 0.05, half - u)
+ col = mix(col, rgb(pal["paper_shade"]) * 0.76, lip * 0.5)
+ # Paler, scuffed corners and edges, where a much-played sleeve wears first.
+ wear = 1 - smooth(-0.05, 0.0, d)
+ worn = smooth(-0.07, -0.005, d) * smooth(0.45, 0.62, fbm(w, h, 18, rng, 3))
+ col = mix(col, rgb(pal["paper_lit"]) * 1.06, worn * (1 - wear * 0) * 0.5)
+ return np.clip(col, 0, 1), mask, d
+
+
+def render_scene(name: str) -> np.ndarray:
+ pal = PALETTES[name]
+ w, h = W * SS, H * SS
+ canvas = gradient(pal["sky_stops"], h, w)
+
+ # A few faint stars up high, one mesh cell each once they reach the phone.
+ if pal["stars"] > 0:
+ count = 18
+ sx, sy = RNG.integers(0, w, count), (RNG.random(count) ** 1.5 * h * 0.5).astype(int)
+ sb = RNG.random(count)
+ ys, xs = np.mgrid[0:h, 0:w].astype(float)
+ star = np.zeros((h, w))
+ for x, y, b in zip(sx, sy, sb):
+ r = 2.4 * SS
+ x0, x1 = max(x - 12, 0), min(x + 12, w)
+ y0, y1 = max(y - 12, 0), min(y + 12, h)
+ star[y0:y1, x0:x1] += np.exp(-(((xs[y0:y1, x0:x1] - x) ** 2 + (ys[y0:y1, x0:x1] - y) ** 2) / (r * r))) * (0.2 + b * 0.6)
+ canvas = mix(canvas, rgb("#dfe5ff"), np.clip(star * pal["stars"], 0, 1))
+
+ # A cloud far off behind the record.
+ canvas = cumulus(canvas, 0.88, 0.125, 0.21, 0.09, pal, 11, puffs=70)
+
+ # The record and sleeve, tilted like something falling slowly through the air.
+ center = (w * 0.395, h * 0.395)
+ size = w * 0.205
+ plane = dict(rot_deg=-16, tilt_x=0.05, tilt_y=-0.04)
+ u, v = plane_coords(w, h, center, size, **plane)
+
+ # Card thickness: the same outline, nudged down and right, in shadow.
+ tu, tv = plane_coords(w, h, (center[0] + 2.0 * SS, center[1] + 3.0 * SS), size, **plane)
+ _, edge_mask, _ = sleeve(tu, tv, pal, 21)
+ canvas = mix(canvas, rgb(pal["rim"]), edge_mask)
+
+ disc, disc_mask, _ = record(u, v, 1.42, pal["ambient"], 31)
+ canvas = mix(canvas, disc, disc_mask)
+
+ paper, paper_mask, _ = sleeve(u, v, pal, 21)
+ # The sleeve's edge shades the disc just where it comes out.
+ shade = (1 - smooth(0.0, 0.16, u - 1.0)) * disc_mask * (1 - paper_mask)
+ canvas = mix(canvas, np.zeros(3), shade * 0.55)
+ canvas = mix(canvas, paper, paper_mask)
+
+ # Clouds in front, so the record sits inside the scene.
+ canvas = cumulus(canvas, 0.07, 0.668, 0.33, 0.105, pal, 5, puffs=120)
+ canvas = cumulus(canvas, 0.92, 0.75, 0.44, 0.21, pal, 8, puffs=150)
+
+ img = Image.fromarray((np.clip(canvas, 0, 1) * 255 + 0.5).astype(np.uint8), "RGB")
+ return np.asarray(img.resize((W, H), Image.LANCZOS))
+
+
+def render_badge(size: int = 108) -> np.ndarray:
+ """The label's record, front on, small enough to sit inside the headline."""
+ s = size * 4
+ ys, xs = np.mgrid[0:s, 0:s].astype(float)
+ u, v = (xs - s / 2) / (s / 2) * 0.92, (ys - s / 2) / (s / 2) * 0.92
+ col, mask, _ = record(u, v, 0.0, 1.0, 41)
+ # Larger label than the real proportion: at 30pt this has to read as amber.
+ r = np.hypot(u, v) / 0.92
+ th = np.arctan2(v, u)
+ amber = mix(rgb("#ff9a2e"), rgb("#c6410b"), smooth(0.02, 0.52, r))
+ amber = mix(amber, rgb("#ffd98a"), np.power(np.abs(np.cos(th + 1.05)), 8) * 0.62)
+ label = 1 - smooth(0.5, 0.515, r)
+ col = mix(col, amber, label)
+ col = mix(col, rgb("#120a05"), 1 - smooth(0.07, 0.085, r))
+ rgba = np.dstack([np.clip(col, 0, 1), mask])
+ img = Image.fromarray((rgba * 255 + 0.5).astype(np.uint8), "RGBA")
+ return img.resize((size, size), Image.LANCZOS)
+
+
+def main() -> None:
+ out = Path(sys.argv[1] if len(sys.argv) > 1 else "assets/images")
+ out.mkdir(parents=True, exist_ok=True)
+ for name in ("night", "morning"):
+ Image.fromarray(render_scene(name)).save(out / f"welcome-{name}.jpg", quality=92, subsampling=0)
+ render_badge().save(out / "welcome-record.png", optimize=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/mobile/tests/playlist-picker-screen.test.tsx b/mobile/tests/playlist-picker-screen.test.tsx
index 67be8b5c..8e76bc2d 100644
--- a/mobile/tests/playlist-picker-screen.test.tsx
+++ b/mobile/tests/playlist-picker-screen.test.tsx
@@ -230,7 +230,7 @@ describe("PlaylistPickerScreen", () => {
mock.trackId = undefined;
mock.playlists = [playlist("p1", "late night")];
const html = renderToStaticMarkup();
- expect(html).toContain("No track to add.");
+ expect(html).toContain("No track to add");
expect(html).not.toContain("late night");
expect(html).not.toContain("loading");
});
diff --git a/mobile/tests/queue-section.test.tsx b/mobile/tests/queue-section.test.tsx
index a26a396e..cdcecec6 100644
--- a/mobile/tests/queue-section.test.tsx
+++ b/mobile/tests/queue-section.test.tsx
@@ -137,7 +137,7 @@ describe("queue first render", () => {
it("shows the empty state immediately at the end of the queue", () => {
const html = render({ startIndex: queue.length });
expect(observed.data).toEqual([]);
- expect(html).toContain("Nothing else queued.");
+ expect(html).toContain("Nothing else queued");
expect(html).toContain("0 songs");
});
diff --git a/mobile/tests/replay-activity-chart.test.tsx b/mobile/tests/replay-activity-chart.test.tsx
new file mode 100644
index 00000000..a69d927b
--- /dev/null
+++ b/mobile/tests/replay-activity-chart.test.tsx
@@ -0,0 +1,53 @@
+import { type ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { expect, it, vi } from "vitest";
+import { ActivityChart } from "../components/replay/activity-chart";
+
+const ACCENT = "#0a84ff";
+
+vi.mock("react-native", () => ({
+ Text: ({ children }: { children: ReactNode }) => {children},
+ View: ({
+ children,
+ style,
+ }: {
+ children?: ReactNode;
+ style?: { backgroundColor?: string; height?: string };
+ }) => (
+
+ {children}
+
+ ),
+}));
+vi.mock("../components/primitives", () => ({
+ Card: ({ children }: { children: ReactNode }) => {children}
,
+}));
+vi.mock("../theme/theme", () => ({
+ useTheme: () => ({ color: { accent: ACCENT, fgMuted: "#8e8e93" } }),
+ withAlpha: (color: string) => `${color}-faint`,
+}));
+
+const bucket = (day: number, plays: number) => ({
+ bucket_start: `2026-10-${String(day).padStart(2, "0")}T00:00:00Z`,
+ plays,
+});
+const render = (plays: number[]) =>
+ renderToStaticMarkup(
+ bucket(i + 1, n))} bucket="day" />,
+ );
+const count = (html: string, needle: string) => html.split(needle).length - 1;
+
+it("lights only the busiest bucket", () => {
+ const html = render([3, 12, 5]);
+ expect(count(html, `data-bg="${ACCENT}"`)).toBe(1);
+ expect(html).toContain(`data-bg="${ACCENT}" data-h="100%"`);
+ expect(count(html, 'data-bg="#8e8e93-faint"')).toBe(2);
+});
+
+it("lights every bucket tied for busiest", () => {
+ expect(count(render([7, 2, 7]), `data-bg="${ACCENT}"`)).toBe(2);
+});
+
+it("lights nothing when nothing was played", () => {
+ expect(count(render([0, 0]), `data-bg="${ACCENT}"`)).toBe(0);
+});
diff --git a/mobile/tests/replay-artist-tiles.test.tsx b/mobile/tests/replay-artist-tiles.test.tsx
new file mode 100644
index 00000000..d71d3d1c
--- /dev/null
+++ b/mobile/tests/replay-artist-tiles.test.tsx
@@ -0,0 +1,159 @@
+import { type ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeEach, expect, it, vi } from "vitest";
+import ReplayScreen from "../app/(tabs)/(settings)/replay";
+
+interface Tile {
+ title: string;
+ accessibilityLabel: string;
+ art: { kind: string; imageUrl?: string | null };
+ onPress?: () => void;
+}
+
+const mock = vi.hoisted(() => ({
+ push: vi.fn(),
+ play: vi.fn(() => true),
+ tiles: [] as Tile[],
+ data: undefined as unknown,
+}));
+
+vi.mock("react-native", () => ({
+ Alert: { alert: vi.fn() },
+ RefreshControl: () => null,
+ ScrollView: ({ children }: { children: ReactNode }) => {children},
+ Share: { share: vi.fn() },
+ View: ({ children }: { children: ReactNode }) => {children}
,
+}));
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({ data: mock.data, isLoading: false, isError: false, refetch: vi.fn() }),
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+}));
+vi.mock("expo-file-system", () => ({ Directory: vi.fn(), File: vi.fn(), Paths: {} }));
+vi.mock("expo-router", () => ({ useRouter: () => ({ push: mock.push }) }));
+vi.mock("expo-haptics", () => ({
+ notificationAsync: vi.fn(),
+ NotificationFeedbackType: {},
+}));
+vi.mock("@music-library/core", async (importOriginal) => ({
+ ...(await importOriginal()),
+ api: {},
+ useAuth: () => ({ me: { id: "me" } }),
+ useLocalDay: () => "2026-11-03",
+}));
+vi.mock("../context/player", () => ({ usePlayTrack: () => mock.play }));
+vi.mock("../lib/use-play-queue", () => ({
+ listPlaybackQueue: (tracks: T[]) => tracks,
+ usePlayQueue: () => vi.fn(),
+}));
+vi.mock("../lib/use-pull-to-refresh", () => ({
+ usePullToRefresh: () => ({ refreshing: false, onRefresh: vi.fn() }),
+}));
+vi.mock("../lib/app-resume", () => ({ subscribeAppActive: vi.fn() }));
+vi.mock("../lib/share-dismissal", () => ({ isShareDismissal: () => false }));
+vi.mock("../theme/theme", () => ({
+ useTheme: () => ({
+ color: { bg: "#000", fgMuted: "#888" },
+ space: { sm: 8, lg: 16, xl: 24 },
+ }),
+}));
+vi.mock("../components/dock/dock-context", () => ({
+ useBottomDockInset: () => 0,
+ useDockScrollHandler: () => ({}),
+}));
+vi.mock("../components/empty-state", () => ({ EmptyState: () => null }));
+vi.mock("../components/horizontal-shelf", () => ({
+ HorizontalShelf: ({ children }: { children: ReactNode }) => ,
+}));
+vi.mock("../components/section", () => ({
+ Section: ({ children }: { children: ReactNode }) => ,
+}));
+vi.mock("../components/replay/activity-chart", () => ({ ActivityChart: () => null }));
+vi.mock("../components/replay/genre-list", () => ({ GenreList: () => null }));
+vi.mock("../components/replay/format", () => ({
+ pluralize: (n: number, noun: string) => `${n} ${noun}s`,
+}));
+vi.mock("../components/replay/period", () => ({
+ buildPeriodOptions: () => [],
+ periodTitle: () => "2026",
+}));
+vi.mock("../components/replay/period-picker", () => ({ PeriodPicker: () => null }));
+vi.mock("../components/replay/replay-action-button", () => ({
+ ReplayActionButton: () => null,
+}));
+vi.mock("../components/replay/replay-empty-state", () => ({ ReplayEmptyState: () => null }));
+vi.mock("../components/replay/replay-hero", () => ({ ReplayHero: () => null }));
+vi.mock("../components/replay/summary-grid", () => ({ SummaryGrid: () => null }));
+vi.mock("../components/replay/top-track-list", () => ({ TopTrackList: () => null }));
+vi.mock("../components/replay/ranked-shelf-tile", () => ({
+ RankedShelfTile: (props: Tile) => {
+ mock.tiles.push(props);
+ return null;
+ },
+}));
+
+const track = (id: string, artist: string, artistId: string) => ({
+ id,
+ title: id,
+ artist,
+ artist_id: artistId,
+ artists: [{ id: artistId, name: artist }],
+ album_id: `album-${id}`,
+ has_cover: true,
+ plays: 1,
+});
+
+function render(topArtists: { id: string; name: string; plays: number; has_page?: boolean }[]) {
+ mock.tiles = [];
+ mock.data = {
+ summary: { total_plays: 9, total_ms: 1000, unique_tracks: 2, unique_artists: 2 },
+ available_years: [],
+ top_tracks: [track("t1", "Library Artist", "lib"), track("t2", "Streamed Artist", "tidal")],
+ top_artists: topArtists,
+ top_albums: [],
+ top_genres: [],
+ activity: [],
+ bucket: "month",
+ };
+ renderToStaticMarkup();
+ return mock.tiles;
+}
+
+beforeEach(() => {
+ mock.push.mockClear();
+ mock.play.mockClear();
+});
+
+it("opens the artist's page when the server says there is one", () => {
+ const [tile] = render([{ id: "lib", name: "Library Artist", plays: 5, has_page: true }]);
+ expect(tile.accessibilityLabel).toBe("View artist Library Artist");
+ tile.onPress?.();
+ expect(mock.push).toHaveBeenCalledWith({
+ pathname: "/(tabs)/(settings)/artists/[id]",
+ params: { id: "lib" },
+ });
+ expect(mock.play).not.toHaveBeenCalled();
+});
+
+it("plays a streamed artist's top tracks, rather than opening Not found", () => {
+ const [tile] = render([{ id: "tidal", name: "Streamed Artist", plays: 4, has_page: false }]);
+ expect(tile.accessibilityLabel).toBe("Play Streamed Artist");
+ tile.onPress?.();
+ expect(mock.push).not.toHaveBeenCalled();
+ expect(mock.play).toHaveBeenCalledTimes(1);
+ expect(mock.play.mock.calls[0]).toMatchObject([
+ { id: "t2" },
+ [{ id: "t2" }],
+ ]);
+});
+
+it("does nothing for an artist with no page and no top tracks", () => {
+ const [tile] = render([{ id: "gone", name: "Gone Artist", plays: 1 }]);
+ expect(tile.accessibilityLabel).toBe("Gone Artist");
+ expect(tile.onPress).toBeUndefined();
+ expect(tile.art).toEqual({ kind: "artist", imageUrl: null });
+});
+
+it("shows a cover from the artist's own tracks", () => {
+ const [tile] = render([{ id: "lib", name: "Library Artist", plays: 5, has_page: true }]);
+ expect(tile.art.imageUrl).toContain("album-t1");
+});
diff --git a/mobile/tests/search-failures.test.tsx b/mobile/tests/search-failures.test.tsx
index 1eb26c21..f4ab3d56 100644
--- a/mobile/tests/search-failures.test.tsx
+++ b/mobile/tests/search-failures.test.tsx
@@ -193,8 +193,8 @@ const markup = () => renderToStaticMarkup();
it("shows a total failure with a working retry control", () => {
mock.query.isError = true;
const html = markup();
- expect(html).toContain("Couldn't load artist.");
- expect(html).not.toContain("No releases found.");
+ expect(html).toContain("Couldn't load artist");
+ expect(html).not.toContain("No releases found");
mock.retry?.onPress();
expect(mock.query.refetch).toHaveBeenCalledOnce();
});
@@ -208,7 +208,7 @@ it("keeps available releases visible with partial failure warnings and during a
expect(markup()).toContain("Couldn't load singles and EPs.");
expect(markup()).toContain("Available album (Album)");
mock.query.isError = true;
- expect(markup()).toContain("Couldn't load artist.");
+ expect(markup()).toContain("Couldn't load artist");
expect(markup()).toContain("Available album");
expect(mock.retry).toBeDefined();
});
@@ -219,14 +219,14 @@ it("distinguishes incomplete empty results from confirmed empty results and clea
tracks: [],
warnings: ["Couldn't load albums."],
};
- expect(markup()).not.toContain("No releases found.");
+ expect(markup()).not.toContain("No releases found");
mock.query.isFetching = true;
expect(markup()).toContain("Retrying…");
expect(mock.retry?.disabled).toBe(true);
mock.query.isFetching = false;
mock.query.data = { albums: [], tracks: [] };
const html = markup();
- expect(html).toContain("No releases found.");
+ expect(html).toContain("No releases found");
expect(html).not.toContain("Retry artist");
expect(html).not.toContain("Couldn");
});
@@ -261,7 +261,7 @@ it("ranks the first five popular tracks and files releases by kind", () => {
expect(collapsed).not.toContain("Track 6");
expect(collapsed).toContain("2 releases · 7 popular tracks");
expect(collapsed).toMatch(/Newer single \(Single\).*Older \(Album\)/);
- expect(collapsed).not.toContain("No releases found.");
+ expect(collapsed).not.toContain("No releases found");
});
it("does not confirm an empty search while a stream failed, and offers retry", () => {
diff --git a/mobile/tests/shelf-tile.test.tsx b/mobile/tests/shelf-tile.test.tsx
new file mode 100644
index 00000000..d15750a2
--- /dev/null
+++ b/mobile/tests/shelf-tile.test.tsx
@@ -0,0 +1,58 @@
+import { type ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeEach, expect, it, vi } from "vitest";
+import { ShelfTile } from "../components/shelf-tile";
+
+interface PressableProps {
+ accessibilityLabel?: string;
+ accessibilityRole?: string;
+ disabled?: boolean;
+ onPress?: () => void;
+ style: (state: { pressed: boolean }) => { opacity: number }[];
+}
+
+const mock = vi.hoisted(() => ({ pressable: undefined as PressableProps | undefined }));
+
+vi.mock("react-native", () => ({
+ Pressable: ({ children, ...props }: PressableProps & { children: ReactNode }) => {
+ mock.pressable = props;
+ return {children};
+ },
+ 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("Play");
expect(out).toContain("More actions");
- 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: () =>
}));
+vi.mock("react-native-reanimated", () => {
+ const builder = { duration: () => builder };
+ return {
+ default: {
+ View: ({ children }: { children: ReactNode }) => ,
+ },
+ FadeIn: builder,
+ };
+});
+vi.mock("@shopify/react-native-skia", () => ({
+ Canvas: ({ children }: { children: ReactNode }) => ,
+ 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("