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
+45
View File
@@ -5,6 +5,51 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [Unreleased]
### 🎨 Neue Melo-Oberfläche: 4 Tabs + Sortierung (2026-08-20)
-**Was:** Die App hat jetzt die von dir gewünschte Struktur mit
**4 Tabs: Meine Musik · Suche · Favoriten · Download**. "Meine Musik" ist
der neue Startbildschirm — **ohne Topbar**, wie du es wolltest.
- 🎵 **Meine Musik:** Oben eine Kopfzeile mit Einstellungs-Icon (links),
Suchfeld (Mitte) und ?-Icon für die Musikerkennung (rechts, kommt später).
Darunter Schnellzugriff-Karten (Favoriten · Wiedergabelisten · Zuletzt ·
Künstler · Alben), dann **Shuffle-Wiedergabe + Sortieren**, dann die
komplette Liederliste.
- ↕️ **Sortieren (neu!):** Über das Symbol rechts neben "Shuffle-Wiedergabe"
kannst du wählen zwischen **Zuletzt hinzugefügt**, **Name (AZ#)** und
**Wie oft abgespielt** — jeweils auf- oder absteigend. Die Wahl wird
gespeichert und überlebt einen Neustart. Bei "Name" stehen Buchstaben
vorne und Ziffern/Sonderzeichen hinten unter "#"; Umlaute sortieren
richtig ein (Ärger steht bei A, nicht hinter Z).
- ❤️ **Favoriten-Tab:** eigener Tab mit Shuffle-Wiedergabe und **genau
derselben Sortierung** — mit eigener, getrennt gespeicherter Einstellung.
- ⬇️ **Download-Tab:** durchsucht den verbundenen Navidrome-Server
(Alben/Künstler); abgespielte Titel werden weiterhin automatisch lokal
zwischengespeichert.
- 🔢 **"Wie oft abgespielt" zählt jetzt wirklich:** Neue Spalte `playCount`
in der Datenbank (Schema 3, bestehende Bibliotheken werden automatisch
migriert). Gezählt wird beim Start eines Titels — nicht sekündlich,
solange er läuft.
- 🎨 **Farben:** Schwarz auf **#0B0B10** und Melo-Rot **#c0392b** (vorher ein
helleres Rot).
- 🔧 **Details:**
- Neu: `library/song_sort.dart` (reine Sortier-Logik, 11 Tests),
`shared/sort_store.dart` (Speichern der Wahl, 4 Tests),
`shared/sortable_song_list.dart` (Shuffle + Sortier-Menü),
`library/my_music_screen.dart`, `library/favorites_screen.dart`,
`downloads/downloads_screen.dart`.
- Der alte `library_screen.dart` (Tabs Alle/Künstler/Alben/Server) ist
entfallen — seine Inhalte leben jetzt in "Meine Musik" (Alle),
als eigene Bildschirme (Künstler/Alben) und im Download-Tab (Server).
- **Musik hinzufügen** und **Erneut scannen** saßen in der entfernten
Topbar und stehen jetzt in den **Einstellungen → Bibliothek**
(zusätzlich weiterhin im leeren Zustand von "Meine Musik").
- Der Mini-Player ist jetzt auf allen Tabs sichtbar (vorher war der
Player ein eigener Tab).
- 🐛 **Nebenbei gefixt:** Das Sortier-Menü ist scrollbar — auf niedrigen
Bildschirmen bzw. im Querformat war der "Übernehmen"-Knopf sonst nicht
erreichbar. Schnellzugriff-Karten liefen um 2 Pixel über.
-**Getestet:** 107 Tests grün, `flutter analyze` ohne Befund.
### 🔎 Login-Dialog zeigt jetzt die echte Server-Fehlermeldung (2026-08-20) ### 🔎 Login-Dialog zeigt jetzt die echte Server-Fehlermeldung (2026-08-20)
-**Was:** Schlägt der Navidrome-Login fehl, zeigt der Dialog jetzt die -**Was:** Schlägt der Navidrome-Login fehl, zeigt der Dialog jetzt die
Original-Meldung des Servers (z. B. „Wrong username or password") statt Original-Meldung des Servers (z. B. „Wrong username or password") statt
@@ -4,256 +4,42 @@ import 'package:provider/provider.dart';
import '../player/audio_handler.dart'; import '../player/audio_handler.dart';
import '../services/navidrome_service.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. /// Download-Tab: durchsucht den verbundenen Navidrome-Server. Abgespielte
/// Scan-Banner/-Fortschritt gelten für alle Unteransichten und liegen daher /// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert
/// oberhalb der [TabBarView]. /// und stehen danach offline zur Verfügung.
class LibraryScreen extends StatelessWidget { class DownloadsScreen extends StatelessWidget {
const LibraryScreen({super.key}); const DownloadsScreen({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final lib = context.watch<LibraryService>(); return const SafeArea(
return DefaultTabController( bottom: false,
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( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.library_music, size: 64, color: Colors.white24), Padding(
const SizedBox(height: 12), padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
const Text('Noch keine Musik', child: Align(
style: TextStyle(color: Colors.white54)), alignment: Alignment.centerLeft,
const SizedBox(height: 16), child: Text('Download',
FilledButton.icon( style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
icon: const Icon(Icons.create_new_folder_outlined), ),
label: Text(addMusicLabel),
onPressed: lib.pickFolderAndScan,
), ),
Expanded(child: _ServerBrowser()),
], ],
), ),
); );
} }
} }
class _NavidromeTab extends StatefulWidget { class _ServerBrowser extends StatefulWidget {
const _NavidromeTab(); const _ServerBrowser();
@override @override
State<_NavidromeTab> createState() => _NavidromeTabState(); State<_ServerBrowser> createState() => _ServerBrowserState();
} }
class _NavidromeTabState extends State<_NavidromeTab> { class _ServerBrowserState extends State<_ServerBrowser> {
late final NavidromeService _nav = NavidromeService(); late final NavidromeService _nav = NavidromeService();
List<SubsonicAlbum> _albums = []; List<SubsonicAlbum> _albums = [];
List<SubsonicArtist> _artists = []; List<SubsonicArtist> _artists = [];
+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()(); IntColumn get updatedAtMs => integer()();
BoolColumn get deleted => boolean().withDefault(const Constant(false))(); 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 @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@@ -85,7 +89,7 @@ class MeloDb extends _$MeloDb {
MeloDb([QueryExecutor? executor]) : super(executor ?? _open()); MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
@override @override
int get schemaVersion => 2; int get schemaVersion => 3;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -97,6 +101,9 @@ class MeloDb extends _$MeloDb {
await m.createTable(favorites); await m.createTable(favorites);
await m.createTable(playbackHistory); await m.createTable(playbackHistory);
} }
if (from < 3) {
await m.addColumn(songs, songs.playCount);
}
}, },
); );
@@ -291,6 +298,17 @@ class MeloDb extends _$MeloDb {
} }
// === Playback History === // === 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 { Future<void> recordPlayback(String songId, int positionMs) async {
await into(playbackHistory).insert(PlaybackHistoryCompanion.insert( await into(playbackHistory).insert(PlaybackHistoryCompanion.insert(
songId: songId, songId: songId,
+70 -2
View File
@@ -113,6 +113,18 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
), ),
defaultValue: const Constant(false), 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 @override
List<GeneratedColumn> get $columns => [ List<GeneratedColumn> get $columns => [
id, id,
@@ -125,6 +137,7 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
dateAddedMs, dateAddedMs,
updatedAtMs, updatedAtMs,
deleted, deleted,
playCount,
]; ];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@@ -211,6 +224,12 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
deleted.isAcceptableOrUnknown(data['deleted']!, _deletedMeta), deleted.isAcceptableOrUnknown(data['deleted']!, _deletedMeta),
); );
} }
if (data.containsKey('play_count')) {
context.handle(
_playCountMeta,
playCount.isAcceptableOrUnknown(data['play_count']!, _playCountMeta),
);
}
return context; return context;
} }
@@ -260,6 +279,10 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
DriftSqlType.bool, DriftSqlType.bool,
data['${effectivePrefix}deleted'], 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 dateAddedMs;
final int updatedAtMs; final int updatedAtMs;
final bool deleted; final bool deleted;
/// Wie oft der Song vollständig gestartet wurde — Grundlage für die
/// Sortierung "Wie oft abgespielt".
final int playCount;
const Song({ const Song({
required this.id, required this.id,
required this.path, required this.path,
@@ -291,6 +318,7 @@ class Song extends DataClass implements Insertable<Song> {
required this.dateAddedMs, required this.dateAddedMs,
required this.updatedAtMs, required this.updatedAtMs,
required this.deleted, required this.deleted,
required this.playCount,
}); });
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { 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['date_added_ms'] = Variable<int>(dateAddedMs);
map['updated_at_ms'] = Variable<int>(updatedAtMs); map['updated_at_ms'] = Variable<int>(updatedAtMs);
map['deleted'] = Variable<bool>(deleted); map['deleted'] = Variable<bool>(deleted);
map['play_count'] = Variable<int>(playCount);
return map; return map;
} }
@@ -336,6 +365,7 @@ class Song extends DataClass implements Insertable<Song> {
dateAddedMs: Value(dateAddedMs), dateAddedMs: Value(dateAddedMs),
updatedAtMs: Value(updatedAtMs), updatedAtMs: Value(updatedAtMs),
deleted: Value(deleted), deleted: Value(deleted),
playCount: Value(playCount),
); );
} }
@@ -355,6 +385,7 @@ class Song extends DataClass implements Insertable<Song> {
dateAddedMs: serializer.fromJson<int>(json['dateAddedMs']), dateAddedMs: serializer.fromJson<int>(json['dateAddedMs']),
updatedAtMs: serializer.fromJson<int>(json['updatedAtMs']), updatedAtMs: serializer.fromJson<int>(json['updatedAtMs']),
deleted: serializer.fromJson<bool>(json['deleted']), deleted: serializer.fromJson<bool>(json['deleted']),
playCount: serializer.fromJson<int>(json['playCount']),
); );
} }
@override @override
@@ -371,6 +402,7 @@ class Song extends DataClass implements Insertable<Song> {
'dateAddedMs': serializer.toJson<int>(dateAddedMs), 'dateAddedMs': serializer.toJson<int>(dateAddedMs),
'updatedAtMs': serializer.toJson<int>(updatedAtMs), 'updatedAtMs': serializer.toJson<int>(updatedAtMs),
'deleted': serializer.toJson<bool>(deleted), 'deleted': serializer.toJson<bool>(deleted),
'playCount': serializer.toJson<int>(playCount),
}; };
} }
@@ -385,6 +417,7 @@ class Song extends DataClass implements Insertable<Song> {
int? dateAddedMs, int? dateAddedMs,
int? updatedAtMs, int? updatedAtMs,
bool? deleted, bool? deleted,
int? playCount,
}) => Song( }) => Song(
id: id ?? this.id, id: id ?? this.id,
path: path ?? this.path, path: path ?? this.path,
@@ -396,6 +429,7 @@ class Song extends DataClass implements Insertable<Song> {
dateAddedMs: dateAddedMs ?? this.dateAddedMs, dateAddedMs: dateAddedMs ?? this.dateAddedMs,
updatedAtMs: updatedAtMs ?? this.updatedAtMs, updatedAtMs: updatedAtMs ?? this.updatedAtMs,
deleted: deleted ?? this.deleted, deleted: deleted ?? this.deleted,
playCount: playCount ?? this.playCount,
); );
Song copyWithCompanion(SongsCompanion data) { Song copyWithCompanion(SongsCompanion data) {
return Song( return Song(
@@ -415,6 +449,7 @@ class Song extends DataClass implements Insertable<Song> {
? data.updatedAtMs.value ? data.updatedAtMs.value
: this.updatedAtMs, : this.updatedAtMs,
deleted: data.deleted.present ? data.deleted.value : this.deleted, 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('coverPath: $coverPath, ')
..write('dateAddedMs: $dateAddedMs, ') ..write('dateAddedMs: $dateAddedMs, ')
..write('updatedAtMs: $updatedAtMs, ') ..write('updatedAtMs: $updatedAtMs, ')
..write('deleted: $deleted') ..write('deleted: $deleted, ')
..write('playCount: $playCount')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@@ -447,6 +483,7 @@ class Song extends DataClass implements Insertable<Song> {
dateAddedMs, dateAddedMs,
updatedAtMs, updatedAtMs,
deleted, deleted,
playCount,
); );
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -461,7 +498,8 @@ class Song extends DataClass implements Insertable<Song> {
other.coverPath == this.coverPath && other.coverPath == this.coverPath &&
other.dateAddedMs == this.dateAddedMs && other.dateAddedMs == this.dateAddedMs &&
other.updatedAtMs == this.updatedAtMs && other.updatedAtMs == this.updatedAtMs &&
other.deleted == this.deleted); other.deleted == this.deleted &&
other.playCount == this.playCount);
} }
class SongsCompanion extends UpdateCompanion<Song> { class SongsCompanion extends UpdateCompanion<Song> {
@@ -475,6 +513,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
final Value<int> dateAddedMs; final Value<int> dateAddedMs;
final Value<int> updatedAtMs; final Value<int> updatedAtMs;
final Value<bool> deleted; final Value<bool> deleted;
final Value<int> playCount;
final Value<int> rowid; final Value<int> rowid;
const SongsCompanion({ const SongsCompanion({
this.id = const Value.absent(), this.id = const Value.absent(),
@@ -487,6 +526,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
this.dateAddedMs = const Value.absent(), this.dateAddedMs = const Value.absent(),
this.updatedAtMs = const Value.absent(), this.updatedAtMs = const Value.absent(),
this.deleted = const Value.absent(), this.deleted = const Value.absent(),
this.playCount = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
}); });
SongsCompanion.insert({ SongsCompanion.insert({
@@ -500,6 +540,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
required int dateAddedMs, required int dateAddedMs,
required int updatedAtMs, required int updatedAtMs,
this.deleted = const Value.absent(), this.deleted = const Value.absent(),
this.playCount = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
}) : id = Value(id), }) : id = Value(id),
path = Value(path), path = Value(path),
@@ -517,6 +558,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
Expression<int>? dateAddedMs, Expression<int>? dateAddedMs,
Expression<int>? updatedAtMs, Expression<int>? updatedAtMs,
Expression<bool>? deleted, Expression<bool>? deleted,
Expression<int>? playCount,
Expression<int>? rowid, Expression<int>? rowid,
}) { }) {
return RawValuesInsertable({ return RawValuesInsertable({
@@ -530,6 +572,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
if (dateAddedMs != null) 'date_added_ms': dateAddedMs, if (dateAddedMs != null) 'date_added_ms': dateAddedMs,
if (updatedAtMs != null) 'updated_at_ms': updatedAtMs, if (updatedAtMs != null) 'updated_at_ms': updatedAtMs,
if (deleted != null) 'deleted': deleted, if (deleted != null) 'deleted': deleted,
if (playCount != null) 'play_count': playCount,
if (rowid != null) 'rowid': rowid, if (rowid != null) 'rowid': rowid,
}); });
} }
@@ -545,6 +588,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
Value<int>? dateAddedMs, Value<int>? dateAddedMs,
Value<int>? updatedAtMs, Value<int>? updatedAtMs,
Value<bool>? deleted, Value<bool>? deleted,
Value<int>? playCount,
Value<int>? rowid, Value<int>? rowid,
}) { }) {
return SongsCompanion( return SongsCompanion(
@@ -558,6 +602,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
dateAddedMs: dateAddedMs ?? this.dateAddedMs, dateAddedMs: dateAddedMs ?? this.dateAddedMs,
updatedAtMs: updatedAtMs ?? this.updatedAtMs, updatedAtMs: updatedAtMs ?? this.updatedAtMs,
deleted: deleted ?? this.deleted, deleted: deleted ?? this.deleted,
playCount: playCount ?? this.playCount,
rowid: rowid ?? this.rowid, rowid: rowid ?? this.rowid,
); );
} }
@@ -595,6 +640,9 @@ class SongsCompanion extends UpdateCompanion<Song> {
if (deleted.present) { if (deleted.present) {
map['deleted'] = Variable<bool>(deleted.value); map['deleted'] = Variable<bool>(deleted.value);
} }
if (playCount.present) {
map['play_count'] = Variable<int>(playCount.value);
}
if (rowid.present) { if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value); map['rowid'] = Variable<int>(rowid.value);
} }
@@ -614,6 +662,7 @@ class SongsCompanion extends UpdateCompanion<Song> {
..write('dateAddedMs: $dateAddedMs, ') ..write('dateAddedMs: $dateAddedMs, ')
..write('updatedAtMs: $updatedAtMs, ') ..write('updatedAtMs: $updatedAtMs, ')
..write('deleted: $deleted, ') ..write('deleted: $deleted, ')
..write('playCount: $playCount, ')
..write('rowid: $rowid') ..write('rowid: $rowid')
..write(')')) ..write(')'))
.toString(); .toString();
@@ -2223,6 +2272,7 @@ typedef $$SongsTableCreateCompanionBuilder =
required int dateAddedMs, required int dateAddedMs,
required int updatedAtMs, required int updatedAtMs,
Value<bool> deleted, Value<bool> deleted,
Value<int> playCount,
Value<int> rowid, Value<int> rowid,
}); });
typedef $$SongsTableUpdateCompanionBuilder = typedef $$SongsTableUpdateCompanionBuilder =
@@ -2237,6 +2287,7 @@ typedef $$SongsTableUpdateCompanionBuilder =
Value<int> dateAddedMs, Value<int> dateAddedMs,
Value<int> updatedAtMs, Value<int> updatedAtMs,
Value<bool> deleted, Value<bool> deleted,
Value<int> playCount,
Value<int> rowid, Value<int> rowid,
}); });
@@ -2359,6 +2410,11 @@ class $$SongsTableFilterComposer extends Composer<_$MeloDb, $SongsTable> {
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
); );
ColumnFilters<int> get playCount => $composableBuilder(
column: $table.playCount,
builder: (column) => ColumnFilters(column),
);
Expression<bool> playlistSongsRefs( Expression<bool> playlistSongsRefs(
Expression<bool> Function($$PlaylistSongsTableFilterComposer f) f, Expression<bool> Function($$PlaylistSongsTableFilterComposer f) f,
) { ) {
@@ -2492,6 +2548,11 @@ class $$SongsTableOrderingComposer extends Composer<_$MeloDb, $SongsTable> {
column: $table.deleted, column: $table.deleted,
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
); );
ColumnOrderings<int> get playCount => $composableBuilder(
column: $table.playCount,
builder: (column) => ColumnOrderings(column),
);
} }
class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> { class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> {
@@ -2538,6 +2599,9 @@ class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> {
GeneratedColumn<bool> get deleted => GeneratedColumn<bool> get deleted =>
$composableBuilder(column: $table.deleted, builder: (column) => column); $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> playlistSongsRefs<T extends Object>(
Expression<T> Function($$PlaylistSongsTableAnnotationComposer a) f, Expression<T> Function($$PlaylistSongsTableAnnotationComposer a) f,
) { ) {
@@ -2656,6 +2720,7 @@ class $$SongsTableTableManager
Value<int> dateAddedMs = const Value.absent(), Value<int> dateAddedMs = const Value.absent(),
Value<int> updatedAtMs = const Value.absent(), Value<int> updatedAtMs = const Value.absent(),
Value<bool> deleted = const Value.absent(), Value<bool> deleted = const Value.absent(),
Value<int> playCount = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
}) => SongsCompanion( }) => SongsCompanion(
id: id, id: id,
@@ -2668,6 +2733,7 @@ class $$SongsTableTableManager
dateAddedMs: dateAddedMs, dateAddedMs: dateAddedMs,
updatedAtMs: updatedAtMs, updatedAtMs: updatedAtMs,
deleted: deleted, deleted: deleted,
playCount: playCount,
rowid: rowid, rowid: rowid,
), ),
createCompanionCallback: createCompanionCallback:
@@ -2682,6 +2748,7 @@ class $$SongsTableTableManager
required int dateAddedMs, required int dateAddedMs,
required int updatedAtMs, required int updatedAtMs,
Value<bool> deleted = const Value.absent(), Value<bool> deleted = const Value.absent(),
Value<int> playCount = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
}) => SongsCompanion.insert( }) => SongsCompanion.insert(
id: id, id: id,
@@ -2694,6 +2761,7 @@ class $$SongsTableTableManager
dateAddedMs: dateAddedMs, dateAddedMs: dateAddedMs,
updatedAtMs: updatedAtMs, updatedAtMs: updatedAtMs,
deleted: deleted, deleted: deleted,
playCount: playCount,
rowid: rowid, rowid: rowid,
), ),
withReferenceMapper: (p0) => p0 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)),
],
),
);
}
}
+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');
}
+21 -20
View File
@@ -2,18 +2,17 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'downloads/downloads_screen.dart';
import 'library/database.dart'; import 'library/database.dart';
import 'library/library_screen.dart'; import 'library/favorites_screen.dart';
import 'library/library_service.dart'; import 'library/library_service.dart';
import 'library/my_music_screen.dart';
import 'library/playlist_service.dart'; import 'library/playlist_service.dart';
import 'library/search_screen.dart'; import 'library/search_screen.dart';
import 'player/audio_handler.dart'; import 'player/audio_handler.dart';
import 'player/mini_player.dart'; import 'player/mini_player.dart';
import 'player/now_playing_screen.dart';
import 'playlists/playlists_screen.dart';
import 'services/logger_service.dart'; import 'services/logger_service.dart';
import 'services/offline_mode.dart'; import 'services/offline_mode.dart';
import 'settings/settings_screen.dart';
import 'shared/theme.dart'; import 'shared/theme.dart';
late final MeloAudioHandler _handler; late final MeloAudioHandler _handler;
@@ -75,35 +74,37 @@ class HomeShell extends StatefulWidget {
class _HomeShellState extends State<HomeShell> { class _HomeShellState extends State<HomeShell> {
int _index = 0; int _index = 0;
static const _tabs = <Widget>[ void _goTo(int index) => setState(() => _index = index);
NowPlayingScreen(),
LibraryScreen(),
PlaylistsScreen(),
SearchScreen(),
SettingsScreen(),
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tabs = <Widget>[
MyMusicScreen(
onSearchTap: () => _goTo(1),
onFavoritesTap: () => _goTo(2),
),
const SearchScreen(),
const FavoritesScreen(),
const DownloadsScreen(),
];
return Scaffold( return Scaffold(
body: Column( body: Column(
children: [ children: [
Expanded(child: IndexedStack(index: _index, children: _tabs)), Expanded(child: IndexedStack(index: _index, children: tabs)),
if (_index != 0) const MiniPlayer(), const MiniPlayer(),
], ],
), ),
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
currentIndex: _index, currentIndex: _index,
onTap: (i) => setState(() => _index = i), onTap: _goTo,
items: const [ items: const [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.play_circle_outline), label: 'Player'), icon: Icon(Icons.headphones), label: 'Meine Musik'),
BottomNavigationBarItem(
icon: Icon(Icons.library_music), label: 'Bibliothek'),
BottomNavigationBarItem(
icon: Icon(Icons.playlist_play), label: 'Playlisten'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suche'), BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suche'),
BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Settings'), BottomNavigationBarItem(
icon: Icon(Icons.favorite_border), label: 'Favoriten'),
BottomNavigationBarItem(
icon: Icon(Icons.download_outlined), label: 'Download'),
], ],
), ),
); );
+15
View File
@@ -20,6 +20,12 @@ bool shouldResumeAt(int lastPositionMs, Duration? trackDuration) {
return true; return true;
} }
/// Entscheidet, ob für den Titel an [newIndex] eine Wiedergabe gezählt wird.
/// Gezählt wird jeder Titelwechsel; eine Wiederholung desselben Titels
/// (LoopMode.one) zählt bewusst nicht erneut.
bool shouldCountPlay(int? newIndex, int? lastCountedIndex) =>
newIndex != null && newIndex != lastCountedIndex;
/// Kern der Wiedergabe: kapselt just_audio hinter audio_service, /// Kern der Wiedergabe: kapselt just_audio hinter audio_service,
/// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren. /// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren.
class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
@@ -29,6 +35,7 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
late final Timer _positionRecordTimer; late final Timer _positionRecordTimer;
late final CacheManager _cache; late final CacheManager _cache;
final NavidromeService _nav = NavidromeService(); final NavidromeService _nav = NavidromeService();
int? _lastCountedIndex;
MeloAudioHandler({required this.db}) { MeloAudioHandler({required this.db}) {
_cache = CacheManager(); _cache = CacheManager();
@@ -56,6 +63,11 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
final q = queue.value; final q = queue.value;
if (index != null && index < q.length) { if (index != null && index < q.length) {
mediaItem.add(q[index].copyWith(duration: _player.duration)); mediaItem.add(q[index].copyWith(duration: _player.duration));
if (shouldCountPlay(index, _lastCountedIndex)) {
_lastCountedIndex = index;
final songId = q[index].extras?['songId'] as String?;
if (songId != null) db.incrementPlayCount(songId);
}
} }
}); });
@@ -72,6 +84,9 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
/// Auto-Caching: Streame von Server + speichere lokal gleichzeitig. /// Auto-Caching: Streame von Server + speichere lokal gleichzeitig.
Future<void> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async { Future<void> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items); queue.add(items);
// Neue Warteschlange: der erste Titel soll wieder zählen, auch wenn er
// denselben Index wie der zuletzt gezählte hat.
_lastCountedIndex = null;
await _nav.ladeGespeicherteZugangsdaten(); await _nav.ladeGespeicherteZugangsdaten();
final sources = <AudioSource>[]; final sources = <AudioSource>[];
+25
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../library/database.dart'; import '../library/database.dart';
import '../library/library_service.dart';
import '../library/permissions.dart'; import '../library/permissions.dart';
import '../library/playlist_service.dart'; import '../library/playlist_service.dart';
import '../services/cache_manager.dart'; import '../services/cache_manager.dart';
@@ -58,6 +59,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: title:
Text('${formatTotalDuration(songs)} Gesamtspieldauer'), Text('${formatTotalDuration(songs)} Gesamtspieldauer'),
), ),
Consumer<LibraryService>(
builder: (context, lib, _) => Column(
children: [
ListTile(
leading: const Icon(Icons.create_new_folder_outlined),
title: Text(addMusicLabel),
subtitle: const Text(
'Musikordner auswählen und einlesen'),
enabled: !lib.scanning,
onTap: lib.pickFolderAndScan,
),
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Erneut scannen'),
subtitle: lib.scanning
? Text('Scanne … ${lib.scanDone}'
'${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}')
: const Text('Bekannte Ordner neu einlesen'),
enabled: !lib.scanning,
onTap: lib.rescan,
),
],
),
),
], ],
); );
}, },
+38
View File
@@ -0,0 +1,38 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../library/song_sort.dart';
/// Sortier-Wahl einer Liederliste: Kriterium + Richtung.
class SortSetting {
const SortSetting(this.mode, {required this.ascending});
final SortMode mode;
final bool ascending;
}
/// Merkt sich die Sortier-Wahl pro Liste ([key] z. B. 'meine_musik'),
/// damit sie einen App-Neustart überlebt.
class SortStore {
static const meineMusik = 'meine_musik';
static const favoriten = 'favoriten';
static const _standard = SortSetting(SortMode.dateAdded, ascending: false);
static Future<SortSetting> load(String key) async {
final prefs = await SharedPreferences.getInstance();
final index = prefs.getInt('sort_${key}_mode');
if (index == null || index < 0 || index >= SortMode.values.length) {
return _standard;
}
final mode = SortMode.values[index];
return SortSetting(
mode,
ascending: prefs.getBool('sort_${key}_asc') ?? defaultAscending(mode),
);
}
static Future<void> save(String key, SortSetting setting) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('sort_${key}_mode', setting.mode.index);
await prefs.setBool('sort_${key}_asc', setting.ascending);
}
}
+236
View File
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../library/database.dart';
import '../library/song_list.dart';
import '../library/song_media.dart';
import '../library/song_sort.dart';
import '../player/audio_handler.dart';
import 'sort_store.dart';
import 'theme.dart';
/// Liederliste mit Shuffle-Wiedergabe und Sortier-Menü darüber — identisch in
/// "Meine Musik" und "Favoriten". [storeKey] trennt die gemerkte Sortier-Wahl
/// der beiden Listen ([SortStore.meineMusik] / [SortStore.favoriten]).
class SortableSongList extends StatefulWidget {
const SortableSongList({
super.key,
required this.songs,
required this.storeKey,
this.empty,
});
final List<Song> songs;
final String storeKey;
/// Wird statt der Liste gezeigt, wenn [songs] leer ist.
final Widget? empty;
@override
State<SortableSongList> createState() => _SortableSongListState();
}
class _SortableSongListState extends State<SortableSongList> {
SortSetting _setting = const SortSetting(
SortMode.dateAdded,
ascending: false,
);
@override
void initState() {
super.initState();
SortStore.load(widget.storeKey).then((s) {
if (mounted) setState(() => _setting = s);
});
}
Future<void> _shuffle() async {
if (widget.songs.isEmpty) return;
final handler = context.read<MeloAudioHandler>();
final messenger = ScaffoldMessenger.of(context);
final shuffled = [...widget.songs]..shuffle();
try {
await playSongs(handler, shuffled, 0);
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
}
Future<void> _chooseSort() async {
final chosen = await showModalBottomSheet<SortSetting>(
context: context,
backgroundColor: MeloTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => _SortSheet(current: _setting),
);
if (chosen == null) return;
setState(() => _setting = chosen);
await SortStore.save(widget.storeKey, chosen);
}
@override
Widget build(BuildContext context) {
final sorted = sortSongs(
widget.songs,
_setting.mode,
ascending: _setting.ascending,
);
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 8, 4),
child: Row(
children: [
Expanded(
child: InkWell(
onTap: widget.songs.isEmpty ? null : _shuffle,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow,
color: Colors.black,
size: 24,
),
),
const SizedBox(width: 12),
const Text(
'Shuffle-Wiedergabe',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
IconButton(
tooltip: 'Sortieren: ${_setting.mode.label}',
icon: const Icon(Icons.swap_vert),
onPressed: _chooseSort,
),
],
),
),
Expanded(
child: sorted.isEmpty
? (widget.empty ?? const SizedBox.shrink())
: SongList(sorted),
),
],
);
}
}
/// Bottom-Sheet zur Wahl von Sortier-Kriterium und Richtung.
class _SortSheet extends StatefulWidget {
const _SortSheet({required this.current});
final SortSetting current;
@override
State<_SortSheet> createState() => _SortSheetState();
}
class _SortSheetState extends State<_SortSheet> {
late SortMode _mode = widget.current.mode;
late bool _ascending = widget.current.ascending;
@override
Widget build(BuildContext context) {
// Scrollbar, damit das Sheet auch auf niedrigen Bildschirmen und im
// Querformat vollständig erreichbar bleibt.
return SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 20, 20, 8),
child: Text(
'Sortieren',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
),
for (final mode in SortMode.values)
ListTile(
title: Text(
mode.label,
style: TextStyle(
color: mode == _mode ? MeloTheme.red : Colors.white,
fontWeight: mode == _mode
? FontWeight.w600
: FontWeight.w400,
),
),
trailing: mode == _mode
? const Icon(Icons.check, color: MeloTheme.red)
: null,
onTap: () => setState(() {
_mode = mode;
// Jedes Kriterium hat eine natürliche Leserichtung; die Wahl
// eines neuen Kriteriums setzt sie zurück.
_ascending = defaultAscending(mode);
}),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
child: Row(
children: [
Expanded(
child: SegmentedButton<bool>(
segments: const [
ButtonSegment(
value: true,
icon: Icon(Icons.arrow_upward, size: 16),
label: Text('Aufsteigend'),
),
ButtonSegment(
value: false,
icon: Icon(Icons.arrow_downward, size: 16),
label: Text('Absteigend'),
),
],
selected: {_ascending},
onSelectionChanged: (s) =>
setState(() => _ascending = s.first),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 16),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(
context,
SortSetting(_mode, ascending: _ascending),
),
child: const Text('Übernehmen'),
),
),
),
],
),
),
);
}
}
+6 -3
View File
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
/// Melo-Theme: Schwarz + Rot. /// Melo-Theme: Schwarz + Rot.
class MeloTheme { class MeloTheme {
static const Color red = Color(0xFFE50914); static const Color red = Color(0xFFC0392B);
static const Color black = Color(0xFF0A0A0A); static const Color black = Color(0xFF0B0B10);
static const Color surface = Color(0xFF161616); static const Color surface = Color(0xFF15151C);
/// Etwas hellere Fläche für Karten und Chips auf [surface].
static const Color surfaceHigh = Color(0xFF1F1F29);
static ThemeData get dark { static ThemeData get dark {
final scheme = ColorScheme.fromSeed( final scheme = ColorScheme.fromSeed(
-113
View File
@@ -1,113 +0,0 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/library_screen.dart';
import 'package:melo/library/library_service.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/player/audio_handler.dart';
Widget _wrap(
MeloDb db,
LibraryService lib,
PlaylistService playlists,
MeloAudioHandler handler,
Widget child,
) {
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
],
child: MaterialApp(home: child),
);
}
Future<void> _insertSong(
MeloDb db, {
required String id,
required String title,
String? artist,
String? album,
}) {
return db.into(db.songs).insert(SongsCompanion.insert(
id: id,
path: '/$id.mp3',
title: title,
artist: Value(artist),
album: Value(album),
dateAddedMs: 0,
updatedAtMs: 0,
));
}
void main() {
testWidgets('zeigt die drei Bibliotheks-Tabs "Alle", "Künstler", "Alben"',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
await tester.pumpWidget(
_wrap(db, lib, playlists, handler, const LibraryScreen()),
);
await tester.pumpAndSettle();
expect(find.text('Alle'), findsOneWidget);
expect(find.text('Künstler'), findsOneWidget);
expect(find.text('Alben'), findsOneWidget);
handler.dispose();
await db.close();
});
testWidgets('Künstler-Tab zeigt je Künstler eine Zeile', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
await _insertSong(db, id: '1', title: 'Song A', artist: 'Alice');
await _insertSong(db, id: '2', title: 'Song B', artist: 'Bob');
await tester.pumpWidget(
_wrap(db, lib, playlists, handler, const LibraryScreen()),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Künstler'));
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
expect(find.text('1 Songs'), findsNWidgets(2));
handler.dispose();
await db.close();
});
testWidgets('Alben-Tab zeigt je Album eine Zeile', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
await _insertSong(db, id: '1', title: 'Song A', artist: 'Alice', album: 'Best Of');
await tester.pumpWidget(
_wrap(db, lib, playlists, handler, const LibraryScreen()),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Alben'));
await tester.pumpAndSettle();
expect(find.text('Best Of'), findsOneWidget);
handler.dispose();
await db.close();
});
}
+149
View File
@@ -0,0 +1,149 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/favorites_screen.dart';
import 'package:melo/library/library_service.dart';
import 'package:melo/library/my_music_screen.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
Widget _wrap(MeloDb db, LibraryService lib, PlaylistService playlists,
MeloAudioHandler handler, Widget child) {
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
],
child: MaterialApp(home: Scaffold(body: child)),
);
}
Future<void> _insert(
MeloDb db, {
required String id,
required String title,
int dateAddedMs = 0,
int playCount = 0,
}) {
return db.into(db.songs).insert(SongsCompanion.insert(
id: id,
path: '/$id.mp3',
title: title,
dateAddedMs: dateAddedMs,
updatedAtMs: 0,
playCount: Value(playCount),
));
}
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets('Meine Musik zeigt Kopfzeile, Schnellzugriffe und Shuffle',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
await _insert(db, id: '1', title: 'Song A');
await tester.pumpWidget(
_wrap(db, lib, playlists, handler, const MyMusicScreen()));
await tester.pumpAndSettle();
expect(find.text('Titel, Künstler und Alben suchen'), findsOneWidget);
expect(find.text('Favoriten'), findsOneWidget);
expect(find.text('Wiedergabelisten'), findsOneWidget);
expect(find.text('Shuffle-Wiedergabe'), findsOneWidget);
expect(find.text('Song A'), findsOneWidget);
handler.dispose();
await db.close();
});
testWidgets('Sortier-Menü bietet alle drei Kriterien und sortiert die Liste',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
await _insert(db, id: '1', title: 'Zenit', dateAddedMs: 300, playCount: 9);
await _insert(db, id: '2', title: 'Anfang', dateAddedMs: 100, playCount: 1);
await tester.pumpWidget(
_wrap(db, lib, playlists, handler, const MyMusicScreen()));
await tester.pumpAndSettle();
// Standard: zuletzt hinzugefügt zuerst.
var titles = tester.widgetList<Text>(find.byType(Text))
.map((t) => t.data)
.where((t) => t == 'Zenit' || t == 'Anfang')
.toList();
expect(titles.first, 'Zenit');
await tester.tap(find.byIcon(Icons.swap_vert));
await tester.pumpAndSettle();
expect(find.text('Zuletzt hinzugefügt'), findsOneWidget);
expect(find.text('Name (AZ#)'), findsOneWidget);
expect(find.text('Wie oft abgespielt'), findsOneWidget);
await tester.tap(find.text('Name (AZ#)'));
await tester.pumpAndSettle();
await tester.tap(find.text('Übernehmen'));
await tester.pumpAndSettle();
titles = tester.widgetList<Text>(find.byType(Text))
.map((t) => t.data)
.where((t) => t == 'Zenit' || t == 'Anfang')
.toList();
expect(titles.first, 'Anfang');
handler.dispose();
await db.close();
});
testWidgets('Favoriten-Tab zeigt nur favorisierte Songs mit Shuffle',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
await _insert(db, id: '1', title: 'Geliebt');
await _insert(db, id: '2', title: 'Egal');
await db.setFavorite('1', true);
await tester.pumpWidget(
_wrap(db, lib, playlists, handler, const FavoritesScreen()));
await tester.pumpAndSettle();
expect(find.text('Shuffle-Wiedergabe'), findsOneWidget);
expect(find.text('Geliebt'), findsOneWidget);
expect(find.text('Egal'), findsNothing);
handler.dispose();
await db.close();
});
testWidgets('Favoriten-Tab zeigt einen Hinweis, wenn nichts favorisiert ist',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
await tester.pumpWidget(
_wrap(db, lib, playlists, handler, const FavoritesScreen()));
await tester.pumpAndSettle();
expect(find.text('Noch keine Favoriten'), findsOneWidget);
handler.dispose();
await db.close();
});
}
+1
View File
@@ -17,6 +17,7 @@ Song _song({
dateAddedMs: 0, dateAddedMs: 0,
updatedAtMs: 0, updatedAtMs: 0,
deleted: false, deleted: false,
playCount: 0,
); );
} }
+1
View File
@@ -15,6 +15,7 @@ void main() {
dateAddedMs: 0, dateAddedMs: 0,
updatedAtMs: 0, updatedAtMs: 0,
deleted: false, deleted: false,
playCount: 0,
); );
final item = songToMediaItem(song); final item = songToMediaItem(song);
+137
View File
@@ -0,0 +1,137 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/song_sort.dart';
Song _song(
String title, {
String? id,
int dateAddedMs = 0,
int playCount = 0,
}) {
return Song(
id: id ?? title,
path: '/$title.mp3',
title: title,
dateAddedMs: dateAddedMs,
updatedAtMs: 0,
deleted: false,
playCount: playCount,
);
}
void main() {
group('SortMode.name — A-Z, Ziffern/Zeichen unter # ans Ende', () {
test('sortiert Buchstaben alphabetisch, Ziffern und Zeichen danach', () {
final songs = [
_song('9 Lives'),
_song('Zenit'),
_song('#Hashtag'),
_song('Anfang'),
];
final sorted = sortSongs(songs, SortMode.name, ascending: true);
expect(sorted.map((s) => s.title), ['Anfang', 'Zenit', '#Hashtag', '9 Lives']);
});
test('ignoriert Groß-/Kleinschreibung', () {
final songs = [_song('beta'), _song('Alpha'), _song('CHARLIE')];
final sorted = sortSongs(songs, SortMode.name, ascending: true);
expect(sorted.map((s) => s.title), ['Alpha', 'beta', 'CHARLIE']);
});
test('Umlaute sortieren wie ihr Grundbuchstabe', () {
final songs = [_song('Zug'), _song('Ärger'), _song('Anfang'), _song('Öl')];
final sorted = sortSongs(songs, SortMode.name, ascending: true);
expect(sorted.map((s) => s.title), ['Anfang', 'Ärger', 'Öl', 'Zug']);
});
test('absteigend dreht die Reihenfolge um', () {
final songs = [_song('Anfang'), _song('9 Lives'), _song('Zenit')];
final sorted = sortSongs(songs, SortMode.name, ascending: false);
expect(sorted.map((s) => s.title), ['9 Lives', 'Zenit', 'Anfang']);
});
});
group('SortMode.dateAdded', () {
test('absteigend = zuletzt hinzugefügt zuerst', () {
final songs = [
_song('Alt', dateAddedMs: 100),
_song('Neu', dateAddedMs: 300),
_song('Mittel', dateAddedMs: 200),
];
final sorted = sortSongs(songs, SortMode.dateAdded, ascending: false);
expect(sorted.map((s) => s.title), ['Neu', 'Mittel', 'Alt']);
});
test('aufsteigend = ältester zuerst', () {
final songs = [
_song('Neu', dateAddedMs: 300),
_song('Alt', dateAddedMs: 100),
];
final sorted = sortSongs(songs, SortMode.dateAdded, ascending: true);
expect(sorted.map((s) => s.title), ['Alt', 'Neu']);
});
});
group('SortMode.playCount', () {
test('absteigend = meistgespielt zuerst', () {
final songs = [
_song('Selten', playCount: 1),
_song('Oft', playCount: 42),
_song('Nie', playCount: 0),
];
final sorted = sortSongs(songs, SortMode.playCount, ascending: false);
expect(sorted.map((s) => s.title), ['Oft', 'Selten', 'Nie']);
});
});
group('Stabilität', () {
test('gleiche Sortierwerte werden nach Titel entschieden', () {
final songs = [
_song('Bravo', id: 'b', dateAddedMs: 5),
_song('Alpha', id: 'a', dateAddedMs: 5),
];
final sorted = sortSongs(songs, SortMode.dateAdded, ascending: false);
expect(sorted.map((s) => s.title), ['Alpha', 'Bravo']);
});
test('verändert die Eingabeliste nicht', () {
final songs = [_song('Zenit'), _song('Anfang')];
sortSongs(songs, SortMode.name, ascending: true);
expect(songs.map((s) => s.title), ['Zenit', 'Anfang']);
});
});
group('Standard-Richtung pro Modus', () {
test('Datum und Wiedergaben starten absteigend, Name aufsteigend', () {
expect(defaultAscending(SortMode.dateAdded), isFalse);
expect(defaultAscending(SortMode.playCount), isFalse);
expect(defaultAscending(SortMode.name), isTrue);
});
});
group('Beschriftungen', () {
test('jeder Modus hat ein deutsches Label', () {
expect(SortMode.dateAdded.label, 'Zuletzt hinzugefügt');
expect(SortMode.name.label, 'Name (AZ#)');
expect(SortMode.playCount.label, 'Wie oft abgespielt');
});
});
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/database.dart';
import 'package:melo/player/audio_handler.dart';
void main() {
group('shouldCountPlay', () {
test('zählt einen neu gestarteten Titel', () {
expect(shouldCountPlay(0, null), isTrue);
expect(shouldCountPlay(3, 2), isTrue);
});
test('zählt denselben Titel nicht mehrfach', () {
expect(shouldCountPlay(2, 2), isFalse);
});
test('zählt nicht ohne aktuellen Titel', () {
expect(shouldCountPlay(null, 1), isFalse);
});
});
group('MeloDb.incrementPlayCount', () {
late MeloDb db;
setUp(() => db = MeloDb(NativeDatabase.memory()));
tearDown(() => db.close());
test('erhöht den Zähler des Songs um eins', () async {
await db.upsertSongs([
SongsCompanion.insert(
id: '1',
path: '/a.mp3',
title: 'Song A',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.incrementPlayCount('1');
await db.incrementPlayCount('1');
final song = (await db.watchSongs().first).single;
expect(song.playCount, 2);
});
test('lässt andere Songs unberührt', () async {
await db.upsertSongs([
SongsCompanion.insert(
id: '1', path: '/a.mp3', title: 'A', dateAddedMs: 0, updatedAtMs: 0),
SongsCompanion.insert(
id: '2', path: '/b.mp3', title: 'B', dateAddedMs: 0, updatedAtMs: 0),
]);
await db.incrementPlayCount('1');
final songs = await db.watchSongs().first;
expect(songs.firstWhere((s) => s.id == '1').playCount, 1);
expect(songs.firstWhere((s) => s.id == '2').playCount, 0);
});
});
}
+1
View File
@@ -11,6 +11,7 @@ Song _song({required String id, int? durationMs}) {
dateAddedMs: 0, dateAddedMs: 0,
updatedAtMs: 0, updatedAtMs: 0,
deleted: false, deleted: false,
playCount: 0,
); );
} }
+7
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:melo/library/database.dart'; import 'package:melo/library/database.dart';
import 'package:melo/library/library_service.dart';
import 'package:melo/services/offline_mode.dart'; import 'package:melo/services/offline_mode.dart';
import 'package:melo/settings/settings_screen.dart'; import 'package:melo/settings/settings_screen.dart';
@@ -12,6 +13,7 @@ Widget _wrap(MeloDb db, Widget child) {
providers: [ providers: [
Provider<MeloDb>.value(value: db), Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<OfflineMode>.value(value: OfflineMode()), ChangeNotifierProvider<OfflineMode>.value(value: OfflineMode()),
ChangeNotifierProvider<LibraryService>.value(value: LibraryService(db)),
], ],
child: MaterialApp(home: child), child: MaterialApp(home: child),
); );
@@ -63,6 +65,11 @@ void main() {
await tester.pumpWidget(_wrap(db, const SettingsScreen())); await tester.pumpWidget(_wrap(db, const SettingsScreen()));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Die Sync-Einträge liegen weiter unten in der Liste und müssen erst
// in den sichtbaren Bereich gescrollt werden.
await tester.scrollUntilVisible(find.text('🌟 Favoriten-Sync'), 200);
await tester.pumpAndSettle();
expect(find.text('🌟 Favoriten-Sync'), findsOneWidget); expect(find.text('🌟 Favoriten-Sync'), findsOneWidget);
expect(find.text('📋 Playlisten-Sync'), findsOneWidget); expect(find.text('📋 Playlisten-Sync'), findsOneWidget);
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/song_sort.dart';
import 'package:melo/shared/sort_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
test('ohne gespeicherte Wahl gilt "Zuletzt hinzugefügt", neueste zuerst',
() async {
final setting = await SortStore.load('meine_musik');
expect(setting.mode, SortMode.dateAdded);
expect(setting.ascending, isFalse);
});
test('gespeicherte Wahl wird zurückgelesen', () async {
await SortStore.save(
'meine_musik',
const SortSetting(SortMode.name, ascending: true),
);
final setting = await SortStore.load('meine_musik');
expect(setting.mode, SortMode.name);
expect(setting.ascending, isTrue);
});
test('Listen speichern getrennt voneinander', () async {
await SortStore.save(
'meine_musik',
const SortSetting(SortMode.playCount, ascending: false),
);
await SortStore.save(
'favoriten',
const SortSetting(SortMode.name, ascending: true),
);
expect((await SortStore.load('meine_musik')).mode, SortMode.playCount);
expect((await SortStore.load('favoriten')).mode, SortMode.name);
});
test('unbrauchbarer gespeicherter Wert fällt auf den Standard zurück',
() async {
SharedPreferences.setMockInitialValues({'sort_meine_musik_mode': 99});
final setting = await SortStore.load('meine_musik');
expect(setting.mode, SortMode.dateAdded);
});
}