Files
Melo/lib/player/player_expansion_controller.dart
T
Hermes (Server)andClaude Sonnet 5 6687f2f67c Fix: PlayerExpansionController.dragEnd rät Öffnen/Schließen nicht mehr aus Vorzeichen
Bug (Code-Review, P0-nah): dragEnd() wählte die Entscheidungslogik allein
über das Vorzeichen von strecke. Bei bereits vollständig offenem Player
klappte ein schwaches, versehentliches Fingerzittern nach oben (kleine
negative Strecke, weit unter der Wisch-Schwelle) den Player ungewollt zu,
weil das Vorzeichen in den ÖFFNEN-Zweig führte statt in den (korrekten)
Schließen-Zweig, der bei so einer schwachen Geste keine Wirkung hätte.

dragEnd() bekommt jetzt den Pflichtparameter `ausVollbild`, der den
Aufrufer-Kontext explizit benennt statt ihn zu erraten. mini_player.dart
(Öffnen-Geste) ruft mit ausVollbild: false, now_playing_screen.dart
(Schließen-Geste) mit ausVollbild: true.

Test zuerst (RED mit der alten 3-Parameter-Signatur reproduziert, dann
GREEN nach dem Fix): test/player/player_expansion_controller_test.dart.
Bestehende Tests (player_expansion_controller_test.dart,
home_shell_expansion_test.dart) an die neue Signatur angepasst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SM6e73V9ZethPiqHSocxUA
2026-08-29 15:36:46 +02:00

90 lines
4.1 KiB
Dart

import 'package:flutter/material.dart';
import '../shared/theme.dart';
import 'wischen.dart';
/// Wie viel Zugstrecke (Pixel) einem vollen Öffnen-Fortschritt entspricht —
/// rein darstellerisch, unabhängig von der Öffnen/Schließen-Entscheidung in
/// [wischen.dart].
const oeffnenReferenzHoehe = 300.0;
/// Optischer Fortschritt zwischen Mini-Player (0) und Vollbild-Player (1).
///
/// Trennt bewusst zwei Dinge: WANN geöffnet/geschlossen wird (unverändert
/// [oeffnetVollbildMitStrecke] aus wischen.dart) und WIE WEIT der Übergang
/// gerade optisch ist (dieser Wert, live an die Zugstrecke gekoppelt).
class PlayerExpansionController extends ChangeNotifier {
PlayerExpansionController({required TickerProvider vsync})
: _animation = AnimationController(vsync: vsync) {
_animation.addListener(notifyListeners);
}
final AnimationController _animation;
double get progress => _animation.value;
/// [dy] ist details.delta.dy einer laufenden Wischgeste, unverändertes
/// Vorzeichen. Nach oben (negativ) erhöht progress, nach unten (positiv)
/// verringert ihn — dieselbe Formel bedient Öffnen- und Schließen-Geste
/// symmetrisch, je nachdem, wo progress gerade steht. Bewusst rekursiv auf
/// dem bereits geclampten Wert (nicht auf der aufsummierten Rohstrecke):
/// so reagiert eine Richtungsumkehr mitten in der Geste sofort, auch wenn
/// die Geste vorher schon über [oeffnenReferenzHoehe] hinausgezogen wurde.
void dragBy(double dy) {
_animation.value = (_animation.value - dy / oeffnenReferenzHoehe).clamp(0.0, 1.0);
}
/// [strecke]/[geschwindigkeit] wie in wischen.dart (negativ = nach oben).
/// [ausVollbild] benennt den Aufrufer-Kontext explizit, statt ihn aus dem
/// Vorzeichen von [strecke] zu raten (früherer Bug: bei bereits offenem
/// Player klappte ein schwaches, versehentliches Aufwärtszittern — negative
/// Strecke, weit unter der Schwelle — den Player zu, weil das Vorzeichen
/// allein in den ÖFFNEN-Zweig führte, der bei so einer schwachen Geste
/// `false` liefert). `false` (Mini-Player, Öffnen-Geste) entscheidet direkt
/// über [oeffnetVollbildMitStrecke]; `true` (Vollbild-Player, Schließen-
/// Geste) über dieselbe Funktion mit gespiegeltem Vorzeichen — "stark genug
/// nach unten" ist spiegelbildlich zu "stark genug nach oben". Fliegt
/// danach mit der echten Loslass-Geschwindigkeit als Startimpuls zum
/// entschiedenen Ziel.
void dragEnd(BuildContext context, double strecke, double geschwindigkeit,
{required bool ausVollbild}) {
final zielIstOffen = ausVollbild
? !oeffnetVollbildMitStrecke(-strecke, -geschwindigkeit)
: oeffnetVollbildMitStrecke(strecke, geschwindigkeit);
if (MediaQuery.disableAnimationsOf(context)) {
_animation.value = zielIstOffen ? 1.0 : 0.0;
return;
}
// -geschwindigkeit/oeffnenReferenzHoehe, weil dragBy() ein negatives dy
// (nach oben) in steigenden progress übersetzt — dieselbe Umrechnung
// rückwärts. Zeigt die tatsächliche Geschwindigkeit nicht zum
// entschiedenen Ziel (z.B. ein Aufwärtswisch, der am Ende minimal
// zurückfedert), erzwingt ein moderater Mindestimpuls die Zielrichtung,
// statt fling() mit widersprüchlichem Vorzeichen zu verwirren.
final rohimpuls = -geschwindigkeit / oeffnenReferenzHoehe;
final zeigtZumZiel = zielIstOffen ? rohimpuls > 0 : rohimpuls < 0;
final impuls = zeigtZumZiel ? rohimpuls : (zielIstOffen ? 1.0 : -1.0);
_animation.fling(velocity: impuls);
}
void open(BuildContext context) => _animiereZu(context, 1.0);
void close(BuildContext context) => _animiereZu(context, 0.0);
/// Für Tap-Auslöser (kein Ziehen, keine Loslass-Geschwindigkeit) — anders
/// als [dragEnd] mit fester Kurve/Dauer aus [MeloMotion], nicht als Fling.
void _animiereZu(BuildContext context, double ziel) {
if (MediaQuery.disableAnimationsOf(context)) {
_animation.value = ziel;
return;
}
_animation.animateTo(ziel, duration: MeloMotion.normal, curve: MeloMotion.curve);
}
@override
void dispose() {
_animation.dispose();
super.dispose();
}
}