From edad58559e3c6f4a8eadfdd05afcf98a5bf88475 Mon Sep 17 00:00:00 2001 From: Philippe Date: Mon, 26 Oct 2026 23:10:17 +0100 Subject: [PATCH] Fullscreen: plain black fade on both platforms, quicker The app fades to black, turns underneath and fades back in (the moving video turned in view on iOS; it stays available behind curtainOnlyOverride). Shorter fades and settle time, two frames instead of a fixed 200 ms after the turn, and Android cuts without its own rotation animation. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- .../two_play/stashappmobile/MainActivity.java | 12 ++-- docs/BACKLOG.md | 4 +- lib/features/player/fullscreen_morph.dart | 59 ++++++++++++++++++- lib/features/player/fullscreen_route.dart | 30 +++++++--- lib/features/player/player_controls.dart | 12 +++- lib/widgets/app_orientation.dart | 8 ++- test/features/pip_return_test.dart | 33 ++++++++++- 8 files changed, 137 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 26026a3..18a0747 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ lib/ **Shell and navigation.** `AppShell` holds an `IndexedStack` with one nested `Navigator` per `AppTab`, with the player's `SlidingPanel` on top. The bottom `NavigationBar` collapses as the player expands, driven by `miniplayerHeightProvider`. `IndexedStack` keeps hidden tabs ticking, so `_TabTickers` turns `TickerMode` off for every tab but the shown one, and for that one too while the player is fully open (animated images and shimmers pause). `ShellLayout` (`features/shell/shell_layout.dart`, NAV-4) picks the navigation by width (`ShellNavigation.forWidth`): the bar below `kWideLayoutBreakpoint`, from there a `NavigationRail` on the left (also on phones in landscape; labels clamped to 1.3× text), extended with labels beside the icons from `kExtendedRailBreakpoint` (1240). The content sits right of the rail (which takes the side's safe area), the collapsed miniplayer at the bottom of the content above the home indicator; while expanding, the panel's left edge slides over the rail (its `MediaQuery` width is the space it has), so the expanded player covers the rail and the content never resizes. The shorts' fullscreen hides the rail like the bar. The bar shows only the tabs in `navBarConfigProvider` (`features/shell/nav_bar_config.dart`, configured in the settings, stored for all servers); `currentTabProvider` starts on the first one and falls back to it when its tab is hidden. Each `LibrarySection` (`features/library/library_page.dart`) is an `AppTab` of its own (`AppTab.section`, shown by `LibrarySectionPage`); only `LibrarySection.media` (scenes, images, galleries) are the library's text-only sub-tabs, the others are on the "You" page. A new `AppTab` needs a root page in `AppShell.rootPage` and should be hidden in `NavBarConfig.standard` (home, shorts, search, library, you). `AppTab.you` (`YouPage`, `features/you/`: shelves for history, watch later, groups and markers with "See all", then stats, performers, studios, tags, the views not in the bar, server tasks and settings; picked views open inside that tab) is `NavBarConfig.alwaysShown` and last by default; it replaced the former `all` tab, and a stored `all` becomes `you` at its place. The bar has at most `NavBarConfig.maxVisible` (5) tabs and stored bars with more are `fitted()`. The tab pages' app bars end with `ServerAvatarButton` (`features/auth/server_avatar.dart`, the active server's initial), which opens the account sheet: switch server, server tasks and settings. The settings are not a tab but a `showCupertinoSheet` over the whole app (`openSettings` in `settings_button.dart`, from the account sheet and the "You" page): closed with its X or by swiping down, with its own nested navigator, so its sub-pages push with `Navigator.of(context)`, not `openPage`. `settingsOpenProvider` tells the shorts to pause while it is open. Re-selecting the current tab (`reselectTab`) pops back to its root, or there scrolls to the top: each tab root gets its own `PrimaryScrollController` (`tabScrollControllersProvider`), so its lists must not bring a controller of their own if they should follow. To navigate, use `openPage`, `openPerformer`, `openStudio` or `openSearch` from `features/shell/navigation.dart`. They push onto the current tab's navigator and collapse the player, and they also work from inside the player, which sits outside the tab navigators. Settings open on a page of categories (`settings_page.dart`) whose sub-pages live in `settings_categories.dart`; "Remove this server" is only on the server page. `openSettings(context, ref, initialPage:)` opens the sheet with a sub-page already pushed (home's empty state opens the server tasks that way). Settings picked from a menu use `SettingsDropdownTile`, which moves the menu under the title when it would take more than half the row (large text). `test/features/text_scale_test.dart` pumps the main pages and the shell's bar/rail at 1.0×, 1.5× and 2.0× text on a 360×780 phone (plus 320×640 and a 1024×768 tablet) and fails on layout errors (A11Y-3); its `_knownOverflows` lists the ones not fixed yet. Optional tab `AppTab.subscriptions`, labelled "Favorites" (`features/subscriptions/`): favorite performers' avatars (dot = new since the last visit) and their newest scenes. `LastVisitNotifier` (`last_visit:`) is the baseline for home's "N new videos since your last visit" banner (`SceneQuery.createdAfter`); `JobFinishedListener` reports server jobs the user started once they end. Home's dice plays a random scene. Scenes are added to groups through `showAddToGroupSheet` (scene menu, player's More sheet). External links go through `openExternalUrl` (`core/utils/external_links.dart`, url_launcher; never with the API key); the description sheet has "Open in Stash" (not for the demo). -**Player.** There is a single media_kit `Player` in `playerProvider`. `nowPlayingProvider.play(scene)` opens the stream with the auth headers, starting at the resume position, and expands the panel. `PlayerPanel` is a single layout that `PlayerTransition` interpolates between the mini bar and the expanded page (video, details, "Up next") based on the panel height. The one `PlayerVideo` instance stays in place, so it is never rebuilt mid-transition. The details are built while the panel rests (collapsed or open, never mid-animation) and then kept (hidden with `Visibility(maintainState:)` while collapsed), laid out at their expanded height and reused as one widget while only the height changes, so expanding and collapsing doesn't rebuild, relayout or reload "Up next" every frame. The video area follows the video's aspect ratio (`PlayerTransition.videoAspect`; portrait videos up to `maxVideoShare` of the panel). From `PlayerTransition.splitBreakpoint` (840 dp) the expanded video takes `splitShare` (half) of the width (`columnWidth`, `expandedWidth`), with `PlayerDetails(part: info)` below it and `part: list` ("Up next" or the queue) on the right; the right `Positioned` is always in the stack so the video never moves in the tree. Phones stay portrait everywhere, the player too (4.28): `AppOrientation` (`widgets/app_orientation.dart`; `AppOrientationScope` in `MaterialApp.builder` tells it whether the window is a phone's) allows only portrait on phones, all on tablets, and the fullscreen sets its orientations through `AppOrientation.turnTo`, which waits until the window has turned (never call `SystemChrome.setPreferredOrientations` elsewhere). On a phone `PlayerVideo` listens to `heldOrientationProvider` (`held_orientation.dart`, the `stash/device_orientation` event channel: Android's `OrientationEventListener` in `MainActivity`, iOS' UIDevice orientation in `DeviceOrientationStream`; `portrait`/`turnedLeft`/`turnedRight`, physical, both respect the system's rotation lock) for as long as it lives, not only while expanded: the panel isn't expanded for a moment while the screen turns, and listening anew reported the sideways hold at once, so leaving fullscreen by its button jumped right back in. A report counts once it held for 350 ms (held at an angle, the reports flip back and forth), and not while the app is locked or away (coming back reported turns that each opened another fullscreen; `enterPlayerFullscreen` also opens only one route, `playerFullscreenOpen`). Sideways in the open player with a wide video enters fullscreen (`fullscreenByRotation`), upright leaves it, and after leaving it while sideways a new turn counts only once the phone was upright. Tablets don't enter fullscreen by turning. Fullscreen goes through `enterPlayerFullscreen`/`exitPlayerFullscreen`/`togglePlayerFullscreen` (`fullscreen_route.dart`, a copy of media_kit's route without its own transition) and `FullscreenMorph` (`fullscreen_morph.dart`, in `MaterialApp.builder`): a second `Video` of the controller moves from the player's place (`inlineVideoRect`) to the fullscreen pose, turned by `HeldOrientation.uprightAngle` in a portrait window, while the app goes black; the screen then turns underneath (both landscape sides allowed, so the system picks the held one), where the pose already matches, and the morph goes. That needs a turn without the system's rotation animation, which would turn the already turned picture once more: iOS' `RunnerViewController` disables it, Android's `MainActivity` sets `ROTATION_ANIMATION_CROSSFADE` on phones (a jump cut showed the old frame stretched to the new window). Leaving, `_onExitFullscreen` shows the pose at once (also after the system's back), turns back and moves the video home. They need a context below the `Video`, so `PlayerVideo` keeps the controls' contexts (inline and fullscreen). A portrait video's extra height covers a spacer at the top of the details and shrinks as they scroll (`PlayerTransition.extraVideoHeight`). Pinch zoom (`video_zoom.dart`) sets mpv's `video-zoom`/`video-pan-x/y`, so it applies in fullscreen too and leaves the controls alone; `NowPlayingNotifier.play` resets it for a new scene. On iOS, `RunnerViewController` (Main.storyboard) turns without UIKit's rotation animation, which stretched the video. Keep the panel's widget tree structurally stable and toggle behavior with flags. Don't pass media_kit's `NoVideoControls` to `Video`: since media_kit_video 2 it is `null`, which `Video` ignores on update, so the old controls would stay; `PlayerVideo` uses an empty builder instead. With `showControls`, `PlayerVideo` (`player_controls.dart`) wraps media_kit's `MaterialVideoControls` on both platforms, with double-tap seeking, the collapse button and the quality sheet. `sceneDetailsProvider` loads `sceneStreams`, scene markers and the file infos (`SceneFile`, shown by `FileInfoCard`) lazily per scene. Chapters appear as gaps in the controls' `PreviewSeekBar` and in `ChapterList`; there is deliberately no progress line under the expanded video. `preferredStreamProvider` stores the chosen stream label (from the HD button, or ` p` from the settings), `pickPreferredStream` matches it by format and height (the `resolution` URL parameter), and `play` only waits for the stream list when a preference is set. Playback errors: `playbackErrorProvider` counts mpv's `stream.error` only when playback hasn't moved on for 2 s (mpv also reports errors it recovers from); `_PlaybackErrorOverlay` and the mini bar offer `retry()` (`NowPlayingNotifier.reopen`) and the quality sheet. Failed casts go through `castFailureProvider` to a snack bar. Under the video, `PlayerDetails` shows title and meta (a tap opens the description sheet, `scene_description.dart`: description, tags, file info, edit scene), the channel row, one action bar (`SceneActions`: rating pill with `showRatingSheet`, O-counter pill with `showOCounterSheet` (−/+, no undo snack bar), watch later, marker, More with `showSceneMoreSheet`), performers, one line of tags (`SceneTagsRow`: as many as fit, then "+N" opening the description sheet), chapters, then Up next; sheet actions pop first and then act through the root navigator. Markers can be ranges on newer Stash (`end_seconds`): the end times come from the optional `SceneMarkerEnds`/`MarkerEnds` queries merged into the markers (a server error means unsupported and stops asking); ranges show as a tinted segment in the seek bar, "0:40–1:10" in the marker list and grid, and the add-marker sheet offers an end only when supported (`SceneDetails.markerRanges`). The list under the video is titled "Markers" (Stash has no chapters). A long press on a chapter or a markers-grid tile opens `showMarkerMenu` (`scene_actions.dart`; the grid adds play and play as loop): edit reuses `AddMarkerSheet(marker:)` and sends only changes (`StashRepository.updateMarker`, a map like the other forms; `end_seconds: null` removes the end), delete asks first (`confirmDeleteMarker`, `deleteMarker`); both reload the scene details and the marker lists. `SceneMarker`/`Marker` carry `ownTitle` and the primary `tag` for that. +**Player.** There is a single media_kit `Player` in `playerProvider`. `nowPlayingProvider.play(scene)` opens the stream with the auth headers, starting at the resume position, and expands the panel. `PlayerPanel` is a single layout that `PlayerTransition` interpolates between the mini bar and the expanded page (video, details, "Up next") based on the panel height. The one `PlayerVideo` instance stays in place, so it is never rebuilt mid-transition. The details are built while the panel rests (collapsed or open, never mid-animation) and then kept (hidden with `Visibility(maintainState:)` while collapsed), laid out at their expanded height and reused as one widget while only the height changes, so expanding and collapsing doesn't rebuild, relayout or reload "Up next" every frame. The video area follows the video's aspect ratio (`PlayerTransition.videoAspect`; portrait videos up to `maxVideoShare` of the panel). From `PlayerTransition.splitBreakpoint` (840 dp) the expanded video takes `splitShare` (half) of the width (`columnWidth`, `expandedWidth`), with `PlayerDetails(part: info)` below it and `part: list` ("Up next" or the queue) on the right; the right `Positioned` is always in the stack so the video never moves in the tree. Phones stay portrait everywhere, the player too (4.28): `AppOrientation` (`widgets/app_orientation.dart`; `AppOrientationScope` in `MaterialApp.builder` tells it whether the window is a phone's) allows only portrait on phones, all on tablets, and the fullscreen sets its orientations through `AppOrientation.turnTo`, which waits until the window has turned (never call `SystemChrome.setPreferredOrientations` elsewhere). On a phone `PlayerVideo` listens to `heldOrientationProvider` (`held_orientation.dart`, the `stash/device_orientation` event channel: Android's `OrientationEventListener` in `MainActivity`, iOS' UIDevice orientation in `DeviceOrientationStream`; `portrait`/`turnedLeft`/`turnedRight`, physical, both respect the system's rotation lock) for as long as it lives, not only while expanded: the panel isn't expanded for a moment while the screen turns, and listening anew reported the sideways hold at once, so leaving fullscreen by its button jumped right back in. A report counts once it held for 250 ms (held at an angle, the reports flip back and forth), and not while the app is locked or away (coming back reported turns that each opened another fullscreen; `enterPlayerFullscreen` also opens only one route, `playerFullscreenOpen`). Sideways in the open player with a wide video enters fullscreen (`fullscreenByRotation`), upright leaves it, and after leaving it while sideways a new turn counts only once the phone was upright. Tablets don't enter fullscreen by turning. Fullscreen goes through `enterPlayerFullscreen`/`exitPlayerFullscreen`/`togglePlayerFullscreen` (`fullscreen_route.dart`, a copy of media_kit's route without its own transition) and `FullscreenMorph` (`fullscreen_morph.dart`, in `MaterialApp.builder`): a second `Video` of the controller moves from the player's place (`inlineVideoRect`) to the fullscreen pose, turned by `HeldOrientation.uprightAngle` in a portrait window, while the app goes black; the screen then turns underneath (both landscape sides allowed, so the system picks the held one), where the pose already matches, and the morph goes. By default (`FullscreenMorph.curtainOnly`, both platforms) the app only fades to black (`cover`/`uncover`), turns underneath and fades back in, both ways: on iOS the morph's turn still showed, and the plain fade read best. The morph (kept, tested with `curtainOnlyOverride = false`) needs a turn without the system's rotation animation, which would turn the already turned picture once more: iOS' `RunnerViewController` disables it, Android's `MainActivity` sets `ROTATION_ANIMATION_JUMPCUT` on phones (no system animation on top of the app's fade; the old frame it stretches for a moment is black). Leaving, `_onExitFullscreen` shows the pose at once (also after the system's back), turns back and moves the video home. They need a context below the `Video`, so `PlayerVideo` keeps the controls' contexts (inline and fullscreen). A portrait video's extra height covers a spacer at the top of the details and shrinks as they scroll (`PlayerTransition.extraVideoHeight`). Pinch zoom (`video_zoom.dart`) sets mpv's `video-zoom`/`video-pan-x/y`, so it applies in fullscreen too and leaves the controls alone; `NowPlayingNotifier.play` resets it for a new scene. On iOS, `RunnerViewController` (Main.storyboard) turns without UIKit's rotation animation, which stretched the video. Keep the panel's widget tree structurally stable and toggle behavior with flags. Don't pass media_kit's `NoVideoControls` to `Video`: since media_kit_video 2 it is `null`, which `Video` ignores on update, so the old controls would stay; `PlayerVideo` uses an empty builder instead. With `showControls`, `PlayerVideo` (`player_controls.dart`) wraps media_kit's `MaterialVideoControls` on both platforms, with double-tap seeking, the collapse button and the quality sheet. `sceneDetailsProvider` loads `sceneStreams`, scene markers and the file infos (`SceneFile`, shown by `FileInfoCard`) lazily per scene. Chapters appear as gaps in the controls' `PreviewSeekBar` and in `ChapterList`; there is deliberately no progress line under the expanded video. `preferredStreamProvider` stores the chosen stream label (from the HD button, or ` p` from the settings), `pickPreferredStream` matches it by format and height (the `resolution` URL parameter), and `play` only waits for the stream list when a preference is set. Playback errors: `playbackErrorProvider` counts mpv's `stream.error` only when playback hasn't moved on for 2 s (mpv also reports errors it recovers from); `_PlaybackErrorOverlay` and the mini bar offer `retry()` (`NowPlayingNotifier.reopen`) and the quality sheet. Failed casts go through `castFailureProvider` to a snack bar. Under the video, `PlayerDetails` shows title and meta (a tap opens the description sheet, `scene_description.dart`: description, tags, file info, edit scene), the channel row, one action bar (`SceneActions`: rating pill with `showRatingSheet`, O-counter pill with `showOCounterSheet` (−/+, no undo snack bar), watch later, marker, More with `showSceneMoreSheet`), performers, one line of tags (`SceneTagsRow`: as many as fit, then "+N" opening the description sheet), chapters, then Up next; sheet actions pop first and then act through the root navigator. Markers can be ranges on newer Stash (`end_seconds`): the end times come from the optional `SceneMarkerEnds`/`MarkerEnds` queries merged into the markers (a server error means unsupported and stops asking); ranges show as a tinted segment in the seek bar, "0:40–1:10" in the marker list and grid, and the add-marker sheet offers an end only when supported (`SceneDetails.markerRanges`). The list under the video is titled "Markers" (Stash has no chapters). A long press on a chapter or a markers-grid tile opens `showMarkerMenu` (`scene_actions.dart`; the grid adds play and play as loop): edit reuses `AddMarkerSheet(marker:)` and sends only changes (`StashRepository.updateMarker`, a map like the other forms; `end_seconds: null` removes the end), delete asks first (`confirmDeleteMarker`, `deleteMarker`); both reload the scene details and the marker lists. `SceneMarker`/`Marker` carry `ownTitle` and the primary `tag` for that. **Player gestures.** The panel is our own `SlidingPanel` (`sliding_panel.dart`, replaced the unmaintained `miniplayer` package): collapsed, a tap expands it and a vertical drag follows the finger, snapping on release (`SlidingPanel.snapAfterDrag`) or dismissing below the bar; expanded, it takes no gestures, so taps belong to the video controls without a guard. media_kit's controls and the details list both claim vertical drags, so `DragToMinimize` reads raw pointer events and drives the panel through `SlidingPanelController.jumpTo`/`expand`/`collapse` (`miniplayerControllerProvider`; calls while no panel is mounted do nothing). Back while the player is open collapses it (`AppShell`'s `PopScope`). An expanded panel stays expanded when the window's height changes. That's also why the player's details have no pull-to-refresh. The player uses its own `StashVideoControls` instead of media_kit's, because media_kit's hide timer removed our seek bar mid-scrub. The controls take the fullscreen state and a toggle callback from media_kit's `VideoState`; buttons sit above the tap/double-tap gesture layer so they react without waiting for a double tap. Holding the video (`HoldDetector`, 250 ms) plays at 2× until released, and the speed button opens `showSpeedSheet` (mpv keeps the speed across files). mpv reports the position every frame, so `PreviewSeekBar` repaints its `SeekBarPainter` through a `ValueNotifier` (`progress`) and rebuilds only once a second (the spoken value). Their `PreviewSeekBar` shows sprite thumbnails (`scrubThumbnailsProvider`, parsed from Stash's WebVTT) while scrubbing. They load lazily: the controls stay mounted while hidden, so the bar reads the provider only once it is `visible` or touched, then precaches the sprite. `sceneDetailsProvider` and `scrubThumbnailsProvider` stay cached for 5 minutes without listeners (`Ref.cacheFor`), so collapsing and expanding the player doesn't refetch. On first use the controls show one-time gesture tips (`gesture_hints.dart`, `gesture_tips_seen`, resettable in Settings → Playback). In fullscreen a lock button (`TouchLockLayer`, `touch_lock.dart`) ignores all touches until its pill is held for 1 s; leaving fullscreen or a playback error unlocks. Chapters show the marker's screenshot (sprite frame as stand-in). The channel row has a favorite heart (performers; studios from Stash 0.25 through the optional `StudioFavorite` query). Channel pages put `ChannelFavoriteButton` (`FavoriteButton`, `StudioFavoriteButton`) and `ChannelRatingButton` (`features/channel/channel_rating.dart`) under the name: stars are saved through `channelRatingsProvider` (optimistic, `:`), a studio's come from the optional `StudioRating` query, and the picker is `showStarsSheet`, shared with the scenes' `showRatingSheet`. Double-tap seeking is a sequence like YouTube's: a double tap on the left/right third starts it and every further tap on that side within 700 ms extends it (5, 10, 20, 30, 40 s; `seekSequenceTotal`), counted from raw pointers so extending taps react at once; it also works while casting (tap areas over `CastingControls`) and not while locked. A tap on the other side starts a new sequence from one step; for 2 s after a seek (`_seekSettling`) a new sequence counts from the last target, since mpv (and a TV) still report the old position. Double-tap seeking is configurable per device (`seekSettingsProvider`, `seek_settings.dart`; Settings → Playback): on/off (off = single taps react without the double-tap delay), step 5/10/15/30 s, and cumulative (`SeekSettings.total`: step, then 2×step×(n−1), i.e. 5, 10, 20, 30, 40 s) or linear (one step per tap). diff --git a/android/app/src/main/java/io/github/two_play/stashappmobile/MainActivity.java b/android/app/src/main/java/io/github/two_play/stashappmobile/MainActivity.java index b3b1046..a25e72c 100644 --- a/android/app/src/main/java/io/github/two_play/stashappmobile/MainActivity.java +++ b/android/app/src/main/java/io/github/two_play/stashappmobile/MainActivity.java @@ -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); } } diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index a4d8f1d..ce7cc3f 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -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 | diff --git a/lib/features/player/fullscreen_morph.dart b/lib/features/player/fullscreen_morph.dart index 7fb9d8a..752fb9b 100644 --- a/lib/features/player/fullscreen_morph.dart +++ b/lib/features/player/fullscreen_morph.dart @@ -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'); @@ -42,7 +58,7 @@ class FullscreenMorph extends StatefulWidget { State createState() => FullscreenMorphState(); } -class FullscreenMorphState extends State with SingleTickerProviderStateMixin { +class FullscreenMorphState extends State 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; @@ -77,9 +93,36 @@ class FullscreenMorphState extends State 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 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 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 _fade(TickerFuture fade) => fade.orCancel.catchError((Object _) {}); + @override void dispose() { _progress.dispose(); + _black.dispose(); super.dispose(); } @@ -99,6 +142,20 @@ class FullscreenMorphState extends State 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), + ), + ), + ), + ), ], ); } diff --git a/lib/features/player/fullscreen_route.dart b/lib/features/player/fullscreen_route.dart index 841301a..cde8e96 100644 --- a/lib/features/player/fullscreen_route.dart +++ b/lib/features/player/fullscreen_route.dart @@ -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 @@ -48,9 +50,15 @@ Future 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( // The morph covers the switch. transitionDuration: Duration.zero, @@ -101,14 +109,22 @@ Future 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 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(); }); diff --git a/lib/features/player/player_controls.dart b/lib/features/player/player_controls.dart index d85de3f..3fdebfe 100644 --- a/lib/features/player/player_controls.dart +++ b/lib/features/player/player_controls.dart @@ -73,7 +73,7 @@ class _PlayerVideoState extends ConsumerState { /// 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. @@ -178,6 +178,16 @@ class _PlayerVideoState extends ConsumerState { 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), diff --git a/lib/widgets/app_orientation.dart b/lib/widgets/app_orientation.dart index 2bf0836..067663c 100644 --- a/lib/widgets/app_orientation.dart +++ b/lib/widgets/app_orientation.dart @@ -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.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.delayed(const Duration(milliseconds: 50)); } } diff --git a/test/features/pip_return_test.dart b/test/features/pip_return_test.dart index 3adfa37..f506107 100644 --- a/test/features/pip_return_test.dart +++ b/test/features/pip_return_test.dart @@ -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 settle(WidgetTester tester) async { for (var i = 0; i < 12; i++) { await tester.pump(const Duration(milliseconds: 100)); @@ -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)); @@ -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.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(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.broadcast(); addTearDown(turns.close);