Compare commits
10
Commits
4402171f46
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a462ee146 | ||
|
|
4bdfae155a | ||
|
|
5778035656 | ||
|
|
0a28f05d53 | ||
|
|
6537c34a38 | ||
|
|
094f116b10 | ||
|
|
c7bd8f0ad3 | ||
|
|
0a26e33bfe | ||
|
|
864ab88627 | ||
|
|
2025225fe4 |
@@ -22,8 +22,8 @@ jobs:
|
||||
runs-on: musicshare-android
|
||||
container:
|
||||
image: musicshare-android-builder
|
||||
# Keep Gradle and npm downloads between builds.
|
||||
options: -v musicshare-gradle:/root/.gradle -v musicshare-npm:/root/.npm
|
||||
# Keep Gradle (downloads and build cache), npm, and compiled C++ (ccache) between builds.
|
||||
options: -v musicshare-gradle:/root/.gradle -v musicshare-npm:/root/.npm -v musicshare-ccache:/root/.ccache
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
SERVER: ${{ github.server_url }}
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
echo "Release: $SERVER/$REPO/releases/tag/$REF_NAME"
|
||||
else
|
||||
owner=${REPO%%/*}
|
||||
version="$(echo "$REF_NAME" | tr -c 'A-Za-z0-9._-\n' '-')-${SHA::8}"
|
||||
version="$(printf '%s' "$REF_NAME" | tr -c 'A-Za-z0-9._-' '-')-$(printf '%s' "$SHA" | cut -c1-8)"
|
||||
for f in *.apk SHA256SUMS; do
|
||||
curl -fsS -X PUT -H "$auth" --upload-file "$f" "$SERVER/api/packages/$owner/generic/musicshare-android/$version/$f"
|
||||
echo "Uploaded $f"
|
||||
|
||||
@@ -10,3 +10,5 @@ next-env.d.ts
|
||||
# Production secrets and database backups
|
||||
deploy/production.env
|
||||
backups/
|
||||
.aider*
|
||||
deploy/gitea-runner/data/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "musicshare",
|
||||
"slug": "musicshare",
|
||||
"version": "1.2.0",
|
||||
"version": "1.5.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "musicshare",
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "tech.chrisedwards.musicshare",
|
||||
"versionCode": 3
|
||||
"versionCode": 6
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
@@ -31,7 +31,7 @@
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"backgroundColor": "#0b0820",
|
||||
"backgroundColor": "#2a2942",
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 150
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
+4
-2
@@ -121,9 +121,11 @@ class MediaListenerService : NotificationListenerService() {
|
||||
private fun snapshot(controller: MediaController): Playback? {
|
||||
val metadata = controller.metadata
|
||||
val title = metadata?.text(MediaMetadata.METADATA_KEY_TITLE) ?: metadata?.text(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
|
||||
val artist = metadata?.text(MediaMetadata.METADATA_KEY_ARTIST)
|
||||
val realArtist = metadata?.text(MediaMetadata.METADATA_KEY_ARTIST)
|
||||
?: metadata?.text(MediaMetadata.METADATA_KEY_ALBUM_ARTIST)
|
||||
?: metadata?.text(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE)
|
||||
val artist = realArtist ?: metadata?.text(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE)
|
||||
// A phone video or other non-song: treat the session as if it weren't there.
|
||||
if (NonMusicFilter.isNonMusic(controller.packageName, title, realArtist)) return null
|
||||
val playbackState = controller.playbackState
|
||||
val state = when (playbackState?.state) {
|
||||
PlaybackState.STATE_PLAYING, PlaybackState.STATE_BUFFERING,
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package tech.chrisedwards.musicshare.nowplaying
|
||||
|
||||
/**
|
||||
* Keeps things that aren't songs (phone videos, screen recordings) out of what we share.
|
||||
* General-purpose players report a local file's name as its title, so a video played
|
||||
* in VLC would otherwise show up as "VID_20261025_181502.mp4".
|
||||
*/
|
||||
internal object NonMusicFilter {
|
||||
/** Players that also open videos and arbitrary files: only share from them when there's a real artist. */
|
||||
private val GENERAL_PLAYERS = setOf(
|
||||
"org.videolan.vlc",
|
||||
"org.videolan.vlc.debug",
|
||||
"com.mxtech.videoplayer.ad",
|
||||
"com.mxtech.videoplayer.pro",
|
||||
"is.xyz.mpv",
|
||||
"com.brouken.player", // Just Player
|
||||
"com.google.android.apps.photos",
|
||||
"com.sec.android.gallery3d",
|
||||
"com.android.gallery3d",
|
||||
"com.miui.gallery",
|
||||
"com.google.android.apps.nbu.files",
|
||||
"com.sec.android.app.myfiles",
|
||||
)
|
||||
|
||||
private val FILE_NAME = Regex(
|
||||
""".*\.(mp4|m4v|mkv|mov|webm|avi|3gp|3g2|wmv|flv|ts|mts|m2ts|mpg|mpeg)$""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
/** Camera, screen-recorder and messaging-app naming schemes. */
|
||||
private val RECORDING_NAME = Regex(
|
||||
"""^(VID|PXL|IMG|MVIMG|MOV|DSC|GOPR|GX\d{2}|Screen[_ ]?Recording|Screenrecorder|Record|signal|WhatsApp Video)[-_ ]?\d.*""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
/**
|
||||
* [realArtist] is the artist/album-artist tag only, not the display-subtitle fallback,
|
||||
* which players fill with folder names and the like.
|
||||
*/
|
||||
fun isNonMusic(packageName: String, title: String?, realArtist: String?): Boolean {
|
||||
val name = title?.trim().orEmpty()
|
||||
if (name.isEmpty()) return false
|
||||
if (FILE_NAME.matches(name) || RECORDING_NAME.matches(name)) return true
|
||||
return packageName in GENERAL_PLAYERS && realArtist.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@musicshare/mobile",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.2.0",
|
||||
"version": "1.5.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
||||
"@expo-google-fonts/poppins": "^0.4.1",
|
||||
"@expo/ui": "~57.0.21",
|
||||
"@musicshare/shared": "*",
|
||||
"expo": "~57.0.26",
|
||||
|
||||
@@ -7,24 +7,37 @@
|
||||
#
|
||||
# Both are signed with the upload key when it's configured (see plugins/withReleaseSigning.js;
|
||||
# in CI it comes from ORG_GRADLE_PROJECT_MUSICSHARE_UPLOAD_* variables). Set REQUIRE_SIGNED=1 to
|
||||
# fail rather than produce unsigned APKs. ABIS picks the CPU types (default: phones, 32 and 64 bit).
|
||||
# Regenerates apps/mobile/android each time (prebuild --clean).
|
||||
# fail rather than produce unsigned APKs. ABIS picks the CPU types (default: 64-bit phones, which is
|
||||
# nearly every phone from the last several years; ABIS=armeabi-v7a,arm64-v8a adds old 32-bit ones).
|
||||
# Regenerates apps/mobile/android each time (prebuild --clean); Gradle's build cache and ccache (when
|
||||
# installed) make the rebuild cheap anyway.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
OUT=$(realpath -m "${1:-dist}")
|
||||
VERSION=$(node -p "require('./app.json').expo.version")
|
||||
ABIS=${ABIS:-armeabi-v7a,arm64-v8a}
|
||||
ABIS=${ABIS:-arm64-v8a}
|
||||
BUILD_TOOLS=$(ls -d "$ANDROID_HOME"/build-tools/*/ | sort -V | tail -1)
|
||||
mkdir -p "$OUT"
|
||||
|
||||
say() { printf '\n==> %s\n' "$*"; }
|
||||
|
||||
# Most of a build is compiling the C++ in React Native and its libraries, which rarely changes:
|
||||
# with ccache, CMake (3.17+) reuses earlier results via these launcher variables.
|
||||
if command -v ccache > /dev/null; then
|
||||
export CMAKE_C_COMPILER_LAUNCHER=ccache CMAKE_CXX_COMPILER_LAUNCHER=ccache
|
||||
export CCACHE_BASEDIR=${CCACHE_BASEDIR:-$PWD} CCACHE_COMPILERCHECK=content CCACHE_MAXSIZE=${CCACHE_MAXSIZE:-6G}
|
||||
ccache --zero-stats > /dev/null
|
||||
fi
|
||||
|
||||
build() {
|
||||
local suffix=$1 foss=$2
|
||||
say "Building musicshare-$VERSION$suffix.apk"
|
||||
MUSICSHARE_FOSS=$foss CI=1 npx expo prebuild --platform android --clean --no-install
|
||||
(cd android && ./gradlew :app:assembleRelease -PreactNativeArchitectures="$ABIS" --console=plain)
|
||||
# --build-cache reuses compiled Kotlin/Java, dex and resources from earlier builds (kept in ~/.gradle).
|
||||
# Release lint is skipped: it takes a while and TypeScript, ESLint and the tests cover what matters.
|
||||
(cd android && ./gradlew :app:assembleRelease -PreactNativeArchitectures="$ABIS" --build-cache \
|
||||
-x lintVitalAnalyzeRelease -x lintVitalReportRelease -x lintVitalRelease --console=plain)
|
||||
local apk
|
||||
apk=$(ls android/app/build/outputs/apk/release/*.apk | head -1)
|
||||
|
||||
@@ -56,3 +69,4 @@ build() {
|
||||
build "" 0
|
||||
build "-foss" 1
|
||||
(cd "$OUT" && sha256sum musicshare-"$VERSION"*.apk > SHA256SUMS && cat SHA256SUMS)
|
||||
if command -v ccache > /dev/null; then say "ccache"; ccache --show-stats | grep -iE 'hits|misses|cache size' || true; fi
|
||||
|
||||
@@ -5,10 +5,11 @@ export default function TabsLayout() {
|
||||
return (
|
||||
<NativeTabs
|
||||
backgroundColor={colors.bg}
|
||||
indicatorColor={colors.surface2}
|
||||
// The active tab gets a pink pill, like the reference's sidebar.
|
||||
indicatorColor={colors.accentSoft}
|
||||
// Android defaults icons and labels to black, which vanishes on the dark bar.
|
||||
iconColor={{ default: colors.muted, selected: colors.text }}
|
||||
labelStyle={{ default: { color: colors.muted }, selected: { color: colors.text } }}
|
||||
iconColor={{ default: colors.muted, selected: colors.accent }}
|
||||
labelStyle={{ default: { color: colors.muted }, selected: { color: colors.accent } }}
|
||||
labelVisibilityMode="labeled"
|
||||
tintColor={colors.accent}>
|
||||
<NativeTabs.Trigger name="index">
|
||||
|
||||
@@ -95,7 +95,19 @@ export default function FeedScreen() {
|
||||
ListHeaderComponent={
|
||||
<View style={styles.header}>
|
||||
<View style={styles.topBar}>
|
||||
{me ? (
|
||||
<Pressable style={styles.greeting} onPress={() => router.push('/profile')} accessibilityRole="link">
|
||||
<Avatar user={me} size={44} />
|
||||
<View style={{ flexShrink: 1 }}>
|
||||
<Text style={styles.hi} numberOfLines={1}>
|
||||
Hi {me.displayName ?? me.username},
|
||||
</Text>
|
||||
<Text style={styles.welcome}>Welcome back</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Text style={styles.brand}>musicshare</Text>
|
||||
)}
|
||||
<ActivityBell />
|
||||
</View>
|
||||
<UpdateBanner />
|
||||
@@ -115,7 +127,7 @@ export default function FeedScreen() {
|
||||
)}
|
||||
{mine?.nowPlaying && (
|
||||
<View style={{ gap: space.sm }}>
|
||||
<Text style={styles.sectionLabel}>YOU</Text>
|
||||
<Text style={styles.sectionLabel}>Your song</Text>
|
||||
<NowPlayingCard np={mine.nowPlaying} preferred={me?.preferredService} showUser={false} />
|
||||
</View>
|
||||
)}
|
||||
@@ -143,7 +155,7 @@ export default function FeedScreen() {
|
||||
ListFooterComponent={
|
||||
quiet.length > 0 ? (
|
||||
<View style={{ gap: space.sm, marginTop: space.md }}>
|
||||
<Text style={styles.sectionLabel}>FOLLOWING · NOTHING PLAYED YET</Text>
|
||||
<Text style={styles.sectionLabel}>Following · nothing played yet</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.quiet}>
|
||||
{quiet.map((u) => (
|
||||
<Pressable key={u.id} onPress={() => router.push(`/u/${u.username}`)} style={styles.quietPerson} accessibilityLabel={u.displayName ?? u.username}>
|
||||
@@ -183,8 +195,11 @@ const styles = StyleSheet.create({
|
||||
header: { gap: space.md, marginBottom: space.xs },
|
||||
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
brand: { color: colors.text, fontFamily: fonts.display, fontSize: 22, letterSpacing: -0.4 },
|
||||
sectionLabel: { color: colors.muted, fontSize: 12, fontWeight: '700', letterSpacing: 0.8 },
|
||||
greeting: { flexDirection: 'row', alignItems: 'center', gap: space.md, flexShrink: 1 },
|
||||
hi: { color: colors.text, fontFamily: fonts.display, fontSize: 19 },
|
||||
welcome: { color: colors.muted, fontFamily: fonts.body, fontSize: 13 },
|
||||
sectionLabel: { color: colors.muted, fontSize: 13, fontFamily: fonts.bodyMedium },
|
||||
quiet: { gap: space.lg, paddingVertical: space.xs },
|
||||
quietPerson: { width: 64, alignItems: 'center', gap: 4 },
|
||||
quietName: { color: colors.text, fontSize: 12, textAlign: 'center' },
|
||||
quietName: { color: colors.text, fontSize: 11.5, fontFamily: fonts.body, textAlign: 'center' },
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { PersonRow, emptyFollowText, useFollowList } from '@/components/FollowLi
|
||||
import { Body, Button, Chip, Field, Row, Title } from '@/components/ui';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { colors, fonts, space } from '@/lib/theme';
|
||||
|
||||
/** Friends: who you follow and who follows you, plus search and QR codes to find new people. */
|
||||
export default function FriendsScreen() {
|
||||
@@ -95,6 +95,6 @@ const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: colors.bg },
|
||||
list: { padding: space.lg, gap: space.md },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.sm },
|
||||
name: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||
handle: { color: colors.muted, fontSize: 13 },
|
||||
name: { color: colors.text, fontFamily: fonts.display, fontSize: 15 },
|
||||
handle: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
||||
import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { checkForUpdate, currentVersion, installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
return (
|
||||
@@ -670,7 +670,7 @@ function Account() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
small: { fontSize: 13 },
|
||||
small: { fontSize: 13, fontFamily: fonts.body },
|
||||
avatarRow: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
|
||||
connection: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||
connectionColumn: {
|
||||
@@ -680,6 +680,6 @@ const styles = StyleSheet.create({
|
||||
paddingTop: space.md,
|
||||
},
|
||||
result: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.xs },
|
||||
resultText: { color: colors.text, flex: 1, fontSize: 14 },
|
||||
resultText: { color: colors.text, flex: 1, fontSize: 14, fontFamily: fonts.body },
|
||||
thumb: { width: 40, height: 40, borderRadius: radius.sm },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SpaceGrotesk_500Medium, SpaceGrotesk_700Bold, useFonts } from '@expo-google-fonts/space-grotesk';
|
||||
import { Poppins_400Regular, Poppins_500Medium, Poppins_600SemiBold, useFonts } from '@expo-google-fonts/poppins';
|
||||
import { DarkTheme, Stack, ThemeProvider } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
@@ -37,7 +37,7 @@ function AppNavigator() {
|
||||
const { status, me, retry } = useSession();
|
||||
const signedIn = status === 'signedIn';
|
||||
const onboarded = Boolean(me?.onboardedAt);
|
||||
const [fontsLoaded, fontError] = useFonts({ SpaceGrotesk_500Medium, SpaceGrotesk_700Bold });
|
||||
const [fontsLoaded, fontError] = useFonts({ Poppins_400Regular, Poppins_500Medium, Poppins_600SemiBold });
|
||||
const ready = status !== 'loading' && (fontsLoaded || Boolean(fontError));
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Avatar } from '@/components/Avatar';
|
||||
import { Body, Button } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { colors, fonts, space } from '@/lib/theme';
|
||||
|
||||
export default function BlockedScreen() {
|
||||
const { data: blocked = [], mutate, isLoading } = useSWR<UserSummaryDTO[]>('/me/blocks');
|
||||
@@ -45,5 +45,5 @@ const styles = StyleSheet.create({
|
||||
list: { padding: space.lg, gap: space.md },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||
person: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||
name: { color: colors.text, fontSize: 15, fontWeight: '600', flexShrink: 1 },
|
||||
name: { color: colors.text, fontSize: 15, fontFamily: fonts.bodyMedium, flexShrink: 1 },
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { notificationPath, notificationText, timeAgo, type NotificationDTO, type
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { Body } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { colors, fonts, space } from '@/lib/theme';
|
||||
|
||||
/** Reactions, new followers and songs sent to you, newest first. Opening it marks them read. */
|
||||
export default function NotificationsScreen() {
|
||||
@@ -77,8 +77,8 @@ const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', gap: space.md, alignItems: 'center', padding: space.md, borderRadius: 14 },
|
||||
unread: { backgroundColor: colors.surface2 },
|
||||
text: { flex: 1, minWidth: 0, gap: 2 },
|
||||
title: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||
body: { color: colors.text, opacity: 0.85, fontSize: 14 },
|
||||
title: { color: colors.text, fontFamily: fonts.display, fontSize: 15 },
|
||||
body: { color: colors.text, opacity: 0.85, fontSize: 14, fontFamily: fonts.body },
|
||||
time: { color: colors.muted, fontSize: 12 },
|
||||
art: { width: 44, height: 44, borderRadius: 8 },
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { WEB_URL } from '@/lib/config';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { successHaptic } from '@/lib/haptics';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { colors, fonts, space } from '@/lib/theme';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
|
||||
/** Step 3: an empty feed is the fastest way to lose someone, so find friends now. */
|
||||
@@ -90,6 +90,6 @@ export default function FriendsStep() {
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.md },
|
||||
who: { flexDirection: 'row', alignItems: 'center', gap: space.md, flexShrink: 1 },
|
||||
name: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||
handle: { color: colors.muted, fontSize: 13 },
|
||||
name: { color: colors.text, fontFamily: fonts.display, fontSize: 15 },
|
||||
handle: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { showError } from '@/lib/errors';
|
||||
import { selectionHaptic } from '@/lib/haptics';
|
||||
import { LISTEN_APPS, finishOnboarding } from '@/lib/onboarding';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||
|
||||
/** Step 1: where do you listen? Sets where friends' songs open, and picks the right setup next. */
|
||||
export default function ListenAppStep() {
|
||||
@@ -69,7 +69,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: space.md,
|
||||
},
|
||||
optionPicked: { borderColor: colors.accent, backgroundColor: 'rgba(124,77,255,0.18)' },
|
||||
optionText: { color: colors.text, fontWeight: '600', fontSize: 15 },
|
||||
optionPicked: { borderColor: colors.accent, backgroundColor: colors.accentSoft },
|
||||
optionText: { color: colors.text, fontFamily: fonts.bodyMedium, fontSize: 15 },
|
||||
optionTextPicked: { color: '#fff' },
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { tapHaptic } from '@/lib/haptics';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||
|
||||
/** One play and its comments: where the 💬 chip and comment notifications lead. */
|
||||
export default function PlayScreen() {
|
||||
@@ -163,9 +163,9 @@ const styles = StyleSheet.create({
|
||||
header: { gap: space.md, marginBottom: space.sm },
|
||||
playedBy: { flexDirection: 'row', alignItems: 'center', gap: space.sm },
|
||||
comment: { flexDirection: 'row', gap: space.md, alignItems: 'flex-start' },
|
||||
name: { color: colors.text, fontWeight: '700' },
|
||||
muted: { color: colors.muted, fontSize: 13 },
|
||||
body: { color: colors.text, fontSize: 15, lineHeight: 21 },
|
||||
name: { color: colors.text, fontFamily: fonts.display },
|
||||
muted: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||
body: { color: colors.text, fontSize: 15, lineHeight: 21, fontFamily: fonts.body },
|
||||
more: { color: colors.muted, fontSize: 18, paddingHorizontal: space.xs },
|
||||
composer: {
|
||||
flexDirection: 'row',
|
||||
@@ -190,6 +190,6 @@ const styles = StyleSheet.create({
|
||||
borderColor: colors.border,
|
||||
},
|
||||
send: { paddingHorizontal: space.md, paddingVertical: space.sm + 2, borderRadius: radius.pill, backgroundColor: colors.accent },
|
||||
sendText: { color: '#fff', fontWeight: '700' },
|
||||
sendText: { color: '#fff', fontFamily: fonts.display },
|
||||
closed: { margin: space.lg, marginTop: 0 },
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Screen } from '@/components/Screen';
|
||||
import { Body, Button } from '@/components/ui';
|
||||
import { WEB_URL } from '@/lib/config';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { colors, fonts, space } from '@/lib/theme';
|
||||
|
||||
/** "My code": friends scan it (in musicshare or any camera app) to open your profile. */
|
||||
export default function MyQrScreen() {
|
||||
@@ -40,8 +40,8 @@ export default function MyQrScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
content: { flexGrow: 1, alignItems: 'center', justifyContent: 'center', gap: space.xl },
|
||||
who: { alignItems: 'center', gap: space.xs },
|
||||
name: { color: colors.text, fontSize: 22, fontWeight: '800' },
|
||||
handle: { color: colors.muted, fontSize: 15 },
|
||||
name: { color: colors.text, fontSize: 22, fontFamily: fonts.display },
|
||||
handle: { color: colors.muted, fontSize: 15, fontFamily: fonts.body },
|
||||
hint: { textAlign: 'center', maxWidth: 300 },
|
||||
actions: { alignSelf: 'stretch', gap: space.sm },
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Body, Button, Title } from '@/components/ui';
|
||||
import { FOSS_BUILD } from '@/lib/build';
|
||||
import { decodeQrFromJpeg, pickSnapshotSize } from '@/lib/qrSnapshot';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||
|
||||
/** Scan a friend's musicshare QR code and open their profile (with its Follow button). */
|
||||
export default function ScanScreen() {
|
||||
@@ -115,7 +115,7 @@ const styles = StyleSheet.create({
|
||||
prompt: {
|
||||
color: colors.accentText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
fontFamily: fonts.bodyMedium,
|
||||
textAlign: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
paddingHorizontal: space.md,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { successHaptic, tapHaptic } from '@/lib/haptics';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||
|
||||
/** Send a song to some of your followers, with an optional note. */
|
||||
export default function SendSongScreen() {
|
||||
@@ -98,10 +98,10 @@ export default function SendSongScreen() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: { padding: space.lg, gap: space.sm, paddingBottom: space.xl * 2 },
|
||||
song: { color: colors.text, fontSize: 17, fontWeight: '700' },
|
||||
muted: { color: colors.muted, fontWeight: '400' },
|
||||
song: { color: colors.text, fontSize: 17, fontFamily: fonts.display },
|
||||
muted: { color: colors.muted, fontFamily: fonts.body },
|
||||
person: { flexDirection: 'row', alignItems: 'center', gap: space.md, padding: space.sm, borderRadius: radius.md },
|
||||
on: { backgroundColor: colors.surface2 },
|
||||
name: { flex: 1, color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||
check: { color: colors.muted, fontSize: 18 },
|
||||
name: { flex: 1, color: colors.text, fontSize: 15, fontFamily: fonts.bodyMedium },
|
||||
check: { color: colors.muted, fontSize: 18, fontFamily: fonts.body },
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { router } from 'expo-router';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import useSWR from 'swr';
|
||||
import type { NotificationListDTO } from '@musicshare/shared';
|
||||
import { colors } from '@/lib/theme';
|
||||
import { colors, fonts } from '@/lib/theme';
|
||||
|
||||
/** Bell with the unread count; opens your notifications. */
|
||||
export function ActivityBell() {
|
||||
@@ -15,7 +16,10 @@ export function ActivityBell() {
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={unread ? `Notifications, ${unread} new` : 'Notifications'}
|
||||
style={styles.bell}>
|
||||
<Text style={styles.icon}>🔔</Text>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke={colors.text} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<Path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<Path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</Svg>
|
||||
{unread > 0 && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{unread > 99 ? '99+' : unread}</Text>
|
||||
@@ -26,8 +30,7 @@ export function ActivityBell() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bell: { width: 38, height: 38, borderRadius: 19, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.surface2 },
|
||||
icon: { fontSize: 18 },
|
||||
bell: { width: 42, height: 42, borderRadius: 21, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.surface2 },
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
@@ -36,9 +39,9 @@ const styles = StyleSheet.create({
|
||||
height: 18,
|
||||
paddingHorizontal: 4,
|
||||
borderRadius: 9,
|
||||
backgroundColor: colors.danger,
|
||||
backgroundColor: colors.accent,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
badgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
|
||||
badgeText: { color: '#fff', fontSize: 11, fontFamily: fonts.display },
|
||||
});
|
||||
|
||||
@@ -1,36 +1,15 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { useEffect } from 'react';
|
||||
import { StyleSheet, useWindowDimensions, View } from 'react-native';
|
||||
import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { colors } from '@/lib/theme';
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
||||
|
||||
/** Two soft colour blobs drifting slowly behind the content, so screens never feel static. */
|
||||
/** The page behind everything: flat slate, a touch lighter at the top. */
|
||||
export function AmbientBackground() {
|
||||
const { width, height } = useWindowDimensions();
|
||||
const t = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
t.set(withRepeat(withTiming(1, { duration: 18000, easing: Easing.inOut(Easing.sin) }), -1, true));
|
||||
}, [t]);
|
||||
|
||||
const size = Math.max(width, height) * 0.9;
|
||||
const purple = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: -size * 0.35 + t.value * width * 0.35 }, { translateY: -size * 0.3 + t.value * height * 0.12 }],
|
||||
}));
|
||||
const blue = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: width - size * 0.6 - t.value * width * 0.3 }, { translateY: height - size * 0.55 - t.value * height * 0.1 }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.bg }]} pointerEvents="none">
|
||||
<AnimatedImage source={require('@/assets/images/ambient-purple.png')} style={[styles.blob, { width: size, height: size }, purple]} />
|
||||
<AnimatedImage source={require('@/assets/images/ambient-blue.png')} style={[styles.blob, { width: size, height: size }, blue]} />
|
||||
</View>
|
||||
<LinearGradient
|
||||
colors={['#32304e', colors.bg, colors.bg]}
|
||||
locations={[0, 0.45, 1]}
|
||||
style={[StyleSheet.absoluteFill, { backgroundColor: colors.bg }]}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
blob: { position: 'absolute', top: 0, left: 0, opacity: 0.45 },
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { KeyboardAvoidingView, Linking, Platform, StyleSheet, Text, View } from
|
||||
import { api } from '@/lib/api';
|
||||
import { WEB_URL } from '@/lib/config';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { colors, fonts, space } from '@/lib/theme';
|
||||
import { Screen } from './Screen';
|
||||
import { Body, Button, Field, Title } from './ui';
|
||||
|
||||
@@ -106,7 +106,7 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
|
||||
const styles = StyleSheet.create({
|
||||
content: { flexGrow: 1, justifyContent: 'center' },
|
||||
link: { color: colors.muted, textAlign: 'center', paddingVertical: space.sm },
|
||||
legal: { color: colors.muted, fontSize: 12, textAlign: 'center', lineHeight: 17 },
|
||||
legal: { color: colors.muted, fontSize: 12, textAlign: 'center', lineHeight: 17, fontFamily: fonts.body },
|
||||
legalLink: { color: colors.text, textDecorationLine: 'underline' },
|
||||
logo: { width: 76, height: 76, borderRadius: 20, marginBottom: space.sm },
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Avatar({ user, size = 40, live }: { user: UserSummaryDTO; size?:
|
||||
const face = user.avatarUrl ? (
|
||||
<Image source={user.avatarUrl} style={shape} contentFit="cover" />
|
||||
) : (
|
||||
<LinearGradient colors={['#4a1278', '#1872cf']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={[styles.empty, shape]}>
|
||||
<LinearGradient colors={[colors.accent, '#8b5cf6']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={[styles.empty, shape]}>
|
||||
<Text style={[styles.letter, { fontSize: size * 0.45 }]}>{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}</Text>
|
||||
</LinearGradient>
|
||||
);
|
||||
@@ -31,7 +31,7 @@ export function Avatar({ user, size = 40, live }: { user: UserSummaryDTO; size?:
|
||||
<View style={{ width: ring, height: ring, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, spin]}>
|
||||
<LinearGradient
|
||||
colors={['#7b2ff7', '#2f8cff', colors.live, '#7b2ff7']}
|
||||
colors={[colors.accent, colors.accent2, colors.live, colors.accent]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={{ width: ring, height: ring, borderRadius: ring / 2 }}
|
||||
|
||||
@@ -5,7 +5,7 @@ import useSWR from 'swr';
|
||||
import type { ProfileDTO, TrackSearchResultDTO } from '@musicshare/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
import { Body, Button, Card, Field, Heading, Row } from './ui';
|
||||
@@ -147,9 +147,9 @@ export function FeaturedPicker({ bare, onSaved }: { bare?: boolean; onSaved?: ()
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
small: { fontSize: 13 },
|
||||
title: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||
small: { fontSize: 13, fontFamily: fonts.body },
|
||||
title: { color: colors.text, fontFamily: fonts.display, fontSize: 15 },
|
||||
result: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.xs },
|
||||
resultText: { color: colors.text, flex: 1, fontSize: 14 },
|
||||
resultText: { color: colors.text, flex: 1, fontSize: 14, fontFamily: fonts.body },
|
||||
thumb: { width: 40, height: 40, borderRadius: radius.sm },
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { FollowListDTO, FollowListItemDTO } from '@musicshare/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { colors, fonts, space } from '@/lib/theme';
|
||||
import { Avatar } from './Avatar';
|
||||
import { Body, Button } from './ui';
|
||||
|
||||
@@ -116,7 +116,7 @@ const styles = StyleSheet.create({
|
||||
list: { padding: space.lg, gap: space.md },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||
person: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||
name: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||
handle: { color: colors.muted, fontSize: 13 },
|
||||
name: { color: colors.text, fontSize: 15, fontFamily: fonts.bodyMedium },
|
||||
handle: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||
actions: { flexDirection: 'row', gap: space.sm },
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, { LinearTransition } from 'react-native-reanimated';
|
||||
import useSWR from 'swr';
|
||||
import { currentLyricIndex, playbackPosition, type LyricLine, type LyricsDTO, type NowPlayingDTO, type TrackDTO } from '@musicshare/shared';
|
||||
import { colors, fonts, space } from '@/lib/theme';
|
||||
import { colors, fonts, space, useInk } from '@/lib/theme';
|
||||
|
||||
/** Lines shown around the current one while following along. */
|
||||
const BEFORE = 1;
|
||||
@@ -16,13 +16,15 @@ const PREVIEW = 8;
|
||||
* lines around the one being sung and follows along; "All lyrics" shows the rest.
|
||||
*/
|
||||
export function Lyrics({ track, np }: { track: TrackDTO; np?: NowPlayingDTO }) {
|
||||
const ink = useInk();
|
||||
const note = [styles.note, { color: ink.muted }];
|
||||
const { data, error, isLoading } = useSWR<LyricsDTO | null>(`/tracks/${track.id}/lyrics`);
|
||||
const [full, setFull] = useState(false);
|
||||
|
||||
if (isLoading) return <Text style={styles.note}>Finding lyrics…</Text>;
|
||||
if (error) return <Text style={styles.note}>Couldn't load lyrics right now.</Text>;
|
||||
if (!data) return <Text style={styles.note}>No lyrics found for this song.</Text>;
|
||||
if (data.instrumental && !data.synced && !data.plain) return <Text style={styles.note}>♪ Instrumental</Text>;
|
||||
if (isLoading) return <Text style={note}>Finding lyrics…</Text>;
|
||||
if (error) return <Text style={note}>Couldn't load lyrics right now.</Text>;
|
||||
if (!data) return <Text style={note}>No lyrics found for this song.</Text>;
|
||||
if (data.instrumental && !data.synced && !data.plain) return <Text style={note}>♪ Instrumental</Text>;
|
||||
|
||||
const synced = data.synced;
|
||||
const live = Boolean(np?.isPlaying && playbackPosition(np, 0));
|
||||
@@ -48,11 +50,11 @@ export function Lyrics({ track, np }: { track: TrackDTO; np?: NowPlayingDTO }) {
|
||||
</Pressable>
|
||||
<View style={styles.footer}>
|
||||
{canToggle && (
|
||||
<Text style={styles.toggle} onPress={() => setFull((v) => !v)}>
|
||||
<Text style={[styles.toggle, ink.hero && { color: '#ffffff' }]} onPress={() => setFull((v) => !v)}>
|
||||
{!full ? 'All lyrics' : synced && live ? 'Follow along' : 'Less'}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.source} onPress={() => Linking.openURL(data.source.url)}>
|
||||
<Text style={[styles.source, { color: ink.muted }]} onPress={() => Linking.openURL(data.source.url)}>
|
||||
Lyrics from {data.source.name}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -85,13 +87,13 @@ function FollowAlong({ lines, np }: { lines: LyricLine[]; np: NowPlayingDTO }) {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { gap: space.sm },
|
||||
note: { color: colors.muted, fontSize: 13 },
|
||||
line: { color: colors.text, fontSize: 15, lineHeight: 22 },
|
||||
note: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||
line: { color: colors.text, fontSize: 14.5, lineHeight: 22, fontFamily: fonts.body },
|
||||
plain: { opacity: 0.9 },
|
||||
past: { opacity: 0.4 },
|
||||
now: { fontFamily: fonts.display, fontSize: 17, lineHeight: 24 },
|
||||
next: { opacity: 0.65 },
|
||||
footer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||
toggle: { color: colors.accent2, fontSize: 13, fontWeight: '600' },
|
||||
source: { color: colors.muted, fontSize: 11, marginLeft: 'auto' },
|
||||
toggle: { color: colors.accent, fontSize: 13, fontFamily: fonts.bodyMedium },
|
||||
source: { color: colors.muted, fontSize: 11, fontFamily: fonts.body, marginLeft: 'auto' },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
@@ -13,7 +12,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated';
|
||||
import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared';
|
||||
import { successHaptic, tapHaptic } from '@/lib/haptics';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { OnHero, colors, fonts, heroGradient, panelShadow, radius, space } from '@/lib/theme';
|
||||
import { Avatar } from './Avatar';
|
||||
import { Equalizer } from './Equalizer';
|
||||
import { Lyrics } from './Lyrics';
|
||||
@@ -54,15 +53,12 @@ export function NowPlayingCard({
|
||||
onTogglePin?.();
|
||||
};
|
||||
|
||||
// Your own song gets the gradient "hero" card; friends' songs sit on plain panels.
|
||||
const hero = !showUser;
|
||||
return (
|
||||
<View style={[styles.card, pinned && styles.pinned]}>
|
||||
{/* The card glows with its album art; crossfades when the song changes. */}
|
||||
{np.track.artworkUrl && (
|
||||
<Animated.View key={np.track.artworkUrl} style={StyleSheet.absoluteFill} entering={FadeIn.duration(700)} exiting={FadeOut.duration(500)}>
|
||||
<Image source={np.track.artworkUrl} style={styles.backdrop} blurRadius={40} contentFit="cover" />
|
||||
</Animated.View>
|
||||
)}
|
||||
<LinearGradient colors={['rgba(7,7,11,0.05)', 'rgba(7,7,11,0.6)']} style={StyleSheet.absoluteFill} />
|
||||
<OnHero.Provider value={hero}>
|
||||
<View style={[styles.card, hero && styles.heroCard, pinned && styles.pinned]}>
|
||||
{hero && <LinearGradient colors={heroGradient} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={StyleSheet.absoluteFill} />}
|
||||
|
||||
<View style={styles.header}>
|
||||
{showUser && (
|
||||
@@ -74,11 +70,11 @@ export function NowPlayingCard({
|
||||
</Pressable>
|
||||
)}
|
||||
<View style={styles.statusRow}>
|
||||
{np.isPlaying && <Equalizer playing />}
|
||||
<Text style={[styles.status, np.isPlaying && styles.live]} numberOfLines={1}>
|
||||
{np.isPlaying && <Equalizer playing color={hero ? '#ffffff' : colors.live} />}
|
||||
<Text style={[styles.status, hero && styles.heroText, np.isPlaying && (hero ? styles.heroLive : styles.live)]} numberOfLines={1}>
|
||||
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
|
||||
<Text
|
||||
style={[styles.via, np.sourceUrl && styles.viaLink]}
|
||||
style={[styles.via, hero && styles.heroText, np.sourceUrl && styles.viaLink]}
|
||||
onPress={np.sourceUrl ? () => Linking.openURL(np.sourceUrl!) : undefined}>
|
||||
{' · '}
|
||||
{np.sourceApp ?? SERVICE_LABELS[np.source]}
|
||||
@@ -109,30 +105,33 @@ export function NowPlayingCard({
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</OnHero.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: radius.lg,
|
||||
padding: space.md + 2,
|
||||
padding: space.md + 4,
|
||||
gap: space.sm + 2,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: 'transparent',
|
||||
...panelShadow,
|
||||
},
|
||||
heroCard: { overflow: 'hidden', shadowColor: colors.accent, shadowOpacity: 0.4, shadowRadius: 22 },
|
||||
heroText: { color: 'rgba(255,255,255,0.85)' },
|
||||
heroLive: { color: '#ffffff', fontFamily: fonts.bodyMedium },
|
||||
details: { gap: space.md },
|
||||
pinned: { borderColor: 'rgba(251,191,36,0.55)' },
|
||||
backdrop: { position: 'absolute', top: '-40%', left: '-40%', width: '180%', height: '180%', opacity: 0.55 },
|
||||
pinned: { borderColor: colors.accentLine },
|
||||
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.sm },
|
||||
user: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexShrink: 1 },
|
||||
name: { color: colors.text, fontWeight: '700', fontSize: 15, flexShrink: 1 },
|
||||
name: { color: colors.text, fontFamily: fonts.bodyMedium, fontSize: 14.5, flexShrink: 1 },
|
||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: space.xs + 2, flexShrink: 1 },
|
||||
status: { color: colors.muted, fontSize: 13, flexShrink: 1 },
|
||||
live: { color: colors.live, fontWeight: '600' },
|
||||
via: { color: colors.muted, fontWeight: '400' },
|
||||
status: { color: colors.muted, fontSize: 12.5, fontFamily: fonts.body, flexShrink: 1 },
|
||||
live: { color: colors.live, fontFamily: fonts.bodyMedium },
|
||||
via: { color: colors.muted, fontFamily: fonts.body },
|
||||
viaLink: { textDecorationLine: 'underline' },
|
||||
pin: { color: colors.muted, fontSize: 21, marginLeft: space.xs },
|
||||
pinOn: { color: colors.pin, textShadowColor: 'rgba(251,191,36,0.6)', textShadowRadius: 10 },
|
||||
pinOn: { color: colors.accent, textShadowColor: 'rgba(255,79,139,0.6)', textShadowRadius: 10 },
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { useRealtime } from '@/lib/useRealtime';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { colors, fonts, space } from '@/lib/theme';
|
||||
import { Avatar } from './Avatar';
|
||||
import { NowPlayingCard } from './NowPlayingCard';
|
||||
import { RecentPlays } from './RecentPlays';
|
||||
@@ -162,9 +162,9 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
|
||||
meta: { color: colors.muted, fontSize: 13 },
|
||||
meta: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||
counts: { flexDirection: 'row', gap: space.lg, marginTop: 2 },
|
||||
count: { color: colors.text, fontWeight: '700' },
|
||||
count: { color: colors.text, fontFamily: fonts.display },
|
||||
quote: {
|
||||
color: colors.text,
|
||||
fontStyle: 'italic',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import { formatDuration, playbackPosition, type NowPlayingDTO } from '@musicshare/shared';
|
||||
import { colors } from '@/lib/theme';
|
||||
import { brandGradient, fonts, useInk } from '@/lib/theme';
|
||||
|
||||
/** Where they are in the song. Renders nothing when the source doesn't report a position. */
|
||||
export function ProgressBar({ np, compact }: { np: NowPlayingDTO; /** Just the bar, no times. */ compact?: boolean }) {
|
||||
@@ -20,6 +20,7 @@ function Bar({ np, compact }: { np: NowPlayingDTO; compact?: boolean }) {
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const ink = useInk();
|
||||
const pos = playbackPosition(np, now - receivedAt);
|
||||
const fraction = pos ? pos.positionMs / pos.durationMs : 0;
|
||||
const width = useSharedValue(fraction);
|
||||
@@ -31,15 +32,15 @@ function Bar({ np, compact }: { np: NowPlayingDTO; compact?: boolean }) {
|
||||
if (!pos) return null;
|
||||
return (
|
||||
<View style={styles.wrap} accessibilityLabel={`${formatDuration(pos.positionMs)} of ${formatDuration(pos.durationMs)}`}>
|
||||
<View style={styles.track}>
|
||||
<Animated.View style={[styles.fill, fill]}>
|
||||
<LinearGradient colors={['#7b2ff7', '#2f8cff']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={StyleSheet.absoluteFill} />
|
||||
<View style={[styles.track, { backgroundColor: ink.faint }]}>
|
||||
<Animated.View style={[styles.fill, fill, ink.hero && { backgroundColor: '#ffffff' }]}>
|
||||
{!ink.hero && <LinearGradient colors={brandGradient} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={StyleSheet.absoluteFill} />}
|
||||
</Animated.View>
|
||||
</View>
|
||||
{!compact && (
|
||||
<View style={styles.times}>
|
||||
<Text style={styles.time}>{formatDuration(pos.positionMs)}</Text>
|
||||
<Text style={styles.time}>{formatDuration(pos.durationMs)}</Text>
|
||||
<Text style={[styles.time, { color: ink.muted }]}>{formatDuration(pos.positionMs)}</Text>
|
||||
<Text style={[styles.time, { color: ink.muted }]}>{formatDuration(pos.durationMs)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -48,8 +49,8 @@ function Bar({ np, compact }: { np: NowPlayingDTO; compact?: boolean }) {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { marginTop: 2 },
|
||||
track: { height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.14)', overflow: 'hidden' },
|
||||
track: { height: 4, borderRadius: 2, overflow: 'hidden' },
|
||||
fill: { height: '100%', borderRadius: 2, overflow: 'hidden' },
|
||||
times: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
|
||||
time: { color: colors.muted, fontSize: 12, fontVariant: ['tabular-nums'] },
|
||||
time: { fontSize: 11.5, fontFamily: fonts.body, fontVariant: ['tabular-nums'] },
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { tapHaptic } from '@/lib/haptics';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { colors, fonts, panelShadow, radius, space } from '@/lib/theme';
|
||||
|
||||
/**
|
||||
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
|
||||
@@ -168,13 +168,13 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 9,
|
||||
paddingVertical: 3,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(255,255,255,0.16)',
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
mine: { backgroundColor: 'rgba(123,47,247,0.3)', borderColor: 'rgba(123,47,247,0.75)' },
|
||||
mine: { backgroundColor: colors.accentSoft, borderColor: colors.accentLine },
|
||||
emoji: { fontSize: 16 },
|
||||
count: { color: colors.text, fontSize: 12, fontVariant: ['tabular-nums'] },
|
||||
count: { color: colors.text, fontSize: 12, fontVariant: ['tabular-nums'], fontFamily: fonts.body },
|
||||
quick: {
|
||||
flexDirection: 'row',
|
||||
alignSelf: 'flex-start',
|
||||
@@ -182,16 +182,15 @@ const styles = StyleSheet.create({
|
||||
gap: 2,
|
||||
padding: 4,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: '#1a1535',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(255,255,255,0.16)',
|
||||
backgroundColor: colors.surface2,
|
||||
...panelShadow,
|
||||
},
|
||||
quickItem: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' },
|
||||
quickEmoji: { fontSize: 26 },
|
||||
backdrop: { flex: 1, backgroundColor: 'rgba(5,3,15,0.6)' },
|
||||
backdrop: { flex: 1, backgroundColor: 'rgba(20,18,40,0.6)' },
|
||||
sheet: {
|
||||
height: '55%',
|
||||
backgroundColor: '#15112c',
|
||||
backgroundColor: colors.surface,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
padding: space.md,
|
||||
@@ -199,7 +198,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
search: {
|
||||
color: colors.text,
|
||||
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||
backgroundColor: colors.surface2,
|
||||
borderRadius: radius.md,
|
||||
paddingHorizontal: space.md,
|
||||
paddingVertical: 10,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Image } from 'expo-image';
|
||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||
import { Card, Heading } from './ui';
|
||||
import { Reactions } from './Reactions';
|
||||
|
||||
@@ -64,9 +64,9 @@ const styles = StyleSheet.create({
|
||||
divider: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border },
|
||||
art: { width: 40, height: 40, borderRadius: radius.sm, backgroundColor: colors.surface2 },
|
||||
artEmpty: { alignItems: 'center', justifyContent: 'center' },
|
||||
title: { color: colors.text, fontWeight: '600', fontSize: 14 },
|
||||
artist: { color: colors.muted, fontSize: 13 },
|
||||
title: { color: colors.text, fontFamily: fonts.bodyMedium, fontSize: 14 },
|
||||
artist: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||
time: { color: colors.muted, fontSize: 12 },
|
||||
reactions: { marginLeft: 40 + space.md, marginTop: -space.sm, marginBottom: space.xs },
|
||||
credit: { color: colors.muted, fontSize: 12, marginTop: space.sm },
|
||||
credit: { color: colors.muted, fontSize: 12, marginTop: space.sm, fontFamily: fonts.body },
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SERVICE_LABELS, openUrl, shareText, trackUrl, viewerServices, type Stre
|
||||
import { WEB_URL } from '@/lib/config';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||
import { colors, fonts, radius, space, useInk } from '@/lib/theme';
|
||||
import { Button } from './ui';
|
||||
|
||||
/**
|
||||
@@ -50,6 +50,8 @@ function PrimaryButton({ track, preferred, label }: { track: TrackDTO; preferred
|
||||
/** Quieter links for your other services, and Share. */
|
||||
export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
|
||||
const { me } = useSession();
|
||||
const ink = useInk();
|
||||
const chip = [styles.service, ink.hero && styles.serviceHero];
|
||||
const others = viewerServices(me).filter((s) => s !== preferred);
|
||||
return (
|
||||
<View style={styles.services}>
|
||||
@@ -57,17 +59,17 @@ export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred:
|
||||
const { url, exact } = openUrl(track, s);
|
||||
return (
|
||||
<Pressable key={s} onPress={() => open(url)} hitSlop={6} accessibilityRole="link">
|
||||
<Text style={[styles.service, !exact && { opacity: 0.55 }]}>{SERVICE_LABELS[s]}</Text>
|
||||
<Text style={[chip, !exact && { opacity: 0.55 }]}>{SERVICE_LABELS[s]}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{me && (
|
||||
<Pressable onPress={() => router.push(`/send/${track.id}`)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Send to friends">
|
||||
<Text style={[styles.service, styles.share]}>Send ✉</Text>
|
||||
<Text style={[chip, ink.hero ? styles.shareHero : styles.share]}>Send ✉</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable onPress={() => shareTrack(track, me?.username)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Share this song">
|
||||
<Text style={[styles.service, styles.share]}>Share ↗</Text>
|
||||
<Text style={[chip, ink.hero ? styles.shareHero : styles.share]}>Share ↗</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -88,6 +90,7 @@ export function TrackRow({
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const ink = useInk();
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable style={styles.rowMain} onPress={onToggle} accessibilityRole="button" accessibilityState={{ expanded }}>
|
||||
@@ -98,7 +101,7 @@ export function TrackRow({
|
||||
</Text>
|
||||
<Text style={styles.artist} numberOfLines={1}>
|
||||
{track.artist}
|
||||
{expanded && track.album ? <Text style={styles.muted}> · {track.album}</Text> : null}
|
||||
{expanded && track.album ? <Text style={{ color: ink.muted }}> · {track.album}</Text> : null}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -109,7 +112,7 @@ export function TrackRow({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={expanded ? 'Show less' : 'Show more'}
|
||||
style={styles.chevron}>
|
||||
<Text style={[styles.chevronText, expanded && { transform: [{ rotate: '180deg' }] }]}>⌄</Text>
|
||||
<Text style={[styles.chevronText, { color: ink.muted }, expanded && { transform: [{ rotate: '180deg' }] }]}>⌄</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -143,32 +146,29 @@ const styles = StyleSheet.create({
|
||||
track: { flexDirection: 'row', gap: space.md + 2, alignItems: 'flex-start' },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.sm },
|
||||
rowMain: { flex: 1, minWidth: 0, flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||
artwork: {
|
||||
backgroundColor: colors.surface2,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(255,255,255,0.12)',
|
||||
},
|
||||
artworkEmpty: { alignItems: 'center', justifyContent: 'center', backgroundColor: '#3a1a6e' },
|
||||
artwork: { backgroundColor: colors.surface2 },
|
||||
artworkEmpty: { alignItems: 'center', justifyContent: 'center', backgroundColor: '#4b3f78' },
|
||||
note: { color: 'rgba(255,255,255,0.85)' },
|
||||
body: { flex: 1, minWidth: 0, gap: 2 },
|
||||
title: { color: colors.text, fontFamily: fonts.display, fontSize: 16, letterSpacing: -0.2 },
|
||||
titleBig: { fontSize: 18 },
|
||||
artist: { color: colors.text, opacity: 0.85, fontSize: 14 },
|
||||
artist: { color: colors.text, opacity: 0.85, fontSize: 13.5, fontFamily: fonts.body },
|
||||
muted: { color: colors.muted },
|
||||
chevron: { paddingHorizontal: space.xs, alignSelf: 'stretch', justifyContent: 'center' },
|
||||
chevronText: { color: colors.muted, fontSize: 22, lineHeight: 24 },
|
||||
share: { backgroundColor: 'rgba(123,47,247,0.28)', borderColor: 'rgba(123,47,247,0.5)' },
|
||||
share: { backgroundColor: colors.accentSoft, color: '#ffd1e1' },
|
||||
shareHero: { backgroundColor: 'rgba(255,255,255,0.32)' },
|
||||
serviceHero: { backgroundColor: 'rgba(255,255,255,0.2)', color: '#ffffff' },
|
||||
primary: { alignSelf: 'flex-start', marginTop: space.sm },
|
||||
services: { flexDirection: 'row', flexWrap: 'wrap', gap: space.xs + 2, marginTop: space.sm + 2 },
|
||||
service: {
|
||||
color: colors.text,
|
||||
fontSize: 12,
|
||||
paddingHorizontal: space.sm + 2,
|
||||
paddingVertical: 4,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
paddingHorizontal: space.sm + 4,
|
||||
paddingVertical: 5,
|
||||
borderRadius: radius.pill,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(255,255,255,0.12)',
|
||||
backgroundColor: colors.surface2,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ConnectionDTO } from '@musicshare/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { SignInError, openSignupPage, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
||||
import { successHaptic } from '@/lib/haptics';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||
import { Body, Button, Field } from '../ui';
|
||||
import { ErrorText, SetupActions, SetupHeader, type SetupProps } from './common';
|
||||
|
||||
@@ -153,9 +153,9 @@ function Option({ selected, onPress, title, badge, detail }: { selected: boolean
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
option: { borderRadius: radius.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, padding: space.md, gap: space.xs },
|
||||
optionSelected: { borderColor: colors.accent, backgroundColor: 'rgba(124,77,255,0.16)' },
|
||||
optionSelected: { borderColor: colors.accent, backgroundColor: colors.accentSoft },
|
||||
optionHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
optionTitle: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
||||
badge: { color: colors.accent2, fontSize: 12, fontWeight: '700' },
|
||||
small: { fontSize: 13 },
|
||||
optionTitle: { color: colors.text, fontFamily: fonts.display, fontSize: 16 },
|
||||
badge: { color: colors.accent, fontSize: 12, fontFamily: fonts.display },
|
||||
small: { fontSize: 13, fontFamily: fonts.body },
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { colors, fonts, space } from '@/lib/theme';
|
||||
import { Body, Button, Card, Title } from '../ui';
|
||||
|
||||
export interface SetupProps {
|
||||
@@ -55,5 +55,5 @@ export const ErrorText = ({ children }: { children: string | null }) =>
|
||||
children ? <Body style={{ color: colors.danger }}>{children}</Body> : null;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
point: { color: colors.text, fontSize: 14 },
|
||||
point: { color: colors.text, fontSize: 14, fontFamily: fonts.body },
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { forwardRef, useState } from 'react';
|
||||
import { forwardRef, useContext, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
||||
import { tapHaptic } from '@/lib/haptics';
|
||||
import { brandGradient, colors, fonts, radius, space } from '@/lib/theme';
|
||||
import { OnHero, brandGradient, colors, fonts, panelShadow, radius, space } from '@/lib/theme';
|
||||
|
||||
export function Title({ style, ...props }: TextProps) {
|
||||
return <Text style={[styles.title, style]} {...props} />;
|
||||
@@ -65,17 +65,19 @@ export function Button({
|
||||
...props
|
||||
}: PressableProps & { title: string; variant?: Variant; loading?: boolean; small?: boolean; style?: ViewProps['style'] }) {
|
||||
const press = usePressScale();
|
||||
const hero = useContext(OnHero);
|
||||
const inactive = disabled || loading;
|
||||
const handlePress = (e: GestureResponderEvent) => {
|
||||
if (variant === 'primary') tapHaptic();
|
||||
onPress?.(e);
|
||||
};
|
||||
// On the pink hero card a pink button would vanish: primary goes white with pink text instead.
|
||||
const heroPrimary = hero && variant === 'primary';
|
||||
const textColor = heroPrimary ? colors.accent : variant === 'secondary' ? colors.text : colors.accentText;
|
||||
const label = loading ? (
|
||||
<ActivityIndicator color={variant === 'secondary' ? colors.text : colors.accentText} />
|
||||
<ActivityIndicator color={textColor} />
|
||||
) : (
|
||||
<Text style={[styles.buttonText, small && styles.buttonTextSmall, variant !== 'secondary' && { color: colors.accentText }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[styles.buttonText, small && styles.buttonTextSmall, { color: textColor }]}>{title}</Text>
|
||||
);
|
||||
return (
|
||||
<Animated.View style={[press.style, inactive && { opacity: 0.55 }, style]}>
|
||||
@@ -86,9 +88,15 @@ export function Button({
|
||||
onPressIn={press.onPressIn}
|
||||
onPressOut={press.onPressOut}
|
||||
onPress={handlePress}
|
||||
style={[styles.button, small && styles.buttonSmall, variant === 'danger' && styles.buttonDanger, variant === 'primary' && styles.buttonPrimary]}
|
||||
style={[
|
||||
styles.button,
|
||||
small && styles.buttonSmall,
|
||||
variant === 'danger' && styles.buttonDanger,
|
||||
variant === 'primary' && styles.buttonPrimary,
|
||||
hero && (variant === 'primary' ? styles.buttonHeroPrimary : styles.buttonHero),
|
||||
]}
|
||||
{...props}>
|
||||
{variant === 'primary' && (
|
||||
{variant === 'primary' && !hero && (
|
||||
<LinearGradient colors={brandGradient} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={StyleSheet.absoluteFill} />
|
||||
)}
|
||||
{label}
|
||||
@@ -183,20 +191,18 @@ export function Chip({ label, selected, onPress }: { label: string; selected?: b
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: { color: colors.text, fontSize: 30, fontFamily: fonts.display, letterSpacing: -0.6 },
|
||||
heading: { color: colors.text, fontSize: 18, fontFamily: fonts.display, marginBottom: space.xs },
|
||||
body: { color: colors.text, fontSize: 15, lineHeight: 21 },
|
||||
title: { color: colors.text, fontSize: 26, fontFamily: fonts.display, letterSpacing: -0.4 },
|
||||
heading: { color: colors.text, fontSize: 17, fontFamily: fonts.display, marginBottom: space.xs },
|
||||
body: { color: colors.text, fontSize: 14.5, lineHeight: 21, fontFamily: fonts.body },
|
||||
muted: { color: colors.muted },
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: radius.lg,
|
||||
padding: space.lg,
|
||||
padding: space.lg + 2,
|
||||
gap: space.md,
|
||||
overflow: 'hidden',
|
||||
...panelShadow,
|
||||
},
|
||||
cardHighlighted: { borderColor: 'rgba(251,191,36,0.55)', borderWidth: 1 },
|
||||
cardHighlighted: { borderColor: colors.accentLine, borderWidth: 1 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexWrap: 'wrap' },
|
||||
button: {
|
||||
minHeight: 46,
|
||||
@@ -205,51 +211,49 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderStrong,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
buttonSmall: { minHeight: 36, paddingHorizontal: space.md + 2 },
|
||||
buttonPrimary: {
|
||||
borderColor: 'transparent',
|
||||
shadowColor: '#5b4cf0',
|
||||
shadowOpacity: 0.6,
|
||||
shadowColor: colors.accent,
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 14,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
},
|
||||
buttonDanger: { backgroundColor: colors.danger, borderColor: colors.danger },
|
||||
buttonText: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||
buttonHero: { backgroundColor: 'rgba(255,255,255,0.22)' },
|
||||
buttonHeroPrimary: { backgroundColor: '#ffffff', shadowColor: '#7a1e4a', shadowOpacity: 0.25 },
|
||||
buttonDanger: { backgroundColor: colors.danger },
|
||||
buttonText: { color: colors.text, fontFamily: fonts.bodyMedium, fontSize: 14.5 },
|
||||
buttonTextSmall: { fontSize: 13 },
|
||||
label: { color: colors.text, fontWeight: '600', fontSize: 14 },
|
||||
label: { color: colors.text, fontFamily: fonts.bodyMedium, fontSize: 14 },
|
||||
listRow: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: 13, paddingHorizontal: 2, borderRadius: radius.sm },
|
||||
listDivider: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border },
|
||||
listLabel: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||
listValue: { flex: 1, textAlign: 'right', color: colors.muted, fontSize: 14 },
|
||||
listChevron: { color: colors.muted, fontSize: 20, lineHeight: 20 },
|
||||
listLabel: { color: colors.text, fontSize: 14.5, fontFamily: fonts.bodyMedium },
|
||||
listValue: { flex: 1, textAlign: 'right', color: colors.muted, fontSize: 13.5, fontFamily: fonts.body },
|
||||
listChevron: { color: colors.muted, fontSize: 20, lineHeight: 20, fontFamily: fonts.body },
|
||||
reveal: { position: 'absolute', right: 0, top: 0, bottom: 0, justifyContent: 'center', paddingHorizontal: space.md },
|
||||
revealText: { color: colors.accent2, fontWeight: '600', fontSize: 14 },
|
||||
revealText: { color: colors.accent, fontFamily: fonts.bodyMedium, fontSize: 13.5 },
|
||||
input: {
|
||||
minHeight: 48,
|
||||
paddingHorizontal: space.md + 2,
|
||||
paddingVertical: space.sm,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: colors.surface2,
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontSize: 14.5,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
inputFocused: { borderColor: colors.accent, backgroundColor: 'rgba(124,77,255,0.10)' },
|
||||
inputFocused: { borderColor: colors.accentLine, backgroundColor: '#463f62' },
|
||||
chip: {
|
||||
paddingHorizontal: space.md,
|
||||
paddingHorizontal: space.md + 2,
|
||||
paddingVertical: space.sm,
|
||||
borderRadius: radius.pill,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
chipSelected: { borderColor: 'transparent' },
|
||||
chipText: { color: colors.text, fontWeight: '600', fontSize: 13 },
|
||||
chipSelected: {},
|
||||
chipText: { color: colors.text, fontFamily: fonts.bodyMedium, fontSize: 13 },
|
||||
});
|
||||
|
||||
@@ -1,28 +1,62 @@
|
||||
/** Mirrors the web palette (apps/web/src/app/globals.css). Brand colours come from the logo. */
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/**
|
||||
* Mirrors the web palette (apps/web/src/app/globals.css): slate-indigo layers with a pink accent.
|
||||
* Panels are a shade lighter than the page instead of outlined.
|
||||
*/
|
||||
export const colors = {
|
||||
bg: '#07070b',
|
||||
surface: 'rgba(255,255,255,0.05)',
|
||||
surfaceSolid: '#12121a',
|
||||
surface2: 'rgba(255,255,255,0.09)',
|
||||
border: 'rgba(255,255,255,0.10)',
|
||||
borderStrong: 'rgba(255,255,255,0.18)',
|
||||
text: '#f4f3fa',
|
||||
muted: '#9b99ae',
|
||||
accent: '#7c4dff',
|
||||
accent2: '#2f8cff',
|
||||
bg: '#2a2942',
|
||||
surface: '#34334f',
|
||||
surfaceSolid: '#34334f',
|
||||
surface2: '#403e5e',
|
||||
border: 'rgba(255,255,255,0.07)',
|
||||
borderStrong: 'rgba(255,255,255,0.14)',
|
||||
text: '#f4f3fb',
|
||||
muted: '#a19fbd',
|
||||
accent: '#ff4f8b',
|
||||
accent2: '#8b7dff',
|
||||
accentText: '#ffffff',
|
||||
live: '#34d399',
|
||||
danger: '#fb7185',
|
||||
pin: '#fbbf24',
|
||||
/** Pink washes for selected chips, "mine" reactions and focus. */
|
||||
accentSoft: 'rgba(255,79,139,0.18)',
|
||||
accentLine: 'rgba(255,79,139,0.6)',
|
||||
live: '#3ee0a1',
|
||||
danger: '#ff6b81',
|
||||
pin: '#ffc95c',
|
||||
} as const;
|
||||
|
||||
/** The logo's purple-to-blue, for buttons and highlights. */
|
||||
export const brandGradient = ['#7b2ff7', '#5b4cf0', '#2f8cff'] as const;
|
||||
/** Buttons and selected chips: pink into violet. */
|
||||
export const brandGradient = ['#ff4f8b', '#d14fc4', '#8b5cf6'] as const;
|
||||
/** The big "your song" card: pink, lavender, sky blue (like a holographic card). */
|
||||
export const heroGradient = ['#ff4f8b', '#e27ad8', '#a99cf6', '#8fc8ff'] as const;
|
||||
|
||||
export const fonts = {
|
||||
display: 'SpaceGrotesk_700Bold',
|
||||
displayMedium: 'SpaceGrotesk_500Medium',
|
||||
display: 'Poppins_600SemiBold',
|
||||
displayMedium: 'Poppins_500Medium',
|
||||
body: 'Poppins_400Regular',
|
||||
bodyMedium: 'Poppins_500Medium',
|
||||
} as const;
|
||||
|
||||
export const space = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 } as const;
|
||||
export const radius = { sm: 8, md: 12, lg: 20, pill: 999 } as const;
|
||||
export const radius = { sm: 10, md: 14, lg: 24, pill: 999 } as const;
|
||||
|
||||
/** Soft drop shadow for panels (the reference separates layers with shadow, not borders). */
|
||||
export const panelShadow = {
|
||||
shadowColor: '#15142a',
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 4,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Inside the gradient hero card, text and controls switch to white so they read on pink.
|
||||
* Components that draw muted text or filled buttons ask useInk() which to use.
|
||||
*/
|
||||
export const OnHero = createContext(false);
|
||||
|
||||
export function useInk() {
|
||||
const hero = useContext(OnHero);
|
||||
return hero
|
||||
? { hero, text: '#ffffff', muted: 'rgba(255,255,255,0.82)', faint: 'rgba(255,255,255,0.28)', live: '#ffffff' }
|
||||
: { hero, text: colors.text, muted: colors.muted, faint: 'rgba(255,255,255,0.12)', live: colors.live };
|
||||
}
|
||||
|
||||
+171
-111
@@ -1,23 +1,31 @@
|
||||
:root {
|
||||
--bg: #07070b;
|
||||
--surface: rgba(255, 255, 255, 0.045);
|
||||
--surface-solid: #12121a;
|
||||
--surface-2: rgba(255, 255, 255, 0.08);
|
||||
--border: rgba(255, 255, 255, 0.09);
|
||||
--border-strong: rgba(255, 255, 255, 0.16);
|
||||
--text: #f4f3fa;
|
||||
--muted: #9b99ae;
|
||||
/* Brand: the logo's purple-to-blue. */
|
||||
--accent: #7c4dff;
|
||||
--accent-2: #2f8cff;
|
||||
--grad: linear-gradient(135deg, #7b2ff7 0%, #5b4cf0 50%, #2f8cff 100%);
|
||||
/* Slate-indigo layers with a pink accent. Panels are a shade apart instead of outlined. */
|
||||
--bg: #2a2942;
|
||||
--side: #2a2942;
|
||||
--panel: #34334f;
|
||||
--card: #34334f;
|
||||
--surface: var(--card);
|
||||
--surface-solid: #34334f;
|
||||
--surface-2: #403e5e;
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--border-strong: rgba(255, 255, 255, 0.13);
|
||||
--text: #f4f3fb;
|
||||
--muted: #a19fbd;
|
||||
--accent: #ff4f8b;
|
||||
--accent-2: #8b7dff;
|
||||
--accent-soft: rgba(255, 79, 139, 0.16);
|
||||
--accent-line: rgba(255, 79, 139, 0.55);
|
||||
--grad: linear-gradient(135deg, #ff4f8b 0%, #d14fc4 55%, #8b5cf6 100%);
|
||||
/* Your own song's card: pink, lavender, sky blue, like a holographic card. */
|
||||
--hero: linear-gradient(135deg, #ff4f8b 0%, #e27ad8 38%, #a99cf6 70%, #8fc8ff 100%);
|
||||
--accent-text: #fff;
|
||||
--live: #34d399;
|
||||
--pin: #fbbf24;
|
||||
--danger: #fb7185;
|
||||
--radius: 20px;
|
||||
--font-display: var(--font-space-grotesk), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-body: var(--font-inter), ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--live: #3ee0a1;
|
||||
--pin: #ffc95c;
|
||||
--danger: #ff6b81;
|
||||
--radius: 24px;
|
||||
--shadow: 0 18px 40px -22px rgba(14, 12, 32, 0.75);
|
||||
--font-display: var(--font-poppins), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-body: var(--font-poppins), ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
color-scheme: dark;
|
||||
}
|
||||
@@ -29,67 +37,57 @@ body {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.55 var(--font-body);
|
||||
font: 14.5px/1.6 var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Slowly drifting colour behind everything, so the app never feels static. */
|
||||
.ambient { position: fixed; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; }
|
||||
.ambient::before, .ambient::after {
|
||||
content: ''; position: absolute; width: 60vmax; height: 60vmax; border-radius: 50%;
|
||||
filter: blur(90px); opacity: 0.34;
|
||||
}
|
||||
.ambient::before { background: #6d28d9; top: -25vmax; left: -15vmax; animation: drift-a 28s ease-in-out infinite alternate; }
|
||||
.ambient::after { background: #1d6fe0; bottom: -30vmax; right: -20vmax; animation: drift-b 34s ease-in-out infinite alternate; }
|
||||
@keyframes drift-a { to { transform: translate(18vmax, 12vmax) scale(1.15); } }
|
||||
@keyframes drift-b { to { transform: translate(-16vmax, -10vmax) scale(0.9); } }
|
||||
|
||||
a { color: inherit; }
|
||||
h1, h2, .brand, .display { font-family: var(--font-display); letter-spacing: -0.02em; }
|
||||
h1, h2, .brand, .display { font-family: var(--font-display); font-weight: 600; letter-spacing: -0.01em; }
|
||||
h1 { font-size: 1.85rem; margin: 0; line-height: 1.15; }
|
||||
h2 { font-size: 1.1rem; margin: 0 0 0.5rem; }
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none; border-color: var(--accent); background: rgba(124, 77, 255, 0.08);
|
||||
box-shadow: 0 0 0 4px rgba(124, 77, 255, 0.2);
|
||||
outline: none; border-color: var(--accent-line); background: #463f62;
|
||||
box-shadow: 0 0 0 4px var(--accent-soft);
|
||||
}
|
||||
|
||||
.container { max-width: 680px; margin: 0 auto; padding: 1.75rem 1rem 5rem; }
|
||||
.stack { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.service-name { display: flex; align-items: center; gap: 0.6rem; font-family: var(--font-display); font-size: 1.12rem; font-weight: 700; margin-bottom: 0.2rem; }
|
||||
.service-name { display: flex; align-items: center; gap: 0.6rem; font-family: var(--font-display); font-size: 1.05rem; font-weight: 600; margin-bottom: 0.2rem; }
|
||||
.service-badge { width: 1.7rem; height: 1.7rem; border-radius: 0.5rem; display: grid; place-items: center; color: #fff; font-size: 0.95rem; border: 1px solid rgba(255, 255, 255, 0.25); flex-shrink: 0; }
|
||||
.chip-small { display: inline-block; margin-left: 0.5rem; padding: 0.05rem 0.5rem; border-radius: 999px; background: rgba(255, 255, 255, 0.1); font-size: 0.75rem; color: var(--text); }
|
||||
.stats a { color: inherit; text-decoration: none; }
|
||||
.stats a:hover strong { text-decoration: underline; }
|
||||
.menu { position: relative; }
|
||||
.menu-list { position: absolute; right: 0; top: calc(100% + 0.35rem); z-index: 20; min-width: 12rem; display: flex; flex-direction: column; padding: 0.35rem; border-radius: 0.8rem; background: #1a1535; border: 1px solid rgba(255, 255, 255, 0.12); box-shadow: 0 18px 40px -12px rgba(0, 0, 0, 0.6); }
|
||||
.menu-list { position: absolute; right: 0; top: calc(100% + 0.35rem); z-index: 20; min-width: 12rem; display: flex; flex-direction: column; padding: 0.35rem; border-radius: 16px; background: var(--surface-2); box-shadow: var(--shadow); }
|
||||
.menu-list button { all: unset; cursor: pointer; padding: 0.55rem 0.75rem; border-radius: 0.55rem; font-size: 0.92rem; }
|
||||
.menu-list button:hover, .menu-list button:focus-visible { background: rgba(255, 255, 255, 0.08); }
|
||||
.menu-list .danger { color: var(--danger); }
|
||||
.modal { max-width: 26rem; width: calc(100% - 2rem); color: var(--text); background: #15112c; }
|
||||
.modal::backdrop { background: rgba(5, 3, 15, 0.7); backdrop-filter: blur(4px); }
|
||||
.modal { max-width: 26rem; width: calc(100% - 2rem); color: var(--text); background: var(--panel); border: 0; }
|
||||
.modal::backdrop { background: rgba(20, 18, 40, 0.65); backdrop-filter: blur(4px); }
|
||||
.modal p { margin-bottom: 1rem; }
|
||||
.modal .form p { margin: 0; }
|
||||
.pick-list { max-height: 45vh; overflow-y: auto; }
|
||||
.pick-list .checkbox { display: flex; align-items: center; gap: 0.6rem; cursor: pointer; font-weight: 500; }
|
||||
.bell {
|
||||
position: relative; display: grid; place-items: center; width: 38px; height: 38px; border-radius: 999px; margin-left: auto; margin-right: 1rem;
|
||||
color: var(--muted); background: rgba(255, 255, 255, 0.06); transition: color 0.2s, background 0.2s;
|
||||
color: var(--text); background: var(--surface-2); transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
.bell:hover { color: var(--text); background: rgba(255, 255, 255, 0.12); }
|
||||
.bell:hover { color: var(--accent); background: #4a4868; }
|
||||
.bell-badge {
|
||||
position: absolute; top: -3px; right: -5px; min-width: 18px; height: 18px; padding: 0 4px; border-radius: 9px;
|
||||
background: var(--danger); color: #fff; font-size: 0.68rem; font-weight: 700; display: grid; place-items: center;
|
||||
background: var(--accent); color: #fff; font-size: 0.68rem; font-weight: 600; display: grid; place-items: center;
|
||||
}
|
||||
.notifications { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.notification { display: flex; align-items: center; gap: 0.8rem; padding: 0.7rem 0.8rem; border-radius: 14px; text-decoration: none; color: var(--text); }
|
||||
@@ -103,12 +101,12 @@ input:focus, textarea:focus, select:focus {
|
||||
.people .user-chip { min-width: 0; }
|
||||
.admin-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: 0.75rem; }
|
||||
.admin-stat { text-align: center; padding: 1rem; }
|
||||
.admin-stat-value { font-family: var(--font-display); font-size: 1.8rem; font-weight: 700; }
|
||||
.admin-stat-value { font-family: var(--font-display); font-size: 1.8rem; font-weight: 600; }
|
||||
.reports { list-style: none; padding: 0; margin: 0.75rem 0 0; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.report { padding: 0.85rem; border-radius: 0.8rem; background: rgba(255, 255, 255, 0.04); display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.report { padding: 0.85rem; border-radius: 16px; background: var(--surface-2); display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.report p { margin: 0; }
|
||||
.chip-small.danger { background: rgba(251, 113, 133, 0.18); color: var(--danger); }
|
||||
.banner { max-width: 680px; margin: 0 auto 0.5rem; padding: 0.6rem 1rem; border-radius: 0.8rem; background: rgba(123, 47, 247, 0.16); border: 1px solid rgba(123, 47, 247, 0.35); font-size: 0.9rem; }
|
||||
.banner { max-width: 680px; margin: 0 auto 0.5rem; padding: 0.6rem 1rem; border-radius: 0.8rem; background: var(--accent-soft); border: 1px solid var(--accent-line); font-size: 0.9rem; }
|
||||
.banner .link-button { font-size: inherit; color: var(--text); text-decoration: underline; }
|
||||
.footer { max-width: 680px; margin: 0 auto; padding: 0 1rem 2.5rem; display: flex; gap: 1.25rem; justify-content: center; font-size: 0.85rem; }
|
||||
.footer a { color: var(--muted); text-decoration: none; }
|
||||
@@ -122,11 +120,11 @@ input:focus, textarea:focus, select:focus {
|
||||
.song-art { width: 100%; max-width: 360px; aspect-ratio: 1; object-fit: cover; border-radius: 1.1rem; align-self: center; box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.8); }
|
||||
.reactions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; margin-top: 0.7rem; position: relative; }
|
||||
.recent .reactions { margin: 0.25rem 0 0.4rem 3.4rem; }
|
||||
.reaction { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.55rem; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.14); background: rgba(255, 255, 255, 0.07); color: var(--text); font: inherit; font-size: 0.9rem; cursor: pointer; transition: transform 0.15s, background 0.2s; }
|
||||
.reaction:hover:not(:disabled) { transform: translateY(-1px); background: rgba(255, 255, 255, 0.13); }
|
||||
.reaction { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.6rem; border-radius: 999px; border: 1px solid transparent; background: rgba(255, 255, 255, 0.1); color: var(--text); font: inherit; font-size: 0.9rem; cursor: pointer; transition: transform 0.15s, background 0.2s; }
|
||||
.reaction:hover:not(:disabled) { transform: translateY(-1px); background: rgba(255, 255, 255, 0.16); }
|
||||
.reaction:active:not(:disabled) { transform: scale(0.9); }
|
||||
.reaction:disabled { cursor: default; }
|
||||
.reaction.mine { border-color: rgba(123, 47, 247, 0.7); background: rgba(123, 47, 247, 0.28); }
|
||||
.reaction.mine { border-color: var(--accent-line); background: var(--accent-soft); }
|
||||
.reaction-emoji { font-size: 1.05rem; line-height: 1.3; }
|
||||
.reaction-count { font-size: 0.78rem; font-variant-numeric: tabular-nums; }
|
||||
.reaction-add { color: var(--muted); font-size: 1rem; }
|
||||
@@ -150,10 +148,10 @@ a.reaction { text-decoration: none; }
|
||||
@media (max-width: 520px) { .comment-actions { opacity: 1; } }
|
||||
.reaction-add span { font-size: 0.75rem; margin-left: -0.15rem; }
|
||||
.reaction-add-wrap { position: static; } /* the quick menu anchors to the row, so it never runs off the card */
|
||||
.reaction-quick { position: absolute; bottom: calc(100% + 0.4rem); left: 0; z-index: 30; display: flex; align-items: center; gap: 0.15rem; padding: 0.3rem; border-radius: 999px; background: #1a1535; border: 1px solid rgba(255, 255, 255, 0.14); box-shadow: 0 14px 30px -10px rgba(0, 0, 0, 0.7); animation: pop 0.18s ease-out; white-space: nowrap; }
|
||||
.reaction-quick { position: absolute; bottom: calc(100% + 0.4rem); left: 0; z-index: 30; display: flex; align-items: center; gap: 0.15rem; padding: 0.3rem; border-radius: 999px; background: var(--surface-2); box-shadow: var(--shadow); animation: pop 0.18s ease-out; white-space: nowrap; }
|
||||
.reaction-quick button { all: unset; cursor: pointer; font-size: 1.45rem; padding: 0.2rem 0.3rem; border-radius: 999px; transition: transform 0.15s; }
|
||||
.reaction-quick button:hover { transform: scale(1.25); }
|
||||
.reaction-quick button.mine { background: rgba(123, 47, 247, 0.35); }
|
||||
.reaction-quick button.mine { background: var(--accent-soft); }
|
||||
.reaction-quick .reaction-more { font-size: 0.85rem; color: var(--muted); padding: 0.3rem 0.6rem; }
|
||||
@keyframes pop { from { opacity: 0; transform: translateY(6px) scale(0.92); } to { opacity: 1; transform: none; } }
|
||||
.emoji-search { width: 100%; margin-bottom: 0.6rem; }
|
||||
@@ -167,8 +165,8 @@ a.reaction { text-decoration: none; }
|
||||
.list { display: flex; flex-direction: column; margin: 0.25rem -0.4rem 0; }
|
||||
.list-row { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 0.75rem; padding: 0.8rem 0.4rem; border-top: 1px solid var(--border); cursor: pointer; border-radius: 0.5rem; transition: background 0.15s; }
|
||||
.list-row:first-child { border-top: 0; }
|
||||
.list-row:hover, .list-row:focus-visible { background: rgba(255, 255, 255, 0.05); }
|
||||
.list-label { font-weight: 600; flex-shrink: 0; }
|
||||
.list-row:hover, .list-row:focus-visible { background: rgba(255, 255, 255, 0.04); }
|
||||
.list-label { font-weight: 500; flex-shrink: 0; }
|
||||
.list-value { flex: 1; min-width: 0; text-align: right; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.list-chevron { color: var(--muted); font-size: 1.2rem; line-height: 1; transition: transform 0.2s; margin-left: auto; }
|
||||
.list-value + .list-chevron { margin-left: 0; }
|
||||
@@ -186,25 +184,22 @@ a.reaction { text-decoration: none; }
|
||||
.grow { flex: 1; min-width: 0; }
|
||||
.muted { color: var(--muted); }
|
||||
.error { color: var(--danger); margin: 0; }
|
||||
.notice {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 0.85rem 1rem; margin: 0;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.section-label { margin: 0; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); font-weight: 700; }
|
||||
.notice { background: var(--card); border-radius: 18px; padding: 0.9rem 1.1rem; margin: 0; box-shadow: var(--shadow); }
|
||||
.section-label { margin: 0; font-size: 0.85rem; color: var(--muted); font-weight: 500; }
|
||||
.narrow { max-width: 420px; margin: 2.5rem auto; }
|
||||
|
||||
.nav {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.9rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; background: rgba(7, 7, 11, 0.65); backdrop-filter: blur(18px) saturate(1.4); z-index: 10;
|
||||
position: sticky; top: 0; background: rgba(42, 41, 66, 0.85); backdrop-filter: blur(18px) saturate(1.4); z-index: 10;
|
||||
}
|
||||
.brand { font-weight: 700; text-decoration: none; font-size: 1.2rem; display: inline-flex; align-items: center; gap: 0.5rem; }
|
||||
.brand-logo { width: 30px; height: 30px; border-radius: 8px; box-shadow: 0 4px 14px -4px rgba(47, 140, 255, 0.6); }
|
||||
.brand-logo { width: 30px; height: 30px; border-radius: 8px; box-shadow: 0 4px 14px -4px rgba(255, 79, 139, 0.6); }
|
||||
.nav-links { display: flex; gap: 1.1rem; align-items: center; }
|
||||
.nav-links a { text-decoration: none; color: var(--muted); transition: color 0.2s; }
|
||||
.nav-links a:hover { color: var(--text); }
|
||||
.nav-links a.btn-primary { color: var(--accent-text); }
|
||||
.nav-links a.active { color: var(--text); font-weight: 600; }
|
||||
.nav-links a.active { color: var(--accent); font-weight: 500; }
|
||||
/* Phones: main places live in a bottom tab bar, like a native app. */
|
||||
.tabbar { display: none; }
|
||||
@media (max-width: 640px) {
|
||||
@@ -215,41 +210,40 @@ a.reaction { text-decoration: none; }
|
||||
.tabbar {
|
||||
display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||
padding: 0.35rem 0.5rem calc(0.35rem + env(safe-area-inset-bottom));
|
||||
background: rgba(7, 7, 11, 0.82); backdrop-filter: blur(18px) saturate(1.4); border-top: 1px solid var(--border);
|
||||
background: rgba(42, 41, 66, 0.92); backdrop-filter: blur(18px) saturate(1.4); border-top: 1px solid var(--border);
|
||||
}
|
||||
.tabbar a {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0.15rem; padding: 0.35rem 0;
|
||||
color: var(--muted); text-decoration: none; font-size: 0.7rem; font-weight: 600; transition: color 0.2s;
|
||||
}
|
||||
.tabbar a.active { color: var(--text); }
|
||||
.tabbar a.active svg { color: #8b9dff; }
|
||||
.tabbar a.active { color: var(--accent); }
|
||||
body:has(.tabbar) .container { padding-bottom: 6.5rem; }
|
||||
body:has(.tabbar) .footer { padding-bottom: 6rem; }
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
|
||||
padding: 0.55rem 1.05rem; border-radius: 999px; border: 1px solid var(--border-strong);
|
||||
background: var(--surface-2); color: var(--text); font: inherit; font-weight: 600;
|
||||
padding: 0.55rem 1.1rem; border-radius: 999px; border: 1px solid transparent;
|
||||
background: var(--surface-2); color: var(--text); font: inherit; font-weight: 500;
|
||||
text-decoration: none; cursor: pointer; white-space: nowrap;
|
||||
transition: transform 0.25s var(--spring), border-color 0.2s, background 0.2s, box-shadow 0.25s, filter 0.2s;
|
||||
}
|
||||
.btn:hover { border-color: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.12); transform: translateY(-1px); }
|
||||
.btn:hover { background: #4a4868; transform: translateY(-1px); }
|
||||
.btn:active { transform: scale(0.96); }
|
||||
.btn:disabled { opacity: 0.55; cursor: default; transform: none; }
|
||||
.btn-primary {
|
||||
background: var(--grad); border-color: transparent; color: var(--accent-text);
|
||||
box-shadow: 0 6px 22px -6px rgba(91, 76, 240, 0.6);
|
||||
box-shadow: 0 8px 22px -8px rgba(255, 79, 139, 0.7);
|
||||
}
|
||||
.btn-primary:hover { background: var(--grad); filter: brightness(1.08); box-shadow: 0 10px 28px -6px rgba(47, 140, 255, 0.65); }
|
||||
.btn-primary:hover { background: var(--grad); filter: brightness(1.08); box-shadow: 0 12px 28px -8px rgba(255, 79, 139, 0.75); }
|
||||
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.btn-small { padding: 0.3rem 0.75rem; font-size: 0.82rem; }
|
||||
.link-button { background: none; border: 0; color: var(--muted); font: inherit; cursor: pointer; padding: 0; transition: color 0.2s; }
|
||||
.link-button:hover { color: var(--text); }
|
||||
|
||||
.card {
|
||||
position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 1.1rem; backdrop-filter: blur(20px) saturate(1.3); overflow: hidden;
|
||||
position: relative; background: var(--card); border-radius: var(--radius);
|
||||
padding: 1.25rem; box-shadow: var(--shadow);
|
||||
}
|
||||
.card-header { position: relative; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.85rem; }
|
||||
.card-header .user-chip { flex-shrink: 0; }
|
||||
@@ -263,45 +257,46 @@ a.reaction { text-decoration: none; }
|
||||
.services-picker legend { font-weight: 600; font-size: 0.9rem; padding: 0; margin-bottom: 0.35rem; }
|
||||
.form label.checkbox { flex-direction: row; align-items: center; gap: 0.5rem; font-weight: 500; }
|
||||
|
||||
/* Now-playing cards take their colour from the album art. */
|
||||
.np-card { border-color: rgba(255, 255, 255, 0.11); padding: 0.9rem 1rem; }
|
||||
.np-card .card-header { margin-bottom: 0.65rem; }
|
||||
.np-card .reactions { margin-top: 0.55rem; }
|
||||
.np-card.pinned { border-color: rgba(251, 191, 36, 0.45); box-shadow: 0 0 0 1px rgba(251, 191, 36, 0.15), 0 18px 40px -20px rgba(251, 191, 36, 0.35); }
|
||||
.np-backdrop {
|
||||
position: absolute; inset: -40%; z-index: 0; background-size: cover; background-position: center;
|
||||
filter: blur(40px) saturate(2.2) brightness(1.1); opacity: 0.6; transform: scale(1.1); transition: opacity 0.6s;
|
||||
}
|
||||
.np-card.playing .np-backdrop { animation: breathe 9s ease-in-out infinite alternate; }
|
||||
@keyframes breathe { to { transform: scale(1.3) rotate(8deg); opacity: 0.75; } }
|
||||
.np-card::after {
|
||||
content: ''; position: absolute; inset: 0; z-index: 0;
|
||||
background: linear-gradient(180deg, rgba(7, 7, 11, 0.05), rgba(7, 7, 11, 0.45));
|
||||
}
|
||||
.np-card > *:not(.np-backdrop) { position: relative; z-index: 1; }
|
||||
.np-card { padding: 1rem 1.15rem; border: 1px solid transparent; }
|
||||
.np-card .card-header { margin-bottom: 0.7rem; }
|
||||
.np-card .reactions { margin-top: 0.6rem; }
|
||||
.np-card.pinned { border-color: var(--accent-line); }
|
||||
|
||||
/* Your own song: the gradient "hero" card, with everything on it in white. */
|
||||
.np-hero { background: var(--hero); overflow: hidden; box-shadow: 0 24px 50px -24px rgba(255, 79, 139, 0.75); color: #fff; }
|
||||
.np-hero .muted, .np-hero .status, .np-hero .status-live, .np-hero .progress-times, .np-hero .lyrics-note, .np-hero .lyrics-source { color: rgba(255, 255, 255, 0.85); }
|
||||
.np-hero .status-live { font-weight: 600; color: #fff; }
|
||||
.np-hero .progress-track { background: rgba(255, 255, 255, 0.3); }
|
||||
.np-hero .progress-fill { background: #fff; }
|
||||
.np-hero .btn-primary { background: #fff; color: var(--accent); box-shadow: 0 8px 20px -10px rgba(122, 30, 74, 0.5); }
|
||||
.np-hero .btn-primary:hover { background: #fff; filter: none; }
|
||||
.np-hero .btn, .np-hero .service-link, .np-hero .chevron, .np-hero .reaction { background: rgba(255, 255, 255, 0.24); color: #fff; }
|
||||
.np-hero .chevron:hover, .np-hero .service-link:hover, .np-hero .reaction:hover:not(:disabled) { background: rgba(255, 255, 255, 0.36); color: #fff; }
|
||||
.np-hero .track-artist, .np-hero a { color: #fff; }
|
||||
.np-hero .artwork { box-shadow: 0 12px 26px -12px rgba(80, 20, 70, 0.6); }
|
||||
|
||||
.pin {
|
||||
background: none; border: 0; cursor: pointer; font-size: 1.25rem; line-height: 1; color: var(--muted);
|
||||
padding: 0.15rem 0.25rem; transition: color 0.2s;
|
||||
}
|
||||
.pin:hover, .pin.pinned { color: var(--pin); }
|
||||
.pin.pinned { text-shadow: 0 0 14px rgba(251, 191, 36, 0.6); }
|
||||
.pin:hover, .pin.pinned { color: var(--accent); }
|
||||
.pin.pinned { text-shadow: 0 0 14px rgba(255, 79, 139, 0.6); }
|
||||
|
||||
.user-chip { display: inline-flex; align-items: center; gap: 0.55rem; text-decoration: none; font-weight: 600; }
|
||||
.user-chip { display: inline-flex; align-items: center; gap: 0.55rem; text-decoration: none; font-weight: 500; }
|
||||
.user-chip:hover .user-name { text-decoration: underline; }
|
||||
.avatar { border-radius: 50%; object-fit: cover; flex-shrink: 0; display: block; }
|
||||
.avatar-empty { display: grid; place-items: center; background: linear-gradient(135deg, #4a1278, #1872cf); color: #fff; font-weight: 700; font-family: var(--font-display); }
|
||||
.avatar-empty { display: grid; place-items: center; background: linear-gradient(135deg, #ff4f8b, #8b5cf6); color: #fff; font-weight: 600; font-family: var(--font-display); }
|
||||
/* A spinning gradient ring while someone is listening. */
|
||||
.avatar-ring { position: relative; display: inline-grid; place-items: center; border-radius: 50%; padding: 2px; flex-shrink: 0; }
|
||||
.avatar-ring.live::before {
|
||||
content: ''; position: absolute; inset: 0; border-radius: 50%;
|
||||
background: conic-gradient(from 0deg, #7b2ff7, #2f8cff, #34d399, #7b2ff7); animation: spin 3.5s linear infinite;
|
||||
background: conic-gradient(from 0deg, #ff4f8b, #8b7dff, #3ee0a1, #ff4f8b); animation: spin 3.5s linear infinite;
|
||||
}
|
||||
.avatar-ring > * { position: relative; border: 2px solid var(--bg); border-radius: 50%; }
|
||||
.avatar-ring > * { position: relative; border: 2px solid var(--card); border-radius: 50%; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.status { font-size: 0.85rem; color: var(--muted); display: inline-flex; align-items: center; gap: 0.45rem; }
|
||||
.status-live { color: var(--live); font-weight: 600; }
|
||||
.status-live { color: var(--live); font-weight: 500; }
|
||||
|
||||
/* Equalizer bars: dancing while playing, resting when paused. */
|
||||
.eq { display: inline-flex; align-items: flex-end; gap: 2px; height: 14px; }
|
||||
@@ -314,15 +309,15 @@ a.reaction { text-decoration: none; }
|
||||
|
||||
.track { display: flex; gap: 1rem; align-items: flex-start; }
|
||||
.artwork {
|
||||
width: 112px; height: 112px; border-radius: 14px; object-fit: cover; flex-shrink: 0; background: var(--surface-2);
|
||||
box-shadow: 0 14px 30px -12px rgba(0, 0, 0, 0.75), 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
width: 112px; height: 112px; border-radius: 16px; object-fit: cover; flex-shrink: 0; background: var(--surface-2);
|
||||
box-shadow: 0 14px 30px -14px rgba(10, 8, 30, 0.8);
|
||||
transition: transform 0.4s var(--spring);
|
||||
}
|
||||
.track:hover .artwork { transform: scale(1.04) rotate(-1deg); }
|
||||
.artwork-empty { display: grid; place-items: center; font-size: 2rem; color: rgba(255, 255, 255, 0.85); background: linear-gradient(135deg, #4a1278, #1872cf); }
|
||||
.artwork-empty { display: grid; place-items: center; font-size: 2rem; color: rgba(255, 255, 255, 0.85); background: linear-gradient(135deg, #4b3f78, #6a4c9c); }
|
||||
.artwork-sm { width: 44px; height: 44px; border-radius: 9px; object-fit: cover; flex-shrink: 0; box-shadow: 0 6px 14px -8px rgba(0, 0, 0, 0.8); }
|
||||
.track-body { min-width: 0; flex: 1; }
|
||||
.track-title { font-family: var(--font-display); font-weight: 700; font-size: 1.15rem; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.track-title { font-family: var(--font-display); font-weight: 600; font-size: 1.15rem; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.track-artist { color: var(--text); opacity: 0.82; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.progress { margin-top: 0.85rem; }
|
||||
.track-row { display: flex; align-items: center; gap: 0.6rem; }
|
||||
@@ -336,9 +331,9 @@ a.reaction { text-decoration: none; }
|
||||
.track-row:hover .artwork { transform: scale(1.04) rotate(-1deg); }
|
||||
.chevron {
|
||||
display: grid; place-items: center; width: 32px; height: 32px; flex-shrink: 0; border-radius: 999px; border: 0; cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.06); color: var(--muted); transition: transform 0.3s var(--spring), color 0.2s, background 0.2s;
|
||||
background: var(--surface-2); color: var(--muted); transition: transform 0.3s var(--spring), color 0.2s, background 0.2s;
|
||||
}
|
||||
.chevron:hover { color: var(--text); background: rgba(255, 255, 255, 0.12); }
|
||||
.chevron:hover { color: var(--text); background: #4a4868; }
|
||||
.chevron.open { transform: rotate(180deg); }
|
||||
.np-card .progress { margin-top: 0.65rem; }
|
||||
.np-details { overflow: hidden; }
|
||||
@@ -348,37 +343,37 @@ a.reaction { text-decoration: none; }
|
||||
.lyric { margin: 0; line-height: 1.45; transition: opacity 0.3s, font-size 0.3s; }
|
||||
.lyric-past { opacity: 0.4; }
|
||||
.lyric-next { opacity: 0.65; }
|
||||
.lyric-now { font-family: var(--font-display); font-weight: 700; font-size: 1.08rem; }
|
||||
.lyric-now { font-family: var(--font-display); font-weight: 600; font-size: 1.08rem; }
|
||||
.lyrics-note { margin: 0.9rem 0 0; font-size: 0.85rem; }
|
||||
.lyrics-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 0.5rem; font-size: 0.82rem; }
|
||||
.lyrics-source { margin-left: auto; font-size: 0.72rem; }
|
||||
.progress-track { height: 4px; border-radius: 999px; background: rgba(255, 255, 255, 0.14); overflow: hidden; }
|
||||
.progress-track { height: 4px; border-radius: 999px; background: rgba(255, 255, 255, 0.12); overflow: hidden; }
|
||||
.progress-fill { height: 100%; border-radius: inherit; background: var(--grad); transition: width 1s linear; }
|
||||
.progress-times { display: flex; justify-content: space-between; margin-top: 0.3rem; font-size: 0.75rem; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
@media (prefers-reduced-motion: reduce) { .progress-fill { transition: none; } }
|
||||
.track-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin-top: 0.8rem; }
|
||||
.service-link {
|
||||
font-size: 0.76rem; color: var(--text); text-decoration: none; padding: 0.2rem 0.55rem; border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.08); transition: background 0.2s, transform 0.25s var(--spring);
|
||||
font-size: 0.76rem; color: var(--text); text-decoration: none; padding: 0.25rem 0.7rem; border-radius: 999px;
|
||||
background: var(--surface-2); transition: background 0.2s, transform 0.25s var(--spring);
|
||||
}
|
||||
.service-link:hover { background: rgba(255, 255, 255, 0.16); transform: translateY(-1px); }
|
||||
.service-link:hover { background: #4a4868; transform: translateY(-1px); }
|
||||
.service-link.inexact { opacity: 0.55; }
|
||||
|
||||
.profile-header { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 0.75rem; padding: 0.5rem 0 0.75rem; }
|
||||
.profile-header h1 { font-size: 2.1rem; }
|
||||
.profile-header .bio { margin: 0; max-width: 30rem; }
|
||||
.stats { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; }
|
||||
.stats span { padding: 0.3rem 0.8rem; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--border); color: var(--muted); font-size: 0.88rem; }
|
||||
.stats span { padding: 0.3rem 0.9rem; border-radius: 999px; background: var(--surface-2); color: var(--muted); font-size: 0.88rem; }
|
||||
.stats strong { color: var(--text); font-family: var(--font-display); }
|
||||
.featured blockquote {
|
||||
margin: 0.9rem 0 0; padding: 0.6rem 0.9rem; border-left: 3px solid transparent; font-style: italic;
|
||||
border-image: var(--grad) 1; background: rgba(255, 255, 255, 0.04);
|
||||
border-image: var(--grad) 1; background: var(--surface-2);
|
||||
}
|
||||
|
||||
.search { position: relative; }
|
||||
.search-results { list-style: none; margin: 0.5rem 0 0; padding: 0.4rem; background: var(--surface-solid); border: 1px solid var(--border); border-radius: 14px; }
|
||||
.search-results { list-style: none; margin: 0.5rem 0 0; padding: 0.4rem; background: var(--surface-2); border-radius: 16px; box-shadow: var(--shadow); position: absolute; left: 0; right: 0; z-index: 25; }
|
||||
.search-results li { padding: 0.4rem 0.45rem; border-radius: 10px; transition: background 0.15s; }
|
||||
.search-results li:hover { background: var(--surface-2); }
|
||||
.search-results li:hover { background: #4a4868; }
|
||||
.result { display: flex; gap: 0.6rem; align-items: center; background: none; border: 0; color: inherit; font: inherit; cursor: pointer; width: 100%; text-align: left; padding: 0; }
|
||||
.recent { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
||||
.recent-row { display: flex; align-items: center; gap: 0.8rem; padding: 0.5rem 0.4rem; text-decoration: none; border-radius: 12px; transition: background 0.15s, transform 0.25s var(--spring); }
|
||||
@@ -386,11 +381,11 @@ a.recent-row:hover { background: var(--surface-2); transform: translateX(3px); }
|
||||
.recent li + li { border-top: 1px solid var(--border); }
|
||||
.recent-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.recent-text > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.recent-title { font-weight: 600; }
|
||||
.recent-title { font-weight: 500; }
|
||||
.recent-time { font-size: 0.8rem; white-space: nowrap; }
|
||||
.artwork-sm.artwork-empty { display: grid; place-items: center; background: var(--surface-2); color: var(--muted); }
|
||||
.qr-card { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; text-align: center; }
|
||||
.qr { border-radius: 14px; box-shadow: 0 20px 50px -20px rgba(47, 140, 255, 0.6); }
|
||||
.qr { border-radius: 16px; box-shadow: 0 20px 50px -20px rgba(255, 79, 139, 0.6); }
|
||||
.credit { margin: 0.6rem 0 0; font-size: 0.8rem; }
|
||||
.picked { display: flex; gap: 0.75rem; align-items: center; }
|
||||
|
||||
@@ -411,12 +406,12 @@ a.recent-row:hover { background: var(--surface-2); transform: translateX(3px); }
|
||||
.hero { padding: 4.5rem 0 3rem; display: flex; flex-direction: column; gap: 1.25rem; }
|
||||
.hero h1 { font-size: 3.2rem; letter-spacing: -0.04em; line-height: 1.02; }
|
||||
.gradient-text {
|
||||
background: linear-gradient(90deg, #a78bfa, #7dd3fc, #60a5fa, #a78bfa); background-size: 300% 100%;
|
||||
background: linear-gradient(90deg, #ff4f8b, #e27ad8, #8fc8ff, #ff4f8b); background-size: 300% 100%;
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent; animation: shimmer 8s linear infinite;
|
||||
}
|
||||
@keyframes shimmer { to { background-position: 300% 0; } }
|
||||
.hero p { color: var(--muted); font-size: 1.15rem; margin: 0; max-width: 34rem; }
|
||||
.hero .eq { height: 42px; gap: 5px; color: #8b9dff; }
|
||||
.hero .eq { height: 42px; gap: 5px; color: var(--accent); }
|
||||
.hero .eq span { width: 8px; border-radius: 4px; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -428,3 +423,68 @@ a.recent-row:hover { background: var(--surface-2); transform: translateX(3px); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
/* Signed in on wide screens: sidebar | page in a rounded panel | your column (components/Shell.tsx). */
|
||||
.sidebar, .rail { display: none; }
|
||||
@media (min-width: 900px) {
|
||||
body:has(.shell) .nav, body:has(.shell) .tabbar { display: none; }
|
||||
.shell { display: grid; grid-template-columns: 232px minmax(0, 1fr); min-height: 100vh; }
|
||||
.shell-main { min-width: 0; padding: 1rem 1rem 0 0; display: flex; flex-direction: column; }
|
||||
/* The page sits in a lighter panel; cards inside it are a shade darker, like the reference. */
|
||||
.main-panel { --card: #2c2b45; --surface-2: #3b395a; flex: 1; background: var(--panel); border-radius: 36px; padding: 0.5rem 0; }
|
||||
.main-panel .container { max-width: 760px; padding: 2rem 2rem 3rem; }
|
||||
.shell .footer { padding: 1.25rem 1rem 1.5rem; }
|
||||
|
||||
.sidebar { display: flex; flex-direction: column; gap: 2rem; position: sticky; top: 0; height: 100vh; padding: 2.2rem 1.1rem 1.6rem; }
|
||||
.sidebar-brand { font-family: var(--font-display); font-weight: 600; font-size: 1.45rem; text-decoration: none; color: #b7b4ff; padding-left: 0.9rem; letter-spacing: -0.01em; }
|
||||
.sidebar-brand span { color: var(--accent); }
|
||||
.sidebar-nav { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.sidebar-nav a, .sidebar-logout {
|
||||
display: flex; align-items: center; gap: 0.85rem; padding: 0.75rem 0.95rem; border-radius: 16px; text-decoration: none;
|
||||
color: var(--muted); font-weight: 400; transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.sidebar-nav a:hover { color: var(--text); background: rgba(255, 255, 255, 0.04); }
|
||||
.sidebar-nav a.active { color: var(--accent); background: var(--panel); font-weight: 500; }
|
||||
.sidebar-badge { margin-left: auto; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; background: var(--accent); color: #fff; font-size: 0.7rem; font-weight: 600; display: grid; place-items: center; }
|
||||
.sidebar-logout { margin-top: auto; background: none; border: 0; font: inherit; font-size: 0.88rem; cursor: pointer; }
|
||||
.sidebar-logout:hover { color: var(--text); }
|
||||
}
|
||||
@media (min-width: 1240px) {
|
||||
.shell { grid-template-columns: 232px minmax(0, 1fr) 330px; }
|
||||
.shell-main { padding-right: 0; }
|
||||
.rail { display: flex; flex-direction: column; gap: 1.6rem; position: sticky; top: 0; height: 100vh; overflow-y: auto; padding: 2rem 1.6rem; }
|
||||
.rail-me { display: flex; align-items: center; gap: 0.75rem; padding-bottom: 1.3rem; border-bottom: 1px solid var(--border); }
|
||||
.rail-me .bell { margin: 0 0 0 auto; }
|
||||
.rail-me-link { display: flex; align-items: center; gap: 0.75rem; min-width: 0; text-decoration: none; }
|
||||
.rail-me-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.35; }
|
||||
.rail-me-text strong { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rail-me-text .muted { font-size: 0.8rem; }
|
||||
.rail-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; }
|
||||
.rail-head h2 { margin: 0; font-size: 1.1rem; }
|
||||
.pill-link { font-size: 0.78rem; color: var(--muted); text-decoration: none; padding: 0.3rem 0.8rem; border-radius: 999px; background: var(--panel); }
|
||||
.pill-link:hover { color: var(--text); }
|
||||
.rail-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
||||
.rail-list li + li { border-top: 1px solid var(--border); }
|
||||
.rail-row { display: flex; align-items: center; gap: 0.8rem; padding: 0.75rem 0.2rem; text-decoration: none; border-radius: 12px; transition: background 0.15s; }
|
||||
.rail-row:hover { background: rgba(255, 255, 255, 0.03); }
|
||||
.rail-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.35; }
|
||||
.rail-row-text > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rail-row-title { font-size: 0.88rem; }
|
||||
.rail-row-text .muted { font-size: 0.74rem; }
|
||||
.rail-list li.unread .rail-row-title { font-weight: 500; }
|
||||
.rail-row-end { font-size: 0.8rem; color: var(--muted); white-space: nowrap; }
|
||||
.rail-art { width: 38px; height: 38px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: var(--panel); }
|
||||
.rail-art-empty { display: grid; place-items: center; color: var(--muted); }
|
||||
}
|
||||
|
||||
/* The feed's greeting and search ("Hi Chris, welcome back"). */
|
||||
.page-head { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||
.page-head-text { display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap; flex: 1; min-width: 0; }
|
||||
.page-head h1 { font-size: 1.45rem; }
|
||||
.page-head .muted { font-size: 1rem; }
|
||||
.page-head .search { flex: 0 1 280px; min-width: 200px; }
|
||||
.page-head .search input { border-radius: 999px; padding-left: 1.1rem; }
|
||||
@media (max-width: 640px) { .page-head .search { flex-basis: 100%; } }
|
||||
.page-section { font-size: 1.15rem; margin: 0.5rem 0 0; }
|
||||
input[type='checkbox'], input[type='radio'] { width: auto; padding: 0; accent-color: var(--accent); box-shadow: none; }
|
||||
.services-picker label.checkbox { white-space: nowrap; }
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter, Space_Grotesk } from 'next/font/google';
|
||||
import { Poppins } from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import { Nav } from '@/components/Nav';
|
||||
import { Shell } from '@/components/Shell';
|
||||
import { Providers } from '@/components/Providers';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
|
||||
const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space-grotesk', display: 'swap' });
|
||||
const poppins = Poppins({ subsets: ['latin'], weight: ['400', '500', '600', '700'], variable: '--font-poppins', display: 'swap' });
|
||||
|
||||
// Previews need absolute image URLs. In production the API lives at <site>/api, so its origin is the site's.
|
||||
const siteUrl = process.env.SITE_URL ?? new URL(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3737').origin;
|
||||
@@ -21,23 +20,26 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#07070b',
|
||||
themeColor: '#2a2942',
|
||||
viewportFit: 'cover',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} ${spaceGrotesk.variable}`}>
|
||||
<html lang="en" className={poppins.variable}>
|
||||
<body>
|
||||
<div className="ambient" aria-hidden />
|
||||
<Providers>
|
||||
<Nav />
|
||||
<main className="container">{children}</main>
|
||||
<Shell
|
||||
footer={
|
||||
<footer className="footer">
|
||||
<Link href="/privacy">Privacy</Link>
|
||||
<Link href="/terms">Terms</Link>
|
||||
<a href="mailto:app@musicshare.binarygnome.com">Contact</a>
|
||||
</footer>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Shell>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,8 +10,8 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
background_color: '#07070b',
|
||||
theme_color: '#07070b',
|
||||
background_color: '#2a2942',
|
||||
theme_color: '#2a2942',
|
||||
categories: ['music', 'social'],
|
||||
icons: [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Avatar } from '@/components/Avatar';
|
||||
import { Equalizer } from '@/components/Equalizer';
|
||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||
import { SharingControls } from '@/components/SharingControls';
|
||||
import { UserSearch } from '@/components/UserSearch';
|
||||
import { api, fetcher, useMe } from '@/lib/api';
|
||||
import { useRealtime } from '@/lib/useRealtime';
|
||||
|
||||
@@ -63,14 +64,21 @@ function Feed() {
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<header className="page-head">
|
||||
<div className="page-head-text">
|
||||
<h1>Hi {me?.displayName ?? me?.username},</h1>
|
||||
<span className="muted">Welcome back</span>
|
||||
</div>
|
||||
<UserSearch placeholder="Search friends" />
|
||||
</header>
|
||||
<SharingControls compact />
|
||||
{mine?.nowPlaying && (
|
||||
<section className="stack">
|
||||
<h2 className="section-label">You</h2>
|
||||
<h2 className="section-label">Your song</h2>
|
||||
<NowPlayingCard np={mine.nowPlaying} preferred={me?.preferredService} showUser={false} />
|
||||
</section>
|
||||
)}
|
||||
<h1>Friends listening</h1>
|
||||
<h2 className="page-section">Friends listening</h2>
|
||||
{!me?.preferredService && (
|
||||
<p className="notice">
|
||||
Pick your streaming service in <Link href="/settings">Settings</Link> so songs open in the right app.
|
||||
@@ -82,16 +90,16 @@ function Feed() {
|
||||
) : (
|
||||
<p className="muted">Nothing yet. <Link href="/friends">Find friends</Link>, and their music will show up here live.</p>
|
||||
))}
|
||||
{/* Cards rise in on load and glide to new positions when someone pins or starts playing. */}
|
||||
{/* Cards fade in and glide to new positions when someone pins or starts playing. */}
|
||||
<AnimatePresence initial={true}>
|
||||
{feed?.map((np, i) => (
|
||||
{feed?.map((np) => (
|
||||
<motion.div
|
||||
key={np.user.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 24, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 32, delay: Math.min(i, 8) * 0.05 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, layout: { duration: 0.25, ease: 'easeOut' } }}
|
||||
>
|
||||
<NowPlayingCard
|
||||
np={np}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from 'react';
|
||||
import { client, useMe } from '@/lib/api';
|
||||
import { ActivityBell } from './ActivityBell';
|
||||
|
||||
/** Top bar: logo and the four main places. On phones those move to a bottom tab bar. */
|
||||
/** Top bar: logo and the four main places. On phones those move to a bottom tab bar; wide screens use Shell's sidebar. */
|
||||
export function Nav() {
|
||||
const { me } = useMe();
|
||||
const path = usePathname();
|
||||
@@ -85,14 +85,14 @@ function VerifyBanner({ email }: { email: string }) {
|
||||
}
|
||||
|
||||
const icon = { width: 22, height: 22, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' } as const;
|
||||
const FeedIcon = () => (
|
||||
export const FeedIcon = () => (
|
||||
<svg {...icon} aria-hidden>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
const FriendsIcon = () => (
|
||||
export const FriendsIcon = () => (
|
||||
<svg {...icon} aria-hidden>
|
||||
<circle cx="9" cy="8" r="3.5" />
|
||||
<path d="M2 20c0-3.6 3.1-6 7-6s7 2.4 7 6" />
|
||||
@@ -100,13 +100,13 @@ const FriendsIcon = () => (
|
||||
<path d="M18 14c2.4.6 4 2.6 4 6" />
|
||||
</svg>
|
||||
);
|
||||
const ProfileIcon = () => (
|
||||
export const ProfileIcon = () => (
|
||||
<svg {...icon} aria-hidden>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21c0-4 3.6-7 8-7s8 3 8 7" />
|
||||
</svg>
|
||||
);
|
||||
const SettingsIcon = () => (
|
||||
export const SettingsIcon = () => (
|
||||
<svg {...icon} aria-hidden>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" />
|
||||
|
||||
@@ -26,22 +26,9 @@ export function NowPlayingCard({
|
||||
}) {
|
||||
const sourceLabel = np.sourceApp ?? SERVICE_LABELS[np.source];
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Your own song gets the gradient "hero" card; friends' songs sit on plain panels.
|
||||
return (
|
||||
<article className={`card np-card ${pinned ? 'pinned' : ''} ${np.isPlaying ? 'playing' : ''}`}>
|
||||
{/* The card glows with its album art; crossfades when the song changes. */}
|
||||
<AnimatePresence initial={false}>
|
||||
{np.track.artworkUrl && (
|
||||
<motion.div
|
||||
key={np.track.artworkUrl}
|
||||
className="np-backdrop"
|
||||
style={{ backgroundImage: `url("${np.track.artworkUrl}")` }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.6 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<article className={`card np-card ${showUser ? '' : 'np-hero'} ${pinned ? 'pinned' : ''} ${np.isPlaying ? 'playing' : ''}`}>
|
||||
<header className="card-header">
|
||||
{showUser && (
|
||||
<Link href={`/u/${np.user.username}`} className="user-chip">
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { notificationPath, notificationText, timeAgo, type MeDTO, type NotificationListDTO, type ProfileDTO } from '@musicshare/shared';
|
||||
import { client, fetcher, useMe } from '@/lib/api';
|
||||
import { ActivityBell } from './ActivityBell';
|
||||
import { Avatar } from './Avatar';
|
||||
import { FeedIcon, FriendsIcon, Nav, ProfileIcon, SettingsIcon } from './Nav';
|
||||
|
||||
/**
|
||||
* Signed in on a wide screen: a sidebar on the left, the page in a rounded panel in the middle, and your
|
||||
* profile, recent activity and recent plays on the right. Narrower screens drop the right column, then
|
||||
* fall back to the top bar and bottom tabs (Nav).
|
||||
*/
|
||||
export function Shell({ children, footer }: { children: React.ReactNode; footer: React.ReactNode }) {
|
||||
const { me } = useMe();
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<div className={me ? 'shell' : undefined}>
|
||||
{me && <Sidebar me={me} />}
|
||||
<div className="shell-main">
|
||||
<main className={me ? 'main-panel' : 'container'}>{me ? <div className="container">{children}</div> : children}</main>
|
||||
{footer}
|
||||
</div>
|
||||
{me && <RightRail me={me} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({ me }: { me: MeDTO }) {
|
||||
const path = usePathname();
|
||||
const router = useRouter();
|
||||
const { data } = useSWR<NotificationListDTO>('/notifications', fetcher, { refreshInterval: 60_000 });
|
||||
const unread = data?.unread ?? 0;
|
||||
const items = [
|
||||
{ href: '/', label: 'Feed', icon: <FeedIcon />, active: path === '/' },
|
||||
{ href: '/friends', label: 'Friends', icon: <FriendsIcon />, active: path.startsWith('/friends') },
|
||||
{ href: '/notifications', label: 'Activity', icon: <BellIcon />, active: path.startsWith('/notifications'), badge: unread },
|
||||
{ href: `/u/${me.username}`, label: 'Profile', icon: <ProfileIcon />, active: path.startsWith(`/u/${me.username}`) },
|
||||
{ href: '/settings', label: 'Settings', icon: <SettingsIcon />, active: path.startsWith('/settings') || path === '/admin' },
|
||||
];
|
||||
const { mutate } = useMe();
|
||||
const logOut = async () => {
|
||||
await client.logout();
|
||||
await mutate(undefined, { revalidate: true });
|
||||
router.push('/');
|
||||
};
|
||||
return (
|
||||
<aside className="sidebar" aria-label="Main">
|
||||
<Link href="/" className="sidebar-brand">
|
||||
musicshare<span>.</span>
|
||||
</Link>
|
||||
<nav className="sidebar-nav">
|
||||
{items.map((i) => (
|
||||
<Link key={i.href} href={i.href} className={i.active ? 'active' : ''} aria-current={i.active ? 'page' : undefined}>
|
||||
{i.icon}
|
||||
<span>{i.label}</span>
|
||||
{i.badge ? <span className="sidebar-badge">{i.badge > 99 ? '99+' : i.badge}</span> : null}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<button type="button" className="sidebar-logout" onClick={logOut}>
|
||||
<LogOutIcon />
|
||||
Log out
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function RightRail({ me }: { me: MeDTO }) {
|
||||
const { data: activity } = useSWR<NotificationListDTO>('/notifications', fetcher, { refreshInterval: 60_000 });
|
||||
const { data: profile } = useSWR<ProfileDTO>(`/users/${me.username}`, fetcher);
|
||||
const name = me.displayName ?? me.username;
|
||||
return (
|
||||
<aside className="rail" aria-label="You">
|
||||
<div className="rail-me">
|
||||
<Link href={`/u/${me.username}`} className="rail-me-link">
|
||||
<Avatar user={me} size={46} />
|
||||
<span className="rail-me-text">
|
||||
<strong>{name}</strong>
|
||||
<span className="muted">@{me.username}</span>
|
||||
</span>
|
||||
</Link>
|
||||
<ActivityBell />
|
||||
</div>
|
||||
|
||||
<section className="rail-section">
|
||||
<header className="rail-head">
|
||||
<h2>Activity</h2>
|
||||
<Link href="/notifications" className="pill-link">
|
||||
See all
|
||||
</Link>
|
||||
</header>
|
||||
{activity?.notifications.length === 0 && <p className="muted small">Reactions, comments and new followers show up here.</p>}
|
||||
<ul className="rail-list">
|
||||
{activity?.notifications.slice(0, 5).map((n) => {
|
||||
const { title, body } = notificationText(n);
|
||||
return (
|
||||
<li key={n.id} className={n.read ? '' : 'unread'}>
|
||||
<Link href={notificationPath(n)} className="rail-row">
|
||||
<Avatar user={n.actor} size={38} />
|
||||
<span className="rail-row-text">
|
||||
<span className="rail-row-title">{title}</span>
|
||||
<span className="muted">{body || timeAgo(n.createdAt)}</span>
|
||||
</span>
|
||||
<span className="rail-row-end">{n.emoji ?? (n.type === 'comment' || n.type === 'reply' ? '💬' : timeAgo(n.createdAt))}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{profile && profile.recent.length > 0 && (
|
||||
<section className="rail-section">
|
||||
<header className="rail-head">
|
||||
<h2>Your recent plays</h2>
|
||||
</header>
|
||||
<ul className="rail-list">
|
||||
{profile.recent.slice(0, 5).map((p) => (
|
||||
<li key={p.listenId}>
|
||||
<Link href={`/play/${p.listenId}`} className="rail-row">
|
||||
{p.track.artworkUrl ? (
|
||||
<img className="rail-art" src={p.track.artworkUrl} alt="" />
|
||||
) : (
|
||||
<span className="rail-art rail-art-empty">♪</span>
|
||||
)}
|
||||
<span className="rail-row-text">
|
||||
<span className="rail-row-title">{p.track.title}</span>
|
||||
<span className="muted">{p.track.artist}</span>
|
||||
</span>
|
||||
<span className="rail-row-end">{timeAgo(p.playedAt)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' } as const;
|
||||
const BellIcon = () => (
|
||||
<svg {...icon} aria-hidden>
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
);
|
||||
const LogOutIcon = () => (
|
||||
<svg {...icon} aria-hidden>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="M16 17l5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</svg>
|
||||
);
|
||||
@@ -8,7 +8,7 @@ import { fetcher } from '@/lib/api';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
export function UserSearch() {
|
||||
export function UserSearch({ placeholder = 'Find friends by username' }: { placeholder?: string }) {
|
||||
const [q, setQ] = useState('');
|
||||
const debounced = useDebounced(q.trim(), 250);
|
||||
const { data } = useSWR<UserSummaryDTO[]>(
|
||||
@@ -17,7 +17,7 @@ export function UserSearch() {
|
||||
);
|
||||
return (
|
||||
<div className="search">
|
||||
<input placeholder="Find friends by username" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<input placeholder={placeholder} aria-label="Search people" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
{data && debounced.length >= 2 && (
|
||||
<ul className="search-results">
|
||||
{data.length === 0 && <li className="muted">No one found</li>}
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
#
|
||||
# Versions match what React Native 0.86 / Expo 57 ask for
|
||||
# (node_modules/react-native/gradle/libs.versions.toml). Gradle downloads anything
|
||||
# else it needs on first use; the workflow keeps ~/.gradle in a volume.
|
||||
# else it needs on first use; the workflow keeps ~/.gradle (and ccache's ~/.ccache) in volumes.
|
||||
FROM node:22-bookworm
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openjdk-17-jdk-headless unzip \
|
||||
&& apt-get install -y --no-install-recommends openjdk-17-jdk-headless unzip ccache \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \
|
||||
|
||||
@@ -20,7 +20,8 @@ container:
|
||||
# Use Docker's default network instead of creating one per job: a server with many compose
|
||||
# projects runs out of address pools ("all predefined address pools have been fully subnetted").
|
||||
network: bridge
|
||||
# The volumes the workflow may mount (Gradle and npm caches), and nothing else.
|
||||
# The volumes the workflow may mount (Gradle, npm and ccache caches), and nothing else.
|
||||
valid_volumes:
|
||||
- musicshare-gradle
|
||||
- musicshare-npm
|
||||
- musicshare-ccache
|
||||
|
||||
+5
-2
@@ -20,8 +20,11 @@ The pieces:
|
||||
|
||||
## One-time setup (on the Gitea server)
|
||||
|
||||
The server needs about 8 GB of free memory during a build and about 15 GB of disk for the image and
|
||||
caches. A first build takes 30-40 minutes; later ones reuse the Gradle cache.
|
||||
The server needs about 8 GB of free memory during a build and about 20 GB of disk for the image and
|
||||
caches. A first build takes 15-40 minutes depending on the machine; later ones reuse the Gradle build
|
||||
cache and ccache's compiled C++ (volumes `musicshare-gradle` and `musicshare-ccache`) and take a few
|
||||
minutes. The APKs are for 64-bit phones (`arm64-v8a`); set `ABIS=armeabi-v7a,arm64-v8a` in the workflow's
|
||||
Build step to include old 32-bit ones.
|
||||
The caches are Docker volumes, which live wherever Docker keeps its data (usually `/var/lib/docker`).
|
||||
Check that disk has room (`df -h /var/lib/docker`): if it fills up, everything else using Docker on the
|
||||
server, including databases, stops writing.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Comments: tap 💬 next to a song's reactions to talk about it. The person who played it and the people they follow can comment, and you'll be notified about comments and replies. Changing your profile picture works again, and lyrics load more reliably.
|
||||
@@ -0,0 +1 @@
|
||||
A fresh new look: softer slate panels, pink accents, a new font, and your own song on a colourful gradient card. The feed greets you, and the bell and tabs are cleaner.
|
||||
@@ -0,0 +1 @@
|
||||
Videos and screen recordings played on your phone no longer show up as your song, and notifications now have their own section.
|
||||
Generated
+5
-5
@@ -809,10 +809,10 @@
|
||||
},
|
||||
"apps/mobile": {
|
||||
"name": "@musicshare/mobile",
|
||||
"version": "1.2.0",
|
||||
"version": "1.5.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
||||
"@expo-google-fonts/poppins": "^0.4.1",
|
||||
"@expo/ui": "~57.0.21",
|
||||
"@musicshare/shared": "*",
|
||||
"expo": "~57.0.26",
|
||||
@@ -3191,10 +3191,10 @@
|
||||
"integrity": "sha512-aZztj44PyA/sj3TXSwwpLJwqMlTUJ49okbZJYURzEcuERxMQB0KbX7RrLBi8WJnuWz5HFOLlTRxORrJfNnUPNQ==",
|
||||
"license": "MIT AND Apache-2.0"
|
||||
},
|
||||
"node_modules/@expo-google-fonts/space-grotesk": {
|
||||
"node_modules/@expo-google-fonts/poppins": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@expo-google-fonts/space-grotesk/-/space-grotesk-0.4.1.tgz",
|
||||
"integrity": "sha512-ZVQYw4Ok/pgcSJiufP8oRZE3AVxS9xtmKEUfsurbHkHNdMc/GA1gDXP9G4Cr7KL4KqSc0haexR2TuMigotCn4Q==",
|
||||
"resolved": "https://registry.npmjs.org/@expo-google-fonts/poppins/-/poppins-0.4.1.tgz",
|
||||
"integrity": "sha512-LiX5oEryJqDt3UmWwr4oqf11hGMz/JEffkkNcZ3UtGwyoMFzXmBmTwcrAWrEttxypxmLNmPykDg+CY2VjKve9A==",
|
||||
"license": "MIT AND OFL-1.1"
|
||||
},
|
||||
"node_modules/@expo/cli": {
|
||||
|
||||
Reference in New Issue
Block a user