Zuletzt zeigt zuletzt Gehörtes statt neu Hinzugefügtes; Herz sitzt jetzt neben dem Songnamen

- MeloDb.watchRecentlyPlayed(): wertet PlaybackHistory statt dateAddedMs aus,
  ein mehrfach gehörter Song erscheint nur einmal, an der Position seines
  jüngsten Abspielens (Subquery mit GROUP BY songId / MAX(playedAtMs)).
- "Zuletzt" (Karte + RecentlyPlayedScreen, vormals RecentlyAddedScreen) in
  my_music_screen.dart nutzt jetzt watchRecentlyPlayed() statt watchRecent().
- Herz im Vollbild-Player: aus der AppBar-actions-Row entfernt, sitzt jetzt
  direkt neben dem Songtitel in _Angaben (now_playing_screen.dart).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
This commit is contained in:
Hermes (Server)
2026-08-29 11:24:42 +02:00
co-authored by Claude Sonnet 5
parent 90b63c467e
commit 2cabe36a6c
6 changed files with 345 additions and 32 deletions
+54
View File
@@ -5,6 +5,60 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased]
### 🎧 "Zuletzt" zeigte neu Hinzugefügtes statt zuletzt Gehörtem; Herz saß fern vom Songnamen (2026-08-29)
- 🐛 **Der Schnellzugriff "Zuletzt" auf "Meine Musik" zeigte "zuletzt hinzugefügte" Songs,
nicht "zuletzt gehörte"** — von Tinker gemeldet. Sowohl die Karte als auch der
Vollbild-Screen dahinter nutzten `db.watchRecent()`, das nach `dateAddedMs`
(Hinzufüge-Datum) sortiert, nicht nach dem tatsächlichen Abspielen.
- 🔧 **Fix:** neue Methode `MeloDb.watchRecentlyPlayed()` (`database.dart`) wertet
stattdessen die `PlaybackHistory`-Tabelle aus — eine Subquery ermittelt je Song per
`GROUP BY songId` / `MAX(playedAtMs)` nur den jüngsten Play-Zeitpunkt, damit ein
mehrfach gehörter Song nur einmal erscheint (an der Position seines letzten
Abspielens), zurückverknüpft auf `songs` (ohne getombstonte). "Zuletzt" in
`my_music_screen.dart` (Karte + Vollbild-Screen, `RecentlyAddedScreen` dafür in
`RecentlyPlayedScreen` umbenannt) nutzt jetzt diese Methode.
- 🐛 **Das Favoriten-Herz im Vollbild-Player saß oben in der AppBar**, weit weg vom
Songnamen — ebenfalls von Tinker gemeldet.
- 🔧 **Fix:** `_Herz` aus der AppBar-`actions`-Row entfernt und stattdessen direkt
neben den Titel in `_Angaben` gesetzt (`now_playing_screen.dart`).
-**Getestet:** 3 neue Unit-Tests für `watchRecentlyPlayed()` (RED→GREEN:
Mehrfach-Play erscheint nur einmal an der neuesten Position, mehrere Songs
absteigend sortiert, getombstonter Song mit Historie fehlt) sowie ein neuer
Widget-Test, der bestätigt, dass das Herz nicht mehr in der AppBar, sondern in
derselben Row wie der Songtitel sitzt. `flutter analyze` ohne Befund.
### 🔑 Auto-Anmeldung schlug beim App-Start fehl — betraf ALLE drei Nutzer (2026-08-29)
- 🐛 **Die automatische Baka-Anmeldung klappte beim App-Start oft nicht**
die App fragte dann nach dem "Baka-Konto", obwohl die Zugangsdaten
korrekt waren. Betraf **alle drei Nutzer** (Dustin, Baka, Tinker), nicht
nur einen — von Dustin selbst gemeldet und bei allen drei bestätigt.
Grund: `main.dart` baut die 5 Haupt-Tabs über `IndexedStack` alle sofort
beim ersten Frame, nicht erst beim Umschalten. Der Download-Tab und die
YT-Suche lösen dabei beide unabhängig voneinander denselben Auto-Login
aus (`pruefeServerUser()``BakaAuth.autoAnmelden()`) — praktisch
gleichzeitig, mit denselben Zugangsdaten. `autoAnmelden()` hatte keine
Schutzvorkehrung gegen so einen doppelten Aufruf (der `istAngemeldet`-
Check lief synchron, bevor die erste Anfrage zurückkam), also gingen
zwei fast-gleichzeitige Login-Anfragen an denselben Server raus — der
lehnte vermutlich eine davon ab (Session-/Lock-Konflikt).
- 🔧 **Fix:** `BakaAuth.autoAnmelden()` in `baka_auth.dart` hängt einen
überlappenden Aufruf jetzt an den bereits laufenden Login-Versuch an
(`_laufenderAutoLogin`), statt einen zweiten Request loszuschicken.
- 🔧 **Nebenbei zwei kleine Anmelde-Dialoge korrigiert:** Der Anmelde-
Dialog im Download-Tab (`downloads_screen.dart`) zeigte das
Benutzername-Feld immer mit "Baka" vorausgefüllt, egal wer die App
nutzt — zeigt jetzt den tatsächlich bekannten Navidrome-Benutzernamen
(leer, falls keiner bekannt). Der Navidrome-Dialog in den Einstellungen
(`settings_screen.dart`) wählte unter Baka/Tinker/Dustin immer "Baka"
vor — wählt jetzt den zuletzt bekannten Benutzer vor, falls bekannt.
-**Getestet:** neuer Unit-Test für `autoAnmelden()` (RED→GREEN, zwei
gleichzeitige Aufrufe lösen nur noch einen HTTP-Request aus statt
zwei) sowie ein neuer Widget-Test für die Benutzername-Vorbelegung im
Download-Tab-Dialog (RED→GREEN). Volle Suite grün, `flutter analyze`
ohne Befund.
### ⬅️ Zurück-Geste auf Nicht-Start-Tabs minimierte die App (2026-08-27)
- 🐛 **Auf "Favoriten" (oder einem anderen Tab außer "Meine Musik") tippen und
+29
View File
@@ -212,6 +212,35 @@ class MeloDb extends _$MeloDb {
.watch();
}
/// Überwacht die zuletzt GEHÖRTEN Songs (nicht getombstonte), aus
/// [PlaybackHistory]. Ein mehrfach gespielter Song erscheint nur einmal —
/// an der Position seines jüngsten Abspielens. Dafür wird pro [songId]
/// zuerst der jüngste `playedAtMs`-Eintrag ermittelt (Subquery mit
/// `GROUP BY`), und darüber zurück auf [songs] verknüpft.
Stream<List<Song>> watchRecentlyPlayed({int limit = 50}) {
final letzterPlay = playbackHistory.playedAtMs.max();
final letztePlaysProSong = selectOnly(playbackHistory)
..addColumns([playbackHistory.songId, letzterPlay])
..groupBy([playbackHistory.songId]);
final subquery = Subquery(letztePlaysProSong, 'letzte_plays');
final letzterPlayRef = subquery.ref(letzterPlay);
final query = select(songs).join([
innerJoin(
subquery,
subquery.ref(playbackHistory.songId).equalsExp(songs.id),
useColumns: false,
),
])
..where(songs.deleted.equals(false))
..orderBy([
OrderingTerm(expression: letzterPlayRef, mode: OrderingMode.desc)
])
..limit(limit);
return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList());
}
/// Alle Songs inkl. getombstonte. Intern für ID-Stabilität beim Scan.
Future<List<Song>> allSongs() => select(songs).get();
+9 -9
View File
@@ -234,17 +234,17 @@ class _QuickAccessRow extends StatelessWidget {
),
if (sektionen.contains(AppSettings.sektionZuletzt))
StreamBuilder<List<Song>>(
stream: db.watchRecent(limit: 50),
stream: db.watchRecentlyPlayed(limit: 50),
builder: (context, snapshot) {
final recent = snapshot.data ?? const <Song>[];
return _QuickCard(
icon: Icons.schedule,
label: 'Zuletzt',
subtitle: 'Neu hinzugefügt',
subtitle: 'Zuletzt gehört',
coverPath: recent.isEmpty ? null : recent.first.coverPath,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const RecentlyAddedScreen()),
builder: (_) => const RecentlyPlayedScreen()),
),
);
},
@@ -363,22 +363,22 @@ class _Empty extends StatelessWidget {
}
}
/// Vollbild-Liste der zuletzt hinzugefügten Songs (Schnellzugriff "Zuletzt").
class RecentlyAddedScreen extends StatelessWidget {
const RecentlyAddedScreen({super.key});
/// Vollbild-Liste der zuletzt gehörten Songs (Schnellzugriff "Zuletzt").
class RecentlyPlayedScreen extends StatelessWidget {
const RecentlyPlayedScreen({super.key});
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return Scaffold(
appBar: AppBar(title: const Text('Zuletzt hinzugefügt')),
appBar: AppBar(title: const Text('Zuletzt gehört')),
body: StreamBuilder<List<Song>>(
stream: db.watchRecent(limit: 100),
stream: db.watchRecentlyPlayed(limit: 100),
builder: (context, snapshot) {
final songs = snapshot.data ?? const <Song>[];
if (songs.isEmpty) {
return const Center(
child: Text('Noch nichts hinzugefügt',
child: Text('Noch nichts abgespielt',
style: TextStyle(color: MeloTheme.text2)),
);
}
+13 -11
View File
@@ -49,10 +49,7 @@ class NowPlayingScreen extends StatelessWidget {
// mit der fragte die App den Server nach einem Titel namens
// "https://…" und bekam nie einen Text.
final songId = songIdOf(item) ?? navidromeIdOf(item) ?? '';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
return IconButton(
tooltip: songId.isEmpty
? 'Kein Songtext für diesen Titel verfügbar'
: 'Songtext',
@@ -60,12 +57,6 @@ class NowPlayingScreen extends StatelessWidget {
onPressed: songId.isEmpty
? null
: () => _showLyrics(context, songId),
),
// Ein Titel der Bibliothek wird lokal favorisiert, einer
// vom Server am Server — beide bekommen dasselbe Herz,
// statt dass eines davon ausgegraut bleibt.
_Herz(item: item),
],
);
},
),
@@ -240,13 +231,24 @@ class _Angaben extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text(
item.title,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
// Ein Titel der Bibliothek wird lokal favorisiert, einer
// vom Server am Server — beide bekommen dasselbe Herz,
// statt dass eines davon ausgegraut bleibt.
_Herz(item: item),
],
),
const SizedBox(height: MeloSpace.sm),
Text(
item.artist ?? 'Unbekannt',
+73
View File
@@ -0,0 +1,73 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/database.dart';
Future<void> _insertSong(MeloDb db, String id, {bool deleted = false}) {
return db.into(db.songs).insert(SongsCompanion.insert(
id: id,
path: '/$id.mp3',
title: id,
dateAddedMs: 0,
updatedAtMs: 0,
deleted: Value(deleted),
));
}
Future<void> _play(MeloDb db, String songId, int playedAtMs) {
return db.into(db.playbackHistory).insert(PlaybackHistoryCompanion.insert(
songId: songId,
positionMs: 0,
playedAtMs: playedAtMs,
));
}
void main() {
test('ein mehrfach gespielter Song erscheint nur einmal, an der Position '
'seines jüngsten Abspielens', () async {
final db = MeloDb(NativeDatabase.memory());
await _insertSong(db, 'a');
await _insertSong(db, 'b');
await _play(db, 'a', 1000);
await _play(db, 'b', 2000);
await _play(db, 'a', 3000); // a erneut gespielt, jetzt jüngster Play
final result = await db.watchRecentlyPlayed().first;
expect(result.map((s) => s.id).toList(), ['a', 'b']);
await db.close();
});
test('mehrere Songs erscheinen absteigend nach ihrem letzten Abspielen',
() async {
final db = MeloDb(NativeDatabase.memory());
await _insertSong(db, 'a');
await _insertSong(db, 'b');
await _insertSong(db, 'c');
await _play(db, 'a', 1000);
await _play(db, 'b', 3000);
await _play(db, 'c', 2000);
final result = await db.watchRecentlyPlayed().first;
expect(result.map((s) => s.id).toList(), ['b', 'c', 'a']);
await db.close();
});
test('ein gelöschter Song mit Wiedergabe-Historie erscheint nicht',
() async {
final db = MeloDb(NativeDatabase.memory());
await _insertSong(db, 'a', deleted: true);
await _insertSong(db, 'b');
await _play(db, 'a', 2000);
await _play(db, 'b', 1000);
final result = await db.watchRecentlyPlayed().first;
expect(result.map((s) => s.id).toList(), ['b']);
await db.close();
});
}
+155
View File
@@ -0,0 +1,155 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/library/song_list.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/now_playing_screen.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:melo/shared/favorite_button.dart';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff —
/// dieselbe Klasse wie in song_list_navigation_test.dart, dort erprobt.
class _FakeAudioHandler extends MeloAudioHandler {
_FakeAudioHandler(MeloDb db) : super(db: db);
@override
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
return 0;
}
}
void main() {
late MeloDb db;
late CategoryService kategorien;
late PlaylistService playlists;
late AppSettings einstellungen;
late _FakeAudioHandler handler;
void stelleKanaeleRuhig() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp/melo-test',
);
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
}
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
stelleKanaeleRuhig();
db = MeloDb(NativeDatabase.memory());
kategorien = CategoryService(db);
playlists = PlaylistService(db);
einstellungen = AppSettings();
await einstellungen.init();
handler = _FakeAudioHandler(db);
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: const Value('Neoni'),
dateAddedMs: 0,
updatedAtMs: 0,
));
});
tearDown(() {
handler.dispose();
kategorien.dispose();
playlists.dispose();
});
Song derSong() => Song(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: 'Neoni',
dateAddedMs: 0,
updatedAtMs: 0,
deleted: false,
playCount: 0,
categoriesEdited: false,
metadataEdited: false,
);
// Öffnet den Vollbild-Player über denselben Weg wie in
// song_list_navigation_test.dart (Tap auf eine SongZeile) — dieser Weg ist
// dort nachweislich unproblematisch, im Unterschied zu einem direkten
// Aufbau von NowPlayingScreen mit vorab befülltem MediaItem-Stream.
Future<void> oeffnePlayer(WidgetTester tester) async {
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
],
child: MaterialApp(
home: Scaffold(
body: SongZeile(
song: derSong(),
warteschlange: [derSong()],
index: 0,
),
),
),
),
);
await tester.tap(find.text('Roses'));
await tester.pumpAndSettle();
}
Future<void> raeumeAuf(WidgetTester tester) async {
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo (Favoriten-Herz)
// beim Abbau des Baums als offener Fake-Timer stehen — dieselbe
// Aufräum-Zeile wie in song_list_navigation_test.dart.
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
}
testWidgets(
'Herz sitzt neben dem Songnamen, nicht mehr in der AppBar',
(tester) async {
await oeffnePlayer(tester);
expect(find.byType(NowPlayingScreen), findsOneWidget);
// Nicht mehr in der AppBar:
final herzInAppBar = find.descendant(
of: find.byType(AppBar),
matching: find.byType(FavoriteButton),
);
expect(herzInAppBar, findsNothing);
// Aber sichtbar auf dem Bildschirm, in derselben Row wie der Titel.
final titelRow = find
.ancestor(of: find.text('Roses'), matching: find.byType(Row))
.first;
final herzBeimTitel = find.descendant(
of: titelRow,
matching: find.byType(FavoriteButton),
);
expect(herzBeimTitel, findsOneWidget);
await raeumeAuf(tester);
});
}