refactor(livetv): one refresh-gating mixin, one multi-server iteration helper, simpler recordings coalescer

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.
This commit is contained in:
edde746
2026-08-17 19:02:20 +02:00
parent 4eb3ab4632
commit fb048097ab
8 changed files with 288 additions and 263 deletions
@@ -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,
};
}
@@ -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<T extends StatefulWidget> on State<T>, 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();
}
}
+28 -20
View File
@@ -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<LiveTvScreen>
}
}
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<LiveTvScreen>
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<LiveTvScreen>
final failedStores = <String>{};
final seenFavorites = <String>{};
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<LiveTvScreen>
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
@@ -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<void> forEachLiveTvServer<C extends MediaServerClient>(
MultiServerProvider multiServer, {
required C? Function(ServerId serverId) resolveClient,
required Future<void> 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 = <String>{};
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);
}
}
}
+42 -73
View File
@@ -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<GuideTab>
with LiveTvActionsMixin<GuideTab>, MountedSetStateMixin, WidgetsBindingObserver {
with LiveTvActionsMixin<GuideTab>, MountedSetStateMixin, WidgetsBindingObserver, LiveTvRefreshMixin<GuideTab> {
static const _slotWidth = 180.0;
static const _channelColumnWidth = 132.0;
static const _rowHeight = 64.0;
@@ -135,14 +136,12 @@ class GuideTabState extends State<GuideTab>
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<GuideTab>
@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<GuideTab>
@override
void dispose() {
_programLoadGeneration++;
WidgetsBinding.instance.removeObserver(this);
_programSelectController.dispose();
_guideFocusNode.dispose();
_gridVerticalController.dispose();
@@ -279,7 +254,6 @@ class GuideTabState extends State<GuideTab>
_headerHorizontalController.dispose();
_gridHorizontalController.dispose();
_channelVerticalController.dispose();
_timeIndicatorTimer?.cancel();
_focusSnapshot.dispose();
super.dispose();
}
@@ -356,14 +330,14 @@ class GuideTabState extends State<GuideTab>
/// 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<GuideTab>
final allPrograms = <LiveTvProgram>[];
final scheduledRecordingKeys = <String>{};
final multiServer = context.read<MultiServerProvider>();
final liveTvServers = List<LiveTvServerInfo>.of(multiServer.liveTvServers);
final queriedServers = <String>{};
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<GuideTab>
if (!mounted) return;
final multiServer = context.read<MultiServerProvider>();
final scheduledRecordingKeys = <String>{};
final queriedServers = <String>{};
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);
+38 -85
View File
@@ -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<RecordingsTab> with WidgetsBindingObserver {
class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserver, LiveTvRefreshMixin<RecordingsTab> {
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<void>? _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<RecordingsTab> 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<void> reload() => _load();
@@ -159,24 +120,16 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
return inFlight;
}
final completer = Completer<void>();
_loadFuture = completer.future;
unawaited(_drainLoads(completer));
return completer.future;
final load = _drainLoads().whenComplete(() => _loadFuture = null);
_loadFuture = load;
return load;
}
Future<void> _drainLoads(Completer<void> completer) async {
try {
do {
_loadPending = false;
await _loadOnce();
} while (mounted && _loadPending);
completer.complete();
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
} finally {
_loadFuture = null;
}
Future<void> _drainLoads() async {
do {
_loadPending = false;
await _loadOnce();
} while (mounted && _loadPending);
}
Future<void> _loadOnce() async {
@@ -191,30 +144,30 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
final results = <_ServerRecordings>[];
var anyAdminError = false;
var anyOtherError = false;
final seenServers = <String>{};
for (final serverInfo in List<LiveTvServerInfo>.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(() {
+22 -73
View File
@@ -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<LiveTvChannel> channels;
@@ -34,106 +35,54 @@ class WhatsOnTab extends StatefulWidget {
}
class WhatsOnTabState extends State<WhatsOnTab>
with LiveTvActionsMixin<WhatsOnTab>, MountedSetStateMixin, WidgetsBindingObserver {
with LiveTvActionsMixin<WhatsOnTab>, MountedSetStateMixin, WidgetsBindingObserver, LiveTvRefreshMixin<WhatsOnTab> {
List<_WhatsOnHub> _hubs = [];
bool _isLoading = true;
Timer? _refreshTimer;
final Map<String, GlobalKey<HubSectionState>> _hubKeysById = {};
List<GlobalKey<HubSectionState>> _hubKeys = [];
final _hubFocusMemory = HubFocusMemory();
bool _refreshRequested = true;
bool _tickerEnabled = false;
bool _appRefreshActive = true;
@override
List<LiveTvChannel> 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<void> _loadHubs() async {
if (!mounted) return;
setState(() => _isLoading = _hubs.isEmpty);
try {
final multiServer = context.read<MultiServerProvider>();
final liveTvServers = multiServer.liveTvServers;
final allHubs = <_WhatsOnHub>[];
final allHubIds = <String>[];
final queriedServers = <String>{};
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();
@@ -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', () {