From fb048097abcfa2ed67af73c8461811f32d656a09 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:49:45 +0200 Subject: [PATCH] refactor(livetv): one refresh-gating mixin, one multi-server iteration helper, simpler recordings coalescer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pause/resume/ticker state machine (three gates, lifecycle observer, TickerMode subscription, timer sync) was triplicated across the What's On, Recordings, and Guide tabs — now LiveTvRefreshMixin, with the guide's drift catch-up layered on its hooks and the lifecycle test asserting the same contract against the shared implementation. Six hand-rolled multi-server loops share forEachLiveTvServer (the channel/favorites loaders deliberately iterate per DVR entry and say so). Recordings' Completer-based load drain became the same whenComplete coalescing shape the screen's channel loader already uses. --- .../livetv/live_tv_refresh_lifecycle.dart | 11 -- lib/screens/livetv/live_tv_refresh_mixin.dart | 118 +++++++++++++++++ lib/screens/livetv/live_tv_screen.dart | 48 ++++--- .../livetv/live_tv_server_iteration.dart | 39 ++++++ lib/screens/livetv/tabs/guide_tab.dart | 115 ++++++---------- lib/screens/livetv/tabs/recordings_tab.dart | 123 ++++++------------ lib/screens/livetv/tabs/whats_on_tab.dart | 95 ++++---------- .../live_tv_refresh_lifecycle_test.dart | 2 +- 8 files changed, 288 insertions(+), 263 deletions(-) delete mode 100644 lib/screens/livetv/live_tv_refresh_lifecycle.dart create mode 100644 lib/screens/livetv/live_tv_refresh_mixin.dart create mode 100644 lib/screens/livetv/live_tv_server_iteration.dart diff --git a/lib/screens/livetv/live_tv_refresh_lifecycle.dart b/lib/screens/livetv/live_tv_refresh_lifecycle.dart deleted file mode 100644 index eaaffff6d..000000000 --- a/lib/screens/livetv/live_tv_refresh_lifecycle.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter/widgets.dart'; - -enum LiveTvRefreshLifecycleTransition { pause, resume, ignore } - -LiveTvRefreshLifecycleTransition liveTvRefreshTransition(AppLifecycleState state) { - return switch (state) { - AppLifecycleState.paused || AppLifecycleState.hidden => LiveTvRefreshLifecycleTransition.pause, - AppLifecycleState.resumed => LiveTvRefreshLifecycleTransition.resume, - AppLifecycleState.inactive || AppLifecycleState.detached => LiveTvRefreshLifecycleTransition.ignore, - }; -} diff --git a/lib/screens/livetv/live_tv_refresh_mixin.dart b/lib/screens/livetv/live_tv_refresh_mixin.dart new file mode 100644 index 000000000..49c4a4abd --- /dev/null +++ b/lib/screens/livetv/live_tv_refresh_mixin.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +enum LiveTvRefreshLifecycleTransition { pause, resume, ignore } + +LiveTvRefreshLifecycleTransition liveTvRefreshTransition(AppLifecycleState state) { + return switch (state) { + AppLifecycleState.paused || AppLifecycleState.hidden => LiveTvRefreshLifecycleTransition.pause, + AppLifecycleState.resumed => LiveTvRefreshLifecycleTransition.resume, + AppLifecycleState.inactive || AppLifecycleState.detached => LiveTvRefreshLifecycleTransition.ignore, + }; +} + +/// What re-opened the refresh gates in [LiveTvRefreshMixin.onRefreshResumed]. +enum LiveTvRefreshResumeReason { tabSelected, subtreeShown, appResumed } + +/// Refresh gating shared by the Live TV tabs. +/// +/// The periodic [onRefreshTick] runs only while all three gates hold: +/// - the tab is selected (the parent screen drives [pauseRefresh]/[resumeRefresh]), +/// - the subtree is visible (TickerMode: main-screen section switch, opaque route push/pop), +/// - the app is foregrounded ([liveTvRefreshTransition] of the [WidgetsBindingObserver] lifecycle stream). +/// +/// [onRefreshPaused]/[onRefreshResumed] fire on gate edges so tabs can layer extra work (RecordingsTab +/// reload-on-select, GuideTab drift catch-up) on top of the timer without re-implementing the gating. +mixin LiveTvRefreshMixin on State, WidgetsBindingObserver { + Timer? _refreshTimer; + bool _refreshRequested = true; + bool _tickerEnabled = true; + bool _appRefreshActive = true; + + /// Interval between [onRefreshTick] calls while all gates hold. + Duration get refreshInterval; + + /// Periodic refresh work. + void onRefreshTick(); + + /// A gate closed: tab deselected, subtree hidden, or app backgrounded. + void onRefreshPaused() {} + + /// A gate re-opened while every other gate holds, so the refresh timer restarted. + void onRefreshResumed(LiveTvRefreshResumeReason reason) {} + + /// Whether the surrounding subtree is visible (TickerMode enabled). + bool get isRefreshSubtreeVisible => _tickerEnabled; + + bool get _gatesOpen => _refreshRequested && _tickerEnabled && _appRefreshActive && mounted; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _syncRefreshTimer(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Subscribes to TickerMode, so this fires whenever the tab is shown or + // hidden (main-screen IndexedStack section switch, opaque route push/pop). + final enabled = TickerMode.valuesOf(context).enabled; + if (enabled == _tickerEnabled) return; + _tickerEnabled = enabled; + _syncRefreshTimer(); + enabled ? _notifyResumed(LiveTvRefreshResumeReason.subtreeShown) : onRefreshPaused(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (liveTvRefreshTransition(state)) { + case LiveTvRefreshLifecycleTransition.pause: + if (!_appRefreshActive) return; + _appRefreshActive = false; + _syncRefreshTimer(); + onRefreshPaused(); + case LiveTvRefreshLifecycleTransition.resume: + if (_appRefreshActive) return; + _appRefreshActive = true; + _syncRefreshTimer(); + _notifyResumed(LiveTvRefreshResumeReason.appResumed); + case LiveTvRefreshLifecycleTransition.ignore: + break; + } + } + + /// Parent screen deselected this tab. + void pauseRefresh() { + _refreshRequested = false; + _syncRefreshTimer(); + onRefreshPaused(); + } + + /// Parent screen selected this tab. + void resumeRefresh() { + _refreshRequested = true; + _syncRefreshTimer(); + _notifyResumed(LiveTvRefreshResumeReason.tabSelected); + } + + void _notifyResumed(LiveTvRefreshResumeReason reason) { + if (_gatesOpen) onRefreshResumed(reason); + } + + void _syncRefreshTimer() { + _refreshTimer?.cancel(); + _refreshTimer = null; + if (!_gatesOpen) return; + _refreshTimer = Timer.periodic(refreshInterval, (_) => onRefreshTick()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _refreshTimer?.cancel(); + super.dispose(); + } +} diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index ee48f0d45..10c88ce7e 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -27,6 +27,7 @@ import '../../utils/snackbar_helper.dart'; import '../../widgets/focusable_tab_chip.dart'; import '../../widgets/overlay_sheet.dart'; import '../libraries/state_messages.dart'; +import 'live_tv_server_iteration.dart'; import 'reorder_favorites_sheet.dart'; import 'tabs/guide_tab.dart'; import 'tabs/recordings_tab.dart'; @@ -337,11 +338,12 @@ class _LiveTvScreenState extends State } } - for (final serverInfo in liveTvServers) { - try { - final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId)); - if (genericClient == null) continue; - + // One liveTvServers entry per DVR: visit them all; channels dedupe below. + await forEachLiveTvServer( + multiServer, + resolveClient: multiServer.getClientForServer, + dedupeByServerId: false, + body: (genericClient, serverInfo) async { final liveTv = genericClient.liveTv; final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); final sourceTitle = _sourceTitleForServerInfo(serverInfo); @@ -375,10 +377,11 @@ class _LiveTvScreenState extends State allChannels.add(scopedChannel); } } - } catch (e) { - appLogger.e('Failed to load channels from server ${serverInfo.serverId}', error: e); - } - } + }, + onError: (client, serverInfo, error, stackTrace) { + appLogger.e('Failed to load channels from server ${serverInfo.serverId}', error: error); + }, + ); allChannels.sort((a, b) { final aNum = double.tryParse(a.number ?? '') ?? 999999; @@ -444,18 +447,21 @@ class _LiveTvScreenState extends State final failedStores = {}; final seenFavorites = {}; - for (final serverInfo in multiServer.liveTvServers) { - final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); - if (client == null) continue; - final liveTv = client.liveTv; - final storeKey = liveTv.favoriteStoreKey; - final liveServerKey = _liveServerScopeKey(serverInfo); + // One liveTvServers entry per DVR: register every favorite scope, but + // fetch each favorite store only once. + await forEachLiveTvServer( + multiServer, + resolveClient: multiServer.getClientForServer, + dedupeByServerId: false, + body: (client, serverInfo) async { + final liveTv = client.liveTv; + final storeKey = liveTv.favoriteStoreKey; + final liveServerKey = _liveServerScopeKey(serverInfo); - try { final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); scopeByLiveServer[liveServerKey] = (source: source, storeKey: storeKey, mode: liveTv.favoritePersistenceMode); storeBySource[source] = storeKey; - if (successfulStores.contains(storeKey)) continue; + if (successfulStores.contains(storeKey)) return; final serverFavorites = await liveTv.fetchFavoriteChannels(); successfulStores.add(storeKey); @@ -464,11 +470,13 @@ class _LiveTvScreenState extends State storeBySource[favorite.source] = storeKey; if (seenFavorites.add(favorite.stableKey)) merged.add(favorite); } - } catch (error, stackTrace) { + }, + onError: (client, serverInfo, error, stackTrace) { + final storeKey = client.liveTv.favoriteStoreKey; if (!successfulStores.contains(storeKey)) failedStores.add(storeKey); appLogger.e('Failed to load favorite channels for $storeKey', error: error, stackTrace: stackTrace); - } - } + }, + ); // A failed store keeps its last committed in-memory slice. Healthy stores // still refresh, but mutations stay disabled until every store has loaded diff --git a/lib/screens/livetv/live_tv_server_iteration.dart b/lib/screens/livetv/live_tv_server_iteration.dart new file mode 100644 index 000000000..a31106f65 --- /dev/null +++ b/lib/screens/livetv/live_tv_server_iteration.dart @@ -0,0 +1,39 @@ +import '../../media/ids.dart'; +import '../../media/media_server_client.dart'; +import '../../providers/multi_server_provider.dart'; + +/// Runs [body] once per Live TV server of [multiServer], isolating per-server failures. +/// +/// Owns the iteration shape shared by the Live TV screens: resolve a client per entry via +/// [resolveClient] (What's On passes the Plex-specific resolver, everything else the generic +/// one), skip entries without a client, dedupe by `serverId`, and route a thrown [body] error +/// to [onError] before moving on to the next server. +/// +/// - [dedupeByServerId] is on for server-scoped fetches. `liveTvServers` carries one entry per +/// DVR, so per-DVR sites (channel/favorites loading in LiveTvScreen) pass `false` to visit +/// every entry. +/// - [isCurrent] is re-checked between servers so a caller whose load generation went stale +/// inside [body] stops without touching the remaining servers. +/// - Omitting [onError] lets a [body] error abort the iteration and propagate to the caller. +Future forEachLiveTvServer( + MultiServerProvider multiServer, { + required C? Function(ServerId serverId) resolveClient, + required Future Function(C client, LiveTvServerInfo serverInfo) body, + void Function(C client, LiveTvServerInfo serverInfo, Object error, StackTrace stackTrace)? onError, + bool dedupeByServerId = true, + bool Function()? isCurrent, +}) async { + final seenServers = {}; + for (final serverInfo in multiServer.liveTvServers) { + if (isCurrent?.call() == false) return; + if (dedupeByServerId && !seenServers.add(serverInfo.serverId)) continue; + final client = resolveClient(ServerId(serverInfo.serverId)); + if (client == null) continue; + try { + await body(client, serverInfo); + } catch (error, stackTrace) { + if (onError == null) rethrow; + onError(client, serverInfo, error, stackTrace); + } + } +} diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 0c07c98c1..d401189f8 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -24,7 +24,8 @@ import '../../../media/media_server_client.dart'; import '../../../theme/mono_tokens.dart'; import '../../../utils/app_logger.dart'; import '../live_tv_actions_mixin.dart'; -import '../live_tv_refresh_lifecycle.dart'; +import '../live_tv_refresh_mixin.dart'; +import '../live_tv_server_iteration.dart'; import '../../../utils/formatters.dart'; import '../../../utils/live_tv_grouping.dart'; import '../../../utils/live_tv_matching.dart'; @@ -108,7 +109,7 @@ final class _GuideChannelRow extends _GuideRow { } class GuideTabState extends State - with LiveTvActionsMixin, MountedSetStateMixin, WidgetsBindingObserver { + with LiveTvActionsMixin, MountedSetStateMixin, WidgetsBindingObserver, LiveTvRefreshMixin { static const _slotWidth = 180.0; static const _channelColumnWidth = 132.0; static const _rowHeight = 64.0; @@ -135,14 +136,12 @@ class GuideTabState extends State final ScrollController _gridVerticalController = ScrollController(); bool _syncingScroll = false; - Timer? _timeIndicatorTimer; final _programSelectController = DpadSelectLongPressController(); final _dayPickerKey = GlobalKey(); // Stale-window catch-up state (#1297). The grid window is only auto // re-anchored when it was live-anchored and has drifted fully into the // past — deliberately picked day/time windows are never yanked. - bool _isGuideVisible = true; DateTime? _hiddenSince; bool _nowWasInWindow = true; @@ -193,66 +192,43 @@ class GuideTabState extends State @override void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); _initTimeRange(); _loadPrograms(); _gridHorizontalController.addListener(_syncGridToHeader); _headerHorizontalController.addListener(_syncHeaderToGrid); - - _startTimeIndicatorTimer(); } - void _startTimeIndicatorTimer() { - _timeIndicatorTimer?.cancel(); - _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { - _checkWindowDrift(); - // ignore: no-empty-block - setState triggers rebuild to update time indicator - setStateIfMounted(() {}); - }); + // Not the gated data-refresh timer the other tabs run: the tick is a + // per-minute UI ticker that advances the time indicator and re-anchors a + // live-anchored window that drifted fully into the past. + @override + Duration get refreshInterval => const Duration(minutes: 1); + + @override + void onRefreshTick() { + _checkWindowDrift(); + // ignore: no-empty-block - setState triggers rebuild to update time indicator + setStateIfMounted(() {}); } - // Not the gated data-refresh timer the other tabs run: pause/resume drive the - // per-minute UI ticker, and pause has to stamp _hiddenSince on both a section - // hide and an app background so _catchUpIfStale can measure the absence. - void pauseRefresh() { + // Pause has to stamp _hiddenSince on a tab switch, a section hide, and an + // app background alike so _catchUpIfStale can measure the absence. + @override + void onRefreshPaused() { _hiddenSince ??= DateTime.now(); - _timeIndicatorTimer?.cancel(); } - void resumeRefresh() { - _startTimeIndicatorTimer(); + @override + void onRefreshResumed(LiveTvRefreshResumeReason reason) { unawaited(_refreshScheduledRecordingKeys()); - // Post-frame: resumeRefresh is invoked during tab transitions/build and - // the catch-up may setState (reload or scroll). + // Post-frame: resume fires during tab transitions/build and the catch-up + // may setState (reload or scroll). WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _catchUpIfStale(); }); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - // Subscribes to TickerMode, so this fires whenever the guide is shown or - // hidden (main-screen IndexedStack section switch, opaque route push/pop). - final visible = TickerMode.valuesOf(context).enabled; - if (visible == _isGuideVisible) return; - _isGuideVisible = visible; - visible ? resumeRefresh() : pauseRefresh(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - switch (liveTvRefreshTransition(state)) { - case LiveTvRefreshLifecycleTransition.pause: - pauseRefresh(); - case LiveTvRefreshLifecycleTransition.resume: - if (_isGuideVisible) resumeRefresh(); - case LiveTvRefreshLifecycleTransition.ignore: - break; - } - } - @override void didUpdateWidget(GuideTab oldWidget) { super.didUpdateWidget(oldWidget); @@ -270,7 +246,6 @@ class GuideTabState extends State @override void dispose() { _programLoadGeneration++; - WidgetsBinding.instance.removeObserver(this); _programSelectController.dispose(); _guideFocusNode.dispose(); _gridVerticalController.dispose(); @@ -279,7 +254,6 @@ class GuideTabState extends State _headerHorizontalController.dispose(); _gridHorizontalController.dispose(); _channelVerticalController.dispose(); - _timeIndicatorTimer?.cancel(); _focusSnapshot.dispose(); super.dispose(); } @@ -356,14 +330,14 @@ class GuideTabState extends State /// Timer path: re-anchor only when a live-anchored window drifted fully past. void _checkWindowDrift() { - if (!_isGuideVisible || _isLoading) return; + if (!isRefreshSubtreeVisible || _isLoading) return; if (_nowWasInWindow && !_nowInWindow(DateTime.now())) _jumpToNow(); } /// Active path (app resume / guide became visible): drift-jump, else /// realign the viewport to the live line after a meaningful absence (#1297). void _catchUpIfStale() { - if (!_isGuideVisible) return; // still hidden — keep _hiddenSince + if (!isRefreshSubtreeVisible) return; // still hidden — keep _hiddenSince final hiddenSince = _hiddenSince; _hiddenSince = null; // evaluated while visible — consume it if (_isLoading) return; // in-flight load already ends in _scrollToNow() @@ -392,30 +366,27 @@ class GuideTabState extends State final allPrograms = []; final scheduledRecordingKeys = {}; final multiServer = context.read(); - final liveTvServers = List.of(multiServer.liveTvServers); - final queriedServers = {}; - for (final serverInfo in liveTvServers) { - if (!queriedServers.add(serverInfo.serverId)) continue; - try { - final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId)); - if (genericClient == null) continue; - - final programs = await genericClient.liveTv.fetchSchedule(from: from, to: to); + await forEachLiveTvServer( + multiServer, + resolveClient: multiServer.getClientForServer, + isCurrent: () => _isCurrentProgramLoad(loadGeneration), + body: (client, serverInfo) async { + final programs = await client.liveTv.fetchSchedule(from: from, to: to); if (!_isCurrentProgramLoad(loadGeneration)) return; allPrograms.addAll(programs); await _addScheduledRecordingKeysForServer( - client: genericClient, + client: client, serverId: ServerId(serverInfo.serverId), keys: scheduledRecordingKeys, isCurrent: () => _isCurrentProgramLoad(loadGeneration), ); + }, + onError: (client, serverInfo, error, stackTrace) { if (!_isCurrentProgramLoad(loadGeneration)) return; - } catch (e) { - if (!_isCurrentProgramLoad(loadGeneration)) return; - appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e); - } - } + appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: error); + }, + ); if (!_isCurrentProgramLoad(loadGeneration)) return; final shouldFocus = _pendingFocus; @@ -455,18 +426,16 @@ class GuideTabState extends State if (!mounted) return; final multiServer = context.read(); final scheduledRecordingKeys = {}; - final queriedServers = {}; - for (final serverInfo in multiServer.liveTvServers) { - if (!queriedServers.add(serverInfo.serverId)) continue; - final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); - if (client == null) continue; - await _addScheduledRecordingKeysForServer( + await forEachLiveTvServer( + multiServer, + resolveClient: multiServer.getClientForServer, + body: (client, serverInfo) => _addScheduledRecordingKeysForServer( client: client, serverId: ServerId(serverInfo.serverId), keys: scheduledRecordingKeys, - ); - } + ), + ); if (!mounted) return; setState(() => _scheduledRecordingKeys = scheduledRecordingKeys); diff --git a/lib/screens/livetv/tabs/recordings_tab.dart b/lib/screens/livetv/tabs/recordings_tab.dart index a68e0746d..4dc6518fa 100644 --- a/lib/screens/livetv/tabs/recordings_tab.dart +++ b/lib/screens/livetv/tabs/recordings_tab.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import '../../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -19,7 +18,8 @@ import '../../../utils/dialogs.dart'; import '../../../utils/formatters.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/settings_section.dart'; -import '../live_tv_refresh_lifecycle.dart'; +import '../live_tv_refresh_mixin.dart'; +import '../live_tv_server_iteration.dart'; import '../livetv_recording_actions.dart'; import '../livetv_styles.dart'; @@ -58,58 +58,40 @@ class _RuleEntry { enum _RuleAction { edit, delete } -class RecordingsTabState extends State with WidgetsBindingObserver { +class RecordingsTabState extends State with WidgetsBindingObserver, LiveTvRefreshMixin { List<_ServerRecordings> _serverRecordings = []; bool _isLoading = true; bool _adminBlocked = false; String? _error; - Timer? _refreshTimer; bool _pendingFocus = false; - bool _refreshRequested = true; - bool _tickerEnabled = false; - bool _appRefreshActive = true; int _loadGeneration = 0; Future? _loadFuture; bool _loadPending = false; final _firstTileFocusNode = FocusNode(debugLabel: 'recordings_tab_first_tile'); + @override + Duration get refreshInterval => const Duration(seconds: 30); + + @override + void onRefreshTick() => unawaited(_load()); + + // Same three gates as WhatsOnTab, but re-selecting the tab also reloads: a + // recording scheduled from the guide has to show up on arrival, not on the + // next 30s tick. + @override + void onRefreshResumed(LiveTvRefreshResumeReason reason) { + if (reason == LiveTvRefreshResumeReason.tabSelected) unawaited(_load()); + } + @override void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); unawaited(_load()); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final enabled = TickerMode.valuesOf(context).enabled; - if (enabled == _tickerEnabled) return; - _tickerEnabled = enabled; - _syncRefreshTimer(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - switch (liveTvRefreshTransition(state)) { - case LiveTvRefreshLifecycleTransition.pause: - if (!_appRefreshActive) return; - _appRefreshActive = false; - _syncRefreshTimer(); - case LiveTvRefreshLifecycleTransition.resume: - if (_appRefreshActive) return; - _appRefreshActive = true; - _syncRefreshTimer(); - case LiveTvRefreshLifecycleTransition.ignore: - break; - } - } - @override void dispose() { _loadGeneration++; - WidgetsBinding.instance.removeObserver(this); - _refreshTimer?.cancel(); _firstTileFocusNode.dispose(); super.dispose(); } @@ -125,27 +107,6 @@ class RecordingsTabState extends State with WidgetsBindingObserve } } - // Same three gates as WhatsOnTab (tab selected, subtree visible, app - // foregrounded), but resume also reloads: a recording scheduled from the - // guide has to show up on arrival, not on the next 30s tick. - void pauseRefresh() { - _refreshRequested = false; - _syncRefreshTimer(); - } - - void resumeRefresh() { - _refreshRequested = true; - _syncRefreshTimer(reload: true); - } - - void _syncRefreshTimer({bool reload = false}) { - _refreshTimer?.cancel(); - _refreshTimer = null; - if (!_refreshRequested || !_tickerEnabled || !_appRefreshActive || !mounted) return; - _refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) => _load()); - if (reload) unawaited(_load()); - } - /// Public reload helper for the parent screen's refresh action. Future reload() => _load(); @@ -159,24 +120,16 @@ class RecordingsTabState extends State with WidgetsBindingObserve return inFlight; } - final completer = Completer(); - _loadFuture = completer.future; - unawaited(_drainLoads(completer)); - return completer.future; + final load = _drainLoads().whenComplete(() => _loadFuture = null); + _loadFuture = load; + return load; } - Future _drainLoads(Completer completer) async { - try { - do { - _loadPending = false; - await _loadOnce(); - } while (mounted && _loadPending); - completer.complete(); - } catch (error, stackTrace) { - completer.completeError(error, stackTrace); - } finally { - _loadFuture = null; - } + Future _drainLoads() async { + do { + _loadPending = false; + await _loadOnce(); + } while (mounted && _loadPending); } Future _loadOnce() async { @@ -191,30 +144,30 @@ class RecordingsTabState extends State with WidgetsBindingObserve final results = <_ServerRecordings>[]; var anyAdminError = false; var anyOtherError = false; - final seenServers = {}; - for (final serverInfo in List.of(multiServer.liveTvServers)) { - if (!seenServers.add(serverInfo.serverId)) continue; - final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); - if (client == null) continue; - final dvr = client.liveTvDvr; - if (dvr == null) continue; - try { + await forEachLiveTvServer( + multiServer, + resolveClient: multiServer.getClientForServer, + isCurrent: () => _isCurrentLoad(loadGeneration), + body: (client, serverInfo) async { + final dvr = client.liveTvDvr; + if (dvr == null) return; final grabs = await dvr.fetchScheduledRecordings(); if (!_isCurrentLoad(loadGeneration)) return; final rules = await dvr.fetchRecordingRules(); if (!_isCurrentLoad(loadGeneration)) return; results.add(_ServerRecordings(serverId: serverInfo.serverId, client: client, grabs: grabs, rules: rules)); - } catch (e) { + }, + onError: (client, serverInfo, error, stackTrace) { if (!_isCurrentLoad(loadGeneration)) return; - appLogger.e('Failed to load recordings for ${serverInfo.serverId}', error: e); - if (e is MediaServerHttpException && e.statusCode == 403) { + appLogger.e('Failed to load recordings for ${serverInfo.serverId}', error: error); + if (error is MediaServerHttpException && error.statusCode == 403) { anyAdminError = true; } else { anyOtherError = true; } - } - } + }, + ); if (!_isCurrentLoad(loadGeneration)) return; setState(() { diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 7d46f7c2e..148ea33f1 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import '../../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -8,6 +7,7 @@ import 'package:provider/provider.dart'; import '../../../focus/hub_vertical_navigation.dart'; import '../../../focus/locked_hub_controller.dart'; import '../../../i18n/strings.g.dart'; +import '../../../media/ids.dart'; import '../../../media/media_hub.dart'; import '../../../media/media_item.dart'; import '../../../media/media_item_types.dart'; @@ -20,7 +20,8 @@ import '../../../utils/app_logger.dart'; import '../../../widgets/hub_section.dart'; import '../live_tv_actions_mixin.dart'; import '../live_tv_show_schedule_screen.dart'; -import '../live_tv_refresh_lifecycle.dart'; +import '../live_tv_refresh_mixin.dart'; +import '../live_tv_server_iteration.dart'; class WhatsOnTab extends StatefulWidget { final List channels; @@ -34,106 +35,54 @@ class WhatsOnTab extends StatefulWidget { } class WhatsOnTabState extends State - with LiveTvActionsMixin, MountedSetStateMixin, WidgetsBindingObserver { + with LiveTvActionsMixin, MountedSetStateMixin, WidgetsBindingObserver, LiveTvRefreshMixin { List<_WhatsOnHub> _hubs = []; bool _isLoading = true; - Timer? _refreshTimer; final Map> _hubKeysById = {}; List> _hubKeys = []; final _hubFocusMemory = HubFocusMemory(); - bool _refreshRequested = true; - bool _tickerEnabled = false; - bool _appRefreshActive = true; @override List get liveTvChannels => widget.channels; + // Resume just re-arms the tick — unlike RecordingsTab there is no immediate + // reload, since nothing done on the other tabs changes hubs. + @override + Duration get refreshInterval => const Duration(seconds: 60); + + @override + void onRefreshTick() => unawaited(_loadHubs()); + @override void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); _loadHubs(); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final enabled = TickerMode.valuesOf(context).enabled; - if (enabled == _tickerEnabled) return; - _tickerEnabled = enabled; - _syncRefreshTimer(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - switch (liveTvRefreshTransition(state)) { - case LiveTvRefreshLifecycleTransition.pause: - if (!_appRefreshActive) return; - _appRefreshActive = false; - _syncRefreshTimer(); - case LiveTvRefreshLifecycleTransition.resume: - if (_appRefreshActive) return; - _appRefreshActive = true; - _syncRefreshTimer(); - case LiveTvRefreshLifecycleTransition.ignore: - break; - } - } - - // Refreshes only while all three gates hold: tab selected, subtree visible, - // app foregrounded. Resume just re-arms the tick — unlike RecordingsTab there - // is no immediate reload, since nothing done on the other tabs changes hubs. - void pauseRefresh() { - _refreshRequested = false; - _syncRefreshTimer(); - } - - void resumeRefresh() { - _refreshRequested = true; - _syncRefreshTimer(); - } - - void _syncRefreshTimer() { - _refreshTimer?.cancel(); - _refreshTimer = null; - if (!_refreshRequested || !_tickerEnabled || !_appRefreshActive || !mounted) return; - _refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) => _loadHubs()); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _refreshTimer?.cancel(); - super.dispose(); - } - Future _loadHubs() async { if (!mounted) return; setState(() => _isLoading = _hubs.isEmpty); try { final multiServer = context.read(); - final liveTvServers = multiServer.liveTvServers; final allHubs = <_WhatsOnHub>[]; final allHubIds = []; - final queriedServers = {}; - - for (final serverInfo in liveTvServers) { - if (!queriedServers.add(serverInfo.serverId)) continue; - try { - // Plex-only: Live TV hubs API is Plex-specific. - final client = multiServer.getPlexClientForServer(ServerId(serverInfo.serverId)); - if (client == null) continue; + // Plex-only: Live TV hubs API is Plex-specific. + await forEachLiveTvServer( + multiServer, + resolveClient: multiServer.getPlexClientForServer, + body: (client, serverInfo) async { final hubs = await client.getLiveTvHubs(); for (final hub in hubs) { allHubs.add(_WhatsOnHub.fromResult(hub)); allHubIds.add('${serverInfo.serverId}\u0000${hub.hubKey}'); } - } catch (e) { - appLogger.e('Failed to load hubs from server ${serverInfo.serverId}', error: e); - } - } + }, + onError: (client, serverInfo, error, stackTrace) { + appLogger.e('Failed to load hubs from server ${serverInfo.serverId}', error: error); + }, + ); if (!mounted) return; final hubIds = allHubIds.toSet(); diff --git a/test/screens/livetv/live_tv_refresh_lifecycle_test.dart b/test/screens/livetv/live_tv_refresh_lifecycle_test.dart index 1510c1f93..32e55d211 100644 --- a/test/screens/livetv/live_tv_refresh_lifecycle_test.dart +++ b/test/screens/livetv/live_tv_refresh_lifecycle_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/screens/livetv/live_tv_refresh_lifecycle.dart'; +import 'package:plezy/screens/livetv/live_tv_refresh_mixin.dart'; void main() { test('desktop inactive state leaves Live TV refresh timers running', () {