5 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
10 changed files with 115 additions and 15 deletions
+1 -1
View File
@@ -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",
+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
@@ -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",