import 'dart:async'; import 'dart:io'; import 'dart:ui'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'downloads/downloads_screen.dart'; import 'downloads/youtube_search_screen.dart'; import 'library/android_scan.dart'; import 'library/auto_scan.dart'; import 'library/category_service.dart'; import 'library/database.dart'; import 'library/favorites_screen.dart'; import 'library/library_service.dart'; import 'library/my_music_screen.dart'; import 'library/playlist_service.dart'; import 'library/search_screen.dart'; import 'player/audio_effects.dart'; import 'player/audio_handler.dart'; import 'player/mini_player.dart'; import 'player/now_playing_screen.dart'; import 'player/player_expansion_controller.dart'; import 'services/baka_auth.dart'; import 'services/logger_service.dart'; import 'services/download_service.dart'; import 'services/geteilte_adresse.dart'; import 'services/melo_cloud_service.dart'; import 'services/offline_mode.dart'; import 'services/navidrome_service.dart'; import 'services/sync_service.dart'; import 'services/yt_download_service.dart'; import 'services/yt_search_service.dart'; import 'settings/app_settings.dart'; import 'shared/theme.dart'; late final MeloAudioHandler _handler; late final MeloDb _db; late final LibraryService _library; late final PlaylistService _playlists; late final OfflineMode _offlineMode; late final CategoryService _categories; late final AppSettings _settings; late final AudioEffects _effects; late final BakaAuth _bakaAuth; late final YtDownloadService _ytDownload; late final YtSearchService _ytSearch; late final SyncService _sync; late final DownloadService _downloads; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await logger.init(); _db = MeloDb(); _library = LibraryService(_db); _playlists = PlaylistService(_db); _offlineMode = OfflineMode(); await _offlineMode.init(); _categories = CategoryService(_db); _settings = AppSettings(); await _settings.init(); _handler = await AudioService.init( builder: () => MeloAudioHandler(db: _db), config: const AudioServiceConfig( androidNotificationChannelId: 'de.baka.melo.audio', androidNotificationChannelName: 'Melo', androidNotificationOngoing: true, androidShowNotificationBadge: true, // Android Auto zeigt die oberste Ebene als Reiter statt als Liste — // vier Ordner passen dort gut hin. androidBrowsableRootExtras: { 'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 2, 'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1, }, ), ); _effects = AudioEffects(); await _effects.init(); _bakaAuth = BakaAuth(); await _bakaAuth.laden(); _ytDownload = YtDownloadService(auth: _bakaAuth); _ytSearch = YtSearchService(auth: _bakaAuth); _sync = SyncService( db: _db, cloud: MeloCloudService(auth: _bakaAuth), ); await _sync.laden(); final navidrome = NavidromeService(); await navidrome.ladeGespeicherteZugangsdaten(); _downloads = DownloadService(db: _db, navidrome: navidrome); // Die Audio-Session steht erst beim Abspielen fest und kann wechseln — // die Klangeffekte müssen dann neu daran gehängt werden. _handler.androidAudioSessionIdStream.listen((id) { if (id != null) _effects.attach(id); }); void uebernehmeEinstellungen() { _handler.autoPlayOnHeadphones = _settings.autoPlayOnHeadphones; _handler.normalizeVolume = _settings.normalizeVolume; _handler.cacheGrenzeBytes = _settings.cacheGrenzeBytes; // Sofort anwenden: wer die Grenze senkt, will nicht warten, bis zufällig // der nächste Titel gestreamt wird. unawaited(_handler.raeumeZwischenspeicherAuf()); } uebernehmeEinstellungen(); _settings.addListener(uebernehmeEinstellungen); // Der Offline-Schalter liegt in einem eigenen Dienst, wirkt aber genauso: // ohne diese Weitergabe war er nur gespeichert und die App streamte weiter. void uebernehmeOfflineModus() => _handler.offlineOnly = _offlineMode.enabled; uebernehmeOfflineModus(); _offlineMode.addListener(uebernehmeOfflineModus); runApp(const MeloApp()); } class MeloApp extends StatefulWidget { const MeloApp({super.key}); @override State createState() => _MeloAppState(); } class _MeloAppState extends State with TickerProviderStateMixin { // Muss oberhalb des Navigators bereitgestellt werden (Review-Fund): auch // per Navigator.push geöffnete Screens (z.B. PlaylistDetailScreen, // ServerTitelScreen) rufen .open(context) auf. Als HomeShell-internen // Provider steckte der Controller nur innerhalb von HomeShell.build() — für // eine gepushte Route (ein Geschwister von HomeShell im selben // Navigator/Overlay, kein Nachfahre) war er dort unsichtbar und // context.read() warf ProviderNotFoundException. late final PlayerExpansionController _expansion; @override void initState() { super.initState(); _expansion = PlayerExpansionController(vsync: this); } @override void dispose() { _expansion.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MultiProvider( providers: [ Provider.value(value: _handler), Provider.value(value: _db), ChangeNotifierProvider.value(value: _library), ChangeNotifierProvider.value(value: _playlists), ChangeNotifierProvider.value(value: _offlineMode), ChangeNotifierProvider.value(value: _categories), ChangeNotifierProvider.value(value: _settings), ChangeNotifierProvider.value(value: _effects), ChangeNotifierProvider.value(value: _bakaAuth), ChangeNotifierProvider.value(value: _ytDownload), ChangeNotifierProvider.value(value: _ytSearch), ChangeNotifierProvider.value(value: _sync), ChangeNotifierProvider.value(value: _downloads), ChangeNotifierProvider.value( value: _expansion), ], child: MaterialApp( title: 'Melo', debugShowCheckedModeBanner: false, theme: MeloTheme.dark, home: const HomeShell(), ), ); } } class HomeShell extends StatefulWidget { const HomeShell({super.key}); @override State createState() => _HomeShellState(); } class _HomeShellState extends State with WidgetsBindingObserver { int _index = 0; late final AutoScan _autoScan; /// Adresse aus einem „Teilen"-Aufruf, die der Downloader übernehmen soll. String? _geteilteAdresse; void _goTo(int index) => setState(() => _index = index); @override void initState() { super.initState(); final db = context.read(); final lib = context.read(); _autoScan = AutoScan( geraeteAnzahl: countAndroidMediaStoreSongs, bekannteAnzahl: db.countSongs, scanne: lib.rescan, ); WidgetsBinding.instance.addObserver(this); // Beim Start einmal mit dem Server abgleichen — neue Titel von anderen // Geräten sind dann sofort da. context.read().automatisch(); unawaited(context.read().ensureStartupPermissions()); unawaited(_pruefeGeteilteAdresse()); } /// Sieht nach, ob die App über „Teilen" mit einer Adresse geöffnet wurde, /// und wechselt dann in den Download-Bereich. Future _pruefeGeteilteAdresse() async { final adresse = await const GeteilteAdresse().abholen(); if (adresse == null || !mounted) return; setState(() { _geteilteAdresse = adresse; _index = 2; }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { // Zurück in der App: nachsehen, ob inzwischen Musik dazugekommen ist — // auf dem Gerät wie am Server. if (state == AppLifecycleState.resumed) { if (Platform.isAndroid) _autoScan.pruefe(); context.read().automatisch(); // Ein zweites Teilen startet die App nicht neu (singleTop), sondern // bringt sie nur nach vorn — deshalb auch hier nachsehen. unawaited(_pruefeGeteilteAdresse()); } } @override Widget build(BuildContext context) { final tabs = [ MyMusicScreen( onSearchTap: () => _goTo(1), onFavoritesTap: () => _goTo(3), ), const SearchScreen(), DownloadsScreen( adresse: _geteilteAdresse, onAdresseUebernommen: () => setState(() => _geteilteAdresse = null), ), const FavoritesScreen(), const YoutubeSearchScreen(), ]; // Nur die Referenz holen, NICHT abonnieren (kein context.watch): die // Scaffold-weiten AnimatedBuilder unten hören direkt am Controller // (animation: expansion) und aktualisieren sich selbst live. Ein // context.watch hier würde stattdessen bei JEDEM _expansion-Tick den // kompletten HomeShell.build() (inkl. Scaffold-Rumpf) neu aufrufen. final expansion = context.read(); // Consumer NUR um PopScope — sein `child:` (der Scaffold-Rumpf) wird // laut Flutter-Semantik bei jedem expansion-Tick NICHT neu gebaut, das // ist hier auch nicht nötig: PopScope selbst braucht nur einen aktuellen // Wert bei jedem HomeShell-Rebuild (z.B. Zurück-Taste gedrückt), keine // Frame-genaue Aktualisierung während des Ziehens. Der Blur-Layer/Mini- // Player-Fade in Task 4 braucht dagegen echte Live-Reaktivität und // bekommt dafür einen eigenen, davon unabhängigen AnimatedBuilder direkt // an seiner Stelle im Stack — siehe Reaktivitäts-Regel in Global // Constraints. return Consumer( builder: (context, expansion, child) => PopScope( // Offener/halb gezogener Player hat Vorrang vor dem Tab-Reset: // Zurück soll ihn zuerst schließen, nicht gleichzeitig auch noch // den Tab wechseln. canPop: expansion.progress == 0 && _index == 0, onPopInvokedWithResult: (didPop, _) { if (didPop) return; if (expansion.progress > 0) { expansion.close(context); } else { setState(() => _index = 0); } }, child: child!, ), child: Scaffold( extendBody: true, // NowPlayingScreen (Task 5) braucht die volle Höhe bis unter die BottomNavigationBar body: Stack( children: [ AnimatedBuilder( animation: expansion, builder: (context, child) => IgnorePointer( ignoring: expansion.progress > 0, child: ExcludeSemantics( excluding: expansion.progress > 0, child: child, ), ), child: Column( children: [ Expanded(child: IndexedStack(index: _index, children: tabs)), const SizedBox(height: MiniPlayer.hoehe), // Platz für den Overlay-Mini-Player ], ), ), AnimatedBuilder( animation: expansion, builder: (context, child) { final p = expansion.progress; if (p == 0) return const SizedBox.shrink(); return Positioned.fill( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: p * 20.0, sigmaY: p * 20.0), child: Container(color: Colors.transparent), ), ); }, ), AnimatedBuilder( animation: expansion, builder: (context, child) => Positioned( left: 0, right: 0, bottom: 0, child: Opacity( opacity: (1 - expansion.progress).clamp(0.0, 1.0), child: IgnorePointer( ignoring: expansion.progress > 0, child: child, ), ), ), child: const MiniPlayer(), ), AnimatedBuilder( animation: expansion, builder: (context, child) { final p = expansion.progress; return Positioned.fromRect( rect: Rect.lerp(_miniRect(context), _vollbildRect(context), p)!, child: Visibility( visible: p > 0, maintainState: true, // _CoverGrundState bleibt erhalten, siehe Spec maintainAnimation: true, child: Opacity( // Detail-Inhalt blendet erst ab p > 0.3 ein, siehe Spec. opacity: ((p - 0.3) / 0.7).clamp(0.0, 1.0), child: child, ), ), ); }, child: const NowPlayingScreen(), ), StreamBuilder( stream: context.read().mediaItem, builder: (context, snapshot) { final item = snapshot.data; if (item == null) return const SizedBox.shrink(); return AnimatedBuilder( animation: expansion, builder: (context, child) => WanderndesCover( progress: expansion.progress, artUri: item.artUri, miniRect: _miniCoverRect(context), vollbildRect: _vollbildCoverRect(context), ), ); }, ), ], ), // Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund // optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü. bottomNavigationBar: AnimatedBuilder( animation: expansion, builder: (context, child) => Opacity( opacity: (1 - expansion.progress).clamp(0.0, 1.0), child: IgnorePointer( ignoring: expansion.progress > 0, child: child, ), ), child: DecoratedBox( decoration: const BoxDecoration( border: Border(top: BorderSide(color: MeloTheme.border)), ), child: BottomNavigationBar( currentIndex: _index, onTap: _goTo, items: const [ BottomNavigationBarItem( icon: Icon(Icons.headphones), label: 'Meine Musik'), BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'), BottomNavigationBarItem( icon: Icon(Icons.download_outlined), activeIcon: Icon(Icons.download), label: 'Download'), BottomNavigationBarItem( icon: Icon(Icons.favorite_border), activeIcon: Icon(Icons.favorite), label: 'Favoriten'), BottomNavigationBarItem( icon: Icon(Icons.smart_display_outlined), activeIcon: Icon(Icons.smart_display), label: 'YT-Suche'), ], ), ), ), ), ); } Rect _miniRect(BuildContext context) { final size = MediaQuery.sizeOf(context); final unten = size.height - MiniPlayer.hoehe - kBottomNavigationBarHeight; return Rect.fromLTWH(0, unten, size.width, MiniPlayer.hoehe); } Rect _vollbildRect(BuildContext context) => Offset.zero & MediaQuery.sizeOf(context); /// Feste 52×52-Größe, linksbündig mit MeloSpace.md-Abstand, vertikal /// mittig in MiniPlayer.hoehe — Werte aus mini_player.dart übernommen /// (dortiges Cover entfällt, siehe Task 6 Step 5). Rect _miniCoverRect(BuildContext context) { final mini = _miniRect(context); const seite = 52.0; final top = mini.top + (MiniPlayer.hoehe - seite) / 2; return Rect.fromLTWH(mini.left + MeloSpace.md, top, seite, seite); } /// Nicht per Formel bestimmbar (Review-Panel-Fund): `_CoverPlatzhalter` /// sitzt in NowPlayingScreens eigenem responsivem LayoutBuilder — die /// tatsächliche Position wird stattdessen gemessen, siehe /// coverPlatzhalterKey. Rect _vollbildCoverRect(BuildContext context) { final box = coverPlatzhalterKey.currentContext?.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) { // Vor dem ersten Layout (z.B. ganz am Anfang, progress noch 0) — // Notlösung: Platzhalter mittig im Vollbild-Rechteck annehmen, bis // der erste Frame mit progress > 0 eine echte Messung liefert. final voll = _vollbildRect(context); final seite = voll.width - 48; return Rect.fromLTWH(voll.left + 24, voll.top + 80, seite, seite); } return box.localToGlobal(Offset.zero) & box.size; } }