HomeShell: PlayerExpansionController bereitstellen, Zurück-Taste schließt offenen Player

This commit is contained in:
Hermes (Server)
2026-08-29 14:24:09 +02:00
parent 2baac9ed12
commit 7a9493955a
2 changed files with 138 additions and 40 deletions
+32 -7
View File
@@ -19,6 +19,7 @@ import 'library/search_screen.dart';
import 'player/audio_effects.dart';
import 'player/audio_handler.dart';
import 'player/mini_player.dart';
import 'player/player_expansion_controller.dart';
import 'services/baka_auth.dart';
import 'services/logger_service.dart';
import 'services/download_service.dart';
@@ -150,9 +151,11 @@ class HomeShell extends StatefulWidget {
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
class _HomeShellState extends State<HomeShell>
with WidgetsBindingObserver, TickerProviderStateMixin {
int _index = 0;
late final AutoScan _autoScan;
late final PlayerExpansionController _expansion;
/// Adresse aus einem „Teilen"-Aufruf, die der Downloader übernehmen soll.
String? _geteilteAdresse;
@@ -162,6 +165,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
_expansion = PlayerExpansionController(vsync: this);
final db = context.read<MeloDb>();
final lib = context.read<LibraryService>();
_autoScan = AutoScan(
@@ -190,6 +194,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
@override
void dispose() {
_expansion.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@@ -222,14 +227,33 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
const FavoritesScreen(),
const YoutubeSearchScreen(),
];
return PopScope(
// Tab-Wechsel laufen nur über setState, nie über Navigator.push — die
// Zurück-Geste auf einem Nicht-Start-Tab hätte sonst nichts zum
// Poppen und würde die App minimieren statt zu "Meine Musik" zu gehen.
canPop: _index == 0,
return ChangeNotifierProvider<PlayerExpansionController>.value(
value: _expansion,
// Consumer NUR um PopScope — sein `child:` (der Scaffold-Rumpf) wird
// laut Flutter-Semantik bei jedem _expansion-Tick NICHT neu gebaut,
// das ist hier auch nicht nötig: PopScope selbst braucht nur einen
// aktuellen Wert bei jedem HomeShell-Rebuild (z.B. Zurück-Taste
// gedrückt), keine Frame-genaue Aktualisierung während des Ziehens.
// Der Blur-Layer/Mini-Player-Fade in Task 4 braucht dagegen echte
// Live-Reaktivität und bekommt dafür einen eigenen, davon unabhängigen
// AnimatedBuilder direkt an seiner Stelle im Stack — siehe
// Reaktivitäts-Regel in Global Constraints.
child: Consumer<PlayerExpansionController>(
builder: (context, expansion, child) => PopScope(
// Offener/halb gezogener Player hat Vorrang vor dem Tab-Reset:
// Zurück soll ihn zuerst schließen, nicht gleichzeitig auch noch
// den Tab wechseln.
canPop: expansion.progress == 0 && _index == 0,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) setState(() => _index = 0);
if (didPop) return;
if (expansion.progress > 0) {
expansion.close(context);
} else {
setState(() => _index = 0);
}
},
child: child!,
),
child: Scaffold(
body: Column(
children: [
@@ -266,6 +290,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
),
),
),
),
);
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:melo/player/player_expansion_controller.dart';
// Minimaler Host, der dieselbe Provider/PopScope-Verdrahtung wie HomeShell
// nachstellt, ohne die volle App (Audio-Handler etc.) hochfahren zu müssen.
// Der eigentliche HomeShell-Umbau wird direkt gegen main.dart getestet,
// sobald Task 4/5 die Stack-Struktur eingeführt haben; dieser erste Test
// sichert nur die PopScope-Erweiterung ab, isoliert an einem Test-Double.
class _TestShell extends StatefulWidget {
const _TestShell();
@override
State<_TestShell> createState() => _TestShellState();
}
class _TestShellState extends State<_TestShell> with TickerProviderStateMixin {
late final PlayerExpansionController _controller;
@override
void initState() {
super.initState();
_controller = PlayerExpansionController(vsync: this);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Eigener AnimatedBuilder um PopScope, damit canPop bei jedem
// _controller-Tick neu ausgewertet wird (Reaktivitäts-Regel aus dem
// Plan) — ohne ihn bliebe canPop auf dem Wert des allerersten Builds
// eingefroren, weil sonst nichts hier ein Rebuild auslöst.
return ChangeNotifierProvider<PlayerExpansionController>.value(
value: _controller,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => PopScope(
canPop: _controller.progress == 0,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _controller.close(context);
},
child: child!,
),
child: const Scaffold(body: SizedBox()),
),
);
}
}
void main() {
testWidgets('Zurück-Taste schließt den Player statt zu poppen, wenn offen',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: _TestShell()));
final controller =
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
controller.open(tester.element(find.byType(_TestShell)));
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
// pageBack() sucht einen sichtbaren Zurück-Button, den es hier ohne
// zweite Route nicht gibt (siehe Plan-Hinweis für den Umsetzer) —
// Navigator.maybePop() löst denselben PopScope-Callback aus.
await Navigator.maybePop(tester.element(find.byType(_TestShell)));
await tester.pumpAndSettle();
expect(controller.progress, 0.0);
});
}