6 Commits
Author SHA1 Message Date
chrisandClaude Opus 5.5 4bdfae155a Fix JSX syntax error on notifications page
The body span was missing its closing brace, which broke typecheck and
the web Docker build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:29:54 -04:00
chrisandClaude Opus 5.5 5778035656 Version 1.5.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:29:54 -04:00
chrisandClaude Opus 5.5 0a28f05d53 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:29:54 -04:00
chrisandaider 6537c34a38 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:29:54 -04:00
chrisandClaude Opus 5.5 094f116b10 CI: test builds' publish step works under plain sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-22 12:01:20 -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
14 changed files with 144 additions and 26 deletions
+3 -3
View File
@@ -22,8 +22,8 @@ jobs:
runs-on: musicshare-android runs-on: musicshare-android
container: container:
image: musicshare-android-builder image: musicshare-android-builder
# Keep Gradle and npm downloads between builds. # Keep Gradle (downloads and build cache), npm, and compiled C++ (ccache) between builds.
options: -v musicshare-gradle:/root/.gradle -v musicshare-npm:/root/.npm options: -v musicshare-gradle:/root/.gradle -v musicshare-npm:/root/.npm -v musicshare-ccache:/root/.ccache
timeout-minutes: 150 timeout-minutes: 150
env: env:
SERVER: ${{ github.server_url }} SERVER: ${{ github.server_url }}
@@ -117,7 +117,7 @@ jobs:
echo "Release: $SERVER/$REPO/releases/tag/$REF_NAME" echo "Release: $SERVER/$REPO/releases/tag/$REF_NAME"
else else
owner=${REPO%%/*} owner=${REPO%%/*}
version="$(echo "$REF_NAME" | tr -c 'A-Za-z0-9._-\n' '-')-${SHA::8}" version="$(printf '%s' "$REF_NAME" | tr -c 'A-Za-z0-9._-' '-')-$(printf '%s' "$SHA" | cut -c1-8)"
for f in *.apk SHA256SUMS; do for f in *.apk SHA256SUMS; do
curl -fsS -X PUT -H "$auth" --upload-file "$f" "$SERVER/api/packages/$owner/generic/musicshare-android/$version/$f" curl -fsS -X PUT -H "$auth" --upload-file "$f" "$SERVER/api/packages/$owner/generic/musicshare-android/$version/$f"
echo "Uploaded $f" echo "Uploaded $f"
+2
View File
@@ -10,3 +10,5 @@ next-env.d.ts
# Production secrets and database backups # Production secrets and database backups
deploy/production.env deploy/production.env
backups/ backups/
.aider*
deploy/gitea-runner/data/
+17 -5
View File
@@ -103,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. */ /** 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 n = schema.notifications;
const before = cursor ? new Date(cursor) : null; const before = cursor ? new Date(cursor) : null;
const rows = await db const query = db
.select() .select()
.from(n) .from(n)
.where(and(eq(n.userId, userId), before && !isNaN(before.getTime()) ? lt(n.createdAt, before) : undefined)) .where(and(
.orderBy(desc(n.createdAt)) eq(n.userId, userId),
.limit(31); 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); const page = rows.slice(0, 30);
return { return {
notifications: await toNotificationDTOs(page), notifications: await toNotificationDTOs(page),
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "musicshare", "name": "musicshare",
"slug": "musicshare", "slug": "musicshare",
"version": "1.4.0", "version": "1.5.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "musicshare", "scheme": "musicshare",
@@ -23,7 +23,7 @@
}, },
"predictiveBackGestureEnabled": false, "predictiveBackGestureEnabled": false,
"package": "tech.chrisedwards.musicshare", "package": "tech.chrisedwards.musicshare",
"versionCode": 5 "versionCode": 6
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
@@ -121,9 +121,11 @@ class MediaListenerService : NotificationListenerService() {
private fun snapshot(controller: MediaController): Playback? { private fun snapshot(controller: MediaController): Playback? {
val metadata = controller.metadata val metadata = controller.metadata
val title = metadata?.text(MediaMetadata.METADATA_KEY_TITLE) ?: metadata?.text(MediaMetadata.METADATA_KEY_DISPLAY_TITLE) 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_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 playbackState = controller.playbackState
val state = when (playbackState?.state) { val state = when (playbackState?.state) {
PlaybackState.STATE_PLAYING, PlaybackState.STATE_BUFFERING, 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()
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@musicshare/mobile", "name": "@musicshare/mobile",
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.4.0", "version": "1.5.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@expo-google-fonts/poppins": "^0.4.1", "@expo-google-fonts/poppins": "^0.4.1",
+18 -4
View File
@@ -7,24 +7,37 @@
# #
# Both are signed with the upload key when it's configured (see plugins/withReleaseSigning.js; # 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 # 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). # fail rather than produce unsigned APKs. ABIS picks the CPU types (default: 64-bit phones, which is
# Regenerates apps/mobile/android each time (prebuild --clean). # 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 set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
OUT=$(realpath -m "${1:-dist}") OUT=$(realpath -m "${1:-dist}")
VERSION=$(node -p "require('./app.json').expo.version") 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) BUILD_TOOLS=$(ls -d "$ANDROID_HOME"/build-tools/*/ | sort -V | tail -1)
mkdir -p "$OUT" mkdir -p "$OUT"
say() { printf '\n==> %s\n' "$*"; } 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() { build() {
local suffix=$1 foss=$2 local suffix=$1 foss=$2
say "Building musicshare-$VERSION$suffix.apk" say "Building musicshare-$VERSION$suffix.apk"
MUSICSHARE_FOSS=$foss CI=1 npx expo prebuild --platform android --clean --no-install 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 local apk
apk=$(ls android/app/build/outputs/apk/release/*.apk | head -1) apk=$(ls android/app/build/outputs/apk/release/*.apk | head -1)
@@ -56,3 +69,4 @@ build() {
build "" 0 build "" 0
build "-foss" 1 build "-foss" 1
(cd "$OUT" && sha256sum musicshare-"$VERSION"*.apk > SHA256SUMS && cat SHA256SUMS) (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
+40 -3
View File
@@ -8,32 +8,68 @@ import { notificationPath, notificationText, timeAgo, type NotificationListDTO }
import { Avatar } from '@/components/Avatar'; import { Avatar } from '@/components/Avatar';
import { client, useMe } from '@/lib/api'; import { client, useMe } from '@/lib/api';
const ADMIN_ACTOR_USERNAME = 'seriouscritter';
/** Reactions, comments, new followers and songs sent to you, newest first. Opening it marks them read. */ /** Reactions, comments, new followers and songs sent to you, newest first. Opening it marks them read. */
export default function NotificationsPage() { export default function NotificationsPage() {
const { me, loggedOut } = useMe(); const { me, loggedOut } = useMe();
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
// Fetch all notifications for the user
const { data, size, setSize, isLoading } = useSWRInfinite<NotificationListDTO>( const { data, size, setSize, isLoading } = useSWRInfinite<NotificationListDTO>(
(i, prev: NotificationListDTO | null) => (!me || (i > 0 && !prev?.nextCursor) ? null : ['notifications', prev?.nextCursor ?? null]), (i, prev: NotificationListDTO | null) => (!me || (i > 0 && !prev?.nextCursor) ? null : ['notifications', prev?.nextCursor ?? null]),
([, cursor]: [string, string | null]) => client.notifications(cursor), ([, cursor]: [string, string | null]) => client.notifications(cursor),
{ revalidateOnFocus: false }, { revalidateOnFocus: false },
); );
const items = data?.flatMap((p) => p.notifications) ?? [];
const allItems = data?.flatMap((p) => p.notifications) ?? [];
const unread = data?.[0]?.unread ?? 0; 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). // Seen once you've looked: clear the bell (rows stay highlighted until you come back).
useEffect(() => { useEffect(() => {
if (unread) client.markNotificationsRead().then(() => mutate('/notifications'), () => {}); if (unread) client.markNotificationsRead().then(() => mutate('/notifications'), () => {});
}, [unread, mutate]); }, [unread, mutate]);
if (loggedOut) return <p className="muted"><Link href="/login">Log in</Link> to see your notifications.</p>; if (loggedOut) return <p className="muted"><Link href="/login">Log in</Link> to see your notifications.</p>;
return ( return (
<div className="stack"> <div className="stack">
<h1>Notifications</h1> <h1>Notifications</h1>
{items.length === 0 && !isLoading && (
{/* Dedicated section for seriouscritter's messages */}
{seriousCritterMessages.length > 0 && (
<div className="admin-section">
<h2 className="muted">From {ADMIN_ACTOR_USERNAME}</h2>
<ul className="notifications">
{seriousCritterMessages.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>
</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> <p className="muted">Nothing yet. Reactions, new followers and songs sent to you show up here.</p>
)} )}
<ul className="notifications"> <ul className="notifications">
{items.map((n) => { {allItems.map((n) => {
const { title, body } = notificationText(n); const { title, body } = notificationText(n);
return ( return (
<li key={n.id} className={n.read ? '' : 'unread'}> <li key={n.id} className={n.read ? '' : 'unread'}>
@@ -50,6 +86,7 @@ export default function NotificationsPage() {
); );
})} })}
</ul> </ul>
{data?.at(-1)?.nextCursor && ( {data?.at(-1)?.nextCursor && (
<button className="btn" onClick={() => setSize(size + 1)}> <button className="btn" onClick={() => setSize(size + 1)}>
Show more Show more
+2 -2
View File
@@ -5,11 +5,11 @@
# #
# Versions match what React Native 0.86 / Expo 57 ask for # Versions match what React Native 0.86 / Expo 57 ask for
# (node_modules/react-native/gradle/libs.versions.toml). Gradle downloads anything # (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 FROM node:22-bookworm
RUN apt-get update \ 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/* && rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \ 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 # 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"). # projects runs out of address pools ("all predefined address pools have been fully subnetted").
network: bridge 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: valid_volumes:
- musicshare-gradle - musicshare-gradle
- musicshare-npm - musicshare-npm
- musicshare-ccache
+5 -2
View File
@@ -20,8 +20,11 @@ The pieces:
## One-time setup (on the Gitea server) ## 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 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 30-40 minutes; later ones reuse the Gradle cache. 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`). 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 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. server, including databases, stops writing.
@@ -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.
+1 -1
View File
@@ -809,7 +809,7 @@
}, },
"apps/mobile": { "apps/mobile": {
"name": "@musicshare/mobile", "name": "@musicshare/mobile",
"version": "1.4.0", "version": "1.5.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@expo-google-fonts/poppins": "^0.4.1", "@expo-google-fonts/poppins": "^0.4.1",