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
container:
image: musicshare-android-builder
# Keep Gradle and npm downloads between builds.
options: -v musicshare-gradle:/root/.gradle -v musicshare-npm:/root/.npm
# Keep Gradle (downloads and build cache), npm, and compiled C++ (ccache) between builds.
options: -v musicshare-gradle:/root/.gradle -v musicshare-npm:/root/.npm -v musicshare-ccache:/root/.ccache
timeout-minutes: 150
env:
SERVER: ${{ github.server_url }}
@@ -117,7 +117,7 @@ jobs:
echo "Release: $SERVER/$REPO/releases/tag/$REF_NAME"
else
owner=${REPO%%/*}
version="$(echo "$REF_NAME" | tr -c 'A-Za-z0-9._-\n' '-')-${SHA::8}"
version="$(printf '%s' "$REF_NAME" | tr -c 'A-Za-z0-9._-' '-')-$(printf '%s' "$SHA" | cut -c1-8)"
for f in *.apk SHA256SUMS; do
curl -fsS -X PUT -H "$auth" --upload-file "$f" "$SERVER/api/packages/$owner/generic/musicshare-android/$version/$f"
echo "Uploaded $f"
+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/
+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. */
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),
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "musicshare",
"slug": "musicshare",
"version": "1.4.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": 5
"versionCode": 6
},
"plugins": [
"expo-router",
@@ -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()
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@musicshare/mobile",
"main": "expo-router/entry",
"version": "1.4.0",
"version": "1.5.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@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;
# 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
+42 -5
View File
@@ -8,32 +8,43 @@ import { notificationPath, notificationText, timeAgo, type NotificationListDTO }
import { Avatar } from '@/components/Avatar';
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. */
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'}>
@@ -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
+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 -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.
@@ -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": {
"name": "@musicshare/mobile",
"version": "1.4.0",
"version": "1.5.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@expo-google-fonts/poppins": "^0.4.1",