Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion lib/screens/discover_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,14 @@ import 'settings/settings_screen.dart';
import '../watch_together/watch_together.dart';
import '../providers/companion_remote_provider.dart';
import '../widgets/companion_remote/remote_session_dialog.dart';
import '../widgets/app_refresh_indicator.dart';
import 'companion_remote/mobile_remote_screen.dart';

extension on CustomScrollView {
Widget _withRefreshIndicator(Future<void> Function() onRefresh) =>
AppRefreshIndicator(onRefresh: onRefresh, child: this);
}

class DiscoverScreen extends StatefulWidget {
const DiscoverScreen({super.key});

Expand Down Expand Up @@ -1063,6 +1069,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
children: [
CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Hero Section (Continue Watching) - at top of screen
Builder(
Expand Down Expand Up @@ -1179,7 +1186,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
SliverToBoxAdapter(child: SizedBox(height: 24 + bottomPadding)),
],
],
),
)._withRefreshIndicator(_discover.load),
// Overlaid app bar — excluded from default focus traversal so that
// initial/tab-switch focus lands on content (hero/hubs), not the toolbar.
// Toolbar buttons are still reachable via explicit UP from hero section.
Expand Down
3 changes: 2 additions & 1 deletion lib/screens/libraries/tabs/base_library_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import '../../../media/media_library.dart';
import '../../../utils/app_logger.dart';
import '../../../mixins/library_tab_state.dart';
import '../../../mixins/refreshable.dart';
import '../../../widgets/app_refresh_indicator.dart';
import '../content_state_builder.dart';

/// Base class for library tab screens that provides common state management
Expand Down Expand Up @@ -268,7 +269,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
emptyIcon: emptyIcon,
emptyMessage: emptyMessage,
onRetry: loadItems,
builder: (items) => RefreshIndicator(onRefresh: loadItems, child: buildContent(items)),
builder: (items) => AppRefreshIndicator(onRefresh: loadItems, child: buildContent(items)),
);
}
}
29 changes: 15 additions & 14 deletions lib/screens/libraries/tabs/library_browse_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import '../../../mixins/paginated_item_loader.dart';
import '../../../widgets/card_inflation_budget.dart';
import '../../../widgets/skeleton_media_card.dart';
import '../../../widgets/sliver_child_memo.dart';
import '../../../widgets/app_refresh_indicator.dart';
import '../../../utils/deletion_notifier.dart';
import '../../../utils/global_key_utils.dart';
import '../../../utils/watch_state_notifier.dart';
Expand Down Expand Up @@ -1484,9 +1485,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows

/// Builds the scrollable content with optional chips, then folder tree or grid/list.
Widget _buildScrollableContent() {
final isFolders = _selectedGrouping == 'folders';

Widget scrollView = NotificationListener<ScrollNotification>(
final scrollView = NotificationListener<ScrollNotification>(
onNotification: (notification) {
// Track scroll activity for phone scroll handle and range-load gating
if (notification is ScrollStartNotification) {
Expand All @@ -1507,6 +1506,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// through to the outer floating header.
// Allow focus decoration to render outside scroll bounds.
clipBehavior: Clip.none,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Capture-only sliver: an invisible Builder whose context lives
// inside this CustomScrollView, used to grab the per-tab
Expand Down Expand Up @@ -1539,18 +1539,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
),
);

// Folders mode previously had its own RefreshIndicator inside FolderTreeView;
// it now lives at this level since FolderTreeView is a sliver.
if (isFolders) {
scrollView = RefreshIndicator(
onRefresh: () async {
// Keep a single indicator around the shared scroll view. Folders own a
// separate loading path; every other grouping uses the existing browse
// loader so pagination/filter state is reset consistently.
return AppRefreshIndicator(
onRefresh: () async {
if (_selectedGrouping == 'folders') {
await _folderTreeKey.currentState?.refresh();
},
child: scrollView,
);
}

return scrollView;
} else {
await loadItems();
}
},
child: scrollView,
);
}

/// Self-healing: when a skeleton is rendered after scrolling stops,
Expand Down
80 changes: 80 additions & 0 deletions lib/widgets/app_refresh_indicator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'dart:async';

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

/// The shared pull-to-refresh behavior for app scroll views.
///
/// [RefreshIndicator] handles touch drags and simple trackpad scrollables.
/// Nested or competing scrollables can consume macOS trackpad pan events
/// before the indicator receives drag details, so this also triggers the same
/// indicator from a vertical trackpad pull that begins at the top.
class AppRefreshIndicator extends StatefulWidget {
final Future<void> Function() onRefresh;
final Widget child;

const AppRefreshIndicator({super.key, required this.onRefresh, required this.child});

@override
State<AppRefreshIndicator> createState() => _AppRefreshIndicatorState();
}

class _AppRefreshIndicatorState extends State<AppRefreshIndicator> {
static const double _trackpadTriggerDistance = 80;

final GlobalKey<RefreshIndicatorState> _indicatorKey = GlobalKey<RefreshIndicatorState>();
bool _isAtTop = false;
bool _canTriggerFromTrackpad = false;
bool _trackpadTriggered = false;

bool _handleMetrics(ScrollMetricsNotification notification) {
if (notification.depth == 0 && notification.metrics.axis == Axis.vertical) {
_isAtTop = notification.metrics.extentBefore <= 1;
}
return false;
}

bool _handleScroll(ScrollNotification notification) {
if (notification.depth == 0 && notification.metrics.axis == Axis.vertical) {
_isAtTop = notification.metrics.extentBefore <= 1;
}
return false;
}
Comment thread
RyanTheTechMan marked this conversation as resolved.

void _handlePanZoomStart(PointerPanZoomStartEvent event) {
_canTriggerFromTrackpad = _isAtTop;
_trackpadTriggered = false;
}

void _handlePanZoomUpdate(PointerPanZoomUpdateEvent event) {
if (!_canTriggerFromTrackpad || _trackpadTriggered) return;
final pan = event.localPan;
if (pan.dy < _trackpadTriggerDistance || pan.dy.abs() < pan.dx.abs()) return;

_trackpadTriggered = true;
final refresh = _indicatorKey.currentState?.show();
if (refresh != null) unawaited(refresh);
}

void _handlePanZoomEnd(PointerPanZoomEndEvent event) {
_canTriggerFromTrackpad = false;
_trackpadTriggered = false;
}

@override
Widget build(BuildContext context) {
return NotificationListener<ScrollMetricsNotification>(
onNotification: _handleMetrics,
child: NotificationListener<ScrollNotification>(
onNotification: _handleScroll,
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerPanZoomStart: _handlePanZoomStart,
onPointerPanZoomUpdate: _handlePanZoomUpdate,
onPointerPanZoomEnd: _handlePanZoomEnd,
child: RefreshIndicator(key: _indicatorKey, onRefresh: widget.onRefresh, child: widget.child),
),
),
);
}
}
22 changes: 20 additions & 2 deletions test/screens/discover_screen_test.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:drift/native.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter/gestures.dart' show PointerDeviceKind;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
Expand Down Expand Up @@ -340,6 +341,20 @@ void main() {
await tester.pump(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 100));

expect(find.byType(RefreshIndicator), findsOneWidget);
final scrollView = tester.widget<CustomScrollView>(find.byType(CustomScrollView));
expect(scrollView.physics, isA<AlwaysScrollableScrollPhysics>());
expect(client.globalHubsFetchCount, 1);

final trackpadGesture = await tester.createGesture(kind: PointerDeviceKind.trackpad);
final scrollViewCenter = tester.getCenter(find.byType(CustomScrollView));
Comment thread
RyanTheTechMan marked this conversation as resolved.
Outdated
await trackpadGesture.panZoomStart(scrollViewCenter);
await trackpadGesture.panZoomUpdate(scrollViewCenter, pan: const Offset(0, 300));
await trackpadGesture.panZoomEnd();
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(client.globalHubsFetchCount, 2, reason: 'one pull gesture performs one Discover load');

expect(find.byType(PageView), findsOneWidget);
expect(find.byIcon(Symbols.pause_rounded), findsOneWidget, reason: 'hero indicators render in pointer mode');

Expand Down Expand Up @@ -385,6 +400,7 @@ void main() {
class _FakeMediaServerClient implements MediaServerClient {
final List<MediaHub> hubs;
final List<MediaItem> continueWatching;
int globalHubsFetchCount = 0;

_FakeMediaServerClient({required this.hubs, this.continueWatching = const []});

Expand All @@ -404,8 +420,10 @@ class _FakeMediaServerClient implements MediaServerClient {
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async => continueWatching;

@override
Future<List<MediaHub>> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true}) async =>
hubs;
Future<List<MediaHub>> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true}) async {
globalHubsFetchCount++;
return hubs;
}

@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
Expand Down