Compare commits

...

8 Commits

Author SHA1 Message Date
bvandeusen 19de0c2874 Merge pull request 'v2026.05.19.2 — hotfix: restore media notification (remove broken custom favorite)' (#55) from dev into main
Merge v2026.05.19.2 hotfix — restore media notification (PR #55)
2026-05-19 07:48:10 -04:00
bvandeusen 1dc298c111 chore(flutter): bump version to 2026.5.19+12 for v2026.05.19.2
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:47:45 -04:00
bvandeusen e605335339 fix(player): remove custom favorite MediaControl — it killed the notification
Device logcat (Pixel 6 Pro / Android 16) showed audio_service throwing
on every state broadcast:

  java.lang.IllegalArgumentException: You must specify an icon resource
  id to build a CustomAction
    at com.ryanheise.audioservice.AudioService...

The #57 MediaControl.custom favorite makes audio_service build a
PlaybackStateCompat.CustomAction whose icon id resolves to 0 on real
builds; the exception aborts the ENTIRE media notification, so nothing
posts to the tray or the watch (emulator tolerated it). Not a
permission / PathParser / FGS issue — POST_NOTIFICATIONS was verified
granted. Pre-#57 there was no CustomAction, matching the regression.

Remove the custom favorite control; the notification is rebuilt with
only the standard transport controls (audio_service ships their icons).
customAction handler / refreshFavoriteControl left as harmless no-ops
to minimise churn. Like/favorite remains in-app + lock screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:19:42 -04:00
bvandeusen 22a4649bfc Merge pull request 'v2026.05.19.1 — hotfix: notification permission + full-player auto-minimize' (#54) from dev into main
Merge v2026.05.19.1 hotfix — notification permission + full-player auto-minimize (PR #54)
2026-05-18 23:08:33 -04:00
bvandeusen 01a1ac148e chore(flutter): bump version to 2026.5.19+11 for v2026.05.19.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:08:13 -04:00
bvandeusen bc34d96329 fix(player): request POST_NOTIFICATIONS; auto-minimize player on teardown
Device-surfaced on physical Android 13+ (worked on emulator):

A) The media notification never appeared because the app never
   requested POST_NOTIFICATIONS at runtime — the manifest declares it
   and the foreground service is correct, but Android 13+ denies it by
   default until asked. Add permission_handler ^12.0.1 and request
   Permission.notification once at startup (post-first-frame,
   Platform.isAndroid-guarded; no-op on <13 / once decided).

B) When the #52 idle/dismiss teardown nulled mediaItem while the full
   NowPlayingScreen was open, it stranded the user on an empty
   "Nothing playing." Scaffold. Now post-frame maybePop() so it
   auto-minimizes (the mini bar is already gone).

pubspec.lock + db.g.dart regenerated by CI/build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:03:31 -04:00
bvandeusen 8e7660c05e Merge pull request 'fix(ci): scope integration Postgres discovery to this job's network' (#53) from dev into main
Merge CI fix: scope integration Postgres discovery to this job's network (PR #53)
2026-05-18 22:50:36 -04:00
bvandeusen eaddb2478a fix(ci): scope integration Postgres discovery to this job's network
`--filter name=integration` matched EVERY concurrent integration run's
Postgres service container. A dev push and the main-merge run overlap
on the shared act_runner daemon → 2 candidates → the "expected exactly
1" guard aborts (false failure; not a code defect).

Discover instead by intersecting networks: act_runner attaches the job
container and its service container to a shared per-job network, so
select the postgres that sits on a network this job container is also
on. The dev-compose container is skipped explicitly as before.

CI-only change; the released v2026.05.19.0 code is unaffected (a clean
re-run of the failed job passes — the failure was a concurrency race).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:50:04 -04:00
5 changed files with 55 additions and 34 deletions
+25 -13
View File
@@ -83,19 +83,31 @@ jobs:
run: |
set -eux
# Discover THIS job's Postgres service container via the
# mounted docker socket. Scope by the job-name filter so the
# operator's dev compose `minstrel-postgres-*` (same image,
# same daemon) can never match. Require exactly one — abort
# loudly otherwise; a wrong target would truncate real data.
PG_LIST=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" --format '{{.ID}} {{.Names}}')
echo "candidates: ${PG_LIST:-<none>}"
PG_COUNT=$(printf '%s\n' "$PG_LIST" | grep -c . || true)
test "$PG_COUNT" = "1" || { echo "FATAL: expected exactly 1 postgres service container, got $PG_COUNT"; exit 1; }
PG_ID=$(printf '%s' "$PG_LIST" | awk '{print $1}')
PG_NAME=$(printf '%s' "$PG_LIST" | awk '{print $2}')
case "$PG_NAME" in
*minstrel-postgres*|*_postgres_*) echo "FATAL: matched the dev compose container ($PG_NAME), refusing"; exit 1 ;;
esac
# mounted docker socket. act_runner attaches the job
# container and its service container(s) to a shared per-job
# network, so scope discovery to a postgres that sits on a
# network THIS job container is also on. The old
# `--filter name=integration` matched EVERY concurrent
# integration run's postgres (a dev push + the main-merge run
# overlap → 2 candidates → false "expected exactly 1" abort).
# The operator's dev compose `minstrel-postgres-*` is never on
# this job's network; skip it explicitly as belt-and-suspenders
# (a wrong target would truncate real data).
SELF=$(cat /etc/hostname)
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
test -n "$SELF_NETS"
echo "self ($SELF) networks: $SELF_NETS"
PG_ID=""
PG_NAME=""
for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do
nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac
for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do
case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac
done
done
test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; }
echo "selected postgres: $PG_ID $PG_NAME"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
test -n "$PG_IP"
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
+11
View File
@@ -1,5 +1,8 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import 'cache/cache_filler.dart';
import 'cache/metadata_prefetcher.dart';
@@ -77,6 +80,14 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
// probe → offlineProvider. Read here to start the poller; S4
// gates system-playlist play + Shuffle-all on it.
ref.read(offlineProvider);
// POST_NOTIFICATIONS (Android 13+) is denied-by-default until
// requested; without it the media notification is silently
// suppressed on physical devices. One-shot, post-first-frame so
// it never blocks launch; no-op on <13 / once already decided.
if (Platform.isAndroid) {
// ignore: unawaited_futures
Permission.notification.request();
}
});
}
+7 -20
View File
@@ -724,31 +724,18 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
// with up-to-date shuffleMode/repeatMode fields.
void _broadcastState(PlaybackEvent? event) {
final playing = _player.playing;
// Favorite control: only when a track is playing and the LikeBridge
// is wired. Icon/label toggle by current like state; tapping it
// routes to customAction('minstrel.favorite'). Implemented as a
// MediaControl.custom (NOT setRating — that path is broken upstream,
// audio_service #376, and regressed the Pixel Watch). Kept out of
// androidCompactActionIndices so the compact/lock view is unchanged.
final favTrackId = mediaItem.value?.id;
final favBridge = _likeBridge;
final showFav = favTrackId != null && favBridge != null;
final favLiked = favTrackId != null &&
favBridge != null &&
favBridge.isTrackLiked(favTrackId);
// No custom favorite MediaControl here. audio_service builds a
// PlaybackStateCompat.CustomAction for it and throws
// "You must specify an icon resource id to build a CustomAction"
// on real builds (the androidIcon doesn't resolve to a usable id),
// and that exception aborts the ENTIRE media notification — no
// tray or Wear controls at all. Removed; like/favorite remains
// available in-app and via the standard lock-screen surface.
playbackState.add(PlaybackState(
controls: [
MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.skipToNext,
if (showFav)
MediaControl.custom(
androidIcon: favLiked
? 'drawable/ic_stat_favorite'
: 'drawable/ic_stat_favorite_border',
label: favLiked ? 'Unfavorite' : 'Favorite',
name: 'minstrel.favorite',
),
],
// androidCompactActionIndices tells the system which controls
// appear in the collapsed/lock-screen view. Without this, some
@@ -222,6 +222,14 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
_displayedDominant = null;
_pendingPreloadId = null;
});
// Session was torn down (#52 idle/dismiss) while the full
// player was open. Don't strand the user on an empty
// "Nothing playing." screen — minimize back (the mini bar is
// already gone too). maybePop is a no-op if this is somehow
// the root route.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).maybePop();
});
}
return;
}
+4 -1
View File
@@ -1,7 +1,7 @@
name: minstrel
description: Minstrel mobile client
publish_to: 'none'
version: 2026.5.19+10
version: 2026.5.19+12
environment:
sdk: '>=3.5.0 <4.0.0'
@@ -25,6 +25,9 @@ dependencies:
# Lucide icon set (design system mandates Lucide, not Material).
# Icons exposed as LucideIcons.<snake_case> IconData usable in Icon().
flutter_lucide: ^1.11.0
# Runtime POST_NOTIFICATIONS request (Android 13+ denies-by-default
# until asked; the media notification is suppressed without it).
permission_handler: ^12.0.1
google_fonts: ^8.1.0
# 10.x conflicts with flutter_secure_storage 10.x on win32. Hold at 8.3.1
# until either lib bumps win32 to 6.x.