Files
Melo/lib/main.dart
T
Hermes (Server) 60c57577a3 Cover-Farbe, Teilen aus YouTube, Android Auto, geschärfte Typografie
P3-Liste aus dem UX-Review: Player nimmt Cover-Farbe auf, "Teilen" aus
YouTube trägt die Adresse in den Downloader ein, Android Auto blättert
durch die Bibliothek, Laufweite der Typografie geschärft, Überschriften
vereinheitlicht. 533 Tests grün, flutter analyze ohne Befund.
2026-08-25 09:09:27 +02:00

271 lines
9.3 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'downloads/downloads_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/permissions.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 '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 '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 SyncService _sync;
late final DownloadService _downloads;
Future<void> 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);
_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 StatelessWidget {
const MeloApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<MeloAudioHandler>.value(value: _handler),
Provider<MeloDb>.value(value: _db),
ChangeNotifierProvider<LibraryService>.value(value: _library),
ChangeNotifierProvider<PlaylistService>.value(value: _playlists),
ChangeNotifierProvider<OfflineMode>.value(value: _offlineMode),
ChangeNotifierProvider<CategoryService>.value(value: _categories),
ChangeNotifierProvider<AppSettings>.value(value: _settings),
ChangeNotifierProvider<AudioEffects>.value(value: _effects),
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
ChangeNotifierProvider<YtDownloadService>.value(value: _ytDownload),
ChangeNotifierProvider<SyncService>.value(value: _sync),
ChangeNotifierProvider<DownloadService>.value(value: _downloads),
],
child: MaterialApp(
title: 'Melo',
debugShowCheckedModeBanner: false,
theme: MeloTheme.dark,
home: const HomeShell(),
),
);
}
}
class HomeShell extends StatefulWidget {
const HomeShell({super.key});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> 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<MeloDb>();
final lib = context.read<LibraryService>();
_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<SyncService>().automatisch();
unawaited(_frageBenachrichtigungsRecht(context.read<MeloAudioHandler>()));
unawaited(_pruefeGeteilteAdresse());
}
/// Sieht nach, ob die App über „Teilen" mit einer Adresse geöffnet wurde,
/// und wechselt dann in den Download-Bereich.
Future<void> _pruefeGeteilteAdresse() async {
final adresse = await const GeteilteAdresse().abholen();
if (adresse == null || !mounted) return;
setState(() {
_geteilteAdresse = adresse;
_index = 1;
});
}
/// Fragt das Recht auf die Wiedergabe-Benachrichtigung, sobald der erste
/// Titel läuft. Beim kalten Start käme die Frage ohne Zusammenhang — und
/// zweimal weggetippt sperrt Android sie dauerhaft.
///
/// Scheitern darf das folgenlos: `permission_handler` wirft, wenn beim
/// Start noch die Abfrage der Musik-Berechtigung offen ist. Die Wiedergabe
/// läuft dann trotzdem, nur ohne Benachrichtigung — daraus einen
/// unbehandelten Fehler zu machen, wäre die schlechtere Antwort.
Future<void> _frageBenachrichtigungsRecht(MeloAudioHandler handler) async {
try {
await handler.mediaItem.firstWhere((item) => item != null);
await ensureNotificationPermission();
} catch (e) {
logger.warning('Benachrichtigungs-Recht nicht erfragt: $e');
}
}
@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<SyncService>().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 = <Widget>[
MyMusicScreen(
onSearchTap: () => _goTo(2),
onFavoritesTap: () => _goTo(3),
),
DownloadsScreen(
adresse: _geteilteAdresse,
onAdresseUebernommen: () => setState(() => _geteilteAdresse = null),
),
const SearchScreen(),
const FavoritesScreen(),
];
return Scaffold(
body: Column(
children: [
Expanded(child: IndexedStack(index: _index, children: tabs)),
const MiniPlayer(),
],
),
// Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
// optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü.
bottomNavigationBar: 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.download_outlined),
activeIcon: Icon(Icons.download),
label: 'Download'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'),
BottomNavigationBarItem(
icon: Icon(Icons.favorite_border),
activeIcon: Icon(Icons.favorite),
label: 'Favoriten'),
],
),
),
);
}
}