Tab-Reihenfolge, konfigurierbare Meine-Musik-Sektionen, Berechtigungen beim Start, schärferes Sperrbildschirm-Cover

- Tabs: Meine Musik, Suchen, Download, Favoriten (vorher Download vor Suchen)
- "Meine Musik": Schnellzugriffe (Favoriten/Wiedergabelisten/Zuletzt) einzeln
  über die Einstellungen abschaltbar
- Audio- und Benachrichtigungs-Berechtigung werden jetzt beim ersten Start
  gemeinsam angefragt statt erst bei Bedarf; Hinweisbanner + Einstellungen-
  Button bei dauerhafter Verweigerung (auch für Benachrichtigungen neu)
- Cover-Extraktion beim Android-Scan von 512px auf 1024px erhöht, für ein
  schärferes Cover im Sperrbildschirm
- Fix: notifyListeners() in LibraryService nur noch bei echter Wertänderung
  (verhinderte einen Test-Hang durch den neuen Start-Berechtigungs-Aufruf)

557 Tests grün, flutter analyze ohne Befund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VM2JK5mV7AL1g2Rt6H6h9w
This commit is contained in:
Hermes (Server)
2026-08-25 14:17:13 +02:00
co-authored by Claude Sonnet 5
parent 54cb523cf4
commit da012a3dca
12 changed files with 364 additions and 73 deletions
+3 -1
View File
@@ -56,7 +56,9 @@ Future<int> scanAndroidMediaStore(
var coverPath = prev?.coverPath;
if (coverPath == null) {
try {
final art = await audioQuery.queryArtwork(s.id, ArtworkType.AUDIO, size: 512);
// 1024px statt 512px: auf dem Sperrbildschirm (MIUI & Co.) erscheint
// das Cover großflächig — bei 512px wirkte es dort weich/verpixelt.
final art = await audioQuery.queryArtwork(s.id, ArtworkType.AUDIO, size: 1024);
if (art != null && art.isNotEmpty) {
final f = File(p.join(covers.path, '$id.img'));
await f.writeAsBytes(art);
+20 -2
View File
@@ -25,8 +25,23 @@ class LibraryService extends ChangeNotifier {
int scanDone = 0;
int scanTotal = 0;
bool permissionDenied = false;
bool notificationPermissionDenied = false;
String? scanError;
/// Fragt beim ersten Start alle für den Betrieb nötigen Berechtigungen an
/// (Musik-Zugriff + Wiedergabe-Benachrichtigung), statt sie erst bei Bedarf
/// einzeln einzufordern.
///
/// Nacheinander, nicht gleichzeitig: `permission_handler` wirft, wenn eine
/// zweite Abfrage startet, während die erste noch offen ist.
Future<void> ensureStartupPermissions() async {
await _ensurePermission();
final denied = !await ensureNotificationPermission();
if (denied == notificationPermissionDenied) return;
notificationPermissionDenied = denied;
notifyListeners();
}
/// Auf Android: automatischer Geräte-Scan. Auf Desktop: Ordner wählen + scannen.
Future<void> pickFolderAndScan() async {
if (!await _ensurePermission()) return;
@@ -52,8 +67,11 @@ class LibraryService extends ChangeNotifier {
Future<bool> _ensurePermission() async {
final granted = await ensureAudioPermission();
permissionDenied = !granted;
notifyListeners();
final denied = !granted;
if (denied != permissionDenied) {
permissionDenied = denied;
notifyListeners();
}
return granted;
}
+63 -42
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../playlists/playlists_screen.dart';
import '../settings/app_settings.dart';
import '../settings/settings_screen.dart';
import '../shared/cover.dart';
import '../shared/sort_store.dart';
@@ -57,6 +58,19 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
),
],
),
if (lib.notificationPermissionDenied)
MaterialBanner(
backgroundColor: Colors.red.shade900,
content: const Text(
'Benachrichtigungen für die Wiedergabe sind deaktiviert. '
'Bitte in den Einstellungen erlauben.'),
actions: [
TextButton(
onPressed: openMusicPermissionSettings,
child: const Text('Einstellungen'),
),
],
),
if (lib.scanError != null)
MaterialBanner(
backgroundColor: Colors.red.shade900,
@@ -180,54 +194,61 @@ class _QuickAccessRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
final sektionen = context.watch<AppSettings>().meineMusikSektionen;
if (sektionen.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: 104,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
StreamBuilder<List<Song>>(
stream: db.watchFavorites(),
builder: (context, snapshot) {
final favorites = snapshot.data ?? const <Song>[];
return _QuickCard(
icon: Icons.favorite,
label: 'Favoriten',
subtitle: '${favorites.length} Songs',
coverPath: favorites.isEmpty ? null : favorites.first.coverPath,
onTap: onFavoritesTap,
);
},
),
StreamBuilder<List<Playlist>>(
stream: db.watchPlaylists(),
builder: (context, snapshot) {
final playlists = snapshot.data ?? const <Playlist>[];
return _QuickCard(
icon: Icons.queue_music,
label: 'Wiedergabelisten',
subtitle: '${playlists.length} Listen',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const PlaylistsScreen()),
),
);
},
),
StreamBuilder<List<Song>>(
stream: db.watchRecent(limit: 50),
builder: (context, snapshot) {
final recent = snapshot.data ?? const <Song>[];
return _QuickCard(
icon: Icons.schedule,
label: 'Zuletzt',
subtitle: 'Neu hinzugefügt',
coverPath: recent.isEmpty ? null : recent.first.coverPath,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const RecentlyAddedScreen()),
),
);
},
),
if (sektionen.contains(AppSettings.sektionFavoriten))
StreamBuilder<List<Song>>(
stream: db.watchFavorites(),
builder: (context, snapshot) {
final favorites = snapshot.data ?? const <Song>[];
return _QuickCard(
icon: Icons.favorite,
label: 'Favoriten',
subtitle: '${favorites.length} Songs',
coverPath:
favorites.isEmpty ? null : favorites.first.coverPath,
onTap: onFavoritesTap,
);
},
),
if (sektionen.contains(AppSettings.sektionWiedergabelisten))
StreamBuilder<List<Playlist>>(
stream: db.watchPlaylists(),
builder: (context, snapshot) {
final playlists = snapshot.data ?? const <Playlist>[];
return _QuickCard(
icon: Icons.queue_music,
label: 'Wiedergabelisten',
subtitle: '${playlists.length} Listen',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const PlaylistsScreen()),
),
);
},
),
if (sektionen.contains(AppSettings.sektionZuletzt))
StreamBuilder<List<Song>>(
stream: db.watchRecent(limit: 50),
builder: (context, snapshot) {
final recent = snapshot.data ?? const <Song>[];
return _QuickCard(
icon: Icons.schedule,
label: 'Zuletzt',
subtitle: 'Neu hinzugefügt',
coverPath: recent.isEmpty ? null : recent.first.coverPath,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const RecentlyAddedScreen()),
),
);
},
),
],
),
);
+5 -23
View File
@@ -13,7 +13,6 @@ 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';
@@ -169,7 +168,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
// 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(context.read<LibraryService>().ensureStartupPermissions());
unawaited(_pruefeGeteilteAdresse());
}
@@ -180,27 +179,10 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
if (adresse == null || !mounted) return;
setState(() {
_geteilteAdresse = adresse;
_index = 1;
_index = 2;
});
}
/// 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);
@@ -224,14 +206,14 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
Widget build(BuildContext context) {
final tabs = <Widget>[
MyMusicScreen(
onSearchTap: () => _goTo(2),
onSearchTap: () => _goTo(1),
onFavoritesTap: () => _goTo(3),
),
const SearchScreen(),
DownloadsScreen(
adresse: _geteilteAdresse,
onAdresseUebernommen: () => setState(() => _geteilteAdresse = null),
),
const SearchScreen(),
const FavoritesScreen(),
];
return Scaffold(
@@ -253,11 +235,11 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
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.search), label: 'Suchen'),
BottomNavigationBarItem(
icon: Icon(Icons.favorite_border),
activeIcon: Icon(Icons.favorite),
+36
View File
@@ -8,6 +8,20 @@ class AppSettings extends ChangeNotifier {
static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer';
static const _keyNormalizeVolume = 'gleiche_lautstaerke';
static const _keyCacheGrenze = 'cache_grenze_mb';
static const _keyMeineMusikSektionen = 'meine_musik_sektionen';
/// Schnellzugriffe oben in „Meine Musik" — Schlüssel für die
/// [meineMusikSektionen]-Auswahl in den Einstellungen.
static const sektionFavoriten = 'favoriten';
static const sektionWiedergabelisten = 'wiedergabelisten';
static const sektionZuletzt = 'zuletzt';
/// Alle wählbaren Sektionen, in der Reihenfolge, in der sie oben erscheinen.
static const alleMeineMusikSektionen = [
sektionFavoriten,
sektionWiedergabelisten,
sektionZuletzt,
];
/// Voreinstellung für den Zwischenspeicher: 2 GB. Groß genug für einige
/// hundert Titel, klein genug, um ein Telefon nicht zuzumüllen.
@@ -20,6 +34,7 @@ class AppSettings extends ChangeNotifier {
bool _autoPlayOnHeadphones = false;
bool _normalizeVolume = true;
int _cacheGrenzeMb = cacheGrenzeStandardMb;
Set<String> _meineMusikSektionen = Set.of(alleMeineMusikSektionen);
/// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der
/// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild.
@@ -39,12 +54,19 @@ class AppSettings extends ChangeNotifier {
int get cacheGrenzeBytes => _cacheGrenzeMb * 1024 * 1024;
/// Welche Schnellzugriffe oben in „Meine Musik" angezeigt werden.
Set<String> get meineMusikSektionen => _meineMusikSektionen;
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
_groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true;
_autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false;
_normalizeVolume = prefs.getBool(_keyNormalizeVolume) ?? true;
_cacheGrenzeMb = prefs.getInt(_keyCacheGrenze) ?? cacheGrenzeStandardMb;
final gespeicherteSektionen = prefs.getStringList(_keyMeineMusikSektionen);
_meineMusikSektionen = gespeicherteSektionen == null
? Set.of(alleMeineMusikSektionen)
: Set.of(gespeicherteSektionen);
notifyListeners();
}
@@ -79,4 +101,18 @@ class AppSettings extends ChangeNotifier {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyAutoPlayHeadphones, value);
}
/// Blendet eine Schnellzugriff-Sektion oben in „Meine Musik" ein oder aus.
Future<void> setMeineMusikSektionAktiv(String sektion, bool aktiv) async {
final aktuell = aktiv == _meineMusikSektionen.contains(sektion);
if (aktuell) return;
_meineMusikSektionen = Set.of(_meineMusikSektionen);
aktiv
? _meineMusikSektionen.add(sektion)
: _meineMusikSektionen.remove(sektion);
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_keyMeineMusikSektionen, _meineMusikSektionen.toList());
}
}
+40
View File
@@ -160,6 +160,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const Divider(height: 1),
const _SectionLabel('Meine Musik'),
Consumer<AppSettings>(
builder: (context, settings, _) => Column(
children: [
for (final sektion in AppSettings.alleMeineMusikSektionen)
CheckboxListTile(
secondary: Icon(_sektionIcon(sektion)),
title: Text(_sektionLabel(sektion)),
value: settings.meineMusikSektionen.contains(sektion),
onChanged: (aktiv) => settings.setMeineMusikSektionAktiv(
sektion, aktiv ?? false),
),
],
),
),
const Divider(height: 1),
const _SectionLabel('Darstellung'),
Consumer<AppSettings>(
builder: (context, settings, _) => SwitchListTile(
@@ -301,6 +317,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: const Text('Einstellungen öffnen'),
),
),
ListTile(
leading: const Icon(Icons.notifications_outlined),
title: const Text('Benachrichtigungs-Berechtigung'),
subtitle: const Text('Für die Steuerung im Sperrbildschirm'),
trailing: TextButton(
onPressed: openMusicPermissionSettings,
child: const Text('Einstellungen öffnen'),
),
),
const Divider(height: 1),
const _SectionLabel('Über Melo'),
ListTile(
@@ -577,6 +602,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
/// Icon und Beschriftung der Schnellzugriff-Sektionen aus [AppSettings].
IconData _sektionIcon(String sektion) => switch (sektion) {
AppSettings.sektionFavoriten => Icons.favorite,
AppSettings.sektionWiedergabelisten => Icons.queue_music,
AppSettings.sektionZuletzt => Icons.schedule,
_ => Icons.circle,
};
String _sektionLabel(String sektion) => switch (sektion) {
AppSettings.sektionFavoriten => 'Favoriten',
AppSettings.sektionWiedergabelisten => 'Wiedergabelisten',
AppSettings.sektionZuletzt => 'Zuletzt hinzugefügt',
_ => sektion,
};
/// Kleine graue Überschrift über einer Einstellungs-Gruppe.
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.label);