From 192a98c4c892246673c5f3d0f15dbcd2d55d766b Mon Sep 17 00:00:00 2001 From: Philippe Date: Sun, 25 Oct 2026 17:20:47 +0100 Subject: [PATCH] Own player panel instead of miniplayer, smoother player and shorts - SlidingPanel replaces the unmaintained miniplayer package: tap and drag on the collapsed bar, no gestures of its own while expanded (no more PanelTapGuard), back collapses the player, stays expanded on rotation. - Video controls ignore pointer events after they were removed mid-gesture. - Player details are built while the panel rests, kept while collapsed, laid out at their expanded height and reused during the transition. - Seek bar repaints from a ValueNotifier and rebuilds once a second. - Hidden tabs and the tab behind the open player stop their tickers. - Cheaper text shadows over the shorts. - Stash's placeholder images (default=true) are not fetched. - List previews play the MP4 preview by default. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 10 +- lib/data/models/json.dart | 9 + lib/data/models/performer.dart | 4 +- lib/data/models/studio.dart | 4 +- lib/data/models/tag.dart | 2 +- lib/features/player/drag_to_minimize.dart | 15 +- lib/features/player/player_controls.dart | 20 -- lib/features/player/player_providers.dart | 12 +- lib/features/player/player_transition.dart | 4 +- lib/features/player/player_view.dart | 193 +++++++----- lib/features/player/preview_seek_bar.dart | 34 ++- lib/features/player/sliding_panel.dart | 282 +++++++++++++++++ lib/features/player/video_controls.dart | 16 +- .../settings/settings_categories.dart | 4 +- lib/features/shell/app_shell.dart | 96 +++++- lib/features/shorts/shorts_page.dart | 4 +- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/gen/app_localizations.dart | 2 +- lib/l10n/gen/app_localizations_de.dart | 2 +- lib/l10n/gen/app_localizations_en.dart | 2 +- lib/widgets/animated_previews.dart | 12 +- pubspec.lock | 8 - pubspec.yaml | 1 - test/data/models_test.dart | 21 ++ test/features/drag_to_minimize_test.dart | 36 ++- test/features/panel_tap_guard_test.dart | 68 ----- test/features/player/seek_sequence_test.dart | 16 + test/features/preview_seek_bar_test.dart | 23 ++ test/features/shell_layout_test.dart | 53 +++- test/features/sliding_panel_test.dart | 287 ++++++++++++++++++ test/widgets/auto_preview_test.dart | 18 +- 32 files changed, 1002 insertions(+), 260 deletions(-) create mode 100644 lib/features/player/sliding_panel.dart delete mode 100644 test/features/panel_tap_guard_test.dart create mode 100644 test/features/sliding_panel_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 4b573b9..e9de258 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,11 +59,11 @@ lib/ **Paginated lists.** `sceneListProvider`, `performerListProvider` and `studioListProvider` are auto-dispose family `PagedNotifier`s keyed by query objects (`SceneQuery`, `PerformerQuery`, `StudioQuery`). These query objects must keep value equality. UI code reads paged lists through `.current` (`PagedValue` extension), not `.value`. On a server switch the lists rebuild (`isReloading`), and `.value` would keep showing the previous server's items, or would hide a failed load behind them. A reload of the same list (pull-to-refresh, the connection banner's `reloadLists`) that fails with a network error keeps the loaded items as data with `PagedState.refreshError` (STATE-1; the banner explains it), but only when `PagedState.origin` (server and paging mode) still matches, so after a server switch the error shows instead; server-reported errors still replace the list by an `ErrorView`. The random sort uses a `random_` sort field so pages stay stable, and the seed is 0 for every other sort. To get an infinite, refreshable list with sort chips, use `SceneFeedView`, or compose `RefreshIndicator`, `LoadMoreListener`, `CustomScrollView` and `PagedSliver` yourself. After a failed page, scrolling doesn't retry; `PagedSliver`'s `onLoadMore` (its "try again" button) passes `loadMore(retry: true)`. `pagingModeProvider` (settings) switches all paged lists to numbered pages: `loadMore` then does nothing, `PagedSliver` shows a `PageBar` driving `PagedNotifier.goToPage` (pass `onGoToPage`), and changing the mode rebuilds the lists. The first page loads as skeletons (`widgets/skeleton.dart`, `PagedSliver(skeletonBuilder:)`), not a spinner. `SceneFeedView(pinChips: true)` pins the filter, sort and saved-filter chips under the home app bar (shelves go into `belowChipsSlivers`); `unfilteredEmptyView` replaces the empty message only without filters or search term (home: "scan your library" with a button to the server tasks). `EmptyView` takes an optional action. -**Shell and navigation.** `AppShell` holds an `IndexedStack` with one nested `Navigator` per `AppTab`, with the `Miniplayer` on top. The bottom `NavigationBar` collapses as the player expands, driven by `miniplayerHeightProvider`. `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). +**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 video area follows the video's aspect ratio (`PlayerTransition.videoAspect`; portrait videos up to `maxVideoShare` of the panel). Turning the phone to landscape with a wide video enters fullscreen through `playerVideoKey`; `fullscreenByRotation` makes turning back leave it. Leaving fullscreen while sideways locks portrait (`_onExitFullscreen`; otherwise the expanded player stayed in landscape, filling the screen with its controls hidden) until sensors_plus' accelerometer shows the phone upright again (`isHeldUpright`), so turning it enters fullscreen again, or the panel collapses. 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). +**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). Turning the phone to landscape with a wide video enters fullscreen through `playerVideoKey`; `fullscreenByRotation` makes turning back leave it. Leaving fullscreen while sideways locks portrait (`_onExitFullscreen`; otherwise the expanded player stayed in landscape, filling the screen with its controls hidden) until sensors_plus' accelerometer shows the phone upright again (`isHeldUpright`), so turning it enters fullscreen again, or the panel collapses. 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). -**Player gestures.** media_kit's controls and the details list both claim vertical drags, so the miniplayer package can't drag the expanded panel itself. `DragToMinimize` reads raw pointer events and drives the panel through `MiniplayerController.animateToHeight(height:, duration: Duration.zero)`. 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). 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). 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. 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). +**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). 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. 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). **Queue and autoplay.** `playQueueProvider` holds the playing list (watch later, a group). `NowPlayingNotifier.playQueue` starts it, `playNext` runs when `playerCompletedProvider` fires, and `play()` of a scene outside the queue ends the queue. Watch later is a list of scene ids stored on the device (`watchLaterProvider`), loaded with `findScenesByIds`. With a queue the video controls show previous/next (`playPrevious` restarts the scene after 3 s). @@ -103,7 +103,7 @@ lib/ **Security.** `features/security/`. `AppLockGate` in `MaterialApp.builder` shows `LockScreen` while `appLockedProvider` is true, and a `PrivacyCover` while the app is inactive (with the lock on or "hide in app switcher"). Without a delay the lock engages as the app is hidden, not on return. `LockScreen` asks for biometrics only once the app is resumed (iOS cancels a prompt while still inactive), again after the app was in the background but not after its own prompt. `confirmPin`/`showCreatePin` push full-screen routes on the root navigator, so they cover the app above the settings sheet too. `AppLockSettings.hidesApp` (lock on or "hide in app switcher"; the switch is fixed on with the lock) drives `SecureWindow`: Android's FLAG_SECURE, and on iOS `PrivacyCoverController` (AppDelegate.swift), a native cover added on `UIScene.willDeactivateNotification`, because the app is in the background before Flutter draws another frame (locking the phone showed the old content on return). Whether to cover it reads from UserDefaults itself (`flutter.lock_enabled`, `flutter.lock_hide_in_switcher`): a channel message sent at startup can arrive before the handler exists. Dart removes it (`SecureWindow.uncover`) once a frame was drawn after resuming; a 2 s fallback removes it otherwise. The PIN is stored only as a salted SHA-256 hash, as `:` under `lock_pin` in the `SecretStore` (older versions' SharedPreferences values are moved there). `PinThrottle` blocks the PIN pad for a growing time after 5 wrong PINs (stored, so restarts don't reset it); `LockScreen` then shows `PinBlockedView`. While locked, `AppLockGate` excludes focus and semantics, and `lockBackGuard` (registered in `main()` before `runApp`) turns back into leaving the app. The main player keeps playing (background playback, PiP); the shorts stop. Native channels: `stash/privacy` (Android `FLAG_SECURE`) and `stash/appicon` (Android activity-aliases / iOS alternate icons). On Android the launcher entries are the `activity-alias`es in `src/main/AndroidManifest.xml`. MainActivity keeps a MAIN/LAUNCHER filter there only because `flutter run` looks for it; the debug/profile/release manifests remove that filter again (`tools:node="remove"`). Android uses `FlutterFragmentActivity` and an AppCompat launch theme for `local_auth`. -**Animated previews.** Stash's looping WebP previews, switched separately (`widgets/animated_previews.dart`, on by default; `previewsPlay` also checks reduced motion): `markerPreviewsProvider` for marker tiles (`Marker.previewUrl`), `feedPreviewsProvider` for the first fully shown video in `SceneFeedView`: `AutoPreviewScope` around the list picks it `feedPreviewDelayProvider` (0.5 s by default) after scrolling stopped, and the `AutoPreview` in `SceneThumbnail` plays `Scene.webpUrl` over the still. Scrolling stops it. Thumbnails outside a scope (the player's "Up next") never play. `previewSourceProvider` switches the lists' previews to Stash's MP4 preview (`Scene.previewUrl`), played by `PreviewVideo` in a media_kit player of its own (one at a time; the settings warn about data, battery and memory); markers always play the WebP. "Only on Wi-Fi" (`previewsWifiOnlyProvider`, off by default) stops them off Wi-Fi/ethernet via `connectivity_plus` (`unmeteredConnectionProvider`). +**Animated previews.** Stash's looping previews, switched separately (`widgets/animated_previews.dart`, on by default; `previewsPlay` also checks reduced motion): `markerPreviewsProvider` for marker tiles (`Marker.previewUrl`), `feedPreviewsProvider` for the first fully shown video in `SceneFeedView`: `AutoPreviewScope` around the list picks it `feedPreviewDelayProvider` (0.5 s by default) after scrolling stopped, and the `AutoPreview` in `SceneThumbnail` plays `Scene.webpUrl` over the still. Scrolling stops it. Thumbnails outside a scope (the player's "Up next") never play. `previewSourceProvider` picks what the lists' previews play: by default Stash's MP4 preview (`Scene.previewUrl`), played by `PreviewVideo` in a media_kit player of its own (one at a time, hardware-decoded), or the WebP (`Scene.webpUrl`), which Flutter decodes frame by frame in software (raster jank with Stash's 640×1136 portrait previews, 3–5 MB); markers always play the WebP (small enough to run smoothly). "Only on Wi-Fi" (`previewsWifiOnlyProvider`, off by default) stops them off Wi-Fi/ethernet via `connectivity_plus` (`unmeteredConnectionProvider`). **Server tasks.** `features/server_tasks/` (Settings → Server tasks, pushed inside the settings sheet; its own `ScaffoldMessenger` so snack bars show above the sheet). Starts Stash's scan, generate, auto tag and clean (`StashRepository.scanLibrary/generate/autoTag/clean`; `ScanOption`/`GenerateOption` are the input keys), shows `jobQueueProvider` (polls `jobQueue` every 2 s while watched; `stopJob`) and `previewCheckProvider`: `checkPreviews` fetches one byte of the WebP/MP4 preview URLs of a few random scenes and markers (Stash answers 404 for missing files) and runs again when a job finishes. The demo accepts the mutations and has an empty queue. @@ -115,4 +115,4 @@ lib/ **About.** *Settings → About* (`features/settings/about.dart`) shows the version (`appInfoProvider`, package_info_plus; null in tests) and Flutter's license page, which collects every Dart package's license. `registerAppLicenses` (called in `main`) adds StashTube's own `LICENSE` and the `nativeLibraries` inside media_kit's libmpv builds (texts in `assets/licenses/`), which their packages don't ship; update that list when the media_kit libs change. The app is licensed under AGPL-3.0 (`LICENSE`). Repository docs: `README.md`, `SECURITY.md` (private vulnerability reporting), `CONTRIBUTING.md`. -**Images and auth.** Always load server images through `StashImage` or `ChannelAvatar`, which add the `ApiKey` header. Stash serves SVG placeholders for missing images, which fall back to an icon or the name's initial. +**Images and auth.** Always load server images through `StashImage` or `ChannelAvatar`, which add the `ApiKey` header. Stash serves SVG placeholders for missing images, which fall back to an icon or the name's initial; performer, studio and tag models drop those URLs up front (`stashImageUrl`: Stash marks them `default=true`), so they aren't fetched and fail to decode. diff --git a/lib/data/models/json.dart b/lib/data/models/json.dart index 5248a0b..8ffe299 100644 --- a/lib/data/models/json.dart +++ b/lib/data/models/json.dart @@ -5,6 +5,15 @@ typedef Json = Map; /// [value], or null when it is null or blank. String? nonEmpty(String? value) => value == null || value.trim().isEmpty ? null : value; +/// A performer's, studio's or tag's image, or null when Stash only has its +/// placeholder: Stash marks those URLs with `default=true` and serves an SVG, +/// which the image decoder can't read (Android logs every failed attempt). +String? stashImageUrl(String? path) { + final url = nonEmpty(path); + if (url == null) return null; + return Uri.tryParse(url)?.queryParameters['default'] == 'true' ? null : url; +} + DateTime? parseDate(String? value) => value == null || value.isEmpty ? null : DateTime.tryParse(value); /// Whether [a] and [b] are the same JSON value (maps, lists, numbers diff --git a/lib/data/models/performer.dart b/lib/data/models/performer.dart index 730a06d..aa912c5 100644 --- a/lib/data/models/performer.dart +++ b/lib/data/models/performer.dart @@ -35,7 +35,7 @@ class Performer { factory Performer.fromRef(Fragment$PerformerRef p, {String? country, bool favorite = false}) => Performer( id: p.id, name: p.name, - imageUrl: nonEmpty(p.image_path), + imageUrl: stashImageUrl(p.image_path), country: nonEmpty(country), favorite: favorite, ); @@ -44,7 +44,7 @@ class Performer { id: p.id, name: p.name, disambiguation: nonEmpty(p.disambiguation), - imageUrl: nonEmpty(p.image_path), + imageUrl: stashImageUrl(p.image_path), country: nonEmpty(p.country), birthdate: parseDate(p.birthdate), gender: p.gender == null ? null : toJson$Enum$GenderEnum(p.gender!), diff --git a/lib/data/models/studio.dart b/lib/data/models/studio.dart index 47c00ad..c81853b 100644 --- a/lib/data/models/studio.dart +++ b/lib/data/models/studio.dart @@ -27,7 +27,7 @@ class Studio { final List children; factory Studio.fromRef(Fragment$StudioRef s, {int sceneCount = 0}) => - Studio(id: s.id, name: s.name, imageUrl: nonEmpty(s.image_path), sceneCount: sceneCount); + Studio(id: s.id, name: s.name, imageUrl: stashImageUrl(s.image_path), sceneCount: sceneCount); factory Studio.fromFields(Fragment$StudioFields s) { final parent = s.parent_studio; @@ -35,7 +35,7 @@ class Studio { return Studio( id: s.id, name: s.name, - imageUrl: nonEmpty(s.image_path), + imageUrl: stashImageUrl(s.image_path), url: nonEmpty(s.url), details: nonEmpty(details?.details), sceneCount: s.scene_count, diff --git a/lib/data/models/tag.dart b/lib/data/models/tag.dart index fcd74cc..f37a4cc 100644 --- a/lib/data/models/tag.dart +++ b/lib/data/models/tag.dart @@ -15,7 +15,7 @@ class Tag { factory Tag.fromFields(Fragment$TagFields t, {String? description}) => Tag( id: t.id, name: t.name, - imageUrl: nonEmpty(t.image_path), + imageUrl: stashImageUrl(t.image_path), description: nonEmpty(description), sceneCount: t.scene_count, ); diff --git a/lib/features/player/drag_to_minimize.dart b/lib/features/player/drag_to_minimize.dart index 5a443da..8155a17 100644 --- a/lib/features/player/drag_to_minimize.dart +++ b/lib/features/player/drag_to_minimize.dart @@ -1,12 +1,13 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; -import 'package:miniplayer/miniplayer.dart'; + +import 'sliding_panel.dart'; /// Lets a downward swipe on the expanded player minimize it, following the /// finger and snapping on release, like YouTube. /// -/// The miniplayer package only drags the panel when no inner widget claims -/// the gesture, but the video controls and the details list both do. So this +/// The expanded [SlidingPanel] takes no drags itself, and the video controls +/// and the details list both claim vertical drags. So this /// watches raw pointer events (outside the gesture arena) and drives the /// panel through its [controller]. A swipe starts the drag when it begins on /// the video, or in the details while they are scrolled to the top. @@ -23,7 +24,7 @@ class DragToMinimize extends StatefulWidget { /// Only start drags while the panel is fully expanded. final bool enabled; - final MiniplayerController controller; + final SlidingPanelController controller; final double minHeight; final double maxHeight; @@ -53,7 +54,7 @@ class _DragToMinimizeState extends State { _pointers++; if (_pointers > 1) { // A second finger: a pinch on the video, not a swipe down. - if (_dragging) widget.controller.animateToHeight(state: PanelState.MAX); + if (_dragging) widget.controller.expand(); _dragging = false; _candidate = false; return; @@ -82,7 +83,7 @@ class _DragToMinimizeState extends State { } } final height = (widget.maxHeight - delta.dy).clamp(widget.minHeight, widget.maxHeight); - widget.controller.animateToHeight(height: height.roundToDouble(), duration: Duration.zero); + widget.controller.jumpTo(height.roundToDouble()); } void _onEnd(PointerEvent e) { @@ -95,7 +96,7 @@ class _DragToMinimizeState extends State { velocity: velocity, maxHeight: widget.maxHeight, ); - widget.controller.animateToHeight(state: collapse ? PanelState.MIN : PanelState.MAX); + collapse ? widget.controller.collapse() : widget.controller.expand(); } _start = null; _dragging = false; diff --git a/lib/features/player/player_controls.dart b/lib/features/player/player_controls.dart index 52b7481..acef363 100644 --- a/lib/features/player/player_controls.dart +++ b/lib/features/player/player_controls.dart @@ -23,26 +23,6 @@ import 'preview_seek_bar.dart'; import 'video_controls.dart'; import '../../l10n/l10n.dart'; -/// Claims single taps inside the expanded player. -/// -/// The miniplayer package wraps the whole panel in a `GestureDetector` whose -/// `onTap` collapses an expanded panel. media_kit detects single taps with a -/// raw `Listener`, so without this guard every tap on the video (or on empty -/// space in the details) ends up collapsing the player. A deeper tap -/// recognizer wins the gesture arena; buttons, double-tap and drag still work. -class PanelTapGuard extends StatelessWidget { - const PanelTapGuard({super.key, this.enabled = true, required this.child}); - - /// When false, taps reach the miniplayer again (e.g. tap-to-expand on the - /// collapsed bar). Toggling keeps the widget tree stable. - final bool enabled; - final Widget child; - - @override - Widget build(BuildContext context) => - GestureDetector(behavior: HitTestBehavior.opaque, onTap: enabled ? () {} : null, child: child); -} - /// media_kit's state of the one [PlayerVideo], to enter fullscreen when the /// phone is turned. final playerVideoKey = GlobalKey(); diff --git a/lib/features/player/player_providers.dart b/lib/features/player/player_providers.dart index c834d39..513aacc 100644 --- a/lib/features/player/player_providers.dart +++ b/lib/features/player/player_providers.dart @@ -4,7 +4,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; -import 'package:miniplayer/miniplayer.dart'; import '../../core/config/server_config.dart'; import '../../data/models/scene.dart'; @@ -16,6 +15,7 @@ import '../cast/cast_media.dart'; import '../cast/cast_providers.dart'; import '../cast/cast_service.dart' show CastKind; import 'playback_tracker.dart'; +import 'sliding_panel.dart'; import 'video_zoom.dart'; const double kMiniPlayerHeight = 64; @@ -32,11 +32,7 @@ final videoControllerProvider = Provider( (ref) => VideoController(ref.watch(playerProvider)), ); -final miniplayerControllerProvider = Provider((ref) { - final controller = MiniplayerController(); - ref.onDispose(controller.dispose); - return controller; -}); +final miniplayerControllerProvider = Provider((ref) => SlidingPanelController()); /// Current miniplayer height in pixels; drives the bottom navigation bar. final miniplayerHeightProvider = Provider>((ref) { @@ -469,9 +465,9 @@ class NowPlayingNotifier extends Notifier { ); } - void expand() => ref.read(miniplayerControllerProvider).animateToHeight(state: PanelState.MAX); + void expand() => ref.read(miniplayerControllerProvider).expand(); - void collapse() => ref.read(miniplayerControllerProvider).animateToHeight(state: PanelState.MIN); + void collapse() => ref.read(miniplayerControllerProvider).collapse(); /// Shows edited metadata for the playing scene without reopening it. void replaceScene(Scene scene) { diff --git a/lib/features/player/player_transition.dart b/lib/features/player/player_transition.dart index e919483..ec5eac4 100644 --- a/lib/features/player/player_transition.dart +++ b/lib/features/player/player_transition.dart @@ -88,7 +88,7 @@ class PlayerTransition { bool get isExpanded => progress >= 0.999; bool get isCollapsed => progress <= 0.001; - /// Whether details exist at all; skipped while collapsed so the "Up next" - /// list isn't loaded for a closed player. + /// Whether the details are shown (they stay built but hidden while + /// collapsed, see `PlayerPanel`). bool get showDetails => progress > 0.01; } diff --git a/lib/features/player/player_view.dart b/lib/features/player/player_view.dart index 968d9f3..a80fa4a 100644 --- a/lib/features/player/player_view.dart +++ b/lib/features/player/player_view.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -65,12 +67,26 @@ class _PlayerPanelState extends ConsumerState { /// How far the details list is scrolled. final _detailsScroll = ValueNotifier(0); + /// The details exist for this scene; collapsed they are only hidden, so + /// expanding doesn't build and load "Up next" mid-animation. + bool _keepDetails = false; + + /// The details widget, reused while only the panel's height changes, so + /// the frames of the transition don't rebuild the list. + PlayerDetails? _details; + @override void didUpdateWidget(PlayerPanel oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.scene.id != widget.scene.id) _detailsScroll.value = 0; } + PlayerDetails _detailsFor(Scene scene, double topSpacer) { + final details = _details; + if (details != null && details.scene == scene && details.topSpacer == topSpacer) return details; + return _details = PlayerDetails(scene: scene, topSpacer: topSpacer); + } + @override void dispose() { _detailsScroll.dispose(); @@ -111,6 +127,12 @@ class _PlayerPanelState extends ConsumerState { ); final progressLineHeight = 2 * t.miniBarOpacity; final detailsTop = t.topPadding + t.baseVideoHeight + progressLineHeight; + // The details keep their expanded height during the transition (the + // panel clips them), so their list isn't laid out anew every frame. + final detailsHeight = math.max(0.0, widget.maxHeight - media.padding.top - media.size.width * 9 / 16); + // Built while the panel rests (collapsed or open), not in the middle of + // its animation: building and loading the list there dropped frames. + if (t.isExpanded || t.isCollapsed) _keepDetails = true; // Every wrapper below is always present (only flags change), so the // video keeps its place in the tree during the transition. @@ -127,28 +149,28 @@ class _PlayerPanelState extends ConsumerState { minHeight: kMiniPlayerHeight, maxHeight: widget.maxHeight, videoBottom: t.topPadding + shownVideoHeight, - child: PanelTapGuard( - // Collapsed or mid-drag, a tap should still expand the panel. - enabled: t.isExpanded, - child: ColoredBox( - color: Color.lerp(colors.surfaceContainer, colors.surface, t.progress)!, - // Always the panel's height: while the phone turns, the space can - // briefly be smaller than the miniplayer's height. - child: ClipRect( - child: OverflowBox( - alignment: Alignment.topCenter, - minHeight: height, - maxHeight: height, - child: Stack( - children: [ - // Below the 16:9 area; the taller video covers their spacer. - Positioned( - top: detailsTop, - left: 0, - right: 0, - bottom: 0, - child: t.showDetails - ? NotificationListener( + child: ColoredBox( + color: Color.lerp(colors.surfaceContainer, colors.surface, t.progress)!, + // Always the panel's height: while the phone turns, the space can + // briefly be smaller than the miniplayer's height. + child: ClipRect( + child: OverflowBox( + alignment: Alignment.topCenter, + minHeight: height, + maxHeight: height, + child: Stack( + children: [ + // Below the 16:9 area; the taller video covers their spacer. + Positioned( + top: detailsTop, + left: 0, + right: 0, + height: detailsHeight, + child: _keepDetails + ? Visibility( + visible: t.showDetails, + maintainState: true, + child: NotificationListener( onNotification: (n) { if (n.depth == 0 && n.metrics.axis == Axis.vertical) { _detailsScroll.value = n.metrics.pixels; @@ -157,82 +179,85 @@ class _PlayerPanelState extends ConsumerState { }, child: Opacity( opacity: t.detailsOpacity, - // Ink of the list tiles above the panel's color. - child: Material( - type: MaterialType.transparency, - child: PlayerDetails(scene: scene, topSpacer: t.extraVideoHeight), + // Fading repaints only the opacity, not the list. + child: RepaintBoundary( + // Ink of the list tiles above the panel's color. + child: Material( + type: MaterialType.transparency, + child: _detailsFor(scene, t.extraVideoHeight), + ), ), ), - ) - : const SizedBox.shrink(), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: Column( - mainAxisSize: MainAxisSize.min, + ), + ) + : const SizedBox.shrink(), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Status bar area: black like the video, so no colored band + // grows above it while expanding. + SizedBox( + height: t.topPadding, + width: double.infinity, + child: const ColoredBox(color: Colors.black), + ), + SizedBox( + height: shownVideoHeight, + // The panel's width even while the phone turns (the video width + // comes from the screen size, which can be ahead of the layout). + child: ClipRect( + child: OverflowBox( + alignment: Alignment.centerLeft, + minWidth: media.size.width, + maxWidth: media.size.width, + child: Row( children: [ - // Status bar area: black like the video, so no colored band - // grows above it while expanding. SizedBox( - height: t.topPadding, - width: double.infinity, - child: const ColoredBox(color: Colors.black), - ), - SizedBox( - height: shownVideoHeight, - // The panel's width even while the phone turns (the video width - // comes from the screen size, which can be ahead of the layout). - child: ClipRect( - child: OverflowBox( - alignment: Alignment.centerLeft, - minWidth: media.size.width, - maxWidth: media.size.width, - child: Row( - children: [ - SizedBox( - width: t.videoWidth, - child: ColoredBox( - color: Colors.black, - // A playback error shows inside the controls - // (StashVideoControls), so also in fullscreen. - child: PlayerVideo(scene: scene, showControls: t.isExpanded), - ), + width: t.videoWidth, + child: ColoredBox( + color: Colors.black, + // A playback error shows inside the controls + // (StashVideoControls), so also in fullscreen. + child: PlayerVideo(scene: scene, showControls: t.isExpanded), ), - // Mini bar info keeps its natural width and is clipped while - // the video takes over the row. - Expanded( - child: ClipRect( - child: OverflowBox( - alignment: Alignment.centerLeft, - minWidth: 0, - maxWidth: media.size.width - kMiniPlayerHeight * 16 / 9, - child: Opacity( - opacity: t.miniBarOpacity, - child: IgnorePointer( - ignoring: t.miniBarOpacity < 0.5, - child: _MiniInfo(scene: scene), - ), + ), + // Mini bar info keeps its natural width and is clipped while + // the video takes over the row. + Expanded( + child: ClipRect( + child: OverflowBox( + alignment: Alignment.centerLeft, + minWidth: 0, + maxWidth: media.size.width - kMiniPlayerHeight * 16 / 9, + child: Opacity( + opacity: t.miniBarOpacity, + child: IgnorePointer( + ignoring: t.miniBarOpacity < 0.5, + child: _MiniInfo(scene: scene), ), ), ), ), - ], - ), - ), - ), - ), - // Mini progress line shrinks away so no gap remains under the video. - SizedBox( - height: progressLineHeight, - child: Opacity(opacity: t.miniBarOpacity, child: const _ProgressBar(height: 2)), ), ], ), ), - ], + ), ), + // Mini progress line shrinks away so no gap remains under the video. + SizedBox( + height: progressLineHeight, + child: Opacity(opacity: t.miniBarOpacity, child: const _ProgressBar(height: 2)), + ), + ], + ), + ), + ], ), ), ), diff --git a/lib/features/player/preview_seek_bar.dart b/lib/features/player/preview_seek_bar.dart index fb55206..3819cab 100644 --- a/lib/features/player/preview_seek_bar.dart +++ b/lib/features/player/preview_seek_bar.dart @@ -80,6 +80,10 @@ class _PreviewSeekBarState extends ConsumerState { final _subscriptions = >[]; Duration _position = Duration.zero; + + /// [_position] as a fraction of the bar, for the painter: mpv reports the + /// position every frame, which repaints the bar without rebuilding it. + final _played = ValueNotifier(0); Duration _duration = Duration.zero; Duration _buffer = Duration.zero; @@ -129,12 +133,20 @@ class _PreviewSeekBarState extends ConsumerState { _duration = player.state.duration; _buffer = player.state.buffer; _subscriptions.addAll([ - player.stream.position.listen((v) => setState(() => _position = v)), + player.stream.position.listen(_onPosition), player.stream.duration.listen((v) => setState(() => _duration = v)), player.stream.buffer.listen((v) => setState(() => _buffer = v)), ]); } + void _onPosition(Duration position) { + final second = _position.inSeconds; + _position = position; + _played.value = _fractionOf(position); + // A rebuild only for the spoken value (screen readers), once a second. + if (position.inSeconds != second) setState(() {}); + } + @override void didUpdateWidget(PreviewSeekBar oldWidget) { super.didUpdateWidget(oldWidget); @@ -148,6 +160,7 @@ class _PreviewSeekBarState extends ConsumerState { for (final s in _subscriptions) { s.cancel(); } + _played.dispose(); super.dispose(); } @@ -224,6 +237,8 @@ class _PreviewSeekBarState extends ConsumerState { final colors = Theme.of(context).colorScheme; final remote = widget.remote; final played = _drag ?? _fractionOf(remote?.position ?? _position); + // The duration or the window may have changed since the last position. + if (remote == null) _played.value = _fractionOf(_position); final headers = ref.watch(authHeadersProvider); final thumbs = _loadThumbnails ? ref.watch(scrubThumbnailsProvider(widget.sceneId)).value : null; if (thumbs != null && thumbs.spriteUrl != _precachedSprite) { @@ -304,6 +319,8 @@ class _PreviewSeekBarState extends ConsumerState { child: CustomPaint( painter: SeekBarPainter( played: played, + // Follows the player between rebuilds. + progress: _drag == null && remote == null ? _played : null, buffered: remote == null ? _fractionOf(_buffer) : 0, dragging: _drag != null, playedColor: colors.primary, @@ -453,16 +470,24 @@ class SpriteFrame extends StatelessWidget { @visibleForTesting class SeekBarPainter extends CustomPainter { SeekBarPainter({ - required this.played, + required this._played, required this.buffered, required this.dragging, required this.playedColor, Color? rangeColor, this.chapters = const [], this.ranges = const [], - }) : rangeColor = rangeColor ?? playedColor; + this.progress, + }) : rangeColor = rangeColor ?? playedColor, + super(repaint: progress); + + final double _played; + + /// When given, the played fraction is read from it at paint time, and a + /// change repaints without a new painter. + final ValueListenable? progress; - final double played; + double get played => progress?.value ?? _played; final double buffered; final bool dragging; final Color playedColor; @@ -530,6 +555,7 @@ class SeekBarPainter extends CustomPainter { @override bool shouldRepaint(SeekBarPainter old) => + old.progress != progress || old.played != played || old.buffered != buffered || old.dragging != dragging || diff --git a/lib/features/player/sliding_panel.dart b/lib/features/player/sliding_panel.dart new file mode 100644 index 0000000..1f896bc --- /dev/null +++ b/lib/features/player/sliding_panel.dart @@ -0,0 +1,282 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// Builds the panel's content for its current height. +typedef SlidingPanelBuilder = Widget Function(BuildContext context, double height); + +/// Where a drag on the collapsed panel ends up. +enum PanelSnap { expand, collapse, dismiss } + +/// Drives a [SlidingPanel] from code: [NowPlayingNotifier.expand] and +/// `collapse`, and [DragToMinimize], which follows the finger with [jumpTo]. +/// +/// Calls while no panel is attached (nothing plays) do nothing. +class SlidingPanelController { + _SlidingPanelState? _panel; + + bool get isAttached => _panel != null; + + /// Animates to the full height. + void expand({Duration? duration}) { + final panel = _panel; + panel?._animateTo(panel.widget.maxHeight, duration: duration); + } + + /// Animates down to the mini bar. + void collapse({Duration? duration}) { + final panel = _panel; + panel?._animateTo(panel.widget.minHeight, duration: duration); + } + + /// Sets the height right away (between the mini bar's and the full + /// height), stopping a running animation. + void jumpTo(double height) => _panel?._jumpTo(height); +} + +/// The player's panel: a mini bar at the bottom that expands to +/// [maxHeight], like YouTube's player (replaces the unmaintained +/// `miniplayer` package). +/// +/// * Collapsed (or on the way), a tap expands it, and a vertical drag +/// follows the finger and snaps to expanded or collapsed on release; a +/// drag below the bar dismisses it ([onDismissed]). +/// * Expanded, the panel takes no gestures itself: taps belong to the video +/// controls and the details, and swiping down is [DragToMinimize]'s, which +/// moves the panel through the [controller]. +/// * [height] always holds the current height (at least [minHeight]), so the +/// shell can shrink the navigation bar along. +/// +/// Back while expanded is handled by `AppShell`, which collapses the panel. +class SlidingPanel extends StatefulWidget { + const SlidingPanel({ + super.key, + required this.controller, + required this.height, + required this.minHeight, + required this.maxHeight, + required this.builder, + this.onDismissed, + this.duration = const Duration(milliseconds: 300), + this.curve = Curves.easeOut, + }); + + final SlidingPanelController controller; + + /// The current height; written by the panel. + final ValueNotifier height; + final double minHeight; + final double maxHeight; + final SlidingPanelBuilder builder; + + /// Called once a drag down below the bar has slid it out; without it the + /// bar can't be dismissed by dragging. + final VoidCallback? onDismissed; + final Duration duration; + final Curve curve; + + /// Release decision for a drag on the panel. [progress]: 0 = collapsed, + /// 1 = expanded; [dismissed]: how far below the bar (0–1); [velocity] in + /// px/s, positive downwards. + static PanelSnap snapAfterDrag({ + required double progress, + required double dismissed, + required double velocity, + required bool canDismiss, + }) { + if (dismissed > 0) { + return canDismiss && (velocity > 300 || (dismissed > 0.3 && velocity > -300)) + ? PanelSnap.dismiss + : PanelSnap.collapse; + } + if (velocity < -300) return PanelSnap.expand; + if (velocity > 300) return PanelSnap.collapse; + return progress > 0.2 ? PanelSnap.expand : PanelSnap.collapse; + } + + @override + State createState() => _SlidingPanelState(); +} + +class _SlidingPanelState extends State with SingleTickerProviderStateMixin { + late final AnimationController _animation = AnimationController(vsync: this) + ..addListener(_onTick) + ..addStatusListener(_onStatus); + Animatable? _tween; + double? _target; + + /// Height including the part below the bar while dragging it out: + /// 0 – [SlidingPanel.maxHeight]. [SlidingPanel.height] is this, but at + /// least the bar's height. + late double _extent = widget.height.value; + + /// How far the bar is dragged out below its place, 0–1. + final _dismissed = ValueNotifier(0); + + bool get _expanded => widget.height.value >= widget.maxHeight - 0.5; + + /// A drag on the panel itself is running; it keeps its gestures even once + /// it reaches the top. + bool _dragging = false; + + @override + void initState() { + super.initState(); + widget.controller._panel = this; + } + + @override + void didUpdateWidget(SlidingPanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + if (oldWidget.controller._panel == this) oldWidget.controller._panel = null; + widget.controller._panel = this; + } + if (oldWidget.maxHeight != widget.maxHeight) { + // The window changed (the phone turned): an expanded panel stays + // expanded. Later, since the height's listeners are being built. + final oldMax = oldWidget.maxHeight; + final wasExpanded = _animation.isAnimating ? _target == oldMax : _extent >= oldMax - 0.5; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (wasExpanded) { + _animation.isAnimating ? _animateTo(widget.maxHeight) : _setExtent(widget.maxHeight); + } else if (!_animation.isAnimating) { + _setExtent(_extent); + } + }); + } + } + + @override + void dispose() { + if (widget.controller._panel == this) widget.controller._panel = null; + _animation.dispose(); + _dismissed.dispose(); + super.dispose(); + } + + bool get _canDismiss => widget.onDismissed != null; + + void _setExtent(double value) { + _extent = value.clamp(_canDismiss ? 0.0 : widget.minHeight, widget.maxHeight); + final height = math.max(_extent, widget.minHeight); + if (widget.height.value != height) widget.height.value = height; + final dismissed = ((widget.minHeight - _extent) / widget.minHeight).clamp(0.0, 1.0); + if (_dismissed.value != dismissed) _dismissed.value = dismissed; + } + + void _jumpTo(double height) { + _animation.stop(); + _setExtent(height.clamp(widget.minHeight, widget.maxHeight)); + } + + void _animateTo(double target, {Duration? duration}) { + _animation.stop(); + final reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; + final length = reduceMotion ? Duration.zero : duration ?? widget.duration; + if (_extent == target || length == Duration.zero) { + _setExtent(target); + _onArrived(); + return; + } + _target = target; + _tween = Tween(begin: _extent, end: target).chain(CurveTween(curve: widget.curve)); + _animation + ..duration = length + ..forward(from: 0); + } + + void _onTick() { + final tween = _tween; + if (tween != null) _setExtent(tween.transform(_animation.value)); + } + + void _onStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) _onArrived(); + } + + void _onArrived() { + if (_canDismiss && _extent <= 0) widget.onDismissed!(); + } + + void _onDragStart(DragStartDetails _) { + _animation.stop(); + _dragging = true; + } + + void _onDragUpdate(DragUpdateDetails details) { + _animation.stop(); + _setExtent(_extent - details.delta.dy); + } + + void _onDragEnd(DragEndDetails details) { + _dragging = false; + final range = widget.maxHeight - widget.minHeight; + final snap = SlidingPanel.snapAfterDrag( + progress: range <= 0 ? 1 : (_extent - widget.minHeight) / range, + dismissed: _dismissed.value, + velocity: details.primaryVelocity ?? 0, + canDismiss: _canDismiss, + ); + _animateTo(switch (snap) { + PanelSnap.expand => widget.maxHeight, + PanelSnap.collapse => widget.minHeight, + PanelSnap.dismiss => 0, + }); + } + + @override + Widget build(BuildContext context) { + final scrim = Theme.of(context).colorScheme.scrim; + return ValueListenableBuilder( + valueListenable: widget.height, + builder: (context, value, _) { + final height = math.min(value, widget.maxHeight); + final range = widget.maxHeight - widget.minHeight; + final progress = range <= 0 ? 1.0 : ((height - widget.minHeight) / range).clamp(0.0, 1.0); + // Expanded, gestures belong to the content (see the class comment). + final own = !_expanded || _dragging; + return Stack( + children: [ + // Dims the page above the panel while it grows; a tap there + // collapses it again. + if (progress > 0) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.controller.collapse, + child: ColoredBox(color: scrim.withValues(alpha: 0.44 * progress)), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + height: height, + width: double.infinity, + // Always the same wrappers (only values change), so the + // content, and with it the video, is never remounted. + child: ValueListenableBuilder( + valueListenable: _dismissed, + builder: (context, dismissed, child) => Opacity( + opacity: 1 - dismissed * 0.8, + child: Transform.translate(offset: Offset(0, widget.minHeight * dismissed * 0.5), child: child), + ), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: own ? widget.controller.expand : null, + onVerticalDragStart: own ? _onDragStart : null, + onVerticalDragUpdate: own ? _onDragUpdate : null, + onVerticalDragEnd: own ? _onDragEnd : null, + onVerticalDragCancel: own ? () => _dragging = false : null, + child: Material(child: widget.builder(context, height)), + ), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/player/video_controls.dart b/lib/features/player/video_controls.dart index b154725..ed98570 100644 --- a/lib/features/player/video_controls.dart +++ b/lib/features/player/video_controls.dart @@ -112,7 +112,12 @@ class _StashVideoControlsState extends ConsumerState { return (points[0] - points[1]).distance; } + // The raw pointer handlers below keep getting a gesture's events after + // the controls were removed mid-gesture (a swipe down collapses the + // player and with it the controls), so each checks [mounted] first. + void _onPointerDown(PointerDownEvent e) { + if (!mounted) return; // The lock and the tips take the touches; no pinch underneath. if (_locked || _tipsShowing) return; _pointers[e.pointer] = e.localPosition; @@ -127,7 +132,7 @@ class _StashVideoControlsState extends ConsumerState { } void _onPointerMove(PointerMoveEvent e) { - if (!_pointers.containsKey(e.pointer)) return; + if (!mounted || !_pointers.containsKey(e.pointer)) return; _pointers[e.pointer] = e.localPosition; final start = _pinchStartZoom; final box = context.size; @@ -142,6 +147,7 @@ class _StashVideoControlsState extends ConsumerState { void _onPointerEnd(PointerEvent e) { _pointers.remove(e.pointer); + if (!mounted) return; if (_pinching && _pointers.length < 2) { final notifier = ref.read(videoZoomProvider.notifier); notifier.set(ref.read(videoZoomProvider).settled()); @@ -276,6 +282,7 @@ class _StashVideoControlsState extends ConsumerState { } void _onTapPointerDown(PointerDownEvent e) { + if (!mounted) return; if (_tapPointer != null) { // A second finger: no tap. _cancelTapPointer(); @@ -301,6 +308,7 @@ class _StashVideoControlsState extends ConsumerState { int? _tapSide(PointerUpEvent e, {int? side}) { if (e.pointer != _tapPointer) return null; _cancelTapPointer(); + if (!mounted) return null; if (_pinching) return null; if (side != null) return side; final width = context.size?.width ?? 0; @@ -529,9 +537,9 @@ class _StashVideoControlsState extends ConsumerState { // tap wait, so the first tap of a double tap doesn't toggle. // Without double-tap seeking, taps toggle right away. onDoubleTap: seek.enabled ? () {} : null, - // Claim vertical drags so the miniplayer's own pan doesn't - // fight with DragToMinimize, which handles the swipe-down - // from raw pointer events. + // Claim vertical drags so nothing above takes them; + // DragToMinimize handles the swipe-down from raw pointer + // events. onVerticalDragStart: (_) {}, onVerticalDragUpdate: (_) {}, ), diff --git a/lib/features/settings/settings_categories.dart b/lib/features/settings/settings_categories.dart index f54ae01..a718aa8 100644 --- a/lib/features/settings/settings_categories.dart +++ b/lib/features/settings/settings_categories.dart @@ -401,8 +401,8 @@ class PreviewSettingsPage extends ConsumerWidget { : null, ), ), - // MP4 previews need a native player per preview (one at a time). - if (ref.watch(previewSourceProvider) == PreviewSource.video && previewsOn) + // How the two formats differ, so the choice is an informed one. + if (previewsOn) ListTile( leading: Icon(Icons.info_outline, color: theme.colorScheme.tertiary), subtitle: Text(l.previewSourceVideoHint), diff --git a/lib/features/shell/app_shell.dart b/lib/features/shell/app_shell.dart index b873b4e..1833d28 100644 --- a/lib/features/shell/app_shell.dart +++ b/lib/features/shell/app_shell.dart @@ -1,7 +1,7 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:miniplayer/miniplayer.dart'; import '../../core/config/haptics.dart'; import '../../l10n/l10n.dart'; @@ -11,6 +11,7 @@ import '../library/library_page.dart'; import '../performers/performers_page.dart'; import '../player/player_providers.dart'; import '../player/player_view.dart'; +import '../player/sliding_panel.dart'; import '../shorts/shorts_page.dart'; import '../search/search_page.dart'; import '../server_tasks/server_tasks_page.dart'; @@ -28,11 +29,14 @@ import 'shell_layout.dart'; /// them and a bottom navigation bar that slides away as the player expands, /// or a navigation rail on wide windows ([ShellLayout], NAV-4). class AppShell extends ConsumerWidget { - const AppShell({super.key, @visibleForTesting this.pageBuilder}); + const AppShell({super.key, @visibleForTesting this.pageBuilder, @visibleForTesting this.panelBuilder}); /// Replaces [rootPage] in tests. final Widget Function(AppTab tab)? pageBuilder; + /// Replaces the [PlayerPanel] in tests (it needs the native player). + final Widget Function(double height)? panelBuilder; + /// The page a tab starts with; also opened from the "You" page. static Widget rootPage(AppTab tab) => switch (tab) { AppTab(:final section?) => LibrarySectionPage(section: section), @@ -84,6 +88,11 @@ class AppShell extends ConsumerWidget { canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; + // Back first collapses the expanded player. + if (scene != null && !hidePlayer && playerHeight.value > kMiniPlayerHeight) { + ref.read(nowPlayingProvider.notifier).collapse(); + return; + } final navigator = navigatorKeys[currentTab]!.currentState; if (navigator != null && navigator.canPop()) { // maybePop: a page may ask to stay (e.g. while it saves). @@ -117,12 +126,17 @@ class AppShell extends ConsumerWidget { index: selected, children: [ for (final tab in tabs) - Navigator( - key: navigatorKeys[tab], - onGenerateRoute: (_) => MaterialPageRoute( - builder: (_) => PrimaryScrollController( - controller: scrollControllers[tab]!, - child: (pageBuilder ?? rootPage)(tab), + _TabTickers( + shown: tab == tabs[selected], + playerHeight: playerHeight, + maxPlayerHeight: maxPlayerHeight, + child: Navigator( + key: navigatorKeys[tab], + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => PrimaryScrollController( + controller: scrollControllers[tab]!, + child: (pageBuilder ?? rootPage)(tab), + ), ), ), ), @@ -131,13 +145,15 @@ class AppShell extends ConsumerWidget { ), miniplayer: scene == null ? null - : Miniplayer( + : SlidingPanel( controller: ref.watch(miniplayerControllerProvider), - valueNotifier: playerHeight, + height: playerHeight, minHeight: kMiniPlayerHeight, maxHeight: maxPlayerHeight, onDismissed: () => ref.read(nowPlayingProvider.notifier).close(), - builder: (height, _) => PlayerPanel(scene: scene, height: height, maxHeight: maxPlayerHeight), + builder: (_, height) => + panelBuilder?.call(height) ?? + PlayerPanel(scene: scene, height: height, maxHeight: maxPlayerHeight), ), ), ), @@ -145,3 +161,61 @@ class AppShell extends ConsumerWidget { ); } } + +/// Stops a tab's animations (shimmers, animated previews, spinners) while +/// nobody sees it: [IndexedStack] keeps hidden tabs ticking, which cost +/// frames in the shorts and behind the expanded player. +class _TabTickers extends StatefulWidget { + const _TabTickers({ + required this.shown, + required this.playerHeight, + required this.maxPlayerHeight, + required this.child, + }); + + /// The selected tab. + final bool shown; + final ValueListenable playerHeight; + final double maxPlayerHeight; + final Widget child; + + @override + State<_TabTickers> createState() => _TabTickersState(); +} + +class _TabTickersState extends State<_TabTickers> { + late bool _covered = _isCovered; + + bool get _isCovered => widget.playerHeight.value >= widget.maxPlayerHeight - 0.5; + + @override + void initState() { + super.initState(); + widget.playerHeight.addListener(_onPlayerHeight); + } + + @override + void didUpdateWidget(_TabTickers oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.playerHeight != widget.playerHeight) { + oldWidget.playerHeight.removeListener(_onPlayerHeight); + widget.playerHeight.addListener(_onPlayerHeight); + } + _covered = _isCovered; + } + + @override + void dispose() { + widget.playerHeight.removeListener(_onPlayerHeight); + super.dispose(); + } + + // Only a change of covered/uncovered rebuilds, not every frame of the + // player's transition. + void _onPlayerHeight() { + if (_isCovered != _covered) setState(() => _covered = _isCovered); + } + + @override + Widget build(BuildContext context) => TickerMode(enabled: widget.shown && !_covered, child: widget.child); +} diff --git a/lib/features/shorts/shorts_page.dart b/lib/features/shorts/shorts_page.dart index b054c85..b4b31bd 100644 --- a/lib/features/shorts/shorts_page.dart +++ b/lib/features/shorts/shorts_page.dart @@ -554,7 +554,9 @@ class _TopBar extends StatelessWidget { } } -const _shadow = [Shadow(blurRadius: 6, color: Colors.black54)]; +// A small blur: the overlay is drawn anew with every video frame, and a +// wide blur behind every label and icon cost raster time. +const _shadow = [Shadow(offset: Offset(0, 1), blurRadius: 2, color: Colors.black54)]; /// One page: the video (or its screenshot until it is loaded), the info at /// the bottom, the actions on the right and a progress bar. diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7c320cf..4d3855f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -699,7 +699,7 @@ "previewSource": "Vorschau-Format", "previewSourceAnimated": "Animiertes Bild (WebP)", "previewSourceVideo": "Video (MP4)", - "previewSourceVideoHint": "Video-Vorschauen laufen in einem eigenen Videoplayer: mehr Daten, Akku und Speicher als WebP, und auf älteren Geräten starten sie langsamer. Marker spielen weiter die WebP-Vorschauen.", + "previewSourceVideoHint": "Video-Vorschauen nutzen den Video-Decoder des Telefons und laufen flüssig; jede öffnet kurz einen eigenen Videoplayer. Animierte Bilder werden Bild für Bild dekodiert und können bei großen Vorschauen ruckeln. Marker spielen weiter die WebP-Vorschauen.", "previewsOnServer": "Vorschauen auf dem Server prüfen", "previewsOnServerSubtitle": "Ob Stash sie erstellt hat, und fehlende erzeugen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e5c9aaa..87495c3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1488,7 +1488,7 @@ "previewSource": "Preview format", "previewSourceAnimated": "Animated image (WebP)", "previewSourceVideo": "Video (MP4)", - "previewSourceVideoHint": "Video previews play in a video player of their own: more data, battery and memory than WebP, and slower to start on older devices. Markers keep playing the WebP previews.", + "previewSourceVideoHint": "Video previews use the phone's video decoder and play smoothly; each one opens a short-lived video player of its own. Animated images are decoded frame by frame and can stutter with large previews. Markers keep playing the WebP previews.", "previewsOnServer": "Check previews on the server", "previewsOnServerSubtitle": "Whether Stash has created them, and generate missing ones" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 0d4ab52..49a73a4 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -4115,7 +4115,7 @@ abstract class AppLocalizations { /// No description provided for @previewSourceVideoHint. /// /// In en, this message translates to: - /// **'Video previews play in a video player of their own: more data, battery and memory than WebP, and slower to start on older devices. Markers keep playing the WebP previews.'** + /// **'Video previews use the phone\'s video decoder and play smoothly; each one opens a short-lived video player of its own. Animated images are decoded frame by frame and can stutter with large previews. Markers keep playing the WebP previews.'** String get previewSourceVideoHint; /// No description provided for @previewsOnServer. diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 7f61d24..2b5bcf1 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -2501,7 +2501,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get previewSourceVideoHint => - 'Video-Vorschauen laufen in einem eigenen Videoplayer: mehr Daten, Akku und Speicher als WebP, und auf älteren Geräten starten sie langsamer. Marker spielen weiter die WebP-Vorschauen.'; + 'Video-Vorschauen nutzen den Video-Decoder des Telefons und laufen flüssig; jede öffnet kurz einen eigenen Videoplayer. Animierte Bilder werden Bild für Bild dekodiert und können bei großen Vorschauen ruckeln. Marker spielen weiter die WebP-Vorschauen.'; @override String get previewsOnServer => 'Vorschauen auf dem Server prüfen'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 863b913..63ec5c0 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -2490,7 +2490,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get previewSourceVideoHint => - 'Video previews play in a video player of their own: more data, battery and memory than WebP, and slower to start on older devices. Markers keep playing the WebP previews.'; + 'Video previews use the phone\'s video decoder and play smoothly; each one opens a short-lived video player of its own. Animated images are decoded frame by frame and can stutter with large previews. Markers keep playing the WebP previews.'; @override String get previewsOnServer => 'Check previews on the server'; diff --git a/lib/widgets/animated_previews.dart b/lib/widgets/animated_previews.dart index 32fbd07..54de33f 100644 --- a/lib/widgets/animated_previews.dart +++ b/lib/widgets/animated_previews.dart @@ -115,9 +115,11 @@ bool previewsPlay(BuildContext context, WidgetRef ref, NotifierProvider { @@ -127,9 +129,9 @@ class PreviewSourceNotifier extends Notifier { PreviewSource build() { try { final name = ref.watch(sharedPreferencesProvider).getString(_key); - return PreviewSource.values.where((s) => s.name == name).firstOrNull ?? PreviewSource.animatedImage; + return PreviewSource.values.where((s) => s.name == name).firstOrNull ?? PreviewSource.video; } catch (_) { - return PreviewSource.animatedImage; + return PreviewSource.video; } } diff --git a/pubspec.lock b/pubspec.lock index 38b430f..b26cb2f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1060,14 +1060,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - miniplayer: - dependency: "direct main" - description: - name: miniplayer - sha256: "6e12c27aef7432fc16508460a6dc824f3edfeb01761bd0dbfbccc84d516121bf" - url: "https://pub.dev" - source: hosted - version: "1.0.1" nm: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 35a1b61..7d58586 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,7 +47,6 @@ dependencies: cached_network_image: ^4.0.4 flutter_cache_manager: ^3.4.1 flutter_riverpod: ^3.4.3 - miniplayer: ^1.0.1 country_flags: ^4.1.2 media_kit: ^1.2.6 media_kit_video: ^2.0.1 diff --git a/test/data/models_test.dart b/test/data/models_test.dart index 9b8c99e..261a1b1 100644 --- a/test/data/models_test.dart +++ b/test/data/models_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:stash_app_mobile/data/models/json.dart'; import 'package:stash_app_mobile/data/models/list_queries.dart'; import '../fixtures.dart'; @@ -107,4 +108,24 @@ void main() { }); }); }); + + group('Stash placeholder images', () { + const placeholder = 'http://s/performer/1/image?t=1700000000&default=true'; + + test('URLs marked default=true count as no image', () { + expect(stashImageUrl(placeholder), isNull); + expect(stashImageUrl('http://s/performer/1/image?t=1700000000'), 'http://s/performer/1/image?t=1700000000'); + expect(stashImageUrl(''), isNull); + expect(stashImageUrl(null), isNull); + }); + + test('performers, studios and scene performers drop them', () { + expect(performerFrom({'image_path': placeholder}).imageUrl, isNull); + expect(studioFrom({'image_path': placeholder.replaceFirst('performer', 'studio')}).imageUrl, isNull); + final scene = sceneFrom({ + 'performers': [scenePerformerJson({'id': '1', 'name': 'Alice', 'image_path': placeholder})], + }); + expect(scene.performers.single.imageUrl, isNull); + }); + }); } diff --git a/test/features/drag_to_minimize_test.dart b/test/features/drag_to_minimize_test.dart index 65c3a78..3759323 100644 --- a/test/features/drag_to_minimize_test.dart +++ b/test/features/drag_to_minimize_test.dart @@ -1,27 +1,35 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:miniplayer/miniplayer.dart'; import 'package:stash_app_mobile/features/player/drag_to_minimize.dart'; +import 'package:stash_app_mobile/features/player/sliding_panel.dart'; const _max = 800.0; const _videoBottom = 220.0; -// How MiniplayerController encodes target states (see miniplayer's ControllerData). -const _min = -1; -const _maxState = -2; +/// Records what [DragToMinimize] asks the panel for: a height, or +/// 'collapse'/'expand'. +class _RecordingController extends SlidingPanelController { + Object? last; + + @override + void expand({Duration? duration}) => last = 'expand'; + + @override + void collapse({Duration? duration}) => last = 'collapse'; + + @override + void jumpTo(double height) => last = height; +} void main() { - late MiniplayerController controller; + late _RecordingController controller; late ScrollController scroll; setUp(() { - controller = MiniplayerController(); + controller = _RecordingController(); scroll = ScrollController(); }); - tearDown(() { - controller.dispose(); - scroll.dispose(); - }); + tearDown(() => scroll.dispose()); /// Video area that claims vertical drags (like media_kit's controls) above /// a details list (like "Up next"). @@ -57,13 +65,13 @@ void main() { ), )); - int? panelTarget() => controller.value?.height; + Object? panelTarget() => controller.last; testWidgets('swiping down on the video collapses, even though the video claims the drag', (tester) async { await pump(tester); await tester.drag(find.byKey(const Key('video')), const Offset(0, 300)); await tester.pump(); - expect(panelTarget(), _min); + expect(panelTarget(), 'collapse'); }); testWidgets('the panel follows the finger during the swipe', (tester) async { @@ -83,13 +91,13 @@ void main() { await tester.pump(const Duration(milliseconds: 50)); } await gesture.up(); - expect(panelTarget(), _maxState); + expect(panelTarget(), 'expand'); }); testWidgets('swiping down in the details at the top collapses', (tester) async { await pump(tester); await tester.drag(find.byKey(const Key('details')), const Offset(0, 300)); - expect(panelTarget(), _min); + expect(panelTarget(), 'collapse'); }); testWidgets('in scrolled details a downward swipe scrolls the list instead', (tester) async { diff --git a/test/features/panel_tap_guard_test.dart b/test/features/panel_tap_guard_test.dart deleted file mode 100644 index 1d70d88..0000000 --- a/test/features/panel_tap_guard_test.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:miniplayer/miniplayer.dart'; -import 'package:stash_app_mobile/features/player/player_controls.dart'; - -void main() { - /// An expanded miniplayer whose content behaves like media_kit's controls: - /// a raw Listener for single taps plus a double-tap recognizer. - Future> pumpExpanded(WidgetTester tester, - {required bool guarded, VoidCallback? onDoubleTap}) async { - final height = ValueNotifier(600); - Widget content = Listener( - onPointerDown: (_) {}, - child: GestureDetector( - onDoubleTap: onDoubleTap ?? () {}, - child: const ColoredBox( - key: Key('video'), color: Colors.black, child: SizedBox.expand()), - ), - ); - if (guarded) content = PanelTapGuard(child: content); - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: Miniplayer( - minHeight: 64, - maxHeight: 600, - valueNotifier: height, - builder: (_, _) => content, - ), - ), - )); - return height; - } - - testWidgets('without the guard a single tap collapses the expanded player', - (tester) async { - final height = await pumpExpanded(tester, guarded: false); - await tester.tap(find.byKey(const Key('video'))); - // The double-tap recognizer holds the arena until its timeout. - await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 50)); - await tester.pumpAndSettle(); - expect(height.value, 64); - }); - - testWidgets('with the guard a single tap keeps the player expanded', - (tester) async { - final height = await pumpExpanded(tester, guarded: true); - await tester.tap(find.byKey(const Key('video'))); - // The double-tap recognizer holds the arena until its timeout. - await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 50)); - await tester.pumpAndSettle(); - expect(height.value, 600); - }); - - testWidgets('double tap still reaches the video controls', (tester) async { - var doubleTaps = 0; - final height = await pumpExpanded(tester, - guarded: true, onDoubleTap: () => doubleTaps++); - final video = find.byKey(const Key('video')); - await tester.tap(video); - await tester.pump(const Duration(milliseconds: 50)); - await tester.tap(video); - await tester.pumpAndSettle(); - expect(doubleTaps, 1); - expect(height.value, 600); - }); -} diff --git a/test/features/player/seek_sequence_test.dart b/test/features/player/seek_sequence_test.dart index 5d6d949..466f274 100644 --- a/test/features/player/seek_sequence_test.dart +++ b/test/features/player/seek_sequence_test.dart @@ -127,6 +127,22 @@ void main() { List seconds(List seeks) => [for (final s in seeks) s.inSeconds]; + testWidgets('fingers still down when the controls go away cause no errors', (tester) async { + // A swipe down collapses the player and removes the controls while the + // fingers are still on the screen; their events keep arriving. + await pump(tester); + final one = await tester.startGesture(left(tester)); + final two = await tester.startGesture(right(tester), pointer: 2); + await one.moveBy(const Offset(10, 0)); + await tester.pumpWidget(const SizedBox()); + await one.moveBy(const Offset(0, 40)); + await two.moveBy(const Offset(0, 40)); + await one.up(); + await two.up(); + await tester.pump(const Duration(seconds: 1)); // gesture timers run out + expect(tester.takeException(), isNull); + }); + testWidgets('every further tap skips further: 5, 10, 20, 30, 40 s', (tester) async { await pump(tester); await tap(tester, right(tester)); diff --git a/test/features/preview_seek_bar_test.dart b/test/features/preview_seek_bar_test.dart index 1cac367..1780fb3 100644 --- a/test/features/preview_seek_bar_test.dart +++ b/test/features/preview_seek_bar_test.dart @@ -45,6 +45,29 @@ void main() { return Offset(rect.left + rect.width * fraction, rect.center.dy); } + SeekBarPainter painter(WidgetTester tester) => tester + .widget(find.byWidgetPredicate((w) => w is CustomPaint && w.painter is SeekBarPainter)) + .painter! as SeekBarPainter; + + testWidgets('positions repaint the bar; it rebuilds only once a second', (tester) async { + await pump(tester); + player.position.add(const Duration(milliseconds: 20200)); + await tester.pump(); + final first = painter(tester); + expect(first.played, closeTo(0.202, 1e-9)); + + // mpv reports every frame: same second, same painter, new value. + player.position.add(const Duration(milliseconds: 20600)); + await tester.pump(); + expect(painter(tester), same(first)); + expect(first.played, closeTo(0.206, 1e-9)); + + player.position.add(const Duration(milliseconds: 21000)); + await tester.pump(); + expect(painter(tester), isNot(same(first)), reason: 'the spoken value follows each second'); + expect(painter(tester).played, closeTo(0.21, 1e-9)); + }); + testWidgets('takes the full available width', (tester) async { await pump(tester); expect(tester.getSize(find.byType(PreviewSeekBar)).width, 400); diff --git a/test/features/shell_layout_test.dart b/test/features/shell_layout_test.dart index 27beaa9..e65ec91 100644 --- a/test/features/shell_layout_test.dart +++ b/test/features/shell_layout_test.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:miniplayer/miniplayer.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:stash_app_mobile/core/config/server_config.dart'; import 'package:stash_app_mobile/data/models/scene.dart'; import 'package:stash_app_mobile/features/player/player_providers.dart'; +import 'package:stash_app_mobile/features/player/sliding_panel.dart'; import 'package:stash_app_mobile/features/shell/app_shell.dart'; import 'package:stash_app_mobile/features/shell/navigation.dart'; import 'package:stash_app_mobile/features/shell/shell_layout.dart'; @@ -68,8 +68,9 @@ void main() { content: const SizedBox.expand(key: contentKey), miniplayer: !player ? null - : Miniplayer( - valueNotifier: height, + : SlidingPanel( + controller: SlidingPanelController(), + height: height, minHeight: kMiniPlayerHeight, maxHeight: maxHeight, builder: (_, _) => const ColoredBox(key: panelKey, color: Colors.red), @@ -172,7 +173,7 @@ void main() { group('AppShell on a tablet', () { late ProviderContainer container; - Future pumpShell(WidgetTester tester, Size size) async { + Future pumpShell(WidgetTester tester, Size size, {bool playing = false}) async { setSize(tester, size); final prefs = (await tester.runAsync(() async { SharedPreferences.setMockInitialValues({}); @@ -181,7 +182,7 @@ void main() { container = ProviderContainer(overrides: [ ...testServer, sharedPreferencesProvider.overrideWithValue(prefs), - nowPlayingProvider.overrideWith(_NothingPlaying.new), + nowPlayingProvider.overrideWith(playing ? _Playing.new : _NothingPlaying.new), ]); addTearDown(container.dispose); await tester.pumpWidget(UncontrolledProviderScope( @@ -191,6 +192,7 @@ void main() { localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: AppShell( + panelBuilder: (_) => const ColoredBox(key: Key('panel'), color: Colors.red), pageBuilder: (tab) => Scaffold( body: ListView( children: [for (var i = 0; i < 100; i++) SizedBox(height: 50, child: Text('${tab.name} $i'))], @@ -236,6 +238,42 @@ void main() { expect(find.byType(NavigationRail), findsOneWidget); }); + testWidgets('back collapses the expanded player before leaving a page', (tester) async { + await pumpShell(tester, const Size(390, 844), playing: true); + final navigator = container.read(tabNavigatorKeysProvider)[AppTab.home]!.currentState!; + navigator.push(MaterialPageRoute(builder: (_) => const Text('detail page'))); + await tester.pumpAndSettle(); + container.read(nowPlayingProvider.notifier).expand(); + await tester.pumpAndSettle(); + final height = container.read(miniplayerHeightProvider); + expect(height.value, 844); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(height.value, kMiniPlayerHeight); + expect(navigator.canPop(), isTrue, reason: 'the page stays'); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(navigator.canPop(), isFalse); + expect(find.byKey(const Key('panel')), findsOneWidget); + }); + + testWidgets('only the shown tab animates, and not behind the open player', (tester) async { + await pumpShell(tester, const Size(390, 844), playing: true); + bool ticking(String text) => TickerMode.valuesOf(tester.element(find.text(text, skipOffstage: false))).enabled; + expect(ticking('home 0'), isTrue); + expect(ticking('search 0'), isFalse, reason: 'a hidden tab'); + + container.read(nowPlayingProvider.notifier).expand(); + await tester.pumpAndSettle(); + expect(ticking('home 0'), isFalse, reason: 'covered by the player'); + + container.read(nowPlayingProvider.notifier).collapse(); + await tester.pumpAndSettle(); + expect(ticking('home 0'), isTrue); + }); + testWidgets('a phone keeps the bottom bar', (tester) async { await pumpShell(tester, const Size(390, 844)); expect(find.byType(NavigationBar), findsOneWidget); @@ -248,3 +286,8 @@ class _NothingPlaying extends NowPlayingNotifier { @override Scene? build() => null; } + +class _Playing extends NowPlayingNotifier { + @override + Scene? build() => const Scene(id: '1', title: 'A'); +} diff --git a/test/features/sliding_panel_test.dart b/test/features/sliding_panel_test.dart new file mode 100644 index 0000000..b70ed8c --- /dev/null +++ b/test/features/sliding_panel_test.dart @@ -0,0 +1,287 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stash_app_mobile/features/player/sliding_panel.dart'; + +const _min = 64.0; +const _max = 600.0; + +/// Counts how often it is mounted, to tell whether the panel kept it. +class _Content extends StatefulWidget { + const _Content({required this.onDoubleTap}); + + final VoidCallback onDoubleTap; + static int mounts = 0; + + @override + State<_Content> createState() => _ContentState(); +} + +class _ContentState extends State<_Content> { + @override + void initState() { + super.initState(); + _Content.mounts++; + } + + // Like media_kit's controls: a raw Listener plus a double-tap recognizer. + @override + Widget build(BuildContext context) => Listener( + onPointerDown: (_) {}, + child: GestureDetector( + onDoubleTap: widget.onDoubleTap, + child: const ColoredBox(key: Key('content'), color: Colors.black, child: SizedBox.expand()), + ), + ); +} + +void main() { + late SlidingPanelController controller; + late ValueNotifier height; + late int dismissed; + late int doubleTaps; + + setUp(() { + controller = SlidingPanelController(); + height = ValueNotifier(_min); + dismissed = 0; + doubleTaps = 0; + _Content.mounts = 0; + }); + tearDown(() => height.dispose()); + + Future pump(WidgetTester tester, {double maxHeight = _max, bool dismissible = true, bool reduceMotion = false}) { + tester.view + ..physicalSize = const Size(400, _max) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + return tester.pumpWidget(MaterialApp( + home: MediaQuery( + data: MediaQueryData(size: const Size(400, _max), disableAnimations: reduceMotion), + child: Scaffold( + body: SlidingPanel( + controller: controller, + height: height, + minHeight: _min, + maxHeight: maxHeight, + onDismissed: dismissible ? () => dismissed++ : null, + builder: (_, _) => _Content(onDoubleTap: () => doubleTaps++), + ), + ), + ), + )); + } + + Finder content() => find.byKey(const Key('content')); + + Future expand(WidgetTester tester) async { + controller.expand(); + await tester.pumpAndSettle(); + expect(height.value, _max); + } + + testWidgets('starts as the mini bar at the bottom', (tester) async { + await pump(tester); + expect(tester.getRect(content()), const Rect.fromLTRB(0, _max - _min, 400, _max)); + expect(controller.isAttached, isTrue); + }); + + testWidgets('the controller animates between bar and full height', (tester) async { + await pump(tester); + controller.expand(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(height.value, inExclusiveRange(_min, _max), reason: 'animates, does not jump'); + await tester.pumpAndSettle(); + expect(height.value, _max); + expect(tester.getRect(content()), const Rect.fromLTRB(0, 0, 400, _max)); + + controller.collapse(); + await tester.pumpAndSettle(); + expect(height.value, _min); + }); + + testWidgets('jumpTo follows right away and stays in range', (tester) async { + await pump(tester); + controller.jumpTo(300); + expect(height.value, 300); + controller.jumpTo(5000); + expect(height.value, _max); + controller.jumpTo(0); + expect(height.value, _min, reason: 'only a drag on the bar goes below it'); + expect(dismissed, 0); + }); + + testWidgets('reduced motion expands without an animation', (tester) async { + await pump(tester, reduceMotion: true); + controller.expand(); + expect(height.value, _max); + }); + + testWidgets('a tap on the bar expands it', (tester) async { + await pump(tester); + await tester.tap(content()); + // The content's double-tap recognizer holds the arena until its timeout. + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); + expect(height.value, _max); + }); + + testWidgets('expanded, a single tap stays with the content', (tester) async { + await pump(tester); + await expand(tester); + await tester.tap(content()); + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); + expect(height.value, _max); + }); + + testWidgets('expanded, a double tap reaches the content', (tester) async { + await pump(tester); + await expand(tester); + await tester.tap(content()); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(content()); + await tester.pumpAndSettle(); + expect(doubleTaps, 1); + expect(height.value, _max); + }); + + testWidgets('expanded, the panel ignores vertical drags (DragToMinimize has them)', (tester) async { + await pump(tester); + await expand(tester); + await tester.drag(content(), const Offset(0, 300)); + await tester.pumpAndSettle(); + expect(height.value, _max); + }); + + testWidgets('dragging the bar up follows the finger and expands on release', (tester) async { + await pump(tester); + final gesture = await tester.startGesture(tester.getCenter(content())); + await gesture.moveBy(const Offset(0, -30)); + await gesture.moveBy(const Offset(0, -100)); + await tester.pump(); + // The first move goes partly into the drag slop. + expect(height.value, inInclusiveRange(_min + 100, _min + 130)); + await gesture.moveBy(const Offset(0, -100)); + await tester.pump(const Duration(milliseconds: 500)); // slow release + await gesture.up(); + await tester.pumpAndSettle(); + expect(height.value, _max); + }); + + testWidgets('a short, slow drag up snaps back to the bar', (tester) async { + await pump(tester); + final gesture = await tester.startGesture(tester.getCenter(content())); + for (var i = 0; i < 6; i++) { + await gesture.moveBy(const Offset(0, -8)); + await tester.pump(const Duration(milliseconds: 100)); + } + await gesture.up(); + await tester.pumpAndSettle(); + expect(height.value, _min); + }); + + testWidgets('dragging up to the top and back down in one go still snaps', (tester) async { + await pump(tester); + final gesture = await tester.startGesture(tester.getCenter(content())); + await gesture.moveBy(const Offset(0, -20)); + await gesture.moveBy(const Offset(0, -_max)); + await tester.pump(); + expect(height.value, _max); + await gesture.moveBy(const Offset(0, 500)); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(height.value, _min); + }); + + testWidgets('dragging the bar down out of the window dismisses it', (tester) async { + await pump(tester); + await tester.drag(content(), const Offset(0, 60)); + await tester.pumpAndSettle(); + expect(dismissed, 1); + expect(height.value, _min, reason: 'the height never goes below the bar'); + }); + + testWidgets('a small, slow drag down slides the bar back', (tester) async { + await pump(tester); + final gesture = await tester.startGesture(tester.getCenter(content())); + await gesture.moveBy(const Offset(0, 20)); + await gesture.moveBy(const Offset(0, 10)); + await tester.pump(); + final opacity = tester.widget(find.ancestor(of: content(), matching: find.byType(Opacity)).first); + expect(opacity.opacity, lessThan(1), reason: 'fades while dragged out'); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(dismissed, 0); + expect(tester.getRect(content()), const Rect.fromLTRB(0, _max - _min, 400, _max)); + }); + + testWidgets('without onDismissed the bar cannot be dragged out', (tester) async { + await pump(tester, dismissible: false); + await tester.drag(content(), const Offset(0, 60)); + await tester.pumpAndSettle(); + expect(tester.getRect(content()), const Rect.fromLTRB(0, _max - _min, 400, _max)); + }); + + testWidgets('a tap on the dimmed page above the panel collapses it', (tester) async { + await pump(tester); + controller.jumpTo(300); + await tester.pump(); + await tester.tapAt(const Offset(200, 100)); + await tester.pumpAndSettle(); + expect(height.value, _min); + }); + + testWidgets('the content is never remounted while the panel moves', (tester) async { + await pump(tester); + await expand(tester); + controller.collapse(); + await tester.pumpAndSettle(); + final gesture = await tester.startGesture(tester.getCenter(content())); + await gesture.moveBy(const Offset(0, 20)); + await gesture.moveBy(const Offset(0, 20)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(_Content.mounts, 1); + }); + + testWidgets('an expanded panel stays expanded when the window changes', (tester) async { + await pump(tester); + await expand(tester); + await pump(tester, maxHeight: 400); + await tester.pump(); + expect(height.value, 400); + await pump(tester, maxHeight: _max); + await tester.pump(); + expect(height.value, _max); + }); + + testWidgets('the controller detaches with the panel', (tester) async { + await pump(tester); + await tester.pumpWidget(const SizedBox()); + expect(controller.isAttached, isFalse); + controller.expand(); // nothing plays: ignored + expect(height.value, _min); + }); + + test('release decision', () { + PanelSnap snap(double progress, double velocity, {double dismissed = 0}) => + SlidingPanel.snapAfterDrag(progress: progress, dismissed: dismissed, velocity: velocity, canDismiss: true); + expect(snap(0.5, 0), PanelSnap.expand, reason: 'far up'); + expect(snap(0.1, 0), PanelSnap.collapse); + expect(snap(0.1, -800), PanelSnap.expand, reason: 'flung up'); + expect(snap(0.8, 800), PanelSnap.collapse, reason: 'flung down'); + expect(snap(0, 0, dismissed: 0.5), PanelSnap.dismiss); + expect(snap(0, 0, dismissed: 0.1), PanelSnap.collapse); + expect(snap(0, 800, dismissed: 0.1), PanelSnap.dismiss, reason: 'flung down'); + expect(snap(0, -800, dismissed: 0.5), PanelSnap.collapse, reason: 'flung back up'); + expect( + SlidingPanel.snapAfterDrag(progress: 0, dismissed: 0.5, velocity: 0, canDismiss: false), + PanelSnap.collapse, + ); + }); +} diff --git a/test/widgets/auto_preview_test.dart b/test/widgets/auto_preview_test.dart index 6dbccb0..3944461 100644 --- a/test/widgets/auto_preview_test.dart +++ b/test/widgets/auto_preview_test.dart @@ -20,7 +20,13 @@ void main() { List overrides = const [], }) => tester.pumpWidget(ProviderScope( - overrides: [...testServer, feedPreviewsProvider.overrideWithBuild((ref, notifier) => previews), ...overrides], + overrides: [ + ...testServer, + feedPreviewsProvider.overrideWithBuild((ref, notifier) => previews), + // The WebP: a video preview would need the native player. + previewSourceProvider.overrideWithBuild((ref, notifier) => PreviewSource.animatedImage), + ...overrides, + ], child: MaterialApp( home: Scaffold( body: AutoPreviewScope( @@ -142,6 +148,16 @@ void main() { expect(asked, isFalse); }); + test('previews play the MP4 by default; a stored choice wins', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final c = ProviderContainer(overrides: [sharedPreferencesProvider.overrideWithValue(prefs)]); + addTearDown(c.dispose); + expect(c.read(previewSourceProvider), PreviewSource.video); + await c.read(previewSourceProvider.notifier).set(PreviewSource.animatedImage); + expect(prefs.getString('preview_source'), 'animatedImage'); + }); + test('the setting is stored, off by default', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance();