Files
minstrel/flutter_client/lib/app.dart
T
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

108 lines
4.7 KiB
Dart

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';
import 'cache/offline_provider.dart';
import 'cache/mutation_queue.dart';
import 'cache/prefetcher.dart';
import 'cache/resume_controller.dart';
import 'cache/sync_controller.dart';
import 'player/play_events_reporter.dart';
import 'player/playback_error_reporter.dart';
import 'shared/live_events_dispatcher.dart';
import 'shared/routing.dart';
import 'theme/theme_data.dart';
import 'theme/theme_mode_provider.dart';
class MinstrelApp extends ConsumerStatefulWidget {
const MinstrelApp({super.key});
@override
ConsumerState<MinstrelApp> createState() => _MinstrelAppState();
}
class _MinstrelAppState extends ConsumerState<MinstrelApp> {
@override
void initState() {
super.initState();
// Activate offline-mode infrastructure once the first frame ships.
// SyncController.sync() is connectivity-aware (no-ops on no auth /
// no server URL), so calling it unconditionally is safe.
// Reading prefetcherProvider runs its constructor, which wires the
// queue + settings listeners.
WidgetsBinding.instance.addPostFrameCallback((_) {
// ignore: unawaited_futures
ref.read(syncControllerProvider.notifier).sync();
ref.read(prefetcherProvider);
// Metadata prefetcher: when /api/home returns, fire background
// albumProvider/artistProvider reads for the top-N items in
// each section so subsequent taps are drift hits, not network
// round trips.
ref.read(metadataPrefetcherProvider);
// Live events (#392): subscribes to /api/events/stream and
// invalidates publicly-scoped providers when relevant events
// arrive. Also installs an AppLifecycleState observer for
// resume-time defensive invalidation.
ref.read(liveEventsDispatcherProvider);
// Cache filler: background sweeper that walks cached_artists
// / cached_albums for missing relations and fetches the
// per-entity detail so tapping an artist surfaces albums
// immediately (drift hit, no /api/artists/:id round-trip at
// tap time). First sweep 10s after launch; every 5 minutes
// thereafter. Throttled 200ms between requests so it never
// competes with user activity.
ref.read(cacheFillerProvider);
// Mutation replayer: drains the cached_mutations queue when
// connectivity comes back. Controllers (LikesController,
// MyQuarantineController, the add-to-playlist + request flows)
// enqueue on REST failure so user intent persists across
// network loss instead of getting rolled back.
ref.read(mutationReplayerProvider);
// Play-events reporter (#415): the Flutter client otherwise
// reports no plays at all — this feeds history, recommendation
// scoring, scrobbles, and system-playlist rotation, and is the
// path that carries the `source` tag for #415.
ref.read(playEventsReporterProvider);
// Resume-on-launch (#54): restores the last persisted session
// (paused) and persists queue/index/position on track change,
// pause, and app teardown. Pairs with the #52 idle teardown so a
// torn-down session is recoverable instead of lost.
ref.read(resumeControllerProvider);
// Playback-error reporter (#58): turns the handler's silent
// dead-track skips into a debounced/coalesced SnackBar so a
// vanished track isn't mysterious (and aids server/cache debug).
ref.read(playbackErrorReporterProvider);
// Offline marker (#427 S1): periodic /healthz reachability
// 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();
}
});
}
@override
Widget build(BuildContext context) {
final router = ref.watch(routerProvider);
final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
return MaterialApp.router(
title: 'Minstrel',
scaffoldMessengerKey: scaffoldMessengerKey,
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
themeMode: mode.materialMode,
routerConfig: router,
);
}
}