15 Commits
Author SHA1 Message Date
chrisandClaude Opus 5.5 0474e168e5 Version 1.5.0
Android / build (push) Successful in 8m0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:13:08 -04:00
chrisandClaude Opus 5.5 5eb93dffae Don't share phone videos and screen recordings as now-playing
General-purpose players (VLC, MX Player, Photos, galleries) report a local
file's name as the title. Skip sessions whose title looks like a video file
or camera/recorder name, or that come from such a player without a real
artist tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:13:08 -04:00
chrisandaider ba405e606b feat: Display dedicated notifications section for seriouscritter
Co-authored-by: aider (ollama_chat/hf.co/TrevorJS/gemma-4-E4B-it-uncensored-GGUF:Q4_K_M) <aider@aider.chat>
2026-10-26 11:10:00 -04:00
chrisandClaude Opus 5.5 c7bd8f0ad3 Faster Android builds: ccache, Gradle build cache, 64-bit only, no release lint
Most of a build was compiling the same C++ twice per APK with nothing reused between builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-15 15:21:35 -04:00
chrisandClaude Opus 5.5 0a26e33bfe Version 1.4.0
Android / build (push) Successful in 16m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-15 15:06:58 -04:00
chrisandClaude Opus 5.5 864ab88627 New look: slate-indigo panels, pink accent, Poppins, and a gradient card for your song
Inspired by a dashboard design the user shared.
- App: new palette and fonts, borderless rounded panels with soft shadows, a pink-to-blue
  gradient card for your own song (text and controls switch to white on it), flat cards for
  friends, a 'Hi Chris, welcome back' header, an outline bell, and a pink pill on the active tab.
- Website: the same look, plus a wide-screen layout with a sidebar, the page in a rounded panel,
  and a right column with your profile, recent activity and recent plays. Phones keep the top
  bar and bottom tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-15 15:01:20 -04:00
chrisandClaude Opus 5.5 2025225fe4 Version 1.3.0
Android / build (push) Successful in 14m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-15 13:33:10 -04:00
chrisandClaude Opus 5.5 4402171f46 Comments on plays
- Anyone who can see a play can read its comments; the player and the people they follow can write.
  The author, the player and admins can delete; comments can be reported (admins see the text).
- Notifications: "commented on" for the player, "replied on" for earlier commenters, each with its
  own setting. Reaction and comment notifications now open the play.
- A 💬 chip with the count next to reactions on the feed and recent plays, opening the thread on
  the app (/play/[id]) and the website. Threads refresh every few seconds while open.
- Blocking removes comments between the two people. Plays with comments survive history trimming.
- Privacy page updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-15 13:32:14 -04:00
chrisandClaude Opus 5.5 31aed8dec4 Fix profile picture uploads from the app
Expo's fetch rejects React Native's { uri, name, type } FormData parts ("Unsupported FormDataPart
implementation"), so every upload failed. Upload an expo-file-system File instead, shrunk to 1024 px
first. The server also takes bigger photos (25 MB, sharp's default pixel limit) and logs why a
picture couldn't be read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-15 13:20:12 -04:00
chrisandClaude Opus 5.5 b705bfcce4 Fix a gap under Friends listening, and lyrics failing while LRCLIB's exact lookup is overloaded
The feed's itemLayoutAnimation left the cards behind when the header above them shrank
(your own card's lyrics loading). LRCLIB rate-limits /api/get on its own; fall back to search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-15 13:16:18 -04:00
chrisandClaude Opus 5.5 82ff5336aa CI: no error message when a tag has no release yet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:02:28 -04:00
chrisandClaude Opus 5.5 4da8487ef9 Version 1.2.0
Android / build (push) Successful in 14m51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:46:34 -04:00
chrisandClaude Opus 5.5 5cbde5e7f9 The Android app updates itself from our Gitea releases; feed cards fade in instead of bouncing
- API: GET /app/android/latest reads the latest Gitea release (ANDROID_RELEASES_URL, optional token for a
  private repo) with the APK's SHA-256 from SHA256SUMS; GET /app/android/apk/:name streams that APK.
- App (modules/updater): downloads in the background, checks the SHA-256, and installs through
  PackageInstaller: quietly once when leaving the app (Android 12+), otherwise from the feed banner or
  Settings > App version. F-Droid builds leave it out and drop the install permissions.
- CI: the release notes come from the fastlane changelog; the app shows the part above ---.
- Settings: Create-account and Link Spotify buttons for Last.fm and ListenBrainz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:34:04 -04:00
chrisandClaude Opus 5.5 804493aee0 Create-account links for Last.fm and ListenBrainz, and Link Spotify for ListenBrainz
ListenBrainz accounts are MusicBrainz accounts, so its sign-up link goes to MusicBrainz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:02:40 -04:00
chrisandClaude Opus 5.5 d64e810866 CI: don't trip over a release with no files yet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:34:56 -04:00
111 changed files with 4650 additions and 494 deletions
+14 -5
View File
@@ -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 }}
@@ -94,13 +94,22 @@ jobs:
auth="Authorization: token $TOKEN"
if [ "$REF_TYPE" = tag ]; then
# Reuse the release if this tag was built before.
id=$(curl -fsS -H "$auth" "$api/releases/tags/$REF_NAME" 2>/dev/null | node -p 'JSON.parse(require("fs").readFileSync(0)).id' || true)
id=$(curl -fsS -H "$auth" "$api/releases/tags/$REF_NAME" 2>/dev/null | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8") || "{}").id ?? ""' || true)
if [ -z "$id" ]; then
body=$(node -e 'console.log(JSON.stringify({ tag_name: process.argv[1], name: process.argv[1], body: "Android builds. musicshare-*-foss.apk has no closed-source code (no Google ML Kit)." }))' "$REF_NAME")
# The changelog first (the app shows the text above "---" as what's new), then a note for this page.
body=$(node -e '
const fs = require("fs");
const code = require("../apps/mobile/app.json").expo.android.versionCode;
const file = `../fastlane/metadata/android/en-US/changelogs/${code}.txt`;
const notes = fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim() : "";
const note = "musicshare-*-foss.apk has no closed-source code (no Google ML Kit); it does not update itself.";
const tag = process.argv[1];
console.log(JSON.stringify({ tag_name: tag, name: tag, body: notes ? `${notes}\n\n---\n${note}` : `---\n${note}` }));
' "$REF_NAME")
id=$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" | node -p 'JSON.parse(require("fs").readFileSync(0)).id')
fi
for f in *.apk SHA256SUMS; do
old=$(curl -fsS -H "$auth" "$api/releases/$id/assets" | node -p 'JSON.parse(require("fs").readFileSync(0)).find(a => a.name === process.argv[1])?.id ?? ""' "$f")
old=$(curl -fsS -H "$auth" "$api/releases/$id/assets" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8") || "[]").find(a => a.name === process.argv[1])?.id ?? ""' "$f")
[ -n "$old" ] && curl -fsS -X DELETE -H "$auth" "$api/releases/$id/assets/$old"
curl -fsS -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$id/assets?name=$f" > /dev/null
echo "Attached $f"
+2
View File
@@ -10,3 +10,5 @@ next-env.d.ts
# Production secrets and database backups
deploy/production.env
backups/
.aider*
deploy/gitea-runner/data/
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE "comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"listen_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"body" varchar(500) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notifications" ADD COLUMN "comment_id" uuid;--> statement-breakpoint
ALTER TABLE "reports" ADD COLUMN "comment_id" uuid;--> statement-breakpoint
ALTER TABLE "reports" ADD COLUMN "comment_body" varchar(500);--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_listen_id_listens_id_fk" FOREIGN KEY ("listen_id") REFERENCES "public"."listens"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "comments_listen_created_idx" ON "comments" USING btree ("listen_id","created_at");--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reports" ADD CONSTRAINT "reports_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -120,6 +120,13 @@
"when": 1792028149643,
"tag": "0016_notifications",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1792084480262,
"tag": "0017_comments",
"breakpoints": true
}
]
}
+4
View File
@@ -11,6 +11,8 @@ import { loadUser } from './lib/auth';
import { HttpError } from './lib/http';
import { redis } from './lib/redis';
import { adminRoutes } from './routes/admin';
import { appUpdateRoutes } from './routes/appUpdates';
import { commentRoutes } from './routes/comments';
import { accountRoutes } from './routes/account';
import { authRoutes } from './routes/auth';
import { avatarRoutes } from './routes/avatars';
@@ -77,10 +79,12 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
await app.register(adminRoutes);
await app.register(trackRoutes);
await app.register(reactionRoutes);
await app.register(commentRoutes);
await app.register(notificationRoutes);
await app.register(connectionRoutes);
await app.register(deviceRoutes);
await app.register(realtimeRoutes);
await app.register(appUpdateRoutes);
// registered as its own plugin so its permissive body parsers stay scoped to webhook routes
await app.register(webhookRoutes);
+25 -3
View File
@@ -105,6 +105,9 @@ export const reports = pgTable(
.references(() => users.id, { onDelete: 'cascade' }),
reason: text('reason').$type<ReportReason>().notNull(),
note: varchar('note', { length: 500 }),
/** When a comment was reported: which, and what it said then (kept if the comment is deleted). */
commentId: uuid('comment_id').references(() => comments.id, { onDelete: 'set null' }),
commentBody: varchar('comment_body', { length: 500 }),
status: text('status').$type<'open' | 'resolved'>().notNull().default('open'),
createdAt: createdAt(),
},
@@ -262,6 +265,23 @@ export const reactions = pgTable(
(t) => [primaryKey({ columns: [t.listenId, t.userId] })],
);
/** Comments on a play. They go with the play (plays with comments are kept when history is trimmed). */
export const comments = pgTable(
'comments',
{
id: uuid('id').primaryKey().defaultRandom(),
listenId: uuid('listen_id')
.notNull()
.references(() => listens.id, { onDelete: 'cascade' }),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
body: varchar('body', { length: 500 }).notNull(),
createdAt: createdAt(),
},
(t) => [index('comments_listen_created_idx').on(t.listenId, t.createdAt)],
);
/** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */
export const emailVerifications = pgTable(
'email_verifications',
@@ -306,7 +326,7 @@ export const lyrics = pgTable('lyrics', {
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
});
/** Things that happened to a user: a reaction, a new follower, a song sent to them. */
/** Things that happened to a user: a reaction or comment, a new follower, a song sent to them. */
export const notifications = pgTable(
'notifications',
{
@@ -319,10 +339,12 @@ export const notifications = pgTable(
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
trackId: uuid('track_id').references(() => tracks.id, { onDelete: 'cascade' }),
/** The play a reaction was on (gone once the play is trimmed from history). */
/** The play a reaction or comment was on (gone once the play is trimmed from history). */
listenId: uuid('listen_id').references(() => listens.id, { onDelete: 'set null' }),
/** The comment (or reply); deleting the comment takes its notifications with it. */
commentId: uuid('comment_id').references(() => comments.id, { onDelete: 'cascade' }),
emoji: varchar('emoji', { length: 32 }),
/** A note sent with a song. */
/** A note sent with a song, or the start of a comment. */
message: varchar('message', { length: 280 }),
readAt: timestamp('read_at', { withTimezone: true }),
createdAt: createdAt(),
+7
View File
@@ -34,6 +34,13 @@ const schema = z.object({
.string()
.default('https://lrclib.net')
.transform((v) => v.replace(/\/+$/, '')),
/**
* App updates: the Gitea/Forgejo API URL of the latest Android release, e.g.
* https://gitea.example.com/api/v1/repos/owner/musicshare/releases/latest. Empty turns update checks off.
* The token (read access to the repo) is only needed when the repo is private; downloads go through us.
*/
ANDROID_RELEASES_URL: z.string().default(''),
ANDROID_RELEASES_TOKEN: optional,
/** Apple Music (MusicKit): Team ID, MusicKit key ID, and the key's .p8 contents (newlines may be written as \n). */
APPLE_TEAM_ID: optional,
APPLE_MUSICKIT_KEY_ID: optional,
+7 -4
View File
@@ -3,7 +3,8 @@ import { env } from '../env';
import { HttpError } from './http';
export const AVATAR_SIZE = 512;
export const AVATAR_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
/** Full-size photos from 50-200 MP phone cameras can be well over 10 MB (the app shrinks them first since 1.3.0). */
export const AVATAR_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
/**
* Uploaded avatars are stored as a path (`/avatars/<id>?v=<version>`) so they keep
@@ -16,14 +17,16 @@ export const publicAvatarUrl = (stored: string | null): string | null =>
* Square-crops, resizes and re-encodes to WebP. Re-encoding also drops EXIF
* (phone photos carry GPS location) and anything that isn't image data.
*/
export async function processAvatar(input: Buffer): Promise<Buffer> {
export async function processAvatar(input: Buffer, log?: (err: unknown) => void): Promise<Buffer> {
try {
return await sharp(input, { limitInputPixels: 50_000_000, animated: false })
// sharp's default pixel limit (~268 MP) fits a 200 MP camera; JPEGs are shrunk while decoding, so it stays cheap.
return await sharp(input, { animated: false })
.rotate() // apply the EXIF orientation before it's stripped
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' })
.webp({ quality: 82 })
.toBuffer();
} catch {
} catch (err) {
log?.(err);
throw new HttpError(400, "That file isn't an image we can read. Try a JPEG, PNG, WebP or HEIC.");
}
}
+1
View File
@@ -40,6 +40,7 @@ export async function adminRoutes(app: FastifyInstance) {
createdAt: report.createdAt.toISOString(),
reporter: by ? toUserSummary(by) : null,
reported: { ...toUserSummary(who), suspended: Boolean(who.suspendedAt) },
comment: report.commentBody ? { id: report.commentId, body: report.commentBody } : null,
}));
});
+45
View File
@@ -0,0 +1,45 @@
import { Readable } from 'node:stream';
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
import type { FastifyInstance } from 'fastify';
import type { AppUpdateDTO } from '@musicshare/shared';
import { HttpError } from '../lib/http';
import { fetchFromReleaseHost, latestAndroidRelease } from '../services/appUpdates';
/** The Android app checks here for a newer version of itself and downloads it through us. */
export async function appUpdateRoutes(app: FastifyInstance) {
app.get(
'/app/android/latest',
{ config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
async (req, reply): Promise<AppUpdateDTO | null> => {
try {
const release = await latestAndroidRelease();
return reply.send(release?.update ?? null);
} catch (err) {
req.log.warn(err, 'release lookup failed');
throw new HttpError(503, "Couldn't check for updates right now");
}
},
);
/** Only the latest release's APK, so this can't be used to fetch anything else from the release host. */
app.get<{ Params: { name: string } }>(
'/app/android/apk/:name',
{ config: { rateLimit: { max: 10, timeWindow: '1 hour' } } },
async (req, reply) => {
const release = await latestAndroidRelease().catch(() => null);
if (!release || req.params.name !== release.apkName) throw new HttpError(404, 'Not found');
const res = await fetchFromReleaseHost(release.assetUrl, { signal: AbortSignal.timeout(10 * 60_000) });
if (!res.ok || !res.body) {
req.log.warn({ status: res.status }, 'APK download from the release host failed');
throw new HttpError(502, "Couldn't download the update right now");
}
reply
.header('content-type', 'application/vnd.android.package-archive')
.header('content-disposition', `attachment; filename="${release.apkName}"`)
.header('cache-control', 'no-store');
const length = res.headers.get('content-length');
if (length) reply.header('content-length', length);
return reply.send(Readable.fromWeb(res.body as WebReadableStream));
},
);
}
+4 -2
View File
@@ -14,9 +14,11 @@ export async function avatarRoutes(app: FastifyInstance) {
const file = await req.file({ limits: { fileSize: AVATAR_MAX_UPLOAD_BYTES, files: 1 } }).catch(() => undefined);
if (!file) throw new HttpError(400, 'Choose a picture to upload');
const input = await file.toBuffer().catch(() => {
throw new HttpError(413, 'That picture is too big (10 MB max)');
throw new HttpError(413, `That picture is too big (${AVATAR_MAX_UPLOAD_BYTES / 1024 / 1024} MB max)`);
});
const image = await processAvatar(input);
const image = await processAvatar(input, (err) =>
req.log.warn({ err, bytes: input.length, mimetype: file.mimetype, filename: file.filename }, 'avatar not readable'),
);
const values = { image, contentType: 'image/webp', updatedAt: new Date() };
await db
+163
View File
@@ -0,0 +1,163 @@
import { and, asc, eq, inArray, isNull, ne, or } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { commentSchema, reportSchema, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { requireUser } from '../lib/auth';
import { HttpError } from '../lib/http';
import { blockStatus, hiddenUserIds } from '../services/blocks';
import { notify } from '../services/notifications';
import { getReactions, publishNowPlaying, toUserSummary } from '../services/nowPlaying';
import { getTrackDTOs } from '../services/tracks';
const params = z.object({ id: z.uuid() });
/** Who may write on a play: the person who played it, and the people they follow. */
async function canCommentOn(viewerId: string, ownerId: string) {
if (viewerId === ownerId) return true;
const [row] = await db
.select({ one: schema.follows.followerId })
.from(schema.follows)
.where(and(eq(schema.follows.followerId, ownerId), eq(schema.follows.followeeId, viewerId)));
return Boolean(row);
}
/** The play and its owner, if the viewer may see it: the owner isn't suspended, and no block either way. */
async function visiblePlay(viewerId: string, listenId: string) {
const [row] = await db
.select({ listen: schema.listens, owner: schema.users })
.from(schema.listens)
.innerJoin(schema.users, eq(schema.users.id, schema.listens.userId))
.where(eq(schema.listens.id, listenId));
if (!row || row.owner.suspendedAt) throw new HttpError(404, 'That play is gone');
const { viewerBlocked, blockedByThem } = await blockStatus(viewerId, row.owner.id);
if (viewerBlocked || blockedByThem) throw new HttpError(404, 'That play is gone');
return row;
}
const toCommentDTO = (c: typeof schema.comments.$inferSelect, author: User, viewer: User, ownerId: string): CommentDTO => ({
id: c.id,
user: toUserSummary(author),
body: c.body,
createdAt: c.createdAt.toISOString(),
canDelete: c.userId === viewer.id || ownerId === viewer.id || viewer.role === 'admin',
});
export async function commentRoutes(app: FastifyInstance) {
app.get('/listens/:id/comments', async (req): Promise<CommentThreadDTO> => {
const viewer = requireUser(req);
const { id } = params.parse(req.params);
const { listen, owner } = await visiblePlay(viewer.id, id);
const hidden = await hiddenUserIds(viewer.id);
const rows = await db
.select({ comment: schema.comments, author: schema.users })
.from(schema.comments)
.innerJoin(schema.users, eq(schema.users.id, schema.comments.userId))
.where(and(eq(schema.comments.listenId, id), isNull(schema.users.suspendedAt)))
.orderBy(asc(schema.comments.createdAt));
const [tracks, reactions, canComment] = await Promise.all([
getTrackDTOs([listen.trackId]),
getReactions([id]),
canCommentOn(viewer.id, owner.id),
]);
return {
listenId: id,
owner: toUserSummary(owner),
track: tracks.get(listen.trackId)!,
playedAt: listen.startedAt.toISOString(),
reactions: reactions.get(id) ?? [],
comments: rows.filter((r) => !hidden.has(r.author.id)).map((r) => toCommentDTO(r.comment, r.author, viewer, owner.id)),
canComment,
};
});
app.post(
'/listens/:id/comments',
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
async (req): Promise<CommentDTO> => {
const viewer = requireUser(req);
const { id } = params.parse(req.params);
const { body } = commentSchema.parse(req.body);
const { listen, owner } = await visiblePlay(viewer.id, id);
if (!(await canCommentOn(viewer.id, owner.id))) {
throw new HttpError(403, `Only people @${owner.username} follows can comment on their songs`);
}
const [comment] = await db.insert(schema.comments).values({ listenId: id, userId: viewer.id, body }).returning();
const excerpt = body.length > 280 ? `${body.slice(0, 279)}…` : body;
const about = { actorId: viewer.id, listenId: id, trackId: listen.trackId, commentId: comment!.id, message: excerpt };
await notify({ ...about, userId: owner.id, type: 'comment' });
// Everyone else who has commented here hears about replies (unless either side has blocked the other).
const others = await db
.selectDistinct({ userId: schema.comments.userId })
.from(schema.comments)
.where(and(eq(schema.comments.listenId, id), ne(schema.comments.userId, viewer.id), ne(schema.comments.userId, owner.id)));
const hidden = await hiddenUserIds(viewer.id);
for (const { userId } of others) {
if (!hidden.has(userId)) await notify({ ...about, userId, type: 'reply' });
}
await publishNowPlaying(owner.id); // the comment count on the feed
return toCommentDTO(comment!, viewer, viewer, owner.id);
},
);
async function findComment(commentId: string) {
const [row] = await db
.select({ comment: schema.comments, ownerId: schema.listens.userId })
.from(schema.comments)
.innerJoin(schema.listens, eq(schema.listens.id, schema.comments.listenId))
.where(eq(schema.comments.id, commentId));
if (!row) throw new HttpError(404, 'That comment is gone');
return row;
}
/** The author, the play's owner, or an admin. Its notifications go with it. */
app.delete('/comments/:id', async (req) => {
const viewer = requireUser(req);
const { id } = params.parse(req.params);
const { comment, ownerId } = await findComment(id);
if (comment.userId !== viewer.id && ownerId !== viewer.id && viewer.role !== 'admin') {
throw new HttpError(403, "You can't delete that comment");
}
await db.delete(schema.comments).where(eq(schema.comments.id, id));
await publishNowPlaying(ownerId);
return { ok: true };
});
app.post(
'/comments/:id/report',
{ config: { rateLimit: { max: 10, timeWindow: '1 hour' } } },
async (req) => {
const viewer = requireUser(req);
const { id } = params.parse(req.params);
const { comment } = await findComment(id);
await visiblePlay(viewer.id, comment.listenId);
if (comment.userId === viewer.id) throw new HttpError(400, "You can't report your own comment");
const input = reportSchema.parse(req.body);
await db.insert(schema.reports).values({
reporterId: viewer.id,
reportedId: comment.userId,
reason: input.reason,
note: input.note || null,
commentId: comment.id,
commentBody: comment.body,
});
return { ok: true };
},
);
}
/** When someone's blocked, their comments on each other's plays go too. */
export async function removeCommentsBetween(a: string, b: string) {
const playsOf = (userId: string) => db.select({ id: schema.listens.id }).from(schema.listens).where(eq(schema.listens.userId, userId));
await db
.delete(schema.comments)
.where(
or(
and(eq(schema.comments.userId, a), inArray(schema.comments.listenId, playsOf(b))),
and(eq(schema.comments.userId, b), inArray(schema.comments.listenId, playsOf(a))),
),
);
}
+2
View File
@@ -11,6 +11,7 @@ import { HttpError } from '../lib/http';
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
import { removeNotificationsBetween } from '../services/notifications';
import { toUserSummary } from '../services/nowPlaying';
import { removeCommentsBetween } from './comments';
import { removeReactionsBetween } from './reactions';
async function findUser(username: string) {
@@ -49,6 +50,7 @@ export async function safetyRoutes(app: FastifyInstance) {
);
});
await removeReactionsBetween(me.id, them.id);
await removeCommentsBetween(me.id, them.id);
await removeNotificationsBetween(me.id, them.id);
await followsChanged(me.id, them.id);
return { ok: true };
+110
View File
@@ -0,0 +1,110 @@
import type { AppUpdateDTO } from '@musicshare/shared';
import { parseVersion } from '@musicshare/shared';
import { env } from '../env';
/** The newest release, plus where its APK really lives (served through GET /app/android/apk/:name). */
export interface AndroidRelease {
update: AppUpdateDTO;
apkName: string;
assetUrl: string;
}
interface GiteaAsset {
name: string;
size: number;
browser_download_url: string;
}
interface GiteaRelease {
tag_name: string;
body?: string | null;
draft?: boolean;
prerelease?: boolean;
published_at?: string;
created_at: string;
assets?: GiteaAsset[];
}
const CACHE_MS = 10 * 60_000;
const FAILURE_CACHE_MS = 60_000;
let cached: { at: number; ttl: number; release: AndroidRelease | null } | null = null;
export const resetAndroidReleaseCache = () => {
cached = null;
};
/**
* Credentials for the release host. Gitea takes an access token as the basic-auth username with this
* password, which works for both its API and the release download links.
*/
function releaseHeaders(url: string): Record<string, string> {
const token = env.ANDROID_RELEASES_TOKEN;
// Only ever send the token to the host it's for (asset links could point elsewhere).
if (!token || new URL(url).origin !== new URL(env.ANDROID_RELEASES_URL).origin) return {};
return { authorization: `Basic ${Buffer.from(`${token}:x-oauth-basic`).toString('base64')}` };
}
export function fetchFromReleaseHost(url: string, init: RequestInit = {}) {
return fetch(url, { ...init, headers: { ...releaseHeaders(url), ...(init.headers as Record<string, string>) } });
}
/** The release notes shown in the app: the text before a "---" line (what follows is for the releases page). */
function notesFrom(body: string | null | undefined): string | null {
const notes = (body ?? '').split(/^\s*---\s*$/m)[0]!.trim();
return notes || null;
}
async function load(): Promise<AndroidRelease | null> {
const res = await fetchFromReleaseHost(env.ANDROID_RELEASES_URL, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
});
if (res.status === 404) return null; // no releases yet
if (!res.ok) throw new Error(`release lookup failed: ${res.status}`);
const release = (await res.json()) as GiteaRelease;
if (release.draft || release.prerelease) return null;
const version = release.tag_name.replace(/^v/, '');
if (!parseVersion(version)) return null;
const apkName = `musicshare-${version}.apk`;
const apk = release.assets?.find((a) => a.name === apkName);
const sums = release.assets?.find((a) => a.name === 'SHA256SUMS');
// Without a checksum the phone can't check the download, so don't offer it.
if (!apk || !sums) return null;
const sumsRes = await fetchFromReleaseHost(sums.browser_download_url, { signal: AbortSignal.timeout(10_000) });
if (!sumsRes.ok) throw new Error(`SHA256SUMS download failed: ${sumsRes.status}`);
const sha256 = (await sumsRes.text())
.split('\n')
.map((line) => line.trim().split(/\s+\*?/))
.find(([, name]) => name === apkName)?.[0]
?.toLowerCase();
if (!sha256 || !/^[0-9a-f]{64}$/.test(sha256)) return null;
return {
apkName,
assetUrl: apk.browser_download_url,
update: {
version,
apkUrl: `${env.API_PUBLIC_URL.replace(/\/+$/, '')}/app/android/apk/${encodeURIComponent(apkName)}`,
sha256,
size: apk.size,
notes: notesFrom(release.body),
publishedAt: release.published_at ?? release.created_at,
},
};
}
/** The newest Android release (cached for a few minutes), or null when there is none or updates are off. */
export async function latestAndroidRelease(): Promise<AndroidRelease | null> {
if (!env.ANDROID_RELEASES_URL) return null;
if (cached && Date.now() - cached.at < cached.ttl) return cached.release;
try {
const release = await load();
cached = { at: Date.now(), ttl: CACHE_MS, release };
return release;
} catch (err) {
// Keep serving the last good answer for a bit rather than hammering the release host.
cached = { at: Date.now(), ttl: FAILURE_CACHE_MS, release: cached?.release ?? null };
throw err;
}
}
+2 -4
View File
@@ -65,10 +65,8 @@ async function lookup(track: { title: string; artist: string; album: string | nu
if (track.durationMs) {
const params = new URLSearchParams({ track_name: track.title, artist_name: artist, duration: String(Math.round(track.durationMs / 1000)) });
if (track.album) params.set('album_name', track.album);
const exact = await fetchJson<LrclibRecord>(`${env.LRCLIB_URL}/api/get?${params}`, { headers }).catch((e: unknown) => {
if (e instanceof HttpError && e.status === 404) return null;
throw e;
});
// Not found, or LRCLIB's exact lookup is overloaded (it rate-limits /api/get separately): try the search.
const exact = await fetchJson<LrclibRecord>(`${env.LRCLIB_URL}/api/get?${params}`, { headers }).catch(() => null);
if (exact) return exact;
}
const params = new URLSearchParams({ track_name: track.title, artist_name: artist });
+19 -5
View File
@@ -34,6 +34,7 @@ export async function notify(input: {
listenId?: string | null;
emoji?: string | null;
message?: string | null;
commentId?: string | null;
}): Promise<string | null> {
if (input.userId === input.actorId) return null;
const n = schema.notifications;
@@ -91,6 +92,7 @@ export async function toNotificationDTOs(rows: Row[]): Promise<NotificationDTO[]
type: r.type,
actor,
track: r.trackId ? (tracks.get(r.trackId) ?? null) : null,
listenId: r.listenId,
emoji: r.emoji,
message: r.message,
read: Boolean(r.readAt),
@@ -101,15 +103,27 @@ export async function toNotificationDTOs(rows: Row[]): Promise<NotificationDTO[]
}
/** Newest first, 30 at a time; the cursor is the last one's time. */
export async function listNotifications(userId: string, cursor: string | null) {
export async function listNotifications(
userId: string,
cursor: string | null,
actorId?: string // Added optional actorId filter
): Promise<{
notifications: NotificationDTO[];
unread: number;
nextCursor: string | null;
}> {
const n = schema.notifications;
const before = cursor ? new Date(cursor) : null;
const rows = await db
const query = db
.select()
.from(n)
.where(and(eq(n.userId, userId), before && !isNaN(before.getTime()) ? lt(n.createdAt, before) : undefined))
.orderBy(desc(n.createdAt))
.limit(31);
.where(and(
eq(n.userId, userId),
before && !isNaN(before.getTime()) ? lt(n.createdAt, before) : undefined,
actorId ? eq(n.actorId, actorId) : undefined // Apply actor filter if provided
));
const rows = await query.orderBy(desc(n.createdAt)).limit(31);
const page = rows.slice(0, 30);
return {
notifications: await toNotificationDTOs(page),
+28 -4
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, inArray, isNull, lt } from 'drizzle-orm';
import { and, count, desc, eq, inArray, isNull, lt, notExists } from 'drizzle-orm';
import type { NowPlayingDTO, NowPlayingProvider, ReactionDTO, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
@@ -40,6 +40,18 @@ export async function getReactions(listenIds: string[]): Promise<Map<string, Rea
return result;
}
/** How many comments each play has (from people who aren't suspended). */
export async function getCommentCounts(listenIds: string[]): Promise<Map<string, number>> {
if (listenIds.length === 0) return new Map();
const rows = await db
.select({ listenId: schema.comments.listenId, n: count() })
.from(schema.comments)
.innerJoin(schema.users, eq(schema.users.id, schema.comments.userId))
.where(and(inArray(schema.comments.listenId, listenIds), isNull(schema.users.suspendedAt)))
.groupBy(schema.comments.listenId);
return new Map(rows.map((r) => [r.listenId, r.n]));
}
/** Phones report changes and a heartbeat every few minutes; if they go quiet (app killed, no signal), stop showing "listening now". */
export const DEVICE_STALE_MS = 9 * 60_000;
const PUSH_SOURCES = new Set<NowPlayingProvider>(['android_media_session']);
@@ -63,7 +75,8 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
const l = listenIds.get(np.userId);
return l && l.trackId === np.trackId ? l.id : null;
};
const reactions = await getReactions(rows.map((r) => listenFor(r.np)).filter((id): id is string => Boolean(id)));
const shownListens = rows.map((r) => listenFor(r.np)).filter((id): id is string => Boolean(id));
const [reactions, commentCounts] = await Promise.all([getReactions(shownListens), getCommentCounts(shownListens)]);
const now = Date.now();
return rows
.map(({ np, user }) => ({ np, user, track: tracks.get(np.trackId)! }))
@@ -78,6 +91,7 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
updatedAt: np.updatedAt.toISOString(),
listenId: listenFor(np),
reactions: reactions.get(listenFor(np) ?? '') ?? [],
commentCount: commentCounts.get(listenFor(np) ?? '') ?? 0,
}))
.sort((a, b) => Number(b.isPlaying) - Number(a.isPlaying) || b.updatedAt.localeCompare(a.updatedAt));
}
@@ -108,7 +122,11 @@ async function recordListen(userId: string, trackId: string, source: NowPlayingP
.offset(LISTEN_HISTORY_LIMIT - 1)
.limit(1);
if (cutoff) {
await db.delete(schema.listens).where(and(eq(schema.listens.userId, userId), lt(schema.listens.startedAt, cutoff.startedAt)));
// Plays with comments stay, so conversations don't vanish as new songs come in.
const commented = db.select({ one: schema.comments.id }).from(schema.comments).where(eq(schema.comments.listenId, schema.listens.id));
await db
.delete(schema.listens)
.where(and(eq(schema.listens.userId, userId), lt(schema.listens.startedAt, cutoff.startedAt), notExists(commented)));
}
}
@@ -122,13 +140,19 @@ export async function getRecentPlays(userId: string, limit = 10): Promise<Recent
// Don't repeat the track that's playing right now.
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
const plays = current?.isPlaying && rows[0]?.trackId === current.trackId ? rows.slice(1) : rows.slice(0, limit);
const [tracks, reactions] = await Promise.all([getTrackDTOs(plays.map((p) => p.trackId)), getReactions(plays.map((p) => p.id))]);
const ids = plays.map((p) => p.id);
const [tracks, reactions, commentCounts] = await Promise.all([
getTrackDTOs(plays.map((p) => p.trackId)),
getReactions(ids),
getCommentCounts(ids),
]);
return plays.map((p) => ({
listenId: p.id,
track: tracks.get(p.trackId)!,
source: p.source,
playedAt: p.startedAt.toISOString(),
reactions: reactions.get(p.id) ?? [],
commentCount: commentCounts.get(p.id) ?? 0,
}));
}
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAndroidReleaseCache } from '../src/services/appUpdates';
import { json, routeFetch, useTestApp } from './helpers';
const app = useTestApp();
beforeEach(resetAndroidReleaseCache);
afterEach(() => vi.unstubAllGlobals());
const SHA = 'ab'.repeat(32);
const release = (over: object = {}) => ({
tag_name: 'v1.2.0',
body: 'Lyrics and notifications.\n\n---\nmusicshare-*-foss.apk has no closed-source code.',
draft: false,
prerelease: false,
published_at: '2026-10-15T15:33:31Z',
created_at: '2026-10-15T15:33:00Z',
assets: [
{ name: 'musicshare-1.2.0.apk', size: 77_000_000, browser_download_url: 'https://git.test/me/musicshare/releases/download/v1.2.0/musicshare-1.2.0.apk' },
{ name: 'musicshare-1.2.0-foss.apk', size: 67_000_000, browser_download_url: 'https://git.test/me/musicshare/releases/download/v1.2.0/musicshare-1.2.0-foss.apk' },
{ name: 'SHA256SUMS', size: 200, browser_download_url: 'https://git.test/me/musicshare/releases/download/v1.2.0/SHA256SUMS' },
],
...over,
});
const sums = `${'cd'.repeat(32)} musicshare-1.2.0-foss.apk\n${SHA} musicshare-1.2.0.apk\n`;
describe('app updates', () => {
it('reports the latest release with its checksum and notes, asking the release host only once', async () => {
const auth: (string | null)[] = [];
const seen = (init?: RequestInit) => auth.push(new Headers(init?.headers).get('authorization'));
vi.stubGlobal(
'fetch',
routeFetch([
[/releases\/latest$/, (_url, init) => (seen(init), json(release()))],
[/SHA256SUMS$/, (_url, init) => (seen(init), new Response(sums))],
]),
);
const res = await app().inject({ url: '/app/android/latest' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({
version: '1.2.0',
apkUrl: 'https://api.test/app/android/apk/musicshare-1.2.0.apk',
sha256: SHA,
size: 77_000_000,
notes: 'Lyrics and notifications.',
publishedAt: '2026-10-15T15:33:31Z',
});
expect(auth).toEqual(Array(2).fill(`Basic ${Buffer.from('test-release-token:x-oauth-basic').toString('base64')}`));
await app().inject({ url: '/app/android/latest' });
expect(auth).toHaveLength(2);
});
it('offers nothing for drafts, pre-releases or releases without a checksum', async () => {
for (const over of [{ prerelease: true }, { draft: true }, { assets: release().assets.slice(0, 2) }]) {
resetAndroidReleaseCache();
vi.stubGlobal('fetch', routeFetch([[/releases\/latest$/, () => json(release(over))]]));
expect((await app().inject({ url: '/app/android/latest' })).json()).toBeNull();
}
});
it('streams only the latest APK, never sending the token to another host', async () => {
let apkAuth: string | null = 'unset';
vi.stubGlobal(
'fetch',
routeFetch([
[
/releases\/latest$/,
() =>
json(
release({
assets: [
{ name: 'musicshare-1.2.0.apk', size: 4, browser_download_url: 'https://cdn.example/musicshare-1.2.0.apk' },
{ name: 'SHA256SUMS', size: 200, browser_download_url: 'https://git.test/SHA256SUMS' },
],
}),
),
],
[/SHA256SUMS$/, () => new Response(sums)],
[/cdn\.example/, (_url, init) => ((apkAuth = new Headers(init?.headers).get('authorization')), new Response('APK!'))],
]),
);
expect((await app().inject({ url: '/app/android/apk/musicshare-1.1.0.apk' })).statusCode).toBe(404);
const res = await app().inject({ url: '/app/android/apk/musicshare-1.2.0.apk' });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('application/vnd.android.package-archive');
expect(res.body).toBe('APK!');
expect(apkAuth).toBeNull();
});
it('says 503 when the release host is down', async () => {
vi.stubGlobal('fetch', routeFetch([[/releases\/latest$/, () => json({ message: 'oops' }, 500)]]));
expect((await app().inject({ url: '/app/android/latest' })).statusCode).toBe(503);
});
});
+8
View File
@@ -22,6 +22,14 @@ const photo = () =>
.toBuffer();
describe('avatars', () => {
it('takes a full-size photo from a high-resolution phone camera (64 MP)', async () => {
const { token } = await createUser(app(), 'hires');
const big = await sharp({ create: { width: 8000, height: 8000, channels: 3, background: '#203040' } }).jpeg().toBuffer();
const { payload, headers } = multipart(big);
const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } });
expect(res.statusCode).toBe(200);
});
it('resizes, re-encodes and serves an uploaded picture', async () => {
const { token, user } = await createUser(app(), 'alice');
const { payload, headers } = multipart(await photo());
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import { applyPollResult, LISTEN_HISTORY_LIMIT } from '../src/services/nowPlaying';
import { bearer, createUser, follow, useTestApp } from './helpers';
const app = useTestApp();
const song = (title: string) => ({ track: { title, artist: 'Massive Attack' }, isPlaying: true, progressMs: null });
/** Alice plays a song. Bob follows her and she follows him back; Carol follows her, but she doesn't follow Carol. */
async function setup() {
const alice = await createUser(app(), 'alice');
const bob = await createUser(app(), 'bob');
const carol = await createUser(app(), 'carol');
await follow(app(), bob.token, 'alice');
await follow(app(), alice.token, 'bob');
await follow(app(), carol.token, 'alice');
await applyPollResult(alice.user.id, 'lastfm', song('Teardrop'));
const feed = (await app().inject({ url: '/feed', headers: bearer(bob.token) })).json();
return { alice, bob, carol, listenId: feed[0].listenId as string };
}
const comment = (token: string, listenId: string, body: string) =>
app().inject({ method: 'POST', url: `/listens/${listenId}/comments`, headers: bearer(token), payload: { body } });
const thread = (token: string, listenId: string) => app().inject({ url: `/listens/${listenId}/comments`, headers: bearer(token) });
/** Comment and reply notifications (setup's follows make some too). */
const notifications = async (token: string) =>
((await app().inject({ url: '/notifications', headers: bearer(token) })).json().notifications as { type: string; message: string }[]).filter(
(n) => n.type === 'comment' || n.type === 'reply',
);
describe('comments', () => {
it('lets the player and people they follow comment, oldest first, with a count on the feed', async () => {
const { alice, bob, carol, listenId } = await setup();
expect((await comment(bob.token, listenId, ' what a tune ')).json()).toMatchObject({ body: 'what a tune', canDelete: true });
await comment(alice.token, listenId, 'right?');
const res = await comment(carol.token, listenId, 'hi');
expect(res.statusCode).toBe(403);
expect(res.json().error).toBe('Only people @alice follows can comment on their songs');
const forCarol = (await thread(carol.token, listenId)).json();
expect(forCarol).toMatchObject({ canComment: false, owner: { username: 'alice' }, track: { title: 'Teardrop' } });
expect(forCarol.comments.map((c: { body: string; canDelete: boolean }) => [c.body, c.canDelete])).toEqual([
['what a tune', false],
['right?', false],
]);
expect((await thread(alice.token, listenId)).json().comments.every((c: { canDelete: boolean }) => c.canDelete)).toBe(true);
const feed = (await app().inject({ url: '/feed', headers: bearer(carol.token) })).json();
expect(feed[0].commentCount).toBe(2);
});
it('rejects empty and overlong comments', async () => {
const { bob, listenId } = await setup();
expect((await comment(bob.token, listenId, ' ')).statusCode).toBe(400);
expect((await comment(bob.token, listenId, 'x'.repeat(501))).statusCode).toBe(400);
});
it('notifies the player, and earlier commenters about replies', async () => {
const { alice, bob, listenId } = await setup();
const dave = await createUser(app(), 'dave');
await follow(app(), alice.token, 'dave');
await comment(bob.token, listenId, 'what a tune');
await comment(dave.token, listenId, 'agreed');
await comment(alice.token, listenId, 'thanks both');
expect((await notifications(alice.token)).map((n) => [n.type, n.message])).toEqual([
['comment', 'agreed'],
['comment', 'what a tune'],
]);
expect((await notifications(bob.token)).map((n) => [n.type, n.message])).toEqual([
['reply', 'thanks both'],
['reply', 'agreed'],
]);
const [latest] = (await app().inject({ url: '/notifications', headers: bearer(bob.token) })).json().notifications;
expect(latest).toMatchObject({ type: 'reply', listenId });
});
it('can be deleted by the author, the player or an admin (taking its notifications along), not anyone else', async () => {
const { alice, bob, carol, listenId } = await setup();
const { id } = (await comment(bob.token, listenId, 'first')).json();
const del = (token: string) => app().inject({ method: 'DELETE', url: `/comments/${id}`, headers: bearer(token) });
expect((await del(carol.token)).statusCode).toBe(403);
expect(await notifications(alice.token)).toHaveLength(1);
expect((await del(alice.token)).statusCode).toBe(200);
expect((await thread(bob.token, listenId)).json().comments).toEqual([]);
expect(await notifications(alice.token)).toEqual([]);
});
it('can be reported, keeping what it said for the admins', async () => {
const { bob, carol, listenId } = await setup();
const { id } = (await comment(bob.token, listenId, 'rude words')).json();
const report = (token: string) =>
app().inject({ method: 'POST', url: `/comments/${id}/report`, headers: bearer(token), payload: { reason: 'harassment' } });
expect((await report(bob.token)).statusCode).toBe(400);
expect((await report(carol.token)).statusCode).toBe(200);
});
it('goes away with a block, both ways', async () => {
const { alice, bob, listenId } = await setup();
await comment(bob.token, listenId, 'hello');
await app().inject({ method: 'POST', url: '/users/bob/block', headers: bearer(alice.token), payload: {} });
expect((await thread(bob.token, listenId)).statusCode).toBe(404);
expect((await thread(alice.token, listenId)).json().comments).toEqual([]);
});
it('keeps plays with comments when old history is trimmed', async () => {
const { alice, bob, listenId } = await setup();
await comment(bob.token, listenId, 'keep this');
for (let i = 0; i < LISTEN_HISTORY_LIMIT + 2; i++) {
await applyPollResult(alice.user.id, 'lastfm', song(`Song ${i}`));
}
const res = await thread(bob.token, listenId);
expect(res.statusCode).toBe(200);
expect(res.json().comments).toHaveLength(1);
});
});
+14
View File
@@ -55,6 +55,20 @@ describe('lyrics', () => {
expect(body.plain).toBe('the one');
});
it("uses the search when LRCLIB's exact lookup is rate-limited", async () => {
const { id } = await upsertTrack({ title: 'Midnight City', artist: 'M83', durationMs: 241_000 });
vi.stubGlobal(
'fetch',
routeFetch([
[/\/api\/get\?/, () => json({ message: 'slow down' }, 429)],
[/\/api\/search\?/, () => json([record()])],
]),
);
const res = await lyrics(id);
expect(res.statusCode).toBe(200);
expect(res.json().synced).toHaveLength(2);
});
it('returns null when nothing matches, and 503 when LRCLIB is down', async () => {
const { id } = await upsertTrack({ title: 'Unknown', artist: 'Nobody', durationMs: 100_000 });
vi.stubGlobal('fetch', routeFetch([[/lrclib/, () => json({ message: 'down' }, 500)]]));
+2 -2
View File
@@ -76,8 +76,8 @@ describe('notifications', () => {
it('saves notification settings', async () => {
const { alice } = await setup();
const me = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false } } })).json();
expect(me.notifyPrefs).toEqual({ reaction: true, follow: false, share: true });
const me = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false, reply: false } } })).json();
expect(me.notifyPrefs).toEqual({ reaction: true, comment: true, reply: false, follow: false, share: true });
});
});
+2
View File
@@ -22,6 +22,8 @@ const testEnv = {
SPOTIFY_CLIENT_ID: 'test-spotify-id',
SPOTIFY_CLIENT_SECRET: 'test-spotify-secret',
ODESLI_API_KEY: '',
ANDROID_RELEASES_URL: 'https://git.test/api/v1/repos/me/musicshare/releases/latest',
ANDROID_RELEASES_TOKEN: 'test-release-token',
// Never send real email from tests (apps/api/.env may hold real SMTP settings).
SMTP_HOST: '',
APPLE_TEAM_ID: 'TEAMID1234',
+6 -1
View File
@@ -21,6 +21,11 @@ export default ({ config }: ConfigContext): ExpoConfig => {
return {
...(config as ExpoConfig),
extra: { ...config.extra, foss },
plugins: [...plugins, ['expo-build-properties', { android: { usesCleartextTraffic: localHttp } }] satisfies Plugin],
plugins: [
...plugins,
['expo-build-properties', { android: { usesCleartextTraffic: localHttp } }] satisfies Plugin,
// F-Droid delivers updates itself; only our own builds install them (modules/updater).
...(foss ? ['./plugins/withoutSelfUpdate'] : []),
],
};
};
+3 -3
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "musicshare",
"slug": "musicshare",
"version": "1.1.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": 2
"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

@@ -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,
@@ -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()
}
}
@@ -0,0 +1,18 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'tech.chrisedwards.musicshare.updater'
version = '0.1.0'
android {
namespace "tech.chrisedwards.musicshare.updater"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
@@ -0,0 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Installing our own updates (downloaded from our server, checked against the release's SHA-256).
The F-Droid build removes both: F-Droid delivers its updates itself (plugins/withoutSelfUpdate.js). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Android 12+: lets updates after the first one install without a confirmation screen. -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<application>
<receiver android:name=".InstallResultReceiver" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,41 @@
package tech.chrisedwards.musicshare.updater
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
/** Where the system reports how an install session went. */
class InstallResultReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
if (intent.getBooleanExtra(EXTRA_INTERACTIVE, false)) {
val confirm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
@Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
}
if (confirm != null) {
context.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
UpdaterBus.emit("waiting", null)
return
}
}
// A quiet attempt (app in the background): drop it; the app offers the update next time it's open.
val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
if (sessionId != -1) runCatching { context.packageManager.packageInstaller.abandonSession(sessionId) }
UpdaterBus.emit("needs_user", null)
}
PackageInstaller.STATUS_SUCCESS -> UpdaterBus.emit("installed", null)
PackageInstaller.STATUS_FAILURE_ABORTED -> UpdaterBus.emit("cancelled", message)
else -> UpdaterBus.emit("failed", message)
}
}
companion object {
const val EXTRA_INTERACTIVE = "tech.chrisedwards.musicshare.updater.INTERACTIVE"
}
}
@@ -0,0 +1,14 @@
package tech.chrisedwards.musicshare.updater
/** Hands install results from the receiver (no JS) to the module while the app is open. */
internal object UpdaterBus {
interface Listener {
fun onInstallStatus(status: String, message: String?)
}
@Volatile var listener: Listener? = null
fun emit(status: String, message: String?) {
listener?.onInstallStatus(status, message)
}
}
@@ -0,0 +1,139 @@
package tech.chrisedwards.musicshare.updater
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
class UpdaterModule : Module() {
private val context: Context get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
private val busListener = object : UpdaterBus.Listener {
override fun onInstallStatus(status: String, message: String?) {
sendEvent("onInstallStatus", mapOf("status" to status, "message" to message))
}
}
override fun definition() = ModuleDefinition {
Name("MusicshareUpdater")
Events("onProgress", "onInstallStatus")
OnStartObserving("onInstallStatus") { UpdaterBus.listener = busListener }
OnStopObserving("onInstallStatus") { if (UpdaterBus.listener === busListener) UpdaterBus.listener = null }
/** Delete downloaded updates (once installed, or no longer wanted). */
Function("clearDownloads") {
updatesDir().deleteRecursively()
}
Function("currentVersion") {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "0"
}
// Runs off the main thread (AsyncFunction), so plain blocking I/O is fine.
AsyncFunction("download") { url: String, sha256: String, fileName: String ->
if (!fileName.matches(Regex("[A-Za-z0-9._-]+\\.apk"))) throw UpdateException("Bad file name")
val dir = updatesDir().apply { mkdirs() }
val target = File(dir, fileName)
// Only the newest download is worth keeping.
dir.listFiles()?.filter { it.name != fileName }?.forEach { it.delete() }
if (target.exists() && sha256Of(target) == sha256.lowercase()) return@AsyncFunction target.absolutePath
val partial = File(dir, "$fileName.part")
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
}
try {
if (connection.responseCode != 200) throw UpdateException("Download failed (HTTP ${connection.responseCode})")
val total = connection.contentLengthLong
val digest = MessageDigest.getInstance("SHA-256")
var received = 0L
var lastReported = 0L
connection.inputStream.use { input ->
partial.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
while (true) {
val n = input.read(buffer)
if (n < 0) break
output.write(buffer, 0, n)
digest.update(buffer, 0, n)
received += n
if (received - lastReported > 1_000_000) {
lastReported = received
sendEvent("onProgress", mapOf("received" to received.toDouble(), "total" to total.toDouble()))
}
}
}
}
val actual = digest.digest().joinToString("") { "%02x".format(it) }
if (actual != sha256.lowercase()) {
partial.delete()
throw UpdateException("The download didn't match its checksum")
}
if (!partial.renameTo(target)) throw UpdateException("Couldn't save the download")
target.absolutePath
} finally {
connection.disconnect()
}
}
AsyncFunction("install") { path: String, interactive: Boolean ->
val apk = File(path)
if (!apk.exists() || apk.parentFile?.canonicalPath != updatesDir().canonicalPath) throw UpdateException("No downloaded update")
val installer = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
setAppPackageName(context.packageName)
setSize(apk.length())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
}
val sessionId = installer.createSession(params)
installer.openSession(sessionId).use { session ->
apk.inputStream().use { input ->
session.openWrite("base.apk", 0, apk.length()).use { output ->
input.copyTo(output)
session.fsync(output)
}
}
val intent = Intent(context, InstallResultReceiver::class.java)
.setPackage(context.packageName)
.putExtra(InstallResultReceiver.EXTRA_INTERACTIVE, interactive)
// Mutable: the system adds the result to it.
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
val pending = PendingIntent.getBroadcast(context, sessionId, intent, flags)
session.commit(pending.intentSender)
}
}
}
// Not cacheDir: Android empties caches when storage runs low, which could lose a download before it's installed.
private fun updatesDir() = File(context.noBackupFilesDir, "updates")
private fun sha256Of(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(64 * 1024)
while (true) {
val n = input.read(buffer)
if (n < 0) break
digest.update(buffer, 0, n)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
}
internal class UpdateException(message: String) : CodedException(message)
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["tech.chrisedwards.musicshare.updater.UpdaterModule"]
}
}
+3
View File
@@ -0,0 +1,3 @@
// Android-only native module; the default export is null where it is unavailable.
export { default } from './src/UpdaterModule';
export type { InstallStatus } from './src/UpdaterModule';
@@ -0,0 +1,30 @@
import { NativeModule, requireOptionalNativeModule } from 'expo';
/**
* What the system did with an install: `installed` (rarely seen: updating ourselves ends this process),
* `needs_user` (a quiet install wasn't allowed; it has to be started from the app), `waiting` (the system's
* confirmation screen is showing), `cancelled`, or `failed`.
*/
export type InstallStatus = 'installed' | 'needs_user' | 'waiting' | 'cancelled' | 'failed';
type Events = {
onProgress: (event: { received: number; total: number }) => void;
onInstallStatus: (event: { status: InstallStatus; message: string | null }) => void;
};
declare class MusicshareUpdaterModule extends NativeModule<Events> {
/** Delete downloaded updates (once installed, or no longer wanted). */
clearDownloads(): void;
/** This build's versionName, e.g. "1.1.0". */
currentVersion(): string;
/** Download to the app's cache and check the SHA-256; resolves with the file's path. Reuses an earlier complete download. */
download(url: string, sha256: string, fileName: string): Promise<string>;
/**
* Hand the APK to the system installer. `interactive` shows the system's confirmation when it's needed;
* otherwise only a quiet install is tried. The outcome arrives as onInstallStatus.
*/
install(path: string, interactive: boolean): Promise<void>;
}
/** Android only; null elsewhere and in Expo Go. */
export default requireOptionalNativeModule<MusicshareUpdaterModule>('MusicshareUpdater');
+4 -2
View File
@@ -1,10 +1,10 @@
{
"name": "@musicshare/mobile",
"main": "expo-router/entry",
"version": "1.1.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",
@@ -12,10 +12,12 @@
"expo-camera": "~57.0.6",
"expo-constants": "~57.0.20",
"expo-device": "~57.0.2",
"expo-file-system": "~57.0.7",
"expo-font": "~57.0.4",
"expo-glass-effect": "~57.0.4",
"expo-haptics": "~57.0.3",
"expo-image": "~57.0.5",
"expo-image-manipulator": "~57.0.21",
"expo-image-picker": "~57.0.20",
"expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.11",
+17
View File
@@ -0,0 +1,17 @@
// F-Droid builds (MUSICSHARE_FOSS=1) get their updates from F-Droid, so they mustn't be able to
// install packages themselves: remove the permissions the updater module (modules/updater) adds.
const { AndroidConfig, withAndroidManifest } = require('expo/config-plugins');
const PERMISSIONS = ['android.permission.REQUEST_INSTALL_PACKAGES', 'android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION'];
module.exports = function withoutSelfUpdate(config) {
return withAndroidManifest(config, (cfg) => {
const manifest = AndroidConfig.Manifest.ensureToolsAvailable(cfg.modResults).manifest;
const existing = manifest['uses-permission'] ?? [];
manifest['uses-permission'] = [
...existing.filter((p) => !PERMISSIONS.includes(p.$['android:name'])),
...PERMISSIONS.map((name) => ({ $: { 'android:name': name, 'tools:node': 'remove' } })),
];
return cfg;
});
};
+18 -4
View File
@@ -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
+4 -3
View File
@@ -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">
+26 -9
View File
@@ -1,7 +1,7 @@
import { router } from 'expo-router';
import { useEffect, useState } from 'react';
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated';
import Animated, { FadeIn } from 'react-native-reanimated';
import { SafeAreaView } from 'react-native-safe-area-context';
import useSWR, { useSWRConfig } from 'swr';
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
@@ -10,6 +10,7 @@ import { ActivityBell } from '@/components/ActivityBell';
import { Avatar } from '@/components/Avatar';
import { NowPlayingCard } from '@/components/NowPlayingCard';
import { RelinkReminder } from '@/components/RelinkReminder';
import { UpdateBanner } from '@/components/UpdateBanner';
import { SharingControls } from '@/components/SharingControls';
import { Body, Button, Card, Title } from '@/components/ui';
import { api } from '@/lib/api';
@@ -84,9 +85,9 @@ export default function FeedScreen() {
return (
<SafeAreaView style={styles.safe} edges={['top']}>
<AmbientBackground />
{/* Cards rise in on load and glide to new positions when someone pins or starts playing. */}
{/* No itemLayoutAnimation: it leaves the cards behind when the header above them changes height
(e.g. your own card's lyrics loading), opening a gap under "Friends listening". */}
<Animated.FlatList
itemLayoutAnimation={LinearTransition.springify().damping(18)}
data={feed ?? []}
keyExtractor={(item) => item.user.id}
contentContainerStyle={styles.list}
@@ -94,9 +95,22 @@ 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 />
<SharingControls compact />
<RelinkReminder />
{offerAppleMusic && (
@@ -113,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>
)}
@@ -141,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}>
@@ -160,8 +174,8 @@ export default function FeedScreen() {
</View>
) : null
}
renderItem={({ item, index }) => (
<Animated.View entering={FadeInDown.delay(Math.min(index, 8) * 60).springify().damping(16)}>
renderItem={({ item }) => (
<Animated.View entering={FadeIn.duration(180)}>
<NowPlayingCard
np={item}
preferred={me?.preferredService}
@@ -181,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' },
});
+3 -3
View File
@@ -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 },
});
+90 -8
View File
@@ -1,3 +1,5 @@
import { File } from 'expo-file-system';
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
import * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router';
import { useState } from 'react';
@@ -26,11 +28,12 @@ import { api } from '@/lib/api';
import { WEB_URL } from '@/lib/config';
import { showError } from '@/lib/errors';
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
import { SignInError, openSignupPage, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push';
import { useSession } from '@/lib/session';
import { colors, radius, space } from '@/lib/theme';
import { checkForUpdate, currentVersion, installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
import { colors, fonts, radius, space } from '@/lib/theme';
export default function SettingsScreen() {
return (
@@ -44,6 +47,7 @@ export default function SettingsScreen() {
<ThisPhone />
<Connections />
<FeaturedPicker />
<AppVersion />
<Account />
</Screen>
);
@@ -101,10 +105,18 @@ function AvatarPicker() {
});
const asset = result.canceled ? null : result.assets[0];
if (!asset) return;
run(async () => {
// Shrink first: photos from 50-200 MP cameras are huge, and the server keeps 512 px anyway.
const side = Math.min(1024, asset.width || 1024);
const image = await (await ImageManipulator.manipulate(asset.uri).resize({ width: side }).renderAsync()).saveAsync({
compress: 0.85,
format: SaveFormat.JPEG,
});
const form = new FormData();
// React Native's FormData takes a file as { uri, name, type }.
form.append('file', { uri: asset.uri, name: asset.fileName ?? 'avatar.jpg', type: asset.mimeType ?? 'image/jpeg' } as unknown as Blob);
run(() => api.uploadAvatar(form));
// Expo's fetch needs a real file object (it rejects React Native's { uri, name, type } parts).
form.append('file', new File(image.uri) as unknown as Blob);
return api.uploadAvatar(form);
});
};
const remove = () =>
@@ -289,6 +301,7 @@ function Connections() {
});
const linkSpotifyOnLastfm = () => openSpotifyLinkPage('lastfm').catch((e) => showError(e));
const linkSpotifyOnListenBrainz = () => openSpotifyLinkPage('listenbrainz').catch((e) => showError(e));
const spotifyLinked = byProvider.has('lastfm') || byProvider.has('listenbrainz');
const disconnect = (provider: NowPlayingProvider) =>
@@ -382,8 +395,10 @@ function Connections() {
<Button small variant="primary" title="Sign in" onPress={() => connectOAuth('lastfm')} />
)}
</View>
{byProvider.has('lastfm') && (
{byProvider.has('lastfm') ? (
<Button small title="Link Spotify to Last.fm" onPress={linkSpotifyOnLastfm} style={{ alignSelf: 'flex-start' }} />
) : (
<Button small title="No account? Create one" onPress={() => openSignupPage('lastfm')} style={{ alignSelf: 'flex-start' }} />
)}
</View>
@@ -393,6 +408,7 @@ function Connections() {
active={byProvider.has('listenbrainz')}
onConnect={(username) => run(() => api.connectUsername('listenbrainz', username))}
onDisconnect={() => disconnect('listenbrainz')}
onLinkSpotify={linkSpotifyOnListenBrainz}
/>
<JellyfinConnection
@@ -417,12 +433,14 @@ function UsernameConnection({
active,
onConnect,
onDisconnect,
onLinkSpotify,
}: {
provider: 'lastfm' | 'listenbrainz';
status: React.ReactNode;
active: boolean;
onConnect: (username: string) => Promise<void>;
onDisconnect: () => void;
onLinkSpotify: () => void;
}) {
const [username, setUsername] = useState('');
const [busy, setBusy] = useState(false);
@@ -454,6 +472,18 @@ function UsernameConnection({
/>
</Row>
)}
{active ? (
<Button small title={`Link Spotify to ${SERVICE_LABELS[provider]}`} onPress={onLinkSpotify} style={{ alignSelf: 'flex-start' }} />
) : (
<>
<Button small title="No account? Create one" onPress={() => openSignupPage(provider)} style={{ alignSelf: 'flex-start' }} />
{provider === 'listenbrainz' && (
<Body muted style={styles.small}>
ListenBrainz uses MusicBrainz accounts: create one there, then sign in to ListenBrainz once.
</Body>
)}
</>
)}
</View>
);
}
@@ -514,6 +544,58 @@ function JellyfinConnection({
);
}
/** This build's version, and checking for a newer one (our own APKs; F-Droid updates its builds itself). */
function AppVersion() {
const state = useUpdateState();
if (!updaterAvailable) return null;
const status = (() => {
switch (state.status) {
case 'checking':
return 'Checking for updates…';
case 'upToDate':
return 'Up to date.';
case 'downloading':
return `Downloading ${state.update.version}… ${Math.round(state.progress * 100)}%`;
case 'ready':
return `Version ${state.update.version} is ready to install.`;
case 'installing':
return `Installing ${state.update.version}…`;
case 'error':
return state.message;
default:
return null;
}
})();
return (
<Card>
<Heading>App version {currentVersion()}</Heading>
{status && (
<Body muted style={styles.small}>
{status}
</Body>
)}
{state.status === 'ready' || state.status === 'installing' ? (
<Button
small
variant="primary"
title="Install update"
disabled={state.status === 'installing'}
onPress={() => installUpdate(true)}
style={{ alignSelf: 'flex-start' }}
/>
) : (
<Button
small
title="Check for updates"
loading={state.status === 'checking' || state.status === 'downloading'}
onPress={() => checkForUpdate({ force: true })}
style={{ alignSelf: 'flex-start' }}
/>
)}
</Card>
);
}
function Account() {
const { me, setMe, signOut } = useSession();
const resend = async () => {
@@ -588,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: {
@@ -598,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 },
});
+9 -2
View File
@@ -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';
@@ -12,6 +12,7 @@ import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic';
import { syncDevice } from '@/lib/nowPlayingReader';
import { syncPush } from '@/lib/push';
import { SessionProvider, useSession } from '@/lib/session';
import { startUpdater } from '@/lib/updates';
import { colors, space } from '@/lib/theme';
SplashScreen.preventAutoHideAsync();
@@ -36,13 +37,18 @@ 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(() => {
if (ready) SplashScreen.hideAsync();
}, [ready]);
// Look for a newer version of the app (our own APKs only; see lib/updates.ts).
useEffect(() => {
if (ready) startUpdater();
}, [ready]);
// Keep the Android listener pointed at this account (no-op elsewhere).
useEffect(() => {
if (signedIn) syncDevice().catch(() => {});
@@ -103,6 +109,7 @@ function AppNavigator() {
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
<Stack.Screen name="notifications" options={{ title: 'Notifications' }} />
<Stack.Screen name="t/[id]" options={{ title: '' }} />
<Stack.Screen name="play/[id]" options={{ title: 'Comments' }} />
<Stack.Screen name="send/[trackId]" options={{ title: 'Send to friends', presentation: 'modal' }} />
</Stack.Protected>
<Stack.Protected guard={!signedIn}>
+2 -2
View File
@@ -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 },
});
+3 -3
View File
@@ -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 },
});
+3 -3
View File
@@ -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 },
});
+3 -3
View File
@@ -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' },
});
+195
View File
@@ -0,0 +1,195 @@
import { Stack, router, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import useSWR from 'swr';
import { COMMENT_MAX_LENGTH, timeAgo, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
import { AmbientBackground } from '@/components/AmbientBackground';
import { Avatar } from '@/components/Avatar';
import { Reactions } from '@/components/Reactions';
import { TrackCard } from '@/components/TrackCard';
import { Body, Card } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { tapHaptic } from '@/lib/haptics';
import { useSession } from '@/lib/session';
import { colors, fonts, radius, space } from '@/lib/theme';
/** One play and its comments: where the 💬 chip and comment notifications lead. */
export default function PlayScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { me } = useSession();
// Polled while open so other people's comments show up without refreshing.
const { data, error, mutate } = useSWR<CommentThreadDTO>(id ? `/listens/${id}/comments` : null, { refreshInterval: 5000 });
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const send = async () => {
const body = draft.trim();
if (!body || !id) return;
setSending(true);
try {
const comment = await api.addComment(id, { body });
tapHaptic();
setDraft('');
await mutate((t) => (t ? { ...t, comments: [...t.comments, comment] } : t), { revalidate: false });
} catch (err) {
showError(err, "Couldn't post that");
}
setSending(false);
};
const remove = (c: CommentDTO) =>
Alert.alert('Delete this comment?', undefined, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await api.deleteComment(c.id);
await mutate((t) => (t ? { ...t, comments: t.comments.filter((x) => x.id !== c.id) } : t), { revalidate: false });
} catch (err) {
showError(err, "Couldn't delete it");
}
},
},
]);
const options = (c: CommentDTO) => {
const mine = c.user.id === me?.id;
Alert.alert(c.user.displayName ?? c.user.username, undefined, [
...(c.canDelete ? [{ text: 'Delete', style: 'destructive' as const, onPress: () => remove(c) }] : []),
...(!mine ? [{ text: 'Report', onPress: () => router.push(`/report/${c.user.username}?comment=${c.id}`) }] : []),
{ text: 'Cancel', style: 'cancel' as const },
]);
};
if (error) {
return (
<View style={styles.center}>
<Stack.Screen options={{ title: '' }} />
<Body muted>That play is gone.</Body>
</View>
);
}
if (!data) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.muted} />
</View>
);
}
const owner = data.owner.displayName ?? data.owner.username;
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<Stack.Screen options={{ title: 'Comments' }} />
<AmbientBackground />
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={90}>
<FlatList
data={data.comments}
keyExtractor={(c) => c.id}
contentContainerStyle={styles.list}
keyboardShouldPersistTaps="handled"
ListHeaderComponent={
<View style={styles.header}>
<Pressable onPress={() => router.push(`/u/${data.owner.username}`)} style={styles.playedBy}>
<Avatar user={data.owner} size={28} />
<Text style={styles.muted}>
<Text style={styles.name}>{owner}</Text> played this · {timeAgo(data.playedAt)}
</Text>
</Pressable>
<TrackCard track={data.track} preferred={me?.preferredService} />
<Reactions listenId={data.listenId} reactions={data.reactions} ownerId={data.owner.id} style={{ marginTop: 0 }} />
</View>
}
ListEmptyComponent={<Body muted>{data.canComment ? 'No comments yet. Say something!' : 'No comments yet.'}</Body>}
renderItem={({ item }) => (
<Pressable onLongPress={() => options(item)} delayLongPress={300} style={styles.comment}>
<Pressable onPress={() => router.push(`/u/${item.user.username}`)} hitSlop={4}>
<Avatar user={item.user} size={34} />
</Pressable>
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
<Text style={styles.muted}>
<Text style={styles.name}>{item.user.displayName ?? item.user.username}</Text> · {timeAgo(item.createdAt)}
</Text>
<Text style={styles.body} selectable>
{item.body}
</Text>
</View>
<Pressable onPress={() => options(item)} hitSlop={10} accessibilityLabel="Comment options">
<Text style={styles.more}>⋯</Text>
</Pressable>
</Pressable>
)}
/>
{data.canComment ? (
<View style={styles.composer}>
<TextInput
value={draft}
onChangeText={setDraft}
placeholder={data.owner.id === me?.id ? 'Comment on your song' : `Comment on ${owner}'s song`}
placeholderTextColor={colors.muted}
multiline
maxLength={COMMENT_MAX_LENGTH}
style={styles.input}
/>
<Pressable
onPress={send}
disabled={!draft.trim() || sending}
accessibilityRole="button"
accessibilityLabel="Post comment"
style={[styles.send, (!draft.trim() || sending) && { opacity: 0.4 }]}>
{sending ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.sendText}>Post</Text>}
</Pressable>
</View>
) : (
<Card style={styles.closed}>
<Body muted style={{ fontSize: 13 }}>
Only people {owner} follows can comment on their songs.
</Body>
</Card>
)}
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg },
list: { padding: space.lg, gap: space.md, paddingBottom: space.xl },
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, 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',
alignItems: 'flex-end',
gap: space.sm,
paddingHorizontal: space.lg,
paddingVertical: space.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
backgroundColor: colors.bg,
},
input: {
flex: 1,
maxHeight: 120,
color: colors.text,
fontSize: 15,
paddingHorizontal: space.md,
paddingVertical: space.sm,
borderRadius: radius.md,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
},
send: { paddingHorizontal: space.md, paddingVertical: space.sm + 2, borderRadius: radius.pill, backgroundColor: colors.accent },
sendText: { color: '#fff', fontFamily: fonts.display },
closed: { margin: space.lg, marginTop: 0 },
});
+3 -3
View File
@@ -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 },
});
+5 -3
View File
@@ -8,7 +8,8 @@ import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
export default function ReportScreen() {
const { username } = useLocalSearchParams<{ username: string }>();
// ?comment=<id> reports one comment of theirs rather than their account.
const { username, comment } = useLocalSearchParams<{ username: string; comment?: string }>();
const [reason, setReason] = useState<ReportReason | null>(null);
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
@@ -18,7 +19,8 @@ export default function ReportScreen() {
if (!reason) return;
setBusy(true);
try {
await api.report(username, { reason, note: note.trim() || null });
const input = { reason, note: note.trim() || null };
await (comment ? api.reportComment(comment, input) : api.report(username, input));
setSent(true);
} catch (err) {
showError(err, "Couldn't send the report");
@@ -38,7 +40,7 @@ export default function ReportScreen() {
return (
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<Screen>
<Body muted>What&apos;s wrong with @{username}&apos;s account?</Body>
<Body muted>{comment ? `What's wrong with @${username}'s comment?` : `What's wrong with @${username}'s account?`}</Body>
<Row>
{REPORT_REASONS.map((r) => (
<Chip key={r} label={REPORT_REASON_LABELS[r]} selected={reason === r} onPress={() => setReason(r)} />
+2 -2
View File
@@ -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,
+5 -5
View File
@@ -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 },
});
+9 -6
View File
@@ -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 },
});
+2 -2
View File
@@ -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 },
});
+2 -2
View File
@@ -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 },
});
+3 -3
View File
@@ -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 },
});
+13 -11
View File
@@ -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&apos;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&apos;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' },
});
+24 -26
View File
@@ -1,11 +1,9 @@
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
import Animated, {
FadeIn,
FadeInDown,
FadeOut,
useAnimatedStyle,
useSharedValue,
@@ -14,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';
@@ -55,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 && (
@@ -75,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]}
@@ -98,11 +93,11 @@ export function NowPlayingCard({
</View>
</View>
<Animated.View key={np.track.id} entering={FadeInDown.duration(380).springify()} exiting={FadeOut.duration(200)}>
<Animated.View key={np.track.id} entering={FadeIn.duration(250)} exiting={FadeOut.duration(200)}>
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={toggle} />
</Animated.View>
<ProgressBar np={np} compact={!expanded} />
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} commentCount={np.commentCount ?? 0} />
{expanded && (
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(120)} style={styles.details}>
<ServiceLinks track={np.track} preferred={preferred} />
@@ -110,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 },
});
+3 -3
View File
@@ -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',
+9 -8
View File
@@ -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'] },
});
+30 -15
View File
@@ -1,26 +1,30 @@
import { useState } from 'react';
import { FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View, type ViewStyle } from 'react-native';
import Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';
import { router } from 'expo-router';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
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); "☺+"
* opens the quick six, and "+" the full picker. One reaction per person.
* With `commentCount`, a 💬 chip opens the play's comments.
*/
export function Reactions({
listenId,
reactions,
ownerId,
commentCount,
style,
}: {
listenId: string | null;
reactions: ReactionDTO[];
ownerId: string;
commentCount?: number;
style?: ViewStyle;
}) {
const { me } = useSession();
@@ -31,7 +35,8 @@ export function Reactions({
const [pickerOpen, setPickerOpen] = useState(false);
const canReact = Boolean(me && listenId && me.id !== ownerId);
if (!listenId || (!canReact && shown.length === 0)) return null;
const showComments = commentCount !== undefined && Boolean(me);
if (!listenId || (!canReact && shown.length === 0 && !showComments)) return null;
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
const choose = async (emoji: string) => {
@@ -50,7 +55,7 @@ export function Reactions({
<View style={[styles.wrap, style]}>
<View style={styles.row}>
{shown.map((r) => (
<Animated.View key={r.emoji} entering={ZoomIn.springify()}>
<Animated.View key={r.emoji} entering={FadeIn.duration(150)}>
<Pressable
disabled={!canReact}
onPress={() => choose(r.emoji)}
@@ -73,6 +78,17 @@ export function Reactions({
<Text style={[styles.count, { color: colors.muted }]}>+</Text>
</Pressable>
)}
{showComments && (
<Pressable
onPress={() => router.push(`/play/${listenId}`)}
accessibilityRole="button"
accessibilityLabel={commentCount ? `${commentCount} comment${commentCount === 1 ? '' : 's'}` : 'Comments'}
hitSlop={6}
style={({ pressed }) => [styles.chip, pressed && { transform: [{ scale: 0.9 }] }]}>
<Text style={styles.emoji}>💬</Text>
{commentCount ? <Text style={styles.count}>{commentCount}</Text> : null}
</Pressable>
)}
</View>
{quickOpen && (
<Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(120)} style={styles.quick}>
@@ -152,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',
@@ -166,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,
@@ -183,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,
+5 -5
View File
@@ -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';
@@ -47,7 +47,7 @@ export function RecentPlays({
</View>
<Text style={styles.time}>{timeAgo(p.playedAt)}</Text>
</Pressable>
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} style={styles.reactions} />
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} commentCount={p.commentCount ?? 0} style={styles.reactions} />
</View>
))}
{credits.map((s) => (
@@ -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 },
});
+19 -19
View File
@@ -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,
},
});
@@ -0,0 +1,32 @@
import { StyleSheet } from 'react-native';
import { installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
import { Body, Button, Card, Heading } from './ui';
/** "Update ready" on the feed, once a newer version has downloaded (lib/updates.ts). */
export function UpdateBanner() {
const state = useUpdateState();
if (!updaterAvailable || (state.status !== 'ready' && state.status !== 'installing')) return null;
return (
<Card highlighted>
<Heading>musicshare {state.update.version} is ready</Heading>
{state.update.notes ? (
<Body muted style={styles.notes} numberOfLines={4}>
{state.update.notes}
</Body>
) : null}
<Button
small
variant="primary"
title={state.status === 'installing' ? 'Installing…' : 'Install update'}
disabled={state.status === 'installing'}
onPress={() => installUpdate(true)}
style={styles.button}
/>
</Card>
);
}
const styles = StyleSheet.create({
notes: { fontSize: 13 },
button: { alignSelf: 'flex-start' },
});
@@ -1,8 +1,7 @@
import { useState } from 'react';
import { Linking } from 'react-native';
import { mutate } from 'swr';
import { SERVICE_LABELS } from '@musicshare/shared';
import { SignInError, signInWithService } from '@/lib/connect';
import { SignInError, openSignupPage, signInWithService } from '@/lib/connect';
import { Button } from '../ui';
import { ErrorText, SetupActions, SetupHeader, type SetupProps } from './common';
@@ -57,7 +56,7 @@ export function ServiceSignInSetup({
/>
<ErrorText>{error}</ErrorText>
<SetupActions primary="Sign in with Last.fm" loading={busy} onPrimary={run} onSkip={onDone} skipLabel={skipLabel} />
<Button small title="New to Last.fm? Create an account" onPress={() => Linking.openURL('https://www.last.fm/join')} />
<Button small title="New to Last.fm? Create an account" onPress={() => openSignupPage('lastfm')} />
</>
);
}
@@ -1,11 +1,11 @@
import { useState } from 'react';
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import useSWR, { mutate } from 'swr';
import type { ConnectionDTO } from '@musicshare/shared';
import { api } from '@/lib/api';
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
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';
@@ -108,7 +108,7 @@ export function SpotifySetup({ onDone, skipLabel }: SetupProps) {
onPress={() => setVia('listenbrainz')}
title="ListenBrainz"
badge="Open source"
detail="Free and run by a nonprofit. Enter your ListenBrainz username."
detail="Free and run by a nonprofit. Enter your ListenBrainz username, then approve Spotify on ListenBrainz."
/>
{via === 'listenbrainz' && (
@@ -123,9 +123,16 @@ export function SpotifySetup({ onDone, skipLabel }: SetupProps) {
)}
<Button title={skipLabel ?? 'Not now'} onPress={onDone} />
</View>
<Body muted style={styles.small} onPress={() => Linking.openURL(via === 'listenbrainz' ? 'https://listenbrainz.org/login/' : 'https://www.last.fm/join')}>
No account yet? Create one free on {via === 'listenbrainz' ? 'ListenBrainz' : 'Last.fm'} first.
{via && (
<View style={{ gap: space.xs }}>
<Button small title={`No account? Create one on ${via === 'listenbrainz' ? 'MusicBrainz' : 'Last.fm'}`} onPress={() => openSignupPage(via)} />
{via === 'listenbrainz' && (
<Body muted style={styles.small}>
ListenBrainz uses MusicBrainz accounts. After creating one, sign in to ListenBrainz once, then come back.
</Body>
)}
</View>
)}
</>
);
}
@@ -146,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 },
});
+2 -2
View File
@@ -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 },
});
+40 -36
View File
@@ -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 },
});
+4 -1
View File
@@ -1,6 +1,6 @@
import * as Linking from 'expo-linking';
import * as WebBrowser from 'expo-web-browser';
import { SPOTIFY_LINK_PAGES } from '@musicshare/shared';
import { SIGNUP_PAGES, SPOTIFY_LINK_PAGES } from '@musicshare/shared';
import { api } from './api';
export type SignInResult = 'connected' | 'cancelled';
@@ -25,3 +25,6 @@ export async function signInWithService(provider: 'plex' | 'lastfm'): Promise<Si
/** Where users approve "Spotify Scrobbling" (Last.fm) or the Spotify link (ListenBrainz). */
export const openSpotifyLinkPage = (on: keyof typeof SPOTIFY_LINK_PAGES = 'lastfm') =>
WebBrowser.openBrowserAsync(SPOTIFY_LINK_PAGES[on]);
/** Their sign-up page, in the in-app browser (ListenBrainz accounts start on MusicBrainz). */
export const openSignupPage = (service: keyof typeof SIGNUP_PAGES) => WebBrowser.openBrowserAsync(SIGNUP_PAGES[service]);
+53 -19
View File
@@ -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 };
}
+133
View File
@@ -0,0 +1,133 @@
import { useSyncExternalStore } from 'react';
import { AppState } from 'react-native';
import { isNewerVersion, type AppUpdateDTO } from '@musicshare/shared';
import Updater, { type InstallStatus } from '@/modules/updater';
import { api } from './api';
import { FOSS_BUILD } from './build';
/**
* Self-updating for the APKs we publish (not F-Droid builds, which F-Droid updates, nor dev builds).
*
* When the app opens (at most every few hours) it asks the server for the newest release and, if
* it's newer, downloads it in the background and checks its SHA-256. Then:
* - leaving the app tries a quiet install, once per version. Android 12+ allows that once this app has
* installed an update itself (and "install unknown apps" is allowed for it); the new version runs next
* time it's opened. On phones with Google Play, Play Protect may still ask to scan a new version.
* - otherwise the feed shows "Update ready", and tapping it opens Android's install screen.
*/
export const updaterAvailable = Updater !== null && !FOSS_BUILD && !__DEV__;
export type UpdateState =
| { status: 'idle' | 'checking' | 'upToDate' }
| { status: 'downloading'; update: AppUpdateDTO; progress: number }
| { status: 'ready' | 'installing'; update: AppUpdateDTO; path: string }
| { status: 'error'; message: string; update?: AppUpdateDTO };
const CHECK_EVERY_MS = 6 * 3600_000;
let state: UpdateState = { status: 'idle' };
let lastCheck = 0;
/** Versions we've already tried to install on the way out; after that, only when asked (no repeat prompts). */
const autoTried = new Set<string>();
const listeners = new Set<() => void>();
function set(next: UpdateState) {
// Visible with `adb logcat -s ReactNativeJS`, for checking updates on a real phone.
if (next.status !== state.status) console.log(`[updates] ${next.status}${'message' in next ? `: ${next.message}` : ''}`);
state = next;
listeners.forEach((l) => l());
}
export const currentVersion = () => Updater?.currentVersion() ?? null;
/** Look for an update and download it. `force` skips the every-few-hours limit (the Settings button). */
export async function checkForUpdate({ force = false } = {}): Promise<void> {
if (!Updater || !updaterAvailable) return;
if (['checking', 'downloading', 'installing'].includes(state.status)) return;
if (state.status === 'ready') return; // already have it
if (!force && Date.now() - lastCheck < CHECK_EVERY_MS) return;
lastCheck = Date.now();
set({ status: 'checking' });
let update: AppUpdateDTO | null;
try {
update = await api.androidUpdate();
} catch {
set({ status: 'error', message: "Couldn't check for updates." });
return;
}
if (!update || !isNewerVersion(update.version, Updater.currentVersion())) {
Updater.clearDownloads(); // e.g. the update we downloaded has since been installed
set({ status: 'upToDate' });
return;
}
set({ status: 'downloading', update, progress: 0 });
const progress = Updater.addListener('onProgress', ({ received, total }) => {
if (state.status === 'downloading') set({ ...state, progress: total > 0 ? received / total : 0 });
});
try {
const path = await Updater.download(update.apkUrl, update.sha256, `musicshare-${update.version}.apk`);
set({ status: 'ready', update, path });
} catch (err) {
set({ status: 'error', message: err instanceof Error ? err.message : "Couldn't download the update.", update });
} finally {
progress.remove();
}
}
/** Install the downloaded update. `interactive` shows Android's confirmation screen if it needs one. */
export async function installUpdate(interactive: boolean): Promise<void> {
if (!Updater || state.status !== 'ready') return;
const ready = state;
set({ ...ready, status: 'installing' });
try {
await Updater.install(ready.path, interactive);
} catch (err) {
set({ status: 'error', message: err instanceof Error ? err.message : "Couldn't install the update.", update: ready.update });
lastCheck = 0; // e.g. the download went missing: fetch it again next time
}
}
function onInstallStatus(status: InstallStatus, message: string | null) {
if (state.status !== 'installing') return;
const { update, path } = state;
switch (status) {
case 'waiting':
return; // Android's screen is up; its answer comes next
case 'needs_user':
case 'cancelled':
return set({ status: 'ready', update, path });
case 'installed':
return set({ status: 'upToDate' });
case 'failed':
return set({ status: 'error', message: message ?? "Couldn't install the update.", update });
}
}
let started = false;
/** Call once at startup: checks now, again when the app comes back, and tries a quiet install on the way out. */
export function startUpdater() {
if (started || !Updater || !updaterAvailable) return;
started = true;
Updater.addListener('onInstallStatus', ({ status, message }) => onInstallStatus(status, message));
AppState.addEventListener('change', (appState) => {
if (appState === 'active') checkForUpdate().catch(() => {});
else if (appState === 'background' && state.status === 'ready' && !autoTried.has(state.update.version)) {
autoTried.add(state.update.version);
installUpdate(false).catch(() => {});
}
});
checkForUpdate().catch(() => {});
}
export function useUpdateState(): UpdateState {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
() => state,
);
}
+5
View File
@@ -71,6 +71,11 @@ export default function AdminPage() {
<strong>{REPORT_REASON_LABELS[r.reason as ReportReason] ?? r.reason}</strong>
{r.reporter && <span className="muted"> · reported by @{r.reporter.username}</span>}
</p>
{r.comment && (
<p className="muted small">
Their comment{r.comment.id ? '' : ' (since deleted)'}: <q>{r.comment.body}</q>
</p>
)}
{r.note && <blockquote>{r.note}</blockquote>}
<div className="row">
{r.status === 'open' && (
+189 -111
View File
@@ -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,20 +120,38 @@ 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; }
a.reaction { text-decoration: none; }
/* Comments on a play (/play/[id]) */
.played-by { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
.played-by-user { display: inline-flex; align-items: center; gap: 0.4rem; color: var(--text); }
.comments { display: flex; flex-direction: column; gap: 0.9rem; }
.comments h2 { margin: 0; }
.comment { display: flex; gap: 0.75rem; align-items: flex-start; }
.comment-main { flex: 1; min-width: 0; }
.comment-main a { color: var(--text); }
.comment-body { margin: 0.15rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.45; }
.comment-actions { display: flex; gap: 0.6rem; opacity: 0.6; }
.comment:hover .comment-actions, .comment:focus-within .comment-actions { opacity: 1; }
.link-button { background: none; border: 0; padding: 0; font: inherit; font-size: 0.8rem; color: var(--muted); cursor: pointer; }
.link-button:hover { color: var(--text); text-decoration: underline; }
.comment-form { display: flex; gap: 0.6rem; align-items: flex-end; }
.comment-form textarea { flex: 1; resize: vertical; min-height: 2.6rem; }
@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; }
@@ -149,8 +165,8 @@ input:focus, textarea:focus, select:focus {
.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; }
@@ -168,25 +184,22 @@ input:focus, textarea:focus, select:focus {
.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) {
@@ -197,41 +210,40 @@ input:focus, textarea:focus, select:focus {
.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; }
@@ -245,45 +257,46 @@ input:focus, textarea:focus, select:focus {
.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; }
@@ -296,15 +309,15 @@ input:focus, textarea:focus, select:focus {
.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; }
@@ -318,9 +331,9 @@ input:focus, textarea:focus, select:focus {
.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; }
@@ -330,37 +343,37 @@ input:focus, textarea:focus, select:focus {
.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); }
@@ -368,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; }
@@ -393,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) {
@@ -410,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; }
+11 -9
View File
@@ -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>
+2 -2
View File
@@ -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' },
+44 -7
View File
@@ -8,32 +8,43 @@ import { notificationPath, notificationText, timeAgo, type NotificationListDTO }
import { Avatar } from '@/components/Avatar';
import { client, useMe } from '@/lib/api';
/** Reactions, new followers and songs sent to you, newest first. Opening it marks them read. */
const ADMIN_ACTOR_USERNAME = 'seriouscritter';
/** Reactions, comments, new followers and songs sent to you, newest first. Opening it marks them read. */
export default function NotificationsPage() {
const { me, loggedOut } = useMe();
const { mutate } = useSWRConfig();
// Fetch all notifications for the user
const { data, size, setSize, isLoading } = useSWRInfinite<NotificationListDTO>(
(i, prev: NotificationListDTO | null) => (!me || (i > 0 && !prev?.nextCursor) ? null : ['notifications', prev?.nextCursor ?? null]),
([, cursor]: [string, string | null]) => client.notifications(cursor),
{ revalidateOnFocus: false },
);
const items = data?.flatMap((p) => p.notifications) ?? [];
const allItems = data?.flatMap((p) => p.notifications) ?? [];
const unread = data?.[0]?.unread ?? 0;
// Filter messages specifically from seriouscritter
const seriousCritterMessages = allItems.filter(n => n.actor?.username === ADMIN_ACTOR_USERNAME);
// Seen once you've looked: clear the bell (rows stay highlighted until you come back).
useEffect(() => {
if (unread) client.markNotificationsRead().then(() => mutate('/notifications'), () => {});
}, [unread, mutate]);
if (loggedOut) return <p className="muted"><Link href="/login">Log in</Link> to see your notifications.</p>;
return (
<div className="stack">
<h1>Notifications</h1>
{items.length === 0 && !isLoading && (
<p className="muted">Nothing yet. Reactions, new followers and songs sent to you show up here.</p>
)}
{/* Dedicated section for seriouscritter's messages */}
{seriousCritterMessages.length > 0 && (
<div className="admin-section">
<h2 className="muted">From {ADMIN_ACTOR_USERNAME}</h2>
<ul className="notifications">
{items.map((n) => {
{seriousCritterMessages.map((n) => {
const { title, body } = notificationText(n);
return (
<li key={n.id} className={n.read ? '' : 'unread'}>
@@ -41,7 +52,7 @@ export default function NotificationsPage() {
<Avatar user={n.actor} size={40} />
<span className="notification-text">
<strong>{title}</strong>
<span>{body}</span>
<span>{body</span >
<span className="muted">{timeAgo(n.createdAt)}</span>
</span>
{n.track?.artworkUrl && <img className="artwork-sm" src={n.track.artworkUrl} alt="" />}
@@ -50,6 +61,32 @@ export default function NotificationsPage() {
);
})}
</ul>
</div>
)}
{/* General notifications list */}
{allItems.length === 0 && !isLoading && (
<p className="muted">Nothing yet. Reactions, new followers and songs sent to you show up here.</p>
)}
<ul className="notifications">
{allItems.map((n) => {
const { title, body } = notificationText(n);
return (
<li key={n.id} className={n.read ? '' : 'unread'}>
<Link href={notificationPath(n)} className="notification">
<Avatar user={n.actor} size={40} />
<span className="notification-text">
<strong>{title}</strong>
<span>{body</span >
<span className="muted">{timeAgo(n.createdAt)}</span>
</span>
{n.track?.artworkUrl && <img className="artwork-sm" src={n.track.artworkUrl} alt="" />}
</Link>
</li>
);
})}
</ul>
{data?.at(-1)?.nextCursor && (
<button className="btn" onClick={() => setSize(size + 1)}>
Show more
+16 -8
View File
@@ -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}
+145
View File
@@ -0,0 +1,145 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useState } from 'react';
import useSWR from 'swr';
import { COMMENT_MAX_LENGTH, timeAgo, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { ReportForm } from '@/components/ProfileMenu';
import { Reactions } from '@/components/Reactions';
import { TrackCard } from '@/components/TrackCard';
import { client, fetcher, useMe } from '@/lib/api';
/** One play and its comments: where the 💬 link and comment notifications lead. */
export default function PlayPage() {
const { id } = useParams<{ id: string }>();
const { me, loggedOut } = useMe();
// Polled while open so other people's comments show up without reloading.
const { data, error, mutate } = useSWR<CommentThreadDTO>(me ? `/listens/${id}/comments` : null, fetcher, { refreshInterval: 5000 });
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [problem, setProblem] = useState<string | null>(null);
const [reporting, setReporting] = useState<CommentDTO | null>(null);
if (loggedOut) {
return (
<p className="muted">
<Link href="/login">Log in</Link> to see the comments.
</p>
);
}
if (error) return <p className="muted">That play is gone.</p>;
if (!data) return null;
const owner = data.owner.displayName ?? data.owner.username;
const send = async (e: React.FormEvent) => {
e.preventDefault();
const body = draft.trim();
if (!body) return;
setSending(true);
setProblem(null);
try {
const comment = await client.addComment(id, { body });
setDraft('');
await mutate((t) => (t ? { ...t, comments: [...t.comments, comment] } : t), { revalidate: false });
} catch (err) {
setProblem((err as Error).message);
}
setSending(false);
};
const remove = async (c: CommentDTO) => {
if (!confirm('Delete this comment?')) return;
try {
await client.deleteComment(c.id);
await mutate((t) => (t ? { ...t, comments: t.comments.filter((x) => x.id !== c.id) } : t), { revalidate: false });
} catch (err) {
setProblem((err as Error).message);
}
};
return (
<div className="stack">
<p className="muted played-by">
<Link href={`/u/${data.owner.username}`} className="played-by-user">
<Avatar user={data.owner} size={28} />
<strong>{owner}</strong>
</Link>{' '}
played this · {timeAgo(data.playedAt)}
</p>
<section className="card">
<TrackCard track={data.track} preferred={me?.preferredService} />
<Reactions listenId={data.listenId} reactions={data.reactions} ownerId={data.owner.id} />
</section>
<section className="card comments">
<h2>Comments</h2>
{data.comments.length === 0 && <p className="muted">{data.canComment ? 'No comments yet. Say something!' : 'No comments yet.'}</p>}
{data.comments.map((c) => (
<div key={c.id} className="comment">
<Link href={`/u/${c.user.username}`}>
<Avatar user={c.user} size={34} />
</Link>
<div className="comment-main">
<div className="muted small">
<Link href={`/u/${c.user.username}`}>
<strong>{c.user.displayName ?? c.user.username}</strong>
</Link>{' '}
· {timeAgo(c.createdAt)}
</div>
<p className="comment-body">{c.body}</p>
</div>
<div className="comment-actions">
{c.canDelete && (
<button type="button" className="link-button" onClick={() => remove(c)}>
Delete
</button>
)}
{c.user.id !== me?.id && (
<button type="button" className="link-button" onClick={() => setReporting(c)}>
Report
</button>
)}
</div>
</div>
))}
{data.canComment ? (
<form className="comment-form" onSubmit={send}>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter posts; Shift+Enter is a new line.
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
placeholder={data.owner.id === me?.id ? 'Comment on your song' : `Comment on ${owner}'s song`}
maxLength={COMMENT_MAX_LENGTH}
rows={2}
/>
<button className="btn btn-primary" disabled={!draft.trim() || sending}>
Post
</button>
</form>
) : (
<p className="muted small">Only people {owner} follows can comment on their songs.</p>
)}
{problem && <p className="error">{problem}</p>}
</section>
{reporting && (
<ReportForm
username={reporting.user.username}
name={reporting.user.displayName ?? reporting.user.username}
commentId={reporting.id}
onClose={() => setReporting(null)}
/>
)}
</div>
);
}
+13 -4
View File
@@ -3,7 +3,7 @@ import Link from 'next/link';
export const metadata: Metadata = { title: 'Privacy policy · musicshare' };
const UPDATED = 'October 14, 2026';
const UPDATED = 'October 15, 2026';
const CONTACT = 'app@musicshare.binarygnome.com';
export default function PrivacyPage() {
@@ -40,11 +40,16 @@ export default function PrivacyPage() {
&ldquo;notification access&rdquo;, but the app only reads the song, artist and album from music apps you allow.
It doesn&apos;t read or store your notifications, messages or anything from other apps.
</li>
<li>
<strong>Comments and reactions</strong> you leave on plays. Comments stay until you or the person who played the
song deletes them, or one of your accounts is deleted. Plays with comments are kept in their history so the
conversation isn&apos;t lost.
</li>
<li>
<strong>People:</strong> who you follow, who you&apos;ve pinned, who you&apos;ve blocked, and reports you send.
</li>
<li>
<strong>Notifications:</strong> reactions, new followers and songs (with any note) sent to you, and your
<strong>Notifications:</strong> reactions, comments and replies, new followers and songs (with any note) sent to you, and your
notification settings. If you turn on push notifications, we store the push address your browser or
phone gives us and its encryption keys.
</li>
@@ -63,13 +68,17 @@ export default function PrivacyPage() {
people who aren&apos;t signed in.
</li>
<li>Your followers see what you play in their feed, live.</li>
<li>
Comments on a play can be read by signed-in people who can see that play. Only you and the people you follow can
write them, and you can delete any comment on your plays.
</li>
<li>
Your email address is never shown to other people. It&apos;s used to sign in, to send you confirmation and
password reset emails, and to contact you about your account.
</li>
<li>
People you block can&apos;t find you or see your profile, and you stop following each other. They aren&apos;t
told.
People you block can&apos;t find you or see your profile, and you stop following each other. Their comments on
your plays, and yours on theirs, are removed. They aren&apos;t told.
</li>
<li>
You can <strong>pause sharing</strong> at any time in Settings. While paused, nothing new you play is shared or
+11 -3
View File
@@ -6,6 +6,7 @@ import { Suspense, useEffect, useRef, useState } from 'react';
import useSWR from 'swr';
import {
SERVICE_LABELS,
SIGNUP_PAGES,
SPOTIFY_LINK_PAGES,
STREAMING_SERVICES,
isQuietScrobbler,
@@ -317,14 +318,21 @@ function Connections() {
</span>
)}
</div>
{!byProvider.has(p) && (
<div className="muted small">
No account?{' '}
<a href={SIGNUP_PAGES[p]} target="_blank" rel="noreferrer">
Create one free on {p === 'lastfm' ? 'Last.fm' : 'MusicBrainz'}
</a>
{p === 'listenbrainz' ? ', then sign in to ListenBrainz once (it uses MusicBrainz accounts).' : '.'}
</div>
)}
</div>
{byProvider.has(p) ? (
<div className="row">
{p === 'lastfm' && (
<a className="btn" href="https://www.last.fm/settings/applications" target="_blank" rel="noreferrer">
<a className="btn" href={SPOTIFY_LINK_PAGES[p]} target="_blank" rel="noreferrer">
Link Spotify
</a>
)}
<button className="btn" onClick={() => disconnect(p)}>
Disconnect
</button>
+5 -5
View File
@@ -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" />
+3 -16
View File
@@ -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">
@@ -89,7 +76,7 @@ export function NowPlayingCard({
>
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={() => setExpanded((v) => !v)} />
<ProgressBar np={np} compact={!expanded} />
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} commentCount={np.commentCount ?? 0} />
<AnimatePresence initial={false}>
{expanded && (
<motion.div
+5 -3
View File
@@ -62,7 +62,8 @@ export function ProfileMenu({ profile, onChanged }: { profile: ProfileDTO; onCha
);
}
function ReportForm({ username, name, onClose }: { username: string; name: string; onClose: () => void }) {
/** Report someone's account, or one of their comments (`commentId`). */
export function ReportForm({ username, name, commentId, onClose }: { username: string; name: string; commentId?: string; onClose: () => void }) {
const [reason, setReason] = useState<ReportReason | null>(null);
const [note, setNote] = useState('');
const [sent, setSent] = useState(false);
@@ -72,7 +73,8 @@ function ReportForm({ username, name, onClose }: { username: string; name: strin
e.preventDefault();
if (!reason) return;
try {
await client.report(username, { reason, note: note.trim() || null });
const input = { reason, note: note.trim() || null };
await (commentId ? client.reportComment(commentId, input) : client.report(username, input));
setSent(true);
} catch (err) {
setError((err as Error).message);
@@ -90,7 +92,7 @@ function ReportForm({ username, name, onClose }: { username: string; name: strin
);
}
return (
<Modal onClose={onClose} title={`Report ${name}`}>
<Modal onClose={onClose} title={commentId ? `Report ${name}'s comment` : `Report ${name}`}>
<form className="form" onSubmit={submit}>
<fieldset className="choices">
<legend className="muted">What&apos;s wrong?</legend>
+25 -2
View File
@@ -1,5 +1,6 @@
'use client';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
import { client, useMe } from '@/lib/api';
@@ -9,7 +10,18 @@ import { Modal } from './ProfileMenu';
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
* offers the quick six, and "More" opens the full picker. One reaction per person.
*/
export function Reactions({ listenId, reactions, ownerId }: { listenId: string | null; reactions: ReactionDTO[]; ownerId: string }) {
export function Reactions({
listenId,
reactions,
ownerId,
commentCount,
}: {
listenId: string | null;
reactions: ReactionDTO[];
ownerId: string;
/** Shows a 💬 link to the play's comments. */
commentCount?: number;
}) {
const { me } = useMe();
// The server's answer to our last tap, until fresh props arrive (e.g. a realtime update).
const [local, setLocal] = useState<{ from: ReactionDTO[]; value: ReactionDTO[] } | null>(null);
@@ -27,7 +39,8 @@ export function Reactions({ listenId, reactions, ownerId }: { listenId: string |
}, [quickOpen]);
const canReact = Boolean(me && listenId && me.id !== ownerId);
if (!listenId || (!canReact && shown.length === 0)) return null;
const showComments = commentCount !== undefined && Boolean(me);
if (!listenId || (!canReact && shown.length === 0 && !showComments)) return null;
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
const choose = async (emoji: string) => {
@@ -77,6 +90,16 @@ export function Reactions({ listenId, reactions, ownerId }: { listenId: string |
)}
</span>
)}
{showComments && (
<Link
href={`/play/${listenId}`}
className="reaction"
aria-label={commentCount ? `${commentCount} comment${commentCount === 1 ? '' : 's'}` : 'Comments'}
>
<span className="reaction-emoji">💬</span>
{commentCount ? <span className="reaction-count">{commentCount}</span> : null}
</Link>
)}
{error && <span className="error">{error}</span>}
{pickerOpen && <EmojiPicker onPick={choose} onClose={() => setPickerOpen(false)} />}
</div>
+1 -1
View File
@@ -48,7 +48,7 @@ export function RecentPlays({
) : (
<div className="recent-row">{row}</div>
)}
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} />
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} commentCount={p.commentCount ?? 0} />
</li>
);
})}
+160
View File
@@ -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>
);
+2 -2
View File
@@ -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>}
+2 -2
View File
@@ -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 \
+2 -1
View File
@@ -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
View File
@@ -43,3 +43,8 @@ VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Lyrics: LRCLIB (or your own copy of it). Empty turns lyrics off.
LRCLIB_URL=https://lrclib.net
# App updates: the Android app checks the latest Gitea release through the API and updates itself.
# Empty turns it off. The token needs read access to the repo, and only if the repo is private.
ANDROID_RELEASES_URL=https://gitea.chrisedwards.tech/api/v1/repos/chris/musicshare/releases/latest
ANDROID_RELEASES_TOKEN=
+31 -2
View File
@@ -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.
@@ -70,6 +73,32 @@ that release's files.
**Test builds:** go to **Actions > Android > Run workflow** and pick a branch. The APKs go to the
repo's **Packages** as `musicshare-android`, versioned `<branch>-<commit>`, instead of to a release.
## App updates
The normal APK updates itself from these releases; the F-Droid build doesn't (F-Droid updates it).
1. The app asks our server (`GET /app/android/latest`) when it opens, at most every 6 hours, or when you
tap **Settings > Check for updates**.
2. The server reads the latest release from Gitea and returns its version, the APK's SHA-256 (from
`SHA256SUMS`) and the release notes. Drafts and pre-releases are skipped.
3. If it's newer, the app downloads the APK through our server and checks the SHA-256.
4. The next time you leave the app, it tries a quiet install (once per version). Android 12+ allows that
after the app has installed one update itself. Otherwise the feed shows **Update ready**, and tapping it
opens Android's install screen; the first time, Android asks you to allow musicshare to install apps.
On phones with Google Play, Play Protect may ask to scan each new version ("App scan recommended")
because the app isn't from the Play Store. Choosing **Scan app** lets the update finish.
Set these on the API server (`deploy/production.env`):
```ini
ANDROID_RELEASES_URL=https://gitea.chrisedwards.tech/api/v1/repos/chris/musicshare/releases/latest
# Only if the repo is private: a token with read:repository (User settings > Applications).
ANDROID_RELEASES_TOKEN=
```
The release notes come from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`: the workflow
puts that text above a `---` line in the release, and the app shows only that part.
## Building by hand
```bash
@@ -0,0 +1 @@
The app now updates itself: new versions download in the background and install when you leave the app, or from the banner on the feed. Song cards fade in instead of bouncing. Create a Last.fm or ListenBrainz account and link Spotify to either one straight from Settings.
@@ -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.

Some files were not shown because too many files have changed in this diff Show More