Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,13 @@ public class MainActivity extends AudioServiceFragmentActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Phones turn only for the player's fullscreen (4.28), whose video
// already lies turned when the screen turns: Android's rotation
// animation would turn that picture once more. A crossfade keeps the
// old picture where it is until the new one is drawn (a cut showed
// the old frame stretched to the new window for a moment), and both
// look the same. Tablets keep the animation.
// Phones turn only for the player's fullscreen (4.28), under the
// app's own black fade: no rotation animation of the system on top,
// just a cut (fastest; the stretched old frame it shows for a moment
// is black). Tablets keep the animation.
if (getResources().getConfiguration().smallestScreenWidthDp < 600) {
WindowManager.LayoutParams params = getWindow().getAttributes();
params.rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_CROSSFADE;
params.rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_JUMPCUT;
getWindow().setAttributes(params);
}
}
Expand Down
4 changes: 2 additions & 2 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,6 @@ Eine wartbare Basis, damit Features unabhängig voneinander wachsen können.
| 4.25 | Als Nutzer möchte ich wie bei YouTube zum vorherigen Video zurück, wenn ich im Player ein anderes Video geöffnet habe. | Ein im aufgeklappten Player gewähltes Video (z. B. aus „Up next“) merkt sich das bisherige samt Position (bis 20, jedes nur einmal). „Zurück“ (Android) bzw. ← links vom Play-Knopf der eingeblendeten Steuerung springt dorthin zurück und spielt an der gemerkten Stelle weiter, → rechts davon wieder vor (wie im Browser; ein neu gewähltes Video verwirft den Weg nach vorn). Die Pfeile erscheinen nur, wenn es etwas zum Springen gibt (die leere Seite ausgegraut); mit Warteschlange stehen dort deren Vorher/Weiter. Ohne Verlauf klappt „Zurück“ den Player ein. Videos aus Listen bei eingeklapptem Player, Wechsel innerhalb einer Warteschlange und Schließen des Players zählen nicht bzw. leeren den Verlauf. | Next | ✅ |
| 4.26 | Als Nutzer möchte ich auf dem Tablet neben dem Video weitere Videos sehen. | Ab 840 dp Breite nimmt das aufgeklappte Video die linke Hälfte ein (ein Viertel der Fläche statt der ganzen Breite), darunter Titel, Kanal, Aktionen und Marker; rechts daneben „Up next“ bzw. die Warteschlange. Tablets gehen beim Drehen ins Querformat nicht automatisch in den Vollbildmodus. | Next | 🟡 Ungetestet auf Gerät |
| 4.27 | Als Nutzer möchte ich Marker als „Loops“ getrennt von den Shorts sehen, die die ganze Zeit wiederholt laufen. | Oben im Shorts-Tab „Shorts \| Loops“ (gespeichert). Shorts zeigen nur Videos, Loops nur Szenenmarker (zufällig, mit den bevorzugten Tags und „Nur Hochkant“; die Länge gilt nicht). Ein Loop spielt nur den Ausschnitt als eigene Datei (mpv-EDL) und wiederholt ihn nahtlos; die Leiste läuft von 0:00 bis zur Länge des Ausschnitts. „Als Loops abspielen“ im Marker-Raster. Die frühere Einstellung „Inhalt“ (Videos & Marker gemischt) entfällt. Shorts und ihre Kacheln werden nur noch formatfüllend gezoomt, wenn dabei höchstens 30 % abgeschnitten werden (sonst ganz, z. B. Hochkant-Videos auf dem Tablet im Querformat). | Next | 🟡 Ungetestet auf Gerät |
| 4.28 | Als Nutzer möchte ich, dass sich auf dem Handy nur das Vollbild des Players ins Querformat dreht. | Handys (kürzeste Seite unter 600 dp) bleiben überall hochkant, auch der aufgeklappte Player. Kippen des Handys meldet das System samt Richtung (Android `OrientationEventListener`, iOS UIDevice; bei aktiver Drehsperre nicht): mit einem breiten Video im aufgeklappten Player öffnet es das Vollbild, aufrecht halten beendet es. Eine Haltung zählt erst nach 0,35 s (schräg gehalten flippte das Video sonst kurz), nicht bei gesperrter oder verlassener App, und es gibt immer nur ein Vollbild (beim Entsperren öffneten sich sonst mehrere). Nach Verlassen per Knopf im Querformat öffnet erst wieder Kippen nach einmal aufrecht halten (vorher sprang es sofort zurück ins Vollbild). Tablets drehen sich überall. | Next | 🟡 Ungetestet auf Gerät |
| 4.29 | Als Nutzer möchte ich einen ruhigen Übergang ins Vollbild und Shorts, deren Knöpfe das Video wenig verdecken. | Vollbild wie bei YouTube: das Video löst sich von seinem Platz, wächst und dreht sich mit dem Handy, während die App dahinter schwarz wird; erst dann dreht sich der Bildschirm unsichtbar darunter (ohne die Dreh-Animation des Systems, die das schon gedrehte Bild noch einmal drehte; Android: Überblendung auf Handys, ein harter Schnitt zeigte kurz ein gestrecktes Bild). Verlassen (Knopf, Zurück, aufrecht halten) läuft rückwärts. Per Knopf im Hochformat wächst das Video ungedreht, der Bildschirm dreht sich sichtbar. Ohne Animationen (Bedienungshilfen) springt es. Shorts: während ein Short läuft, bleiben rechts nur Performer, Bewertung und O-Zähler, oben Vollbild und Ton; Später ansehen, Ganzes Video, ⋮, Mischen und Shorts-Einstellungen gleiten beim Pausieren herein. Text unten links (Studio, Performer, Titel, Tags, Wiedergaben) und der Umschalter „Shorts \| Loops“ blenden 2 s nach jedem Wischen und nach der Rückkehr in den Tab aus und erscheinen beim Pausieren wieder. Ohne Shorts und mit Screenreader ist alles sichtbar. | Next | 🟡 Ungetestet auf Gerät |
| 4.28 | Als Nutzer möchte ich, dass sich auf dem Handy nur das Vollbild des Players ins Querformat dreht. | Handys (kürzeste Seite unter 600 dp) bleiben überall hochkant, auch der aufgeklappte Player. Kippen des Handys meldet das System samt Richtung (Android `OrientationEventListener`, iOS UIDevice; bei aktiver Drehsperre nicht): mit einem breiten Video im aufgeklappten Player öffnet es das Vollbild, aufrecht halten beendet es. Eine Haltung zählt erst nach 0,25 s (schräg gehalten flippte das Video sonst kurz), nicht bei gesperrter oder verlassener App, und es gibt immer nur ein Vollbild (beim Entsperren öffneten sich sonst mehrere). Nach Verlassen per Knopf im Querformat öffnet erst wieder Kippen nach einmal aufrecht halten (vorher sprang es sofort zurück ins Vollbild). Tablets drehen sich überall. | Next | 🟡 Ungetestet auf Gerät |
| 4.29 | Als Nutzer möchte ich einen ruhigen Übergang ins Vollbild und Shorts, deren Knöpfe das Video wenig verdecken. | Vollbild wie bei YouTube: das Video löst sich von seinem Platz, wächst und dreht sich mit dem Handy, während die App dahinter schwarz wird; erst dann dreht sich der Bildschirm unsichtbar darunter (ohne die Dreh-Animation des Systems, die das schon gedrehte Bild noch einmal drehte; Android: harter Schnitt auf Handys, die Dreh-Animation des Systems ist aus; das kurz gestreckte alte Bild liegt unter der Schwarzblende). Verlassen (Knopf, Zurück, aufrecht halten) läuft rückwärts. Auf iOS war die Drehung trotzdem zu sehen; jetzt blendet die App auf beiden Plattformen stattdessen nach Schwarz, dreht sich darunter und blendet wieder ein (0,12 s abdunkeln, warten bis das Fenster gedreht ist und zwei Bilder gemalt sind, 0,2 s aufhellen) (der bewegte Übergang bleibt im Code abschaltbar erhalten). Per Knopf im Hochformat wächst das Video ungedreht, der Bildschirm dreht sich sichtbar. Ohne Animationen (Bedienungshilfen) springt es. Shorts: während ein Short läuft, bleiben rechts nur Performer, Bewertung und O-Zähler, oben Vollbild und Ton; Später ansehen, Ganzes Video, ⋮, Mischen und Shorts-Einstellungen gleiten beim Pausieren herein. Text unten links (Studio, Performer, Titel, Tags, Wiedergaben) und der Umschalter „Shorts \| Loops“ blenden 2 s nach jedem Wischen und nach der Rückkehr in den Tab aus und erscheinen beim Pausieren wieder. Ohne Shorts und mit Screenreader ist alles sichtbar. | Next | 🟡 Ungetestet auf Gerät |
| 4.30 | Als Nutzer möchte ich Marker bearbeiten und löschen. | Langes Drücken auf einen Marker im Player (Kapitel) oder im Marker-Raster (dort auch ⋮) öffnet ein Menü: im Raster Abspielen und Als Loop abspielen, dann „Marker bearbeiten“ (dasselbe Sheet wie beim Setzen, vorausgefüllt mit Titel, Haupt-Tag und Ende; gesendet wird nur, was sich geändert hat, das Ende nur bei Servern mit Endzeiten) und „Marker löschen“ (mit Rückfrage). Kapitel, Marker-Raster und -Zähler laden danach neu. Auch in der Demo. | Next | 🟡 Ungetestet auf Gerät |
59 changes: 58 additions & 1 deletion lib/features/player/fullscreen_morph.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ class FullscreenMorph extends StatefulWidget {

static const duration = Duration(milliseconds: 420);

/// The app only fades to black around the turn ([FullscreenMorphState.cover])
/// instead of moving the video: on iOS the turn still showed, and the
/// plain fade read best on both platforms. The moving video stays
/// available (tests) should it come back.
static bool get curtainOnly => curtainOnlyOverride ?? true;

@visibleForTesting
static bool? curtainOnlyOverride;

static const coverDuration = Duration(milliseconds: 120);
static const uncoverDuration = Duration(milliseconds: 200);

/// The black of [FullscreenMorphState.cover], while it shows.
@visibleForTesting
static const curtainKey = ValueKey('fullscreenCurtain');

/// The moving video, while the morph shows.
@visibleForTesting
static const videoKey = ValueKey('fullscreenMorph');
Expand All @@ -42,7 +58,7 @@ class FullscreenMorph extends StatefulWidget {
State<FullscreenMorph> createState() => FullscreenMorphState();
}

class FullscreenMorphState extends State<FullscreenMorph> with SingleTickerProviderStateMixin {
class FullscreenMorphState extends State<FullscreenMorph> with TickerProviderStateMixin {
/// 0: the video in its place in the player; 1: the fullscreen pose.
late final _progress = AnimationController(vsync: this, duration: FullscreenMorph.duration);
FullscreenMorphSpec? _spec;
Expand Down Expand Up @@ -77,9 +93,36 @@ class FullscreenMorphState extends State<FullscreenMorph> with SingleTickerProvi
if (_spec != null && mounted) setState(() => _spec = null);
}

/// The black over the whole app of [FullscreenMorph.curtainOnly].
late final _black = AnimationController(vsync: this);

bool get _animate => !MediaQuery.disableAnimationsOf(context);

/// Fades the app to black ([instantly]: at once, e.g. after the system's
/// back, when the fullscreen is already gone).
Future<void> cover({bool instantly = false}) {
if (instantly || !_animate) {
_black.value = 1;
return Future.value();
}
return _fade(_black.animateTo(1, duration: FullscreenMorph.coverDuration, curve: Curves.easeOut));
}

Future<void> uncover() {
if (!_animate) {
_black.value = 0;
return Future.value();
}
return _fade(_black.animateTo(0, duration: FullscreenMorph.uncoverDuration, curve: Curves.easeIn));
}

/// A fade cut short by the next one just ends.
Future<void> _fade(TickerFuture fade) => fade.orCancel.catchError((Object _) {});

@override
void dispose() {
_progress.dispose();
_black.dispose();
super.dispose();
}

Expand All @@ -99,6 +142,20 @@ class FullscreenMorphState extends State<FullscreenMorph> with SingleTickerProvi
),
),
),
Positioned.fill(
child: AnimatedBuilder(
animation: _black,
builder: (_, _) => _black.value == 0
? const SizedBox.shrink()
: AbsorbPointer(
child: Opacity(
key: FullscreenMorph.curtainKey,
opacity: _black.value,
child: const ColoredBox(color: Colors.black),
),
),
),
),
],
);
}
Expand Down
30 changes: 23 additions & 7 deletions lib/features/player/fullscreen_route.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ double playingAspect(VideoController controller) {
}

/// Like media_kit's [enterFullscreen], whose route just appears and lets
/// the system turn the screen in view: the [FullscreenMorph] first carries
/// the system turn the screen in view. With [FullscreenMorph.curtainOnly]
/// (the default) the app fades to black, the route opens and the screen turns
/// underneath, then the black fades out. Otherwise the [FullscreenMorph] first carries
/// the video from its place ([inline]) into the fullscreen pose, turned the
/// way the phone is [held], while the app goes black. Then the fullscreen
/// route opens underneath, the video's `onEnterFullscreen` turns the screen
Expand All @@ -48,9 +50,15 @@ Future<void> enterPlayerFullscreen(BuildContext context, {HeldOrientation? held,
final parameters = inherited.videoViewParametersNotifier.value;
final controller = state.widget.controller;
final morph = fullscreenMorphKey.currentState;
morph?.show((controller: controller, aspect: playingAspect(controller), held: held, inline: inline), at: 0);
await morph?.animateTo(1);
if (!context.mounted) return morph?.hide();
final curtain = FullscreenMorph.curtainOnly;
if (curtain) {
await morph?.cover();
if (!context.mounted) return morph?.uncover();
} else {
morph?.show((controller: controller, aspect: playingAspect(controller), held: held, inline: inline), at: 0);
await morph?.animateTo(1);
if (!context.mounted) return morph?.hide();
}
final route = PageRouteBuilder<void>(
// The morph covers the switch.
transitionDuration: Duration.zero,
Expand Down Expand Up @@ -101,14 +109,22 @@ Future<void> enterPlayerFullscreen(BuildContext context, {HeldOrientation? held,
try {
await state.widget.onEnterFullscreen();
} finally {
morph?.hide();
if (curtain) {
await morph?.uncover();
} else {
morph?.hide();
}
}
});

/// Like media_kit's [exitFullscreen]. The route's pop calls the video's
/// `onExitFullscreen`, which shows the morph in the fullscreen pose at once
/// (also after the system's back), turns the screen back and carries the
/// video back into its place.
/// video back into its place. With [FullscreenMorph.curtainOnly] the app
/// first fades to black, and `onExitFullscreen` fades it in again.
Future<void> exitPlayerFullscreen(BuildContext context) => lock.synchronized(() async {
if (context.mounted && isFullscreen(context)) await Navigator.of(context).maybePop();
if (!context.mounted || !isFullscreen(context)) return;
final morph = fullscreenMorphKey.currentState;
if (FullscreenMorph.curtainOnly) await morph?.cover();
if (!context.mounted || !await Navigator.of(context).maybePop()) await morph?.uncover();
});
12 changes: 11 additions & 1 deletion lib/features/player/player_controls.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class _PlayerVideoState extends ConsumerState<PlayerVideo> {
/// A turn counts once the phone was held that way for [_settleTime]:
/// held at an angle, the system's report flips back and forth.
Timer? _settle;
static const _settleTime = Duration(milliseconds: 350);
static const _settleTime = Duration(milliseconds: 250);

/// Left fullscreen (its button, back) while held sideways: sideways
/// doesn't enter it again until the phone was upright.
Expand Down Expand Up @@ -178,6 +178,16 @@ class _PlayerVideoState extends ConsumerState<PlayerVideo> {
fullscreenByRotation.value = false;
_waitForUpright = _held?.isLandscape ?? false;
final morph = fullscreenMorphKey.currentState;
if (FullscreenMorph.curtainOnly) {
// Black already after the button; at once after the system's back.
await morph?.cover(instantly: true);
try {
await _exitFullscreen();
} finally {
await morph?.uncover();
}
return;
}
final controller = ref.read(videoControllerProvider);
morph?.show(
(controller: controller, aspect: playingAspect(controller), held: _fullscreenHeld, inline: inlineVideoRect),
Expand Down
8 changes: 5 additions & 3 deletions lib/widgets/app_orientation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,11 @@ abstract final class AppOrientation {
} finally {
WidgetsBinding.instance.removeObserver(observer);
}
// A few frames in the new shape before what covers the turn goes: the
// video surface takes a moment to follow.
await Future<void>.delayed(const Duration(milliseconds: 200));
// Two frames in the new shape and a moment for the video surface to
// follow before what covers the turn goes (a fixed 200 ms felt slow).
await WidgetsBinding.instance.endOfFrame;
await WidgetsBinding.instance.endOfFrame;
await Future<void>.delayed(const Duration(milliseconds: 50));
}
}

Expand Down
33 changes: 32 additions & 1 deletion test/features/pip_return_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ void main() {
});

group('turning the phone (4.19)', () {
// The moving video, except where a test asks for the plain fade.
setUp(() => FullscreenMorph.curtainOnlyOverride = false);
tearDown(() => FullscreenMorph.curtainOnlyOverride = null);

Future<void> settle(WidgetTester tester) async {
for (var i = 0; i < 12; i++) {
await tester.pump(const Duration(milliseconds: 100));
Expand All @@ -183,7 +187,7 @@ void main() {
await pump(tester, turns: turns.stream);

turns.add(HeldOrientation.turnedLeft);
// A turn counts after 350 ms; the morph starts with the next frame.
// A turn counts after 250 ms; the morph starts with the next frame.
await tester.pump(const Duration(milliseconds: 400));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
Expand Down Expand Up @@ -227,6 +231,33 @@ void main() {
expect(playerVideoKey.currentState!.isFullscreen(), isTrue);
});

testWidgets('the app fades to black around the turn instead of moving the video', (tester) async {
FullscreenMorph.curtainOnlyOverride = null;
final turns = StreamController<HeldOrientation>.broadcast();
addTearDown(turns.close);
await pump(tester, turns: turns.stream);
double black() {
final curtain = find.byKey(FullscreenMorph.curtainKey);
return curtain.evaluate().isEmpty ? 0 : tester.widget<Opacity>(curtain).opacity;
}

turns.add(HeldOrientation.turnedLeft);
await tester.pump(const Duration(milliseconds: 400));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(black(), greaterThan(0));
expect(morphing(), isFalse);
await settle(tester);
expect(playerVideoKey.currentState!.isFullscreen(), isTrue);
expect(black(), 0);

turns.add(HeldOrientation.portrait);
await settle(tester);
expect(playerVideoKey.currentState!.isFullscreen(), isFalse);
expect(black(), 0);
expect(morphing(), isFalse);
});

testWidgets('held at an angle, the flipping reports change nothing', (tester) async {
final turns = StreamController<HeldOrientation>.broadcast();
addTearDown(turns.close);
Expand Down
Loading