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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user