From 68ec5a8d994bfd683602418451104e5c66915c76 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 14 Oct 2026 21:19:56 -0400 Subject: [PATCH] Slim song cards with an expand arrow, and lyrics Feed and profile cards now show one slim line (small artwork, title, artist, Open button) with the progress bar and reactions. The arrow opens the rest: album, your other services, Share, and lyrics. Lyrics come from LRCLIB (open source; LRCLIB_URL can point at a self-hosted copy, or be empty to turn lyrics off). The server looks each song up once and caches it, including misses (retried after a week). Timed lyrics follow along with the song; otherwise the first lines show with "All lyrics". Co-Authored-By: Claude Opus 5.5 --- apps/api/.env.example | 2 + apps/api/drizzle/0015_lyrics.sql | 10 + apps/api/drizzle/meta/0015_snapshot.json | 1555 +++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 14 + apps/api/src/env.ts | 5 + apps/api/src/routes/tracks.ts | 18 + apps/api/src/services/lyrics.ts | 85 + apps/api/test/lyrics.test.ts | 68 + apps/mobile/src/components/Lyrics.tsx | 97 + apps/mobile/src/components/NowPlayingCard.tsx | 27 +- apps/mobile/src/components/ProgressBar.tsx | 18 +- apps/mobile/src/components/TrackCard.tsx | 142 +- apps/web/src/app/globals.css | 31 +- apps/web/src/components/Lyrics.tsx | 84 + apps/web/src/components/NowPlayingCard.tsx | 27 +- apps/web/src/components/ProgressBar.tsx | 16 +- apps/web/src/components/TrackCard.tsx | 112 +- packages/shared/src/client.ts | 3 + packages/shared/src/index.ts | 1 + packages/shared/src/lyrics.test.ts | 25 + packages/shared/src/lyrics.ts | 47 + 22 files changed, 2299 insertions(+), 95 deletions(-) create mode 100644 apps/api/drizzle/0015_lyrics.sql create mode 100644 apps/api/drizzle/meta/0015_snapshot.json create mode 100644 apps/api/src/services/lyrics.ts create mode 100644 apps/api/test/lyrics.test.ts create mode 100644 apps/mobile/src/components/Lyrics.tsx create mode 100644 apps/web/src/components/Lyrics.tsx create mode 100644 packages/shared/src/lyrics.test.ts create mode 100644 packages/shared/src/lyrics.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 0334073..319c0e4 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -19,6 +19,8 @@ LASTFM_API_KEY= LASTFM_SHARED_SECRET= # song.link API key (request at https://odesli.co). Enables YouTube Music/TIDAL links; without it those fall back to search. ODESLI_API_KEY= +# Lyrics source: LRCLIB (open source; run your own and point this at it). Empty turns lyrics off. +LRCLIB_URL=https://lrclib.net # Stable identifier for this app in Plex's auth flow PLEX_CLIENT_ID=musicshare-dev # Let users connect Jellyfin/Plex servers on private networks (LAN). Never enable in a hosted deployment. diff --git a/apps/api/drizzle/0015_lyrics.sql b/apps/api/drizzle/0015_lyrics.sql new file mode 100644 index 0000000..32760fd --- /dev/null +++ b/apps/api/drizzle/0015_lyrics.sql @@ -0,0 +1,10 @@ +CREATE TABLE "lyrics" ( + "track_id" uuid PRIMARY KEY NOT NULL, + "synced" text, + "plain" text, + "instrumental" boolean DEFAULT false NOT NULL, + "source_url" text, + "fetched_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "lyrics" ADD CONSTRAINT "lyrics_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0015_snapshot.json b/apps/api/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000..0efd65c --- /dev/null +++ b/apps/api/drizzle/meta/0015_snapshot.json @@ -0,0 +1,1555 @@ +{ + "id": "f7a38758-1668-4e86-bb01-e20b2d1e36e0", + "prevId": "4f894073-ca01-4ef4-be6f-3d7722938c0e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.avatars": { + "name": "avatars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "avatars_user_id_users_id_fk": { + "name": "avatars_user_id_users_id_fk", + "tableFrom": "avatars", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blocks": { + "name": "blocks", + "schema": "", + "columns": { + "blocker_id": { + "name": "blocker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_id": { + "name": "blocked_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blocks_blocked_idx": { + "name": "blocks_blocked_idx", + "columns": [ + { + "expression": "blocked_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocks_blocker_id_users_id_fk": { + "name": "blocks_blocker_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocks_blocked_id_users_id_fk": { + "name": "blocks_blocked_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocked_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blocks_blocker_id_blocked_id_pk": { + "name": "blocks_blocker_id_blocked_id_pk", + "columns": [ + "blocker_id", + "blocked_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connections": { + "name": "connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connections_user_provider_idx": { + "name": "connections_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connections_user_id_users_id_fk": { + "name": "connections_user_id_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "connections_webhook_secret_unique": { + "name": "connections_webhook_secret_unique", + "nullsNotDistinct": false, + "columns": [ + "webhook_secret" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_idx": { + "name": "devices_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_token_hash_unique": { + "name": "devices_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verifications": { + "name": "email_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_verifications_user_idx": { + "name": "email_verifications_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_verifications_user_id_users_id_fk": { + "name": "email_verifications_user_id_users_id_fk", + "tableFrom": "email_verifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.featured_songs": { + "name": "featured_songs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "varchar(280)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "featured_songs_user_id_users_id_fk": { + "name": "featured_songs_user_id_users_id_fk", + "tableFrom": "featured_songs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "featured_songs_track_id_tracks_id_fk": { + "name": "featured_songs_track_id_tracks_id_fk", + "tableFrom": "featured_songs", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "followee_id": { + "name": "followee_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "follows_followee_idx": { + "name": "follows_followee_idx", + "columns": [ + { + "expression": "followee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follows_follower_id_users_id_fk": { + "name": "follows_follower_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follows_followee_id_users_id_fk": { + "name": "follows_followee_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "followee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follows_follower_id_followee_id_pk": { + "name": "follows_follower_id_followee_id_pk", + "columns": [ + "follower_id", + "followee_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listens": { + "name": "listens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listens_user_started_idx": { + "name": "listens_user_started_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listens_user_id_users_id_fk": { + "name": "listens_user_id_users_id_fk", + "tableFrom": "listens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listens_track_id_tracks_id_fk": { + "name": "listens_track_id_tracks_id_fk", + "tableFrom": "listens", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lyrics": { + "name": "lyrics", + "schema": "", + "columns": { + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "synced": { + "name": "synced", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plain": { + "name": "plain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instrumental": { + "name": "instrumental", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "lyrics_track_id_tracks_id_fk": { + "name": "lyrics_track_id_tracks_id_fk", + "tableFrom": "lyrics", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.now_playing": { + "name": "now_playing", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_playing": { + "name": "is_playing", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "now_playing_track_idx": { + "name": "now_playing_track_idx", + "columns": [ + { + "expression": "track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "now_playing_user_id_users_id_fk": { + "name": "now_playing_user_id_users_id_fk", + "tableFrom": "now_playing", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "now_playing_track_id_tracks_id_fk": { + "name": "now_playing_track_id_tracks_id_fk", + "tableFrom": "now_playing", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_resets": { + "name": "password_resets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_resets_user_idx": { + "name": "password_resets_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_resets_user_id_users_id_fk": { + "name": "password_resets_user_id_users_id_fk", + "tableFrom": "password_resets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "listen_id": { + "name": "listen_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reactions_listen_id_listens_id_fk": { + "name": "reactions_listen_id_listens_id_fk", + "tableFrom": "reactions", + "tableTo": "listens", + "columnsFrom": [ + "listen_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reactions_user_id_users_id_fk": { + "name": "reactions_user_id_users_id_fk", + "tableFrom": "reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reactions_listen_id_user_id_pk": { + "name": "reactions_listen_id_user_id_pk", + "columns": [ + "listen_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reported_id": { + "name": "reported_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reports_reporter_id_users_id_fk": { + "name": "reports_reporter_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reports_reported_id_users_id_fk": { + "name": "reports_reported_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reported_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.track_links": { + "name": "track_links", + "schema": "", + "columns": { + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "track_links_external_idx": { + "name": "track_links_external_idx", + "columns": [ + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "track_links_track_id_tracks_id_fk": { + "name": "track_links_track_id_tracks_id_fk", + "tableFrom": "track_links", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "track_links_track_id_service_pk": { + "name": "track_links_track_id_service_pk", + "columns": [ + "track_id", + "service" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracks": { + "name": "tracks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "isrc": { + "name": "isrc", + "type": "varchar(12)", + "primaryKey": false, + "notNull": false + }, + "match_key": { + "name": "match_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artist": { + "name": "artist", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "album": { + "name": "album", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "artwork_url": { + "name": "artwork_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "links_resolved_at": { + "name": "links_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tracks_isrc_idx": { + "name": "tracks_isrc_idx", + "columns": [ + { + "expression": "isrc", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_match_key_idx": { + "name": "tracks_match_key_idx", + "columns": [ + { + "expression": "match_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pending_email": { + "name": "pending_email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "varchar(280)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "preferred_service": { + "name": "preferred_service", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "services": { + "name": "services", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sharing_paused_until": { + "name": "sharing_paused_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "plan_expires_at": { + "name": "plan_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index e9a1ce0..48e3730 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1792025011207, "tag": "0014_user_services", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1792025274882, + "tag": "0015_lyrics", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 61b449f..4b3749c 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -290,6 +290,20 @@ export const passwordResets = pgTable( (t) => [index('password_resets_user_idx').on(t.userId)], ); +/** Lyrics looked up per track (misses too, so we don't keep asking). */ +export const lyrics = pgTable('lyrics', { + trackId: uuid('track_id') + .primaryKey() + .references(() => tracks.id, { onDelete: 'cascade' }), + /** LRC text with timestamps. */ + synced: text('synced'), + plain: text('plain'), + instrumental: boolean('instrumental').notNull().default(false), + /** Where they came from (the LRCLIB record), null when none were found. */ + sourceUrl: text('source_url'), + fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(), +}); + const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' }); /** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */ diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index fdc59ba..af2d468 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -26,6 +26,11 @@ const schema = z.object({ /** Enables "Sign in with Last.fm" (web auth). Without it, users type their username. */ LASTFM_SHARED_SECRET: optional, ODESLI_API_KEY: optional, + /** Lyrics come from LRCLIB (open source, self-hostable). Point this at your own instance, or set it empty to turn lyrics off. */ + LRCLIB_URL: z + .string() + .default('https://lrclib.net') + .transform((v) => v.replace(/\/+$/, '')), /** 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, diff --git a/apps/api/src/routes/tracks.ts b/apps/api/src/routes/tracks.ts index c993fb3..b0afa0c 100644 --- a/apps/api/src/routes/tracks.ts +++ b/apps/api/src/routes/tracks.ts @@ -1,9 +1,11 @@ import { eq } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; import { STREAMING_SERVICES, openUrl, type StreamingService, type TrackSearchResultDTO } from '@musicshare/shared'; import { db, schema } from '../db'; import { HttpError } from '../lib/http'; import { deezerSearch } from '../providers/catalog'; +import { getLyrics } from '../services/lyrics'; import { resolveTrackLinks } from '../services/resolver'; import { getTrackDTOs } from '../services/tracks'; @@ -32,6 +34,22 @@ export async function trackRoutes(app: FastifyInstance) { return track; }); + /** Lyrics (synced when available) or null. Looked up once per song, then cached. */ + app.get<{ Params: { id: string } }>( + '/tracks/:id/lyrics', + { config: { rateLimit: { max: 60, timeWindow: '1 minute' } } }, + async (req, reply) => { + if (!z.uuid().safeParse(req.params.id).success) throw new HttpError(404, 'Track not found'); + try { + return reply.send(await getLyrics(req.params.id)); + } catch (err) { + if (err instanceof HttpError && err.status === 404) throw err; + req.log.warn(err, 'lyrics lookup failed'); + throw new HttpError(503, "Couldn't get lyrics right now"); + } + }, + ); + /** Shareable redirect: /tracks/:id/open?service=spotify (defaults to the viewer's preferred service). */ app.get<{ Params: { id: string }; Querystring: { service?: string } }>('/tracks/:id/open', async (req, reply) => { const service = (req.query.service ?? req.user?.preferredService ?? 'spotify') as StreamingService; diff --git a/apps/api/src/services/lyrics.ts b/apps/api/src/services/lyrics.ts new file mode 100644 index 0000000..776e2c1 --- /dev/null +++ b/apps/api/src/services/lyrics.ts @@ -0,0 +1,85 @@ +import { eq } from 'drizzle-orm'; +import { parseLrc, type LyricsDTO } from '@musicshare/shared'; +import { db, schema } from '../db'; +import { env } from '../env'; +import { HttpError, fetchJson, normalize, primaryArtist } from '../lib/http'; + +/** Look again for songs that had none after this long; lyrics get added to LRCLIB all the time. */ +const RETRY_MISS_MS = 7 * 86_400_000; +/** A search result has to be this close in length to count as the same recording. */ +const DURATION_SLACK_S = 4; + +interface LrclibRecord { + id: number; + trackName: string; + artistName: string; + duration: number | null; + instrumental: boolean; + plainLyrics: string | null; + syncedLyrics: string | null; +} + +type Row = typeof schema.lyrics.$inferSelect; + +/** The track's lyrics, from our cache or LRCLIB. Null when there are none (or lyrics are turned off). */ +export async function getLyrics(trackId: string): Promise { + if (!env.LRCLIB_URL) return null; + const [track] = await db.select().from(schema.tracks).where(eq(schema.tracks.id, trackId)); + if (!track) throw new HttpError(404, 'Track not found'); + + let [row] = await db.select().from(schema.lyrics).where(eq(schema.lyrics.trackId, trackId)); + const stale = row && !row.sourceUrl && Date.now() - row.fetchedAt.getTime() > RETRY_MISS_MS; + if (!row || stale) { + const found = await lookup(track); + const values = { + synced: found?.syncedLyrics || null, + plain: found?.plainLyrics || null, + instrumental: found?.instrumental ?? false, + sourceUrl: found ? `${env.LRCLIB_URL}/api/get/${found.id}` : null, + fetchedAt: new Date(), + }; + [row] = await db + .insert(schema.lyrics) + .values({ trackId, ...values }) + .onConflictDoUpdate({ target: schema.lyrics.trackId, set: values }) + .returning(); + } + return toDTO(row!); +} + +function toDTO(row: Row): LyricsDTO | null { + if (!row.sourceUrl || (!row.synced && !row.plain && !row.instrumental)) return null; + const synced = row.synced ? parseLrc(row.synced) : null; + return { + synced: synced?.length ? synced : null, + plain: row.plain, + instrumental: row.instrumental, + source: { name: 'LRCLIB', url: 'https://lrclib.net' }, + }; +} + +async function lookup(track: { title: string; artist: string; album: string | null; durationMs: number | null }) { + const artist = primaryArtist(track.artist); + const headers = { 'user-agent': 'musicshare (https://musicshare.binarygnome.com)' }; + // Exact lookup needs the length; LRCLIB matches it within a couple of seconds. + 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(`${env.LRCLIB_URL}/api/get?${params}`, { headers }).catch((e: unknown) => { + if (e instanceof HttpError && e.status === 404) return null; + throw e; + }); + if (exact) return exact; + } + const params = new URLSearchParams({ track_name: track.title, artist_name: artist }); + const results = (await fetchJson(`${env.LRCLIB_URL}/api/search?${params}`, { headers })) ?? []; + const title = normalize(track.title); + const matches = results.filter( + (r) => + normalize(r.trackName) === title && + normalize(r.artistName).includes(normalize(artist)) && + (!track.durationMs || !r.duration || Math.abs(r.duration - track.durationMs / 1000) <= DURATION_SLACK_S), + ); + // Prefer synced lyrics, then anything with words. + return matches.find((r) => r.syncedLyrics) ?? matches.find((r) => r.plainLyrics || r.instrumental) ?? null; +} diff --git a/apps/api/test/lyrics.test.ts b/apps/api/test/lyrics.test.ts new file mode 100644 index 0000000..5b8b1e4 --- /dev/null +++ b/apps/api/test/lyrics.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { upsertTrack } from '../src/services/tracks'; +import { json, routeFetch, useTestApp } from './helpers'; + +const app = useTestApp(); +afterEach(() => vi.unstubAllGlobals()); + +const record = (over: object = {}) => ({ + id: 70627, + trackName: 'Midnight City', + artistName: 'M83', + duration: 241, + instrumental: false, + plainLyrics: 'Waiting in a car\nWaiting for a ride in the dark', + syncedLyrics: '[00:01.00] Waiting in a car\n[00:04.50] Waiting for a ride in the dark', + ...over, +}); +const lyrics = (id: string) => app().inject({ url: `/tracks/${id}/lyrics` }); + +describe('lyrics', () => { + it('looks up synced lyrics once and serves them from the cache after', async () => { + const { id } = await upsertTrack({ title: 'Midnight City', artist: 'M83', durationMs: 241_000 }); + const calls: string[] = []; + vi.stubGlobal('fetch', routeFetch([[/lrclib\.net\/api\/get\?/, (url) => (calls.push(url), json(record()))]])); + + const first = (await lyrics(id)).json(); + expect(first.synced).toEqual([ + { timeMs: 1000, text: 'Waiting in a car' }, + { timeMs: 4500, text: 'Waiting for a ride in the dark' }, + ]); + expect(first.source.name).toBe('LRCLIB'); + expect(new URL(calls[0]!).searchParams.get('duration')).toBe('241'); + expect((await lyrics(id)).json()).toEqual(first); + expect(calls).toHaveLength(1); + }); + + it('falls back to a search, skipping recordings of a different length', async () => { + const { id } = await upsertTrack({ title: 'Song', artist: 'Band', durationMs: 200_000 }); + vi.stubGlobal( + 'fetch', + routeFetch([ + [/\/api\/get\?/, () => json({ code: 404 }, 404)], + [ + /\/api\/search\?/, + () => + json([ + record({ id: 1, trackName: 'Song', artistName: 'Band', duration: 320, plainLyrics: 'extended mix' }), + record({ id: 2, trackName: 'Song', artistName: 'Band', duration: 201, syncedLyrics: null, plainLyrics: 'the one' }), + ]), + ], + ]), + ); + const body = (await lyrics(id)).json(); + expect(body.synced).toBeNull(); + expect(body.plain).toBe('the one'); + }); + + 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)]])); + expect((await lyrics(id)).statusCode).toBe(503); + vi.stubGlobal('fetch', routeFetch([[/\/api\/get\?/, () => json({}, 404)], [/\/api\/search\?/, () => json([])]])); + const res = await lyrics(id); + expect(res.statusCode).toBe(200); + expect(res.json()).toBeNull(); + expect((await lyrics('not-a-uuid')).statusCode).toBe(404); + }); +}); diff --git a/apps/mobile/src/components/Lyrics.tsx b/apps/mobile/src/components/Lyrics.tsx new file mode 100644 index 0000000..3d5202b --- /dev/null +++ b/apps/mobile/src/components/Lyrics.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +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'; + +/** Lines shown around the current one while following along. */ +const BEFORE = 1; +const AFTER = 3; +/** Lines shown before "All lyrics" when there's nothing to follow. */ +const PREVIEW = 8; + +/** + * A song's lyrics. With timed lyrics and a live position (`np`), shows a few + * lines around the one being sung and follows along; "All lyrics" shows the rest. + */ +export function Lyrics({ track, np }: { track: TrackDTO; np?: NowPlayingDTO }) { + const { data, error, isLoading } = useSWR(`/tracks/${track.id}/lyrics`); + const [full, setFull] = useState(false); + + if (isLoading) return Finding lyrics…; + if (error) return Couldn't load lyrics right now.; + if (!data) return No lyrics found for this song.; + if (data.instrumental && !data.synced && !data.plain) return ♪ Instrumental; + + const synced = data.synced; + const live = Boolean(np?.isPlaying && playbackPosition(np, 0)); + const following = Boolean(synced && live && !full); + const text = synced ? synced.map((l) => l.text).join('\n') : (data.plain ?? ''); + const all = text.split('\n'); + const clipped = !following && !full && all.length > PREVIEW; + const canToggle = (synced && live) || clipped || full; + + return ( + + setFull((v) => !v)} disabled={!canToggle} accessibilityRole="button" accessibilityLabel={full ? 'Show fewer lyrics' : 'Show all lyrics'}> + {following && synced && np ? ( + // Each fresh position from the server restarts the clock. + + ) : ( + (clipped ? all.slice(0, PREVIEW) : all).map((t, i) => ( + + {t} + + )) + )} + + + {canToggle && ( + setFull((v) => !v)}> + {!full ? 'All lyrics' : synced && live ? 'Follow along' : 'Less'} + + )} + Linking.openURL(data.source.url)}> + Lyrics from {data.source.name} + + + + ); +} + +/** The line being sung, one before it and a few after, moving with the song. */ +function FollowAlong({ lines, np }: { lines: LyricLine[]; np: NowPlayingDTO }) { + const [receivedAt] = useState(() => Date.now()); + const [now, setNow] = useState(receivedAt); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 500); + return () => clearInterval(id); + }, []); + + const position = playbackPosition(np, now - receivedAt)?.positionMs ?? 0; + const current = currentLyricIndex(lines, position); + const from = Math.max(0, current - BEFORE); + return lines.slice(from, Math.max(current, 0) + AFTER + 1).map((l, i) => { + const index = from + i; + const state = index < current ? styles.past : index === current ? styles.now : styles.next; + return ( + + {l.text || '♪'} + + ); + }); +} + +const styles = StyleSheet.create({ + wrap: { gap: space.sm }, + note: { color: colors.muted, fontSize: 13 }, + line: { color: colors.text, fontSize: 15, lineHeight: 22 }, + 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' }, +}); diff --git a/apps/mobile/src/components/NowPlayingCard.tsx b/apps/mobile/src/components/NowPlayingCard.tsx index b085dc7..05f2018 100644 --- a/apps/mobile/src/components/NowPlayingCard.tsx +++ b/apps/mobile/src/components/NowPlayingCard.tsx @@ -1,7 +1,7 @@ import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { router } from 'expo-router'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'; import Animated, { FadeIn, @@ -17,9 +17,10 @@ import { successHaptic, tapHaptic } from '@/lib/haptics'; import { colors, radius, space } from '@/lib/theme'; import { Avatar } from './Avatar'; import { Equalizer } from './Equalizer'; +import { Lyrics } from './Lyrics'; import { ProgressBar } from './ProgressBar'; import { Reactions } from './Reactions'; -import { TrackCard } from './TrackCard'; +import { ServiceLinks, TrackRow } from './TrackCard'; export function NowPlayingCard({ np, @@ -42,6 +43,11 @@ export function NowPlayingCard({ wasPinned.current = pinned; }, [pinned, star]); const starStyle = useAnimatedStyle(() => ({ transform: [{ scale: star.value }] })); + const [expanded, setExpanded] = useState(false); + const toggle = () => { + tapHaptic(); + setExpanded((v) => !v); + }; const togglePin = () => { if (pinned) tapHaptic(); @@ -62,7 +68,7 @@ export function NowPlayingCard({ {showUser && ( router.push(`/u/${np.user.username}`)} accessibilityRole="link"> - + {np.user.displayName ?? np.user.username} @@ -93,10 +99,16 @@ export function NowPlayingCard({ - + - + + {expanded && ( + + + + + )} ); } @@ -104,13 +116,14 @@ export function NowPlayingCard({ const styles = StyleSheet.create({ card: { borderRadius: radius.lg, - padding: space.lg, - gap: space.md, + padding: space.md + 2, + gap: space.sm + 2, overflow: 'hidden', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, }, + 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 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.sm }, diff --git a/apps/mobile/src/components/ProgressBar.tsx b/apps/mobile/src/components/ProgressBar.tsx index 4c29ca7..bb76d57 100644 --- a/apps/mobile/src/components/ProgressBar.tsx +++ b/apps/mobile/src/components/ProgressBar.tsx @@ -6,13 +6,13 @@ import { formatDuration, playbackPosition, type NowPlayingDTO } from '@musicshar import { colors } from '@/lib/theme'; /** Where they are in the song. Renders nothing when the source doesn't report a position. */ -export function ProgressBar({ np }: { np: NowPlayingDTO }) { +export function ProgressBar({ np, compact }: { np: NowPlayingDTO; /** Just the bar, no times. */ compact?: boolean }) { if (!np.isPlaying || playbackPosition(np, 0) === null) return null; // Each fresh position from the server restarts the clock, realigning the bar. - return ; + return ; } -function Bar({ np }: { np: NowPlayingDTO }) { +function Bar({ np, compact }: { np: NowPlayingDTO; compact?: boolean }) { const [receivedAt] = useState(() => Date.now()); const [now, setNow] = useState(receivedAt); useEffect(() => { @@ -36,16 +36,18 @@ function Bar({ np }: { np: NowPlayingDTO }) { - - {formatDuration(pos.positionMs)} - {formatDuration(pos.durationMs)} - + {!compact && ( + + {formatDuration(pos.positionMs)} + {formatDuration(pos.durationMs)} + + )} ); } const styles = StyleSheet.create({ - wrap: { marginTop: 12 }, + wrap: { marginTop: 2 }, track: { height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.14)', overflow: 'hidden' }, fill: { height: '100%', borderRadius: 2, overflow: 'hidden' }, times: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 }, diff --git a/apps/mobile/src/components/TrackCard.tsx b/apps/mobile/src/components/TrackCard.tsx index c289775..8f6deff 100644 --- a/apps/mobile/src/components/TrackCard.tsx +++ b/apps/mobile/src/components/TrackCard.tsx @@ -20,50 +20,114 @@ function shareTrack(track: TrackDTO, from: string | undefined) { Share.share({ message: `${shareText(track)} ${url}`, url, title: shareText(track) }).catch((e) => showError(e, "Couldn't share")); } -export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) { - const { me } = useSession(); - const primary = preferred ? openUrl(track, preferred) : null; - const others = viewerServices(me).filter((s) => s !== preferred); +function Artwork({ track, size }: { track: TrackDTO; size: number }) { + const box = { width: size, height: size, borderRadius: size > 64 ? 14 : 10 }; + return track.artworkUrl ? ( + + ) : ( + + ♪ + + ); +} +/** The big "Open in Spotify" button for your main service ("Search in" when we have no direct link). */ +function PrimaryButton({ track, preferred, label }: { track: TrackDTO; preferred: StreamingService; label?: 'short' }) { + const { url, exact } = openUrl(track, preferred); + const name = SERVICE_LABELS[preferred]; + return ( + + )} + + Lyrics from {data.source.name} + + + + ); +} + +/** The line being sung, one before it and a few after, moving with the song. */ +function FollowAlong({ lines, np }: { lines: LyricLine[]; np: NowPlayingDTO }) { + const [receivedAt] = useState(() => Date.now()); + const [now, setNow] = useState(receivedAt); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 500); + return () => clearInterval(id); + }, []); + + const position = playbackPosition(np, now - receivedAt)?.positionMs ?? 0; + const current = currentLyricIndex(lines, position); + const from = Math.max(0, current - BEFORE); + return lines.slice(from, Math.max(current, 0) + AFTER + 1).map((l, i) => { + const index = from + i; + const state = index < current ? 'past' : index === current ? 'now' : 'next'; + return ( +

+ {l.text || '♪'} +

+ ); + }); +} diff --git a/apps/web/src/components/NowPlayingCard.tsx b/apps/web/src/components/NowPlayingCard.tsx index 4dd091f..d5d9e84 100644 --- a/apps/web/src/components/NowPlayingCard.tsx +++ b/apps/web/src/components/NowPlayingCard.tsx @@ -1,13 +1,15 @@ 'use client'; import { AnimatePresence, motion } from 'motion/react'; +import { useState } from 'react'; import Link from 'next/link'; import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared'; import { Avatar } from './Avatar'; import { Equalizer } from './Equalizer'; +import { Lyrics } from './Lyrics'; import { ProgressBar } from './ProgressBar'; import { Reactions } from './Reactions'; -import { TrackCard } from './TrackCard'; +import { ServiceLinks, TrackRow } from './TrackCard'; export function NowPlayingCard({ np, @@ -23,6 +25,7 @@ export function NowPlayingCard({ onTogglePin?: () => void; }) { const sourceLabel = np.sourceApp ?? SERVICE_LABELS[np.source]; + const [expanded, setExpanded] = useState(false); return (
{/* The card glows with its album art; crossfades when the song changes. */} @@ -42,7 +45,7 @@ export function NowPlayingCard({
{showUser && ( - + {np.user.displayName ?? np.user.username} )} @@ -84,9 +87,25 @@ export function NowPlayingCard({ exit={{ opacity: 0, y: -12, filter: 'blur(6px)' }} transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }} > - - + setExpanded((v) => !v)} /> + + + {expanded && ( + +
+ +
+ +
+ )} +
diff --git a/apps/web/src/components/ProgressBar.tsx b/apps/web/src/components/ProgressBar.tsx index c90cd05..bf43a66 100644 --- a/apps/web/src/components/ProgressBar.tsx +++ b/apps/web/src/components/ProgressBar.tsx @@ -4,13 +4,13 @@ import { useEffect, useState } from 'react'; import { formatDuration, playbackPosition, type NowPlayingDTO } from '@musicshare/shared'; /** Where they are in the song. Renders nothing when the source doesn't report a position. */ -export function ProgressBar({ np }: { np: NowPlayingDTO }) { +export function ProgressBar({ np, compact }: { np: NowPlayingDTO; /** Just the bar, no times. */ compact?: boolean }) { if (!np.isPlaying || playbackPosition(np, 0) === null) return null; // Each fresh position from the server restarts the clock, realigning the bar. - return ; + return ; } -function Bar({ np }: { np: NowPlayingDTO }) { +function Bar({ np, compact }: { np: NowPlayingDTO; compact?: boolean }) { const [receivedAt] = useState(() => Date.now()); const [now, setNow] = useState(receivedAt); useEffect(() => { @@ -25,10 +25,12 @@ function Bar({ np }: { np: NowPlayingDTO }) {
-
- {formatDuration(pos.positionMs)} - {formatDuration(pos.durationMs)} -
+ {!compact && ( +
+ {formatDuration(pos.positionMs)} + {formatDuration(pos.durationMs)} +
+ )}
); } diff --git a/apps/web/src/components/TrackCard.tsx b/apps/web/src/components/TrackCard.tsx index 2aadac0..47a428c 100644 --- a/apps/web/src/components/TrackCard.tsx +++ b/apps/web/src/components/TrackCard.tsx @@ -4,6 +4,85 @@ import { SERVICE_LABELS, openUrl, viewerServices, type StreamingService, type Tr import { useMe } from '@/lib/api'; import { ShareButton } from './ShareButton'; +function Artwork({ track, small }: { track: TrackDTO; small?: boolean }) { + const cls = small ? 'artwork artwork-row' : 'artwork'; + return track.artworkUrl ? :
♪
; +} + +/** The big "Open in Spotify" button for your main service ("Search in" when we have no direct link). */ +function PrimaryLink({ track, preferred, short }: { track: TrackDTO; preferred: StreamingService; short?: boolean }) { + const { url, exact } = openUrl(track, preferred); + const full = `${exact ? 'Open' : 'Search'} in ${SERVICE_LABELS[preferred]}`; + return ( + + {short ? (exact ? 'Open ▶' : 'Search') : full} + + ); +} + +/** Quieter links for your other services, and Share. */ +export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) { + const { me } = useMe(); + const others = viewerServices(me).filter((s) => s !== preferred); + return ( + <> + {others.map((s) => { + const { url, exact } = openUrl(track, s); + return ( + + {SERVICE_LABELS[s]} + + ); + })} + + + ); +} + +/** + * One slim line: small artwork, title and artist, the open button, and an + * arrow that shows the rest (`expanded` / `onToggle`). + */ +export function TrackRow({ + track, + preferred, + expanded, + onToggle, +}: { + track: TrackDTO; + preferred: StreamingService | null | undefined; + expanded: boolean; + onToggle: () => void; +}) { + return ( +
+ + {preferred && } + +
+ ); +} + /** * Artwork + title, with a primary button for the viewer's preferred service * and quieter links for the rest. Links are https universal links, so on @@ -21,17 +100,9 @@ export function TrackCard({ /** When the page already shows the artwork big. */ hideArtwork?: boolean; }) { - const { me } = useMe(); - const others = viewerServices(me).filter((s) => s !== preferred); - const primary = preferred ? openUrl(track, preferred) : null; - return (
- {hideArtwork ? null : track.artworkUrl ? ( - - ) : ( -
♪
- )} + {!hideArtwork && }
{track.title}
@@ -39,27 +110,8 @@ export function TrackCard({ {track.album && · {track.album}}
- {preferred && primary && ( - - {primary.exact ? 'Open' : 'Search'} in {SERVICE_LABELS[preferred]} - - )} - {others.map((s) => { - const { url, exact } = openUrl(track, s); - return ( - - {SERVICE_LABELS[s]} - - ); - })} - + {preferred && } +
diff --git a/packages/shared/src/client.ts b/packages/shared/src/client.ts index ca1a2e6..3eda13f 100644 --- a/packages/shared/src/client.ts +++ b/packages/shared/src/client.ts @@ -11,6 +11,7 @@ import type { UserSummaryDTO, } from './dto'; import type { DeviceDTO } from './devices'; +import type { LyricsDTO } from './lyrics'; import type { ChangeEmailInput, ChangePasswordInput, @@ -132,6 +133,8 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt unreact: (listenId: string) => request(`/listens/${q(listenId)}/reaction`, { method: 'DELETE' }), searchTracks: (query: string) => request(`/tracks/search?q=${q(query)}`), + /** Null when no lyrics were found for the song. */ + lyrics: (trackId: string) => request(`/tracks/${q(trackId)}/lyrics`), connections: () => request('/connections'), authorizeUrl: (provider: 'plex' | 'lastfm', body: { client: 'web' | 'mobile'; returnTo?: string }) => diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9f91993..81d9a9a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,3 +9,4 @@ export * from './links'; export * from './qr'; export * from './progress'; export * from './reactions'; +export * from './lyrics'; diff --git a/packages/shared/src/lyrics.test.ts b/packages/shared/src/lyrics.test.ts new file mode 100644 index 0000000..cfb4eed --- /dev/null +++ b/packages/shared/src/lyrics.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { currentLyricIndex, parseLrc } from './lyrics'; + +describe('parseLrc', () => { + it('reads timestamps, skips tags, and sorts lines repeated by several tags', () => { + const lines = parseLrc('[ar:M83]\n[00:12.50] Waiting in a car\n[00:05.00][01:00.1]Chorus\n[00:20.00]\n\nnot a lyric'); + expect(lines).toEqual([ + { timeMs: 5000, text: 'Chorus' }, + { timeMs: 12500, text: 'Waiting in a car' }, + { timeMs: 20000, text: '' }, + { timeMs: 60100, text: 'Chorus' }, + ]); + }); +}); + +describe('currentLyricIndex', () => { + const lines = parseLrc('[00:05.00]a\n[00:10.00]b\n[00:15.00]c'); + it('finds the line being sung', () => { + expect(currentLyricIndex(lines, 0)).toBe(-1); + expect(currentLyricIndex(lines, 5000)).toBe(0); + expect(currentLyricIndex(lines, 14_999)).toBe(1); + expect(currentLyricIndex(lines, 99_000)).toBe(2); + expect(currentLyricIndex([], 1000)).toBe(-1); + }); +}); diff --git a/packages/shared/src/lyrics.ts b/packages/shared/src/lyrics.ts new file mode 100644 index 0000000..d498e6d --- /dev/null +++ b/packages/shared/src/lyrics.ts @@ -0,0 +1,47 @@ +/** One line of time-synced lyrics; empty text is an instrumental gap. */ +export interface LyricLine { + timeMs: number; + text: string; +} + +export interface LyricsDTO { + /** Lines with timestamps, when the song has synced lyrics. */ + synced: LyricLine[] | null; + /** Plain text, when there are no synced lyrics (or as a fallback). */ + plain: string | null; + instrumental: boolean; + /** Where they came from, for attribution. */ + source: { name: string; url: string }; +} + +const TIME_TAG = /\[(\d{1,3}):(\d{1,2}(?:\.\d{1,3})?)\]/g; + +/** + * Parse LRC ("[01:23.45] words"). A line may carry several time tags; tags + * that aren't times (like [ar:Artist]) are skipped. Sorted by time. + */ +export function parseLrc(lrc: string): LyricLine[] { + const lines: LyricLine[] = []; + for (const raw of lrc.split(/\r?\n/)) { + const times = [...raw.matchAll(TIME_TAG)]; + if (times.length === 0) continue; + const text = raw.replace(TIME_TAG, '').trim(); + for (const [, min, sec] of times) lines.push({ timeMs: Math.round((Number(min) * 60 + Number(sec)) * 1000), text }); + } + return lines.sort((a, b) => a.timeMs - b.timeMs); +} + +/** Index of the line being sung at `positionMs`, or -1 before the first one. */ +export function currentLyricIndex(lines: LyricLine[], positionMs: number): number { + let lo = 0; + let hi = lines.length - 1; + let found = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (lines[mid]!.timeMs <= positionMs) { + found = mid; + lo = mid + 1; + } else hi = mid - 1; + } + return found; +}