Tinker-Feedback: 5 Bugfixes (Shuffle/Repeat, Full-Cover, Auto-Play, Scan-Button, Back-Geste) #6
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user