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:
co-authored by
Claude Sonnet 5
parent
54cb523cf4
commit
da012a3dca
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user