Neue Melo-UI Phase 1: 4 Tabs (Meine Musik/Suche/Favoriten/Download) + Sortierung

- Meine Musik als Startbildschirm ohne Topbar: Kopfzeile (Einstellungen,
  Suche, Musikerkennung), Schnellzugriffe, Shuffle + Sortieren, Liederliste
- Sortierung nach Zuletzt hinzugefuegt / Name (A-Z-#) / Wie oft abgespielt,
  auf- und absteigend, pro Liste gespeichert (SortStore)
- Favoriten als eigener Tab mit gleicher Shuffle-/Sortier-Leiste
- Download-Tab uebernimmt den Navidrome-Server-Browser
- DB-Schema 3: playCount, gezaehlt beim Titelstart
- Theme auf #0B0B10 / #c0392b
- Scan-Aktionen aus der entfallenen Topbar jetzt in den Einstellungen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011snPXPafPC5i87o68H14W7
This commit is contained in:
2026-08-20 18:00:53 +02:00
co-authored by Claude Opus 5
parent 5d08da1086
commit 2667613a2e
24 changed files with 1413 additions and 372 deletions
+13
View File
@@ -67,3 +67,16 @@ class _AlbumSongsScreen extends StatelessWidget {
);
}
}
/// Alben-Übersicht als eigener Bildschirm (Schnellzugriff "Alben").
class AlbumsScreen extends StatelessWidget {
const AlbumsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Alben')),
body: const AlbumListScreen(),
);
}
}
+13
View File
@@ -59,3 +59,16 @@ class _ArtistSongsScreen extends StatelessWidget {
);
}
}
/// Künstler-Übersicht als eigener Bildschirm (Schnellzugriff "Künstler").
class ArtistsScreen extends StatelessWidget {
const ArtistsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Künstler')),
body: const ArtistListScreen(),
);
}
}
+19 -1
View File
@@ -22,6 +22,10 @@ class Songs extends Table {
IntColumn get updatedAtMs => integer()();
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
/// Wie oft der Song vollständig gestartet wurde — Grundlage für die
/// Sortierung "Wie oft abgespielt".
IntColumn get playCount => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id};
}
@@ -85,7 +89,7 @@ class MeloDb extends _$MeloDb {
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
@override
int get schemaVersion => 2;
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -97,6 +101,9 @@ class MeloDb extends _$MeloDb {
await m.createTable(favorites);
await m.createTable(playbackHistory);
}
if (from < 3) {
await m.addColumn(songs, songs.playCount);
}
},
);
@@ -291,6 +298,17 @@ class MeloDb extends _$MeloDb {
}
// === Playback History ===
/// Zählt eine Wiedergabe. Wird beim Start eines Tracks aufgerufen, nicht beim
/// periodischen Speichern der Position — sonst würde der Zähler hochlaufen,
/// solange ein Song nur läuft.
Future<void> incrementPlayCount(String songId) async {
await customUpdate(
'UPDATE songs SET play_count = play_count + 1 WHERE id = ?',
variables: [Variable<String>(songId)],
updates: {songs},
);
}
Future<void> recordPlayback(String songId, int positionMs) async {
await into(playbackHistory).insert(PlaybackHistoryCompanion.insert(
songId: songId,
+70 -2
View File
@@ -113,6 +113,18 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
),
defaultValue: const Constant(false),
);
static const VerificationMeta _playCountMeta = const VerificationMeta(
'playCount',
);
@override
late final GeneratedColumn<int> playCount = GeneratedColumn<int>(
'play_count',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const Constant(0),
);
@override
List<GeneratedColumn> get $columns => [
id,
@@ -125,6 +137,7 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
dateAddedMs,
updatedAtMs,
deleted,
playCount,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -211,6 +224,12 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
deleted.isAcceptableOrUnknown(data['deleted']!, _deletedMeta),
);
}
if (data.containsKey('play_count')) {
context.handle(
_playCountMeta,
playCount.isAcceptableOrUnknown(data['play_count']!, _playCountMeta),
);
}
return context;
}
@@ -260,6 +279,10 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
DriftSqlType.bool,
data['${effectivePrefix}deleted'],
)!,
playCount: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}play_count'],
)!,
);
}
@@ -280,6 +303,10 @@ class Song extends DataClass implements Insertable<Song> {
final int dateAddedMs;
final int updatedAtMs;
final bool deleted;
/// Wie oft der Song vollständig gestartet wurde — Grundlage für die
/// Sortierung "Wie oft abgespielt".
final int playCount;
const Song({
required this.id,
required this.path,
@@ -291,6 +318,7 @@ class Song extends DataClass implements Insertable<Song> {
required this.dateAddedMs,
required this.updatedAtMs,
required this.deleted,
required this.playCount,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -313,6 +341,7 @@ class Song extends DataClass implements Insertable<Song> {
map['date_added_ms'] = Variable<int>(dateAddedMs);
map['updated_at_ms'] = Variable<int>(updatedAtMs);
map['deleted'] = Variable<bool>(deleted);
map['play_count'] = Variable<int>(playCount);
return map;
}
@@ -336,6 +365,7 @@ class Song extends DataClass implements Insertable<Song> {
dateAddedMs: Value(dateAddedMs),
updatedAtMs: Value(updatedAtMs),
deleted: Value(deleted),
playCount: Value(playCount),
);
}
@@ -355,6 +385,7 @@ class Song extends DataClass implements Insertable<Song> {
dateAddedMs: serializer.fromJson<int>(json['dateAddedMs']),
updatedAtMs: serializer.fromJson<int>(json['updatedAtMs']),
deleted: serializer.fromJson<bool>(json['deleted']),
playCount: serializer.fromJson<int>(json['playCount']),
);
}
@override
@@ -371,6 +402,7 @@ class Song extends DataClass implements Insertable<Song> {
'dateAddedMs': serializer.toJson<int>(dateAddedMs),
'updatedAtMs': serializer.toJson<int>(updatedAtMs),
'deleted': serializer.toJson<bool>(deleted),
'playCount': serializer.toJson<int>(playCount),
};
}
@@ -385,6 +417,7 @@ class Song extends DataClass implements Insertable<Song> {
int? dateAddedMs,
int? updatedAtMs,
bool? deleted,
int? playCount,
}) => Song(
id: id ?? this.id,
path: path ?? this.path,
@@ -396,6 +429,7 @@ class Song extends DataClass implements Insertable<Song> {
dateAddedMs: dateAddedMs ?? this.dateAddedMs,
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
deleted: deleted ?? this.deleted,
playCount: playCount ?? this.playCount,
);
Song copyWithCompanion(SongsCompanion data) {
return Song(
@@ -415,6 +449,7 @@ class Song extends DataClass implements Insertable<Song> {
? data.updatedAtMs.value
: this.updatedAtMs,
deleted: data.deleted.present ? data.deleted.value : this.deleted,
playCount: data.playCount.present ? data.playCount.value : this.playCount,
);
}
@@ -430,7 +465,8 @@ class Song extends DataClass implements Insertable<Song> {
..write('coverPath: $coverPath, ')
..write('dateAddedMs: $dateAddedMs, ')
..write('updatedAtMs: $updatedAtMs, ')
..write('deleted: $deleted')
..write('deleted: $deleted, ')
..write('playCount: $playCount')
..write(')'))
.toString();
}
@@ -447,6 +483,7 @@ class Song extends DataClass implements Insertable<Song> {
dateAddedMs,
updatedAtMs,
deleted,
playCount,
);
@override
bool operator ==(Object other) =>
@@ -461,7 +498,8 @@ class Song extends DataClass implements Insertable<Song> {
other.coverPath == this.coverPath &&
other.dateAddedMs == this.dateAddedMs &&
other.updatedAtMs == this.updatedAtMs &&
other.deleted == this.deleted);
other.deleted == this.deleted &&
other.playCount == this.playCount);
}
class SongsCompanion extends UpdateCompanion<Song> {
@@ -475,6 +513,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
final Value<int> dateAddedMs;
final Value<int> updatedAtMs;
final Value<bool> deleted;
final Value<int> playCount;
final Value<int> rowid;
const SongsCompanion({
this.id = const Value.absent(),
@@ -487,6 +526,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
this.dateAddedMs = const Value.absent(),
this.updatedAtMs = const Value.absent(),
this.deleted = const Value.absent(),
this.playCount = const Value.absent(),
this.rowid = const Value.absent(),
});
SongsCompanion.insert({
@@ -500,6 +540,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
required int dateAddedMs,
required int updatedAtMs,
this.deleted = const Value.absent(),
this.playCount = const Value.absent(),
this.rowid = const Value.absent(),
}) : id = Value(id),
path = Value(path),
@@ -517,6 +558,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
Expression<int>? dateAddedMs,
Expression<int>? updatedAtMs,
Expression<bool>? deleted,
Expression<int>? playCount,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
@@ -530,6 +572,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
if (dateAddedMs != null) 'date_added_ms': dateAddedMs,
if (updatedAtMs != null) 'updated_at_ms': updatedAtMs,
if (deleted != null) 'deleted': deleted,
if (playCount != null) 'play_count': playCount,
if (rowid != null) 'rowid': rowid,
});
}
@@ -545,6 +588,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
Value<int>? dateAddedMs,
Value<int>? updatedAtMs,
Value<bool>? deleted,
Value<int>? playCount,
Value<int>? rowid,
}) {
return SongsCompanion(
@@ -558,6 +602,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
dateAddedMs: dateAddedMs ?? this.dateAddedMs,
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
deleted: deleted ?? this.deleted,
playCount: playCount ?? this.playCount,
rowid: rowid ?? this.rowid,
);
}
@@ -595,6 +640,9 @@ class SongsCompanion extends UpdateCompanion<Song> {
if (deleted.present) {
map['deleted'] = Variable<bool>(deleted.value);
}
if (playCount.present) {
map['play_count'] = Variable<int>(playCount.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
@@ -614,6 +662,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
..write('dateAddedMs: $dateAddedMs, ')
..write('updatedAtMs: $updatedAtMs, ')
..write('deleted: $deleted, ')
..write('playCount: $playCount, ')
..write('rowid: $rowid')
..write(')'))
.toString();
@@ -2223,6 +2272,7 @@ typedef $$SongsTableCreateCompanionBuilder =
required int dateAddedMs,
required int updatedAtMs,
Value<bool> deleted,
Value<int> playCount,
Value<int> rowid,
});
typedef $$SongsTableUpdateCompanionBuilder =
@@ -2237,6 +2287,7 @@ typedef $$SongsTableUpdateCompanionBuilder =
Value<int> dateAddedMs,
Value<int> updatedAtMs,
Value<bool> deleted,
Value<int> playCount,
Value<int> rowid,
});
@@ -2359,6 +2410,11 @@ class $$SongsTableFilterComposer extends Composer<_$MeloDb, $SongsTable> {
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get playCount => $composableBuilder(
column: $table.playCount,
builder: (column) => ColumnFilters(column),
);
Expression<bool> playlistSongsRefs(
Expression<bool> Function($$PlaylistSongsTableFilterComposer f) f,
) {
@@ -2492,6 +2548,11 @@ class $$SongsTableOrderingComposer extends Composer<_$MeloDb, $SongsTable> {
column: $table.deleted,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get playCount => $composableBuilder(
column: $table.playCount,
builder: (column) => ColumnOrderings(column),
);
}
class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> {
@@ -2538,6 +2599,9 @@ class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> {
GeneratedColumn<bool> get deleted =>
$composableBuilder(column: $table.deleted, builder: (column) => column);
GeneratedColumn<int> get playCount =>
$composableBuilder(column: $table.playCount, builder: (column) => column);
Expression<T> playlistSongsRefs<T extends Object>(
Expression<T> Function($$PlaylistSongsTableAnnotationComposer a) f,
) {
@@ -2656,6 +2720,7 @@ class $$SongsTableTableManager
Value<int> dateAddedMs = const Value.absent(),
Value<int> updatedAtMs = const Value.absent(),
Value<bool> deleted = const Value.absent(),
Value<int> playCount = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => SongsCompanion(
id: id,
@@ -2668,6 +2733,7 @@ class $$SongsTableTableManager
dateAddedMs: dateAddedMs,
updatedAtMs: updatedAtMs,
deleted: deleted,
playCount: playCount,
rowid: rowid,
),
createCompanionCallback:
@@ -2682,6 +2748,7 @@ class $$SongsTableTableManager
required int dateAddedMs,
required int updatedAtMs,
Value<bool> deleted = const Value.absent(),
Value<int> playCount = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => SongsCompanion.insert(
id: id,
@@ -2694,6 +2761,7 @@ class $$SongsTableTableManager
dateAddedMs: dateAddedMs,
updatedAtMs: updatedAtMs,
deleted: deleted,
playCount: playCount,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../shared/sort_store.dart';
import '../shared/sortable_song_list.dart';
import 'database.dart';
/// Favoriten-Tab: Shuffle-Wiedergabe und dieselbe Sortierung wie "Meine Musik",
/// nur auf den favorisierten Songs.
class FavoritesScreen extends StatelessWidget {
const FavoritesScreen({super.key});
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return SafeArea(
bottom: false,
child: Column(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('Favoriten',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
),
),
Expanded(
child: StreamBuilder<List<Song>>(
stream: db.watchFavorites(),
builder: (context, snapshot) {
final songs = snapshot.data ?? const <Song>[];
return SortableSongList(
songs: songs,
storeKey: SortStore.favoriten,
empty: const _Empty(),
);
},
),
),
],
),
);
}
}
class _Empty extends StatelessWidget {
const _Empty();
@override
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.favorite_border, size: 64, color: Colors.white24),
SizedBox(height: 12),
Text('Noch keine Favoriten', style: TextStyle(color: Colors.white54)),
SizedBox(height: 6),
Text('Tippe in einer Liederliste auf das Herz.',
style: TextStyle(color: Colors.white30, fontSize: 12)),
],
),
);
}
}
-519
View File
@@ -1,519 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../player/audio_handler.dart';
import '../services/navidrome_service.dart';
import '../shared/cover.dart';
import 'album_list.dart';
import 'artist_list.dart';
import 'database.dart';
import 'library_service.dart';
import 'permissions.dart';
import 'song_list.dart';
import 'song_media.dart';
/// Bibliotheks-Tab mit drei Unteransichten: alle Songs, Künstler, Alben.
/// Scan-Banner/-Fortschritt gelten für alle Unteransichten und liegen daher
/// oberhalb der [TabBarView].
class LibraryScreen extends StatelessWidget {
const LibraryScreen({super.key});
@override
Widget build(BuildContext context) {
final lib = context.watch<LibraryService>();
return DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: const Text('Bibliothek'),
actions: [
IconButton(
tooltip: addMusicLabel,
icon: const Icon(Icons.create_new_folder_outlined),
onPressed: lib.scanning ? null : lib.pickFolderAndScan,
),
IconButton(
tooltip: 'Erneut scannen',
icon: const Icon(Icons.refresh),
onPressed: lib.scanning ? null : lib.rescan,
),
],
bottom: const TabBar(
// Engere Abstände, damit '🌐 Server' nicht abgeschnitten wird
// (4 feste Tabs, gleich breit) — Schriftgröße bleibt unverändert.
labelPadding: EdgeInsets.symmetric(horizontal: 4),
tabs: [
Tab(text: 'Alle'),
Tab(text: 'Künstler'),
Tab(text: 'Alben'),
Tab(text: '🌐 Server'),
],
),
),
body: Column(
children: [
if (lib.permissionDenied)
MaterialBanner(
backgroundColor: Colors.red.shade900,
content: const Text('Zugriff auf Musik verweigert. '
'Bitte in den Einstellungen erlauben.'),
actions: [
TextButton(
onPressed: openMusicPermissionSettings,
child: const Text('Einstellungen'),
),
],
),
if (lib.scanError != null)
MaterialBanner(
backgroundColor: Colors.red.shade900,
content: Text(lib.scanError!),
actions: [
TextButton(
onPressed: lib.dismissScanError,
child: const Text('Verwerfen'),
),
],
),
if (lib.scanning)
Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
const LinearProgressIndicator(),
const SizedBox(height: 6),
Text(
'Scanne … ${lib.scanDone}'
'${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
Expanded(
child: TabBarView(
children: [
const _AllSongsTab(),
const ArtistListScreen(),
const AlbumListScreen(),
const _NavidromeTab(),
],
),
),
],
),
),
);
}
}
/// "Alle"-Tab: zuletzt hinzugefügt + vollständige Songliste (unverändertes
/// Verhalten aus der Zeit vor der Künstler-/Alben-Aufteilung).
class _AllSongsTab extends StatelessWidget {
const _AllSongsTab();
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
final lib = context.watch<LibraryService>();
return Column(
children: [
StreamBuilder<List<Song>>(
stream: db.watchRecent(limit: 10),
builder: (context, snapshot) {
final recent = snapshot.data ?? const [];
if (recent.isEmpty || lib.scanning) return const SizedBox.shrink();
return _RecentlyAdded(songs: recent);
},
),
Expanded(
child: StreamBuilder<List<Song>>(
stream: db.watchSongs(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: Colors.white54)),
);
}
final songs = snapshot.data ?? const [];
if (songs.isEmpty && !lib.scanning) {
return const _Empty();
}
return SongList(songs);
},
),
),
],
);
}
}
/// Horizontal scrollende Leiste mit den zuletzt hinzugefügten Songs.
class _RecentlyAdded extends StatelessWidget {
const _RecentlyAdded({required this.songs});
final List<Song> songs;
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Zuletzt hinzugefügt',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
),
SizedBox(
height: 140,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: songs.length,
itemBuilder: (context, i) {
final s = songs[i];
return Padding(
padding: const EdgeInsets.only(right: 12),
child: SizedBox(
width: 96,
child: InkWell(
onTap: () async {
try {
await playSongs(handler, songs, i);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CoverImage(
artUri:
s.coverPath != null ? Uri.file(s.coverPath!) : null,
size: 96,
radius: 8,
),
const SizedBox(height: 4),
Text(
s.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
],
),
),
),
);
},
),
),
],
);
}
}
class _Empty extends StatelessWidget {
const _Empty();
@override
Widget build(BuildContext context) {
final lib = context.read<LibraryService>();
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.library_music, size: 64, color: Colors.white24),
const SizedBox(height: 12),
const Text('Noch keine Musik',
style: TextStyle(color: Colors.white54)),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.create_new_folder_outlined),
label: Text(addMusicLabel),
onPressed: lib.pickFolderAndScan,
),
],
),
);
}
}
class _NavidromeTab extends StatefulWidget {
const _NavidromeTab();
@override
State<_NavidromeTab> createState() => _NavidromeTabState();
}
class _NavidromeTabState extends State<_NavidromeTab> {
late final NavidromeService _nav = NavidromeService();
List<SubsonicAlbum> _albums = [];
List<SubsonicArtist> _artists = [];
bool _loading = false;
bool _showArtists = false;
String? _error;
@override
void initState() {
super.initState();
_nav.ladeGespeicherteZugangsdaten().then((_) {
if (_nav.istVerbunden && mounted) {
_loadAlbums();
}
});
}
Future<void> _loadAlbums() async {
if (!_nav.istVerbunden) {
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
final alben = await _nav.getAlben(anzahl: 50);
if (mounted) {
setState(() {
_albums = alben;
_loading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_loading = false;
_error = e is NavidromeException ? e.message : 'Verbindungsfehler';
});
}
debugPrint('Fehler beim Laden der Alben: $e');
}
}
Future<void> _loadArtists() async {
if (!_nav.istVerbunden) return;
setState(() {
_loading = true;
_error = null;
});
try {
final artists = await _nav.getArtists();
if (mounted) {
setState(() {
_artists = artists;
_loading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_loading = false;
_error = e is NavidromeException ? e.message : 'Verbindungsfehler';
});
}
debugPrint('Fehler beim Laden der Künstler: $e');
}
}
@override
Widget build(BuildContext context) {
if (!_nav.istVerbunden) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 64, color: Colors.white24),
const SizedBox(height: 12),
const Text('Musikserver nicht verbunden',
style: TextStyle(color: Colors.white54)),
const SizedBox(height: 12),
const Text('Gehe zu Einstellungen → Navidrome zum Verbinden',
style: TextStyle(color: Colors.white30, fontSize: 12)),
],
),
);
}
if (_loading) {
return const Center(
child: CircularProgressIndicator(color: Colors.redAccent),
);
}
if (_albums.isEmpty) {
final hatFehler = _error != null;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(hatFehler ? Icons.error_outline : Icons.album,
size: 64,
color: hatFehler ? Colors.redAccent : Colors.white24),
const SizedBox(height: 12),
Text(
hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54),
),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
onPressed: _loadAlbums,
),
],
),
),
);
}
return Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: FilledButton(
onPressed: () => setState(() {
_showArtists = false;
if (_albums.isEmpty) _loadAlbums();
}),
style: FilledButton.styleFrom(
backgroundColor: !_showArtists ? Colors.redAccent : Colors.grey.shade700,
),
child: const Text('📀 Alben'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: () => setState(() {
_showArtists = true;
if (_artists.isEmpty) _loadArtists();
}),
style: FilledButton.styleFrom(
backgroundColor: _showArtists ? Colors.redAccent : Colors.grey.shade700,
),
child: const Text('🎤 Künstler'),
),
),
],
),
),
Expanded(
child: _showArtists
? ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: _artists.length,
itemBuilder: (context, i) {
final artist = _artists[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const Icon(Icons.person, size: 48),
title: Text(artist.name),
onTap: () => _playArtist(artist),
),
);
},
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: _albums.length,
itemBuilder: (context, i) {
final album = _albums[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
Icons.album,
size: 48,
color: Colors.redAccent.withValues(alpha: 0.5),
),
title: Text(album.name),
subtitle: Text('${album.songCount} Songs'),
onTap: () => _playAlbum(album),
),
);
},
),
),
],
);
}
Future<void> _playAlbum(SubsonicAlbum album) async {
final handler = context.read<MeloAudioHandler>();
try {
final songs = await _nav.getSongs(album.id);
if (songs.isEmpty) {
if (!mounted) {
return;
}
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(content: Text('Album hat keine Songs')),
);
return;
}
final items = songs
.map((s) => MediaItem(
id: _nav.streamUrl(s.id).toString(),
title: s.titel,
artist: s.kuenstler,
album: s.album,
duration: Duration(seconds: s.dauerSekunden),
))
.toList();
await handler.loadPlaylist(items);
} catch (e) {
if (!mounted) {
return;
}
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
SnackBar(content: Text('Fehler: $e')),
);
}
}
Future<void> _playArtist(SubsonicArtist artist) async {
final handler = context.read<MeloAudioHandler>();
try {
final songs = await _nav.getArtistSongs(artist.id);
if (songs.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Künstler hat keine Songs')),
);
return;
}
final items = songs
.map((s) => MediaItem(
id: _nav.streamUrl(s.id).toString(),
title: s.titel,
artist: s.kuenstler,
album: s.album,
duration: Duration(seconds: s.dauerSekunden),
))
.toList();
await handler.loadPlaylist(items);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler: $e')),
);
}
}
}
+359
View File
@@ -0,0 +1,359 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../playlists/playlists_screen.dart';
import '../settings/settings_screen.dart';
import '../shared/cover.dart';
import '../shared/sort_store.dart';
import '../shared/sortable_song_list.dart';
import '../shared/theme.dart';
import 'album_list.dart';
import 'artist_list.dart';
import 'database.dart';
import 'library_service.dart';
import 'permissions.dart';
import 'song_list.dart';
/// Startbildschirm: Kopfzeile mit Einstellungen/Suche/Musikerkennung,
/// Schnellzugriffe, darunter Shuffle + sortierbare Liederliste.
/// Bewusst ohne AppBar — die Kopfzeile ist Teil des Inhalts.
class MyMusicScreen extends StatelessWidget {
const MyMusicScreen({super.key, this.onSearchTap, this.onFavoritesTap});
/// Wechselt zum Suche-Tab; die Suche ist ein eigener Tab, kein eigener Screen.
final VoidCallback? onSearchTap;
/// Wechselt zum Favoriten-Tab.
final VoidCallback? onFavoritesTap;
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
final lib = context.watch<LibraryService>();
return SafeArea(
bottom: false,
child: Column(
children: [
_Header(onSearchTap: onSearchTap),
if (lib.permissionDenied)
MaterialBanner(
backgroundColor: Colors.red.shade900,
content: const Text('Zugriff auf Musik verweigert. '
'Bitte in den Einstellungen erlauben.'),
actions: [
TextButton(
onPressed: openMusicPermissionSettings,
child: const Text('Einstellungen'),
),
],
),
if (lib.scanError != null)
MaterialBanner(
backgroundColor: Colors.red.shade900,
content: Text(lib.scanError!),
actions: [
TextButton(
onPressed: lib.dismissScanError,
child: const Text('Verwerfen'),
),
],
),
if (lib.scanning)
Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
const LinearProgressIndicator(),
const SizedBox(height: 6),
Text(
'Scanne … ${lib.scanDone}'
'${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
_QuickAccessRow(onFavoritesTap: onFavoritesTap),
Expanded(
child: StreamBuilder<List<Song>>(
stream: db.watchSongs(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: Colors.white54)),
);
}
final songs = snapshot.data ?? const <Song>[];
return SortableSongList(
songs: songs,
storeKey: SortStore.meineMusik,
empty: lib.scanning ? const SizedBox.shrink() : const _Empty(),
);
},
),
),
],
),
);
}
}
/// Einstellungen · Suchfeld · Musikerkennung.
class _Header extends StatelessWidget {
const _Header({this.onSearchTap});
final VoidCallback? onSearchTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Row(
children: [
IconButton(
tooltip: 'Einstellungen',
icon: const Icon(Icons.tune),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
),
Expanded(
child: InkWell(
onTap: onSearchTap,
borderRadius: BorderRadius.circular(24),
child: Container(
height: 44,
decoration: BoxDecoration(
color: MeloTheme.surfaceHigh,
borderRadius: BorderRadius.circular(24),
),
child: const Row(
children: [
SizedBox(width: 14),
Icon(Icons.search, color: Colors.white54, size: 20),
SizedBox(width: 10),
Text(
'Titel, Künstler und Alben suchen',
style: TextStyle(color: Colors.white54, fontSize: 14),
),
],
),
),
),
),
IconButton(
tooltip: 'Musik erkennen',
icon: const Icon(Icons.help_outline),
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Musikerkennung kommt später')),
),
),
],
),
);
}
}
/// Waagerecht scrollende Schnellzugriffe (Favoriten, Wiedergabelisten, …).
class _QuickAccessRow extends StatelessWidget {
const _QuickAccessRow({this.onFavoritesTap});
final VoidCallback? onFavoritesTap;
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
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()),
),
);
},
),
_QuickCard(
icon: Icons.person,
label: 'Künstler',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ArtistsScreen()),
),
),
_QuickCard(
icon: Icons.album,
label: 'Alben',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const AlbumsScreen()),
),
),
],
),
);
}
}
class _QuickCard extends StatelessWidget {
const _QuickCard({
required this.icon,
required this.label,
this.subtitle,
this.coverPath,
this.onTap,
});
final IconData icon;
final String label;
final String? subtitle;
final String? coverPath;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: SizedBox(
width: 150,
child: Material(
color: MeloTheme.surfaceHigh,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Stack(
fit: StackFit.expand,
children: [
if (coverPath != null)
Opacity(
opacity: 0.35,
child: CoverImage(
artUri: Uri.file(coverPath!),
size: 150,
radius: 0,
),
),
Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(icon, size: 22, color: Colors.white),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 14)),
if (subtitle != null)
Text(subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white54, fontSize: 11)),
],
),
],
),
),
],
),
),
),
),
);
}
}
/// Leerer Zustand mit direktem Weg zum Ordner-Scan.
class _Empty extends StatelessWidget {
const _Empty();
@override
Widget build(BuildContext context) {
final lib = context.read<LibraryService>();
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.library_music, size: 64, color: Colors.white24),
const SizedBox(height: 12),
const Text('Noch keine Musik',
style: TextStyle(color: Colors.white54)),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.create_new_folder_outlined),
label: Text(addMusicLabel),
onPressed: lib.pickFolderAndScan,
),
],
),
);
}
}
/// Vollbild-Liste der zuletzt hinzugefügten Songs (Schnellzugriff "Zuletzt").
class RecentlyAddedScreen extends StatelessWidget {
const RecentlyAddedScreen({super.key});
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return Scaffold(
appBar: AppBar(title: const Text('Zuletzt hinzugefügt')),
body: StreamBuilder<List<Song>>(
stream: db.watchRecent(limit: 100),
builder: (context, snapshot) {
final songs = snapshot.data ?? const <Song>[];
if (songs.isEmpty) {
return const Center(
child: Text('Noch nichts hinzugefügt',
style: TextStyle(color: Colors.white54)),
);
}
return SongList(songs);
},
),
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'database.dart';
/// Sortier-Kriterien der Liederlisten (Meine Musik + Favoriten).
enum SortMode { dateAdded, name, playCount }
extension SortModeLabel on SortMode {
String get label => switch (this) {
SortMode.dateAdded => 'Zuletzt hinzugefügt',
SortMode.name => 'Name (AZ#)',
SortMode.playCount => 'Wie oft abgespielt',
};
}
/// Richtung, in der ein Modus üblicherweise gelesen wird: neueste bzw.
/// meistgespielte Songs zuerst, Namen dagegen von A nach Z.
bool defaultAscending(SortMode mode) => mode == SortMode.name;
/// Sortiert eine Kopie von [songs]. Bei gleichem Sortierwert entscheidet der
/// Titel, damit die Reihenfolge zwischen Aufrufen stabil bleibt.
List<Song> sortSongs(List<Song> songs, SortMode mode, {required bool ascending}) {
final sorted = [...songs];
sorted.sort((a, b) {
final cmp = switch (mode) {
SortMode.dateAdded => a.dateAddedMs.compareTo(b.dateAddedMs),
SortMode.playCount => a.playCount.compareTo(b.playCount),
SortMode.name => _compareNames(a.title, b.title),
};
if (cmp != 0) return ascending ? cmp : -cmp;
// Tiebreak immer aufsteigend nach Titel — sonst wäre die Reihenfolge
// gleichwertiger Songs beim Umschalten der Richtung willkürlich.
return _compareNames(a.title, b.title);
});
return sorted;
}
/// AZ zuerst, alles was nicht mit einem Buchstaben beginnt (Ziffern,
/// Sonderzeichen) landet unter "#" am Ende.
int _compareNames(String a, String b) {
final keyA = _sortKey(a);
final keyB = _sortKey(b);
final letterA = _startsWithLetter(keyA);
final letterB = _startsWithLetter(keyB);
if (letterA != letterB) return letterA ? -1 : 1;
return keyA.compareTo(keyB);
}
final _letter = RegExp(r'^\p{L}', unicode: true);
bool _startsWithLetter(String s) => _letter.hasMatch(s);
/// Kleinschreibung + Umlaute auf ihren Grundbuchstaben, damit "Ärger"
/// zwischen "Anfang" und "Berg" steht und nicht hinter "Zug".
String _sortKey(String title) {
return title
.trim()
.toLowerCase()
.replaceAll('ä', 'a')
.replaceAll('ö', 'o')
.replaceAll('ü', 'u')
.replaceAll('ß', 'ss');
}