Compare commits

...

13 Commits

Author SHA1 Message Date
bvandeusen ccbd3b62a0 Merge pull request 'v2026.05.19.3 — playback stall resilience + legacy home cleanup' (#56) from dev into main 2026-05-19 15:48:08 -04:00
bvandeusen 5c1a5f5e8b chore(flutter): bump version to 2026.5.19+13 for v2026.05.19.3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:47:28 -04:00
bvandeusen bb1ab3a2e3 test(web): un-skip discover/requests route tests — mock $app (#374)
Root cause: discover.test.ts and requests.test.ts were the only route
page tests NOT mocking $app/state (+ $app/navigation), so rendering
the +page.svelte pulled in SvelteKit's client runtime and threw
`TypeError: notifiable_store is not a function` at module load. They'd
been describe.skip'd AND hard-excluded in vitest.config.ts.

Fix mirrors every other route test: vi.hoisted pageState +
vi.mock('$app/state', () => pageUrlModule(state)) +
vi.mock('$app/navigation', () => ({ goto: vi.fn() })). Un-skip both
describe blocks; drop the vitest.config exclude. The web test job
now runs both suites again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:42:11 -04:00
bvandeusen 154626ae94 feat(player): stall watchdog + bounded retry + skip-to-cached (#66)
Reported: on poor coverage a track ends and the next (uncached) track
never starts — streams, hangs, no retry. Root cause: a buffering stall
emits NO error event so the onError path never fires and there was no
stall watchdog; even on a real error _handlePlaybackError immediately
skipped the literal-next (likely also-unreachable) source with no retry.

- _reconcileStallWatchdog: while playing+buffering, a 15s window; if
  buffered position hasn't advanced it's a dead stream → recover; if
  progressing, re-arm (slow-but-downloading is fine). Driven from
  _broadcastState like the idle/position reconcilers.
- _recoverPlayback unifies stall + onError: retry the SAME track once
  (skipToQueueItem rebuilds a fresh source/HTTP — a transient blip no
  longer loses it); on exhaustion, surface via the #58 SnackBar and
  skip to the next cached track, else pause (no thrashing through
  unreachable streams).
- per-track retry budget resets when a track reaches ready+playing.
- _handlePlaybackError now delegates into the unified path.

Core playback change — device-verify on a throttled connection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:14:25 -04:00
bvandeusen 1646b02ca2 refactor(flutter): drop legacy home snapshot path (#406)
The home screen renders solely from the per-item cached_home_index
path (proven on devices for many releases); the legacy snapshot stack
was dead weight.

- library_providers: remove homeProvider + _encodeHomeData +
  _albumToJson/_artistToJson/_trackToJson; drop now-unused dart:convert
  and models/home_data.dart imports
- metadata_prefetcher: re-point off homeIndexProvider/HomeIndex —
  pre-warm artistProvider from the rediscover/last-played artist
  sections (album/track tiles hydrate their own artist on render)
- live_events_dispatcher: homeProvider -> homeIndexProvider so live
  events still refresh the home screen
- db.dart: drop CachedHomeSnapshot (table class + @DriftDatabase
  entry); schemaVersion 10->11; from<3 createTable -> raw
  customStatement so the historical step compiles without the
  generated symbol; from<11 DROP TABLE cached_home_snapshot

No test references the removed symbols. db.g.dart regenerated by CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:04:43 -04:00
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
12 changed files with 234 additions and 226 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();
}
});
}
+20 -19
View File
@@ -120,19 +120,6 @@ class SyncMetadata extends Table {
Set<Column> get primaryKey => {id};
}
/// Single-row cache of the /api/home response (#357 follow-up). The
/// home screen is the first thing the user sees on app open; storing
/// the last successful HomeData as JSON lets us yield it immediately
/// before the REST round-trip resolves, eliminating the cold-start
/// blank on slow / remote connections. Schema 3+.
class CachedHomeSnapshot extends Table {
IntColumn get id => integer().withDefault(const Constant(1))();
TextColumn get json => text()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
/// Single-row cache of the /api/me/history response. Mirrors the
/// CachedHomeSnapshot pattern: opening the History tab in the Library
/// screen yields the last-known page immediately while a fresh REST
@@ -255,7 +242,6 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
CachedPlaylistTracks,
AudioCacheIndex,
SyncMetadata,
CachedHomeSnapshot,
CachedHistorySnapshot,
CachedQuarantineMine,
CachedHomeIndex,
@@ -267,7 +253,7 @@ class AppDb extends _$AppDb {
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
@override
int get schemaVersion => 10;
int get schemaVersion => 11;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -284,10 +270,16 @@ class AppDb extends _$AppDb {
await customStatement('UPDATE sync_metadata SET cursor = 0');
}
if (from < 3) {
// Schema 3: cached_home_snapshot for #357 drift-first
// home page. No existing rows to migrate — table starts
// empty; the first /api/home fetch populates it.
await m.createTable(cachedHomeSnapshot);
// Schema 3: cached_home_snapshot (legacy drift-first home).
// The table + its homeProvider/JSON encoders were removed
// in #406; recreated here as raw SQL so this historical
// step still compiles without the generated table symbol.
// The from<11 step below drops it.
await customStatement(
'CREATE TABLE IF NOT EXISTS "cached_home_snapshot" '
'("id" INTEGER NOT NULL DEFAULT 1, "json" TEXT, '
'"updated_at" INTEGER, PRIMARY KEY ("id"));',
);
}
if (from < 4) {
// Schema 4: cached_history_snapshot for drift-first
@@ -337,6 +329,15 @@ class AppDb extends _$AppDb {
// first persist populates it.
await m.createTable(cachedResumeState);
}
if (from < 11) {
// Schema 11 (#406): drop the legacy cached_home_snapshot
// table. The home screen now renders solely from the
// per-item cached_home_index path; the legacy homeProvider
// + its JSON encoders were removed.
await customStatement(
'DROP TABLE IF EXISTS "cached_home_snapshot";',
);
}
},
);
}
+15 -22
View File
@@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../library/library_providers.dart';
import '../models/home_data.dart';
import '../models/home_index.dart';
/// Pre-warms the drift cache for likely-tap targets. Conservative on
/// purpose: only warms artistProvider rows (single row, single round
@@ -10,10 +10,15 @@ import '../models/home_data.dart';
/// list when missing, and pre-warming N albums fans out N parallel
/// "fetch tracks" round trips that compete with the user's actual
/// playback request for bandwidth.
///
/// Driven off the per-item home path (homeIndexProvider). Only the
/// artist-typed sections (rediscover / last-played artists) carry
/// artist ids directly; album/track tiles hydrate their own artist on
/// render via the per-item path, so warming those here is unnecessary.
class MetadataPrefetcher {
MetadataPrefetcher(this._ref) {
_ref.listen<AsyncValue<HomeData>>(homeProvider, (_, next) {
next.whenData(_warmHome);
_ref.listen<AsyncValue<HomeIndex>>(homeIndexProvider, (_, next) {
next.whenData(_warmIndex);
});
}
@@ -24,7 +29,8 @@ class MetadataPrefetcher {
/// ids we've already warmed.
final Set<String> _warmedArtists = {};
/// Cap per section. Covers what fits on screen without scrolling.
/// Cap warmed per emission. Covers what fits on screen without
/// scrolling.
static const _topN = 8;
/// Pre-warm artists. Albums are intentionally not pre-warmed —
@@ -39,24 +45,11 @@ class MetadataPrefetcher {
}
}
void _warmHome(HomeData h) {
final artistIds = <String>{};
for (final a in h.recentlyAddedAlbums.take(_topN)) {
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
}
for (final a in h.rediscoverAlbums.take(_topN)) {
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
}
for (final ar in h.rediscoverArtists.take(_topN)) {
if (ar.id.isNotEmpty) artistIds.add(ar.id);
}
for (final ar in h.lastPlayedArtists.take(_topN)) {
if (ar.id.isNotEmpty) artistIds.add(ar.id);
}
for (final t in h.mostPlayedTracks.take(_topN)) {
if (t.artistId.isNotEmpty) artistIds.add(t.artistId);
}
warmArtists(artistIds);
void _warmIndex(HomeIndex h) {
warmArtists(<String>{
...h.rediscoverArtists.take(_topN),
...h.lastPlayedArtists.take(_topN),
});
}
/// Discards the return value and any error from a fire-and-forget
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' as drift;
@@ -16,7 +15,6 @@ import '../cache/connectivity_provider.dart';
import '../cache/db.dart';
import '../models/album.dart';
import '../models/artist.dart';
import '../models/home_data.dart';
import '../models/home_index.dart';
import '../models/track.dart';
@@ -44,57 +42,6 @@ final libraryApiProvider = FutureProvider<LibraryApi>((ref) async {
return LibraryApi(await ref.watch(dioProvider.future));
});
/// Drift-first home data (#357 follow-up). The home screen is the first
/// view the user sees on app open; without a local cache the cold-start
/// blocks on the /api/home round-trip, which is the dominant felt-
/// latency on slow / remote connections.
///
/// Cache layout: a single row in `cached_home_snapshot` storing the
/// last successful HomeData as JSON. On stream subscription:
///
/// - If a row exists, yield it immediately (parsed from JSON) and
/// kick off a background revalidation against /api/home (SWR).
/// The drift watch() re-emits when the new row is written.
/// - If no row exists yet (cold cache), fetch /api/home synchronously,
/// upsert into drift, and emit. Subsequent emissions come from the
/// drift watch as background revalidations land.
///
/// The HomeData JSON encodes back to the same wire shape /api/home
/// emits, so the existing HomeData.fromJson handles both sources.
final homeProvider = StreamProvider<HomeData>((ref) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedHomeSnapshotData, HomeData>(
driftStream: db.select(db.cachedHomeSnapshot).watch(),
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getHome();
await db.into(db.cachedHomeSnapshot).insertOnConflictUpdate(
CachedHomeSnapshotCompanion.insert(
json: _encodeHomeData(fresh),
updatedAt: drift.Value(DateTime.now()),
),
);
},
toResult: (rows) => rows.isEmpty
? const HomeData(
recentlyAddedAlbums: [],
rediscoverAlbums: [],
rediscoverArtists: [],
mostPlayedTracks: [],
lastPlayedArtists: [],
)
: HomeData.fromJson(jsonDecode(rows.first.json) as Map<String, dynamic>),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// SWR: when cached snapshot exists, still hit /api/home in the
// background so the user sees the freshest curation. The cache
// mostly serves the first frame, not the canonical state.
alwaysRefresh: true,
tag: 'home',
);
});
/// Drift-first per-item home index. Reads from cached_home_index
/// (populated by /api/home/index discovery) and yields a HomeIndex
/// the screen consumes to lay out section→tile slots. Each tile is
@@ -184,49 +131,6 @@ final homeIndexProvider = StreamProvider<HomeIndex>((ref) {
);
});
/// Encodes HomeData back to the wire-format JSON shape /api/home
/// emits, so HomeData.fromJson can round-trip through the drift cache.
String _encodeHomeData(HomeData h) => jsonEncode({
'recently_added_albums': h.recentlyAddedAlbums.map(_albumToJson).toList(),
'rediscover_albums': h.rediscoverAlbums.map(_albumToJson).toList(),
'rediscover_artists': h.rediscoverArtists.map(_artistToJson).toList(),
'most_played_tracks': h.mostPlayedTracks.map(_trackToJson).toList(),
'last_played_artists': h.lastPlayedArtists.map(_artistToJson).toList(),
});
Map<String, dynamic> _albumToJson(AlbumRef a) => {
'id': a.id,
'title': a.title,
'sort_title': a.sortTitle,
'artist_id': a.artistId,
'artist_name': a.artistName,
'year': a.year,
'track_count': a.trackCount,
'duration_sec': a.durationSec,
'cover_url': a.coverUrl,
};
Map<String, dynamic> _artistToJson(ArtistRef a) => {
'id': a.id,
'name': a.name,
'sort_name': a.sortName,
'album_count': a.albumCount,
'cover_url': a.coverUrl,
};
Map<String, dynamic> _trackToJson(TrackRef t) => {
'id': t.id,
'title': t.title,
'album_id': t.albumId,
'album_title': t.albumTitle,
'artist_id': t.artistId,
'artist_name': t.artistName,
'track_number': t.trackNumber,
'disc_number': t.discNumber,
'duration_sec': t.durationSec,
'stream_url': t.streamUrl,
};
/// Drift-first per #357 plan C. Watches cached_artists for the row;
/// when empty + online, fetches via REST + populates drift, which
/// re-emits via watch().
+123 -46
View File
@@ -76,6 +76,24 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
/// need this. Active only while playing; cancelled otherwise.
Timer? _positionBroadcastTimer;
/// Buffering-stall watchdog. On poor coverage a stream hangs in
/// ProcessingState.buffering with NO error event, so the onError
/// recovery never fires. Armed while playing+buffering; on fire, if
/// buffered position hasn't advanced it's a dead stream → recover.
/// Re-arms a fresh window while buffering-but-progressing (slow-but-
/// downloading is not a stall).
static const _stallTimeout = Duration(seconds: 15);
Timer? _stallTimer;
Duration _stallMarkBuffered = Duration.zero;
/// Per-track recovery budget. Reset when a track reaches
/// ready+playing. One retry of the same source (rebuilt fresh), then
/// skip to the next cached track (or pause) instead of thrashing
/// through unreachable streams.
static const _maxRecoverRetries = 1;
String? _recoveringTrackId;
int _recoverAttempts = 0;
/// Player volume captured when an OS duck interruption begins,
/// restored when it ends. Null when not currently ducked.
double? _volumeBeforeDuck;
@@ -492,33 +510,12 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
///
/// If we're at the last track, seekToNext is a no-op; the state
/// drops to idle and _broadcastState reflects that.
/// onError entrypoint (404 / decoder / premature EOS / network drop).
/// Routes into the unified recovery path (retry the track once, then
/// skip to the next cached track or pause) instead of immediately
/// skipping the literal next — possibly also-unreachable — source.
Future<void> _handlePlaybackError() async {
// mediaItem is the correctly-mapped current track (post #49 logical
// index), so it's the one that just failed — no player-index math.
final failed = mediaItem.value?.title;
if (failed != null && failed.isNotEmpty) {
_playbackErrors.add(failed);
}
final currentIdx = _player.currentIndex;
final queueLen = queue.value.length;
if (currentIdx == null || currentIdx + 1 >= queueLen) {
// Last track or no queue context — just pause; _broadcastState
// already reflects the idle/error processingState.
try {
await _player.pause();
} catch (_) {}
return;
}
try {
await _player.seekToNext();
} catch (_) {
// If seekToNext fails too (next source not built yet, etc.),
// there's nothing safe to do — pause and let the user recover
// manually.
try {
await _player.pause();
} catch (_) {}
}
await _recoverPlayback();
}
void _onCurrentIndexChanged(int? idx) {
@@ -573,6 +570,8 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
_idleStopTimer = null;
_positionBroadcastTimer?.cancel();
_positionBroadcastTimer = null;
_stallTimer?.cancel();
_stallTimer = null;
try {
await _player.stop();
} catch (_) {}
@@ -724,31 +723,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
@@ -799,6 +785,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
));
_reconcileIdleTimer();
_reconcilePositionBroadcast();
_reconcileStallWatchdog();
}
/// Arms (or cancels) the idle-cleanup timer based on the current
@@ -855,6 +842,96 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
);
}
/// Buffering-stall watchdog. Armed while playing+buffering/loading.
/// On fire: still stuck with no >=1s buffered progress → dead stream,
/// recover; progressing → re-arm a fresh window (slow-but-downloading
/// is fine). When the track is happily ready+playing, clears the
/// per-track recovery budget. Driven from _broadcastState; mirrors
/// the other reconcile helpers.
void _reconcileStallWatchdog() {
final ps = _player.processingState;
if (ps == ProcessingState.ready && _player.playing) {
_recoveringTrackId = null;
_recoverAttempts = 0;
}
final buffering = _player.playing &&
(ps == ProcessingState.buffering || ps == ProcessingState.loading);
if (!buffering) {
_stallTimer?.cancel();
_stallTimer = null;
return;
}
if (_stallTimer != null) return; // window already running
_stallMarkBuffered = _player.bufferedPosition;
_stallTimer = Timer(_stallTimeout, () {
_stallTimer = null;
final ps2 = _player.processingState;
final stillBuffering = _player.playing &&
(ps2 == ProcessingState.buffering ||
ps2 == ProcessingState.loading);
if (!stillBuffering) return;
final progressed = (_player.bufferedPosition - _stallMarkBuffered) >=
const Duration(seconds: 1);
if (progressed) {
_reconcileStallWatchdog(); // slow but downloading — re-arm
} else {
unawaited(_recoverPlayback());
}
});
}
/// Unified recovery for a failed OR stalled stream. Retries the
/// current track once (rebuilt from scratch via skipToQueueItem so a
/// transient blip doesn't lose it); on exhaustion, surfaces the
/// failure (#58 SnackBar) and skips to the next cached track — or
/// pauses — instead of blindly walking into more unreachable streams.
Future<void> _recoverPlayback() async {
_stallTimer?.cancel();
_stallTimer = null;
final media = mediaItem.value;
if (media == null) return;
final trackId = media.id;
final curIdx = _lastTracks.indexWhere((t) => t.id == trackId);
if (curIdx < 0) {
try {
await _player.pause();
} catch (_) {}
return;
}
if (_recoveringTrackId != trackId) {
_recoveringTrackId = trackId;
_recoverAttempts = 0;
}
if (_recoverAttempts < _maxRecoverRetries) {
_recoverAttempts++;
// Rebuild + restart the SAME track (fresh source / HTTP).
await skipToQueueItem(curIdx);
return;
}
if (media.title.isNotEmpty) _playbackErrors.add(media.title);
_recoveringTrackId = null;
_recoverAttempts = 0;
await _skipToNextCachedOrPause(curIdx);
}
/// Skips to the first track after [fromIdx] that is fully cached on
/// disk (plays with zero network). If none, pauses rather than
/// thrashing through unreachable streams on a dead connection.
Future<void> _skipToNextCachedOrPause(int fromIdx) async {
final mgr = _audioCacheManager;
if (mgr != null) {
for (var i = fromIdx + 1; i < _lastTracks.length; i++) {
if (await mgr.pathFor(_lastTracks[i].id) != null) {
await skipToQueueItem(i);
return;
}
}
}
try {
await _player.pause();
} catch (_) {}
}
/// Configures the OS audio session for music playback and wires
/// interruption + becoming-noisy handling. just_audio auto-activates /
/// deactivates the session around playback once it's configured, but
@@ -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;
}
@@ -20,7 +20,7 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../library/library_providers.dart' show homeProvider;
import '../library/library_providers.dart' show homeIndexProvider;
import '../quarantine/quarantine_provider.dart';
import 'live_events_provider.dart';
@@ -62,8 +62,8 @@ class _LiveEventsDispatcher with WidgetsBindingObserver {
case 'quarantine.deleted_via_lidarr':
_ref.invalidate(myQuarantineProvider);
// Hidden / liked / album rows referencing a deleted track may
// now be stale — homeProvider re-fetch refreshes the cards.
_ref.invalidate(homeProvider);
// now be stale — homeIndexProvider re-fetch refreshes the cards.
_ref.invalidate(homeIndexProvider);
case 'playlist.created':
case 'playlist.updated':
case 'playlist.deleted':
@@ -72,7 +72,7 @@ class _LiveEventsDispatcher with WidgetsBindingObserver {
// is reflected immediately. Detail screens that need the
// mutated row will get a separate invalidate from their own
// listener (filed as a follow-up).
_ref.invalidate(homeProvider);
_ref.invalidate(homeIndexProvider);
case 'scan.run_started':
case 'scan.run_finished':
// Admin scan card provider lives in the admin feature folder
@@ -93,7 +93,7 @@ class _LiveEventsDispatcher with WidgetsBindingObserver {
// own, but the re-invalidate flushes any stale data that
// landed while the app was backgrounded.
_ref.invalidate(myQuarantineProvider);
_ref.invalidate(homeProvider);
_ref.invalidate(homeIndexProvider);
}
}
+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+13
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.
+11 -7
View File
@@ -3,6 +3,16 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
import { apiClientMock } from '../../test-utils/mocks/client';
import type { LidarrSearchResult } from '$lib/api/types';
import { pageUrlModule } from '../../test-utils/mocks/appState';
// $app/state / $app/navigation must be mocked or rendering the page
// pulls in SvelteKit's client runtime (`notifiable_store is not a
// function`) at module load. Same pattern as the other route tests.
const pageState = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/discover')
}));
vi.mock('$app/state', () => pageUrlModule(pageState));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
// Lidarr search query factory and createRequest are mocked at the module
// level so each test can shape what the page sees without standing up a
@@ -56,13 +66,7 @@ afterEach(() => {
vi.clearAllMocks();
});
// FIXME(M7 #374): suite errors at module-load with
// `TypeError: notifiable_store is not a function` from SvelteKit's
// internal client.js. Surfaces only on the new dev-push CI; PR-to-main
// runs were green. Probably a vitest module-resolution interaction
// with a recent SvelteKit; needs targeted triage. Skipped to unblock
// CI for #372 — the page itself isn't broken, the test harness is.
describe.skip('Discover page', () => {
describe('Discover page', () => {
test('initial state (no query) shows the suggestion feed', () => {
render(DiscoverPage);
expect(screen.getByText(/suggested for you/i)).toBeInTheDocument();
+11 -4
View File
@@ -3,6 +3,16 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
import { apiClientMock } from '../../test-utils/mocks/client';
import type { LidarrRequest } from '$lib/api/types';
import { pageUrlModule } from '../../test-utils/mocks/appState';
// $app/state / $app/navigation must be mocked or rendering the page
// pulls in SvelteKit's client runtime (`notifiable_store is not a
// function`) at module load. Same pattern as the other route tests.
const pageState = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/requests')
}));
vi.mock('$app/state', () => pageUrlModule(pageState));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
// Capture invalidateQueries on the mocked QueryClient so the cancel test can
// assert against the same instance the page consumed. Per-file mock OVERRIDES
@@ -66,10 +76,7 @@ afterEach(() => {
vi.clearAllMocks();
});
// FIXME(M7 #374): same SvelteKit `notifiable_store is not a function`
// failure as discover.test.ts. Skipped to unblock CI for #372 — needs
// triage as a separate task.
describe.skip('Requests page', () => {
describe('Requests page', () => {
test('renders one row per request from the API', () => {
const rows: LidarrRequest[] = [
req({ id: 'r1', kind: 'artist', artist_name: 'Boards of Canada' }),
+1 -13
View File
@@ -7,19 +7,7 @@ export default defineConfig({
test: {
environment: 'jsdom',
include: ['src/**/*.test.ts', 'src/**/*.svelte.test.ts', 'scripts/**/*.test.js'],
// M7 #374: these two test files explode at module-load with
// `TypeError: notifiable_store is not a function` from SvelteKit's
// internal client.js — the import chain triggers SvelteKit's client
// runtime init in a way the dev-push vitest invocation can't satisfy.
// describe.skip inside the files doesn't help because vitest must
// load the file before it can see the describe block. Excluded here
// until the triage in #374 lands a real fix (probably a $app/paths
// mock in vitest.setup.ts).
exclude: [
...configDefaults.exclude,
'src/routes/discover/discover.test.ts',
'src/routes/requests/requests.test.ts'
],
exclude: [...configDefaults.exclude],
setupFiles: ['./vitest.setup.ts']
}
});