From b4575c9780e7946eb65c3124da539ac17ed1df6e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:56:21 +0200 Subject: [PATCH] fix: preserve live TV state across refreshes --- lib/screens/livetv/live_tv_screen.dart | 98 ++++++++++++++++----- lib/screens/livetv/tabs/guide_tab.dart | 5 +- lib/screens/livetv/tabs/recordings_tab.dart | 45 ++++++++-- lib/screens/livetv/tabs/whats_on_tab.dart | 96 ++++++++++++++------ 4 files changed, 185 insertions(+), 59 deletions(-) diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index a70970d9..08def534 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -72,6 +72,9 @@ class _LiveTvScreenState extends State final Map _favoriteStoreByChannel = {}; final Map _favoriteStoreBySource = {}; final Map _favoriteModeByStore = {}; + Future? _channelsLoadFuture; + int _favoritesLoadGeneration = 0; + int _favoritesMutationGeneration = 0; List get _filteredChannels => filterLiveTvChannelsForFavorites( channels: _channels, @@ -268,7 +271,18 @@ class _LiveTvScreenState extends State return trimmed == null || trimmed.isEmpty ? null : trimmed; } - Future _loadChannels() async { + Future _loadChannels() { + final inFlight = _channelsLoadFuture; + if (inFlight != null) return inFlight; + late final Future load; + load = _loadChannelsOnce().whenComplete(() { + if (identical(_channelsLoadFuture, load)) _channelsLoadFuture = null; + }); + _channelsLoadFuture = load; + return load; + } + + Future _loadChannelsOnce() async { if (!mounted) return; setState(() { _isLoading = true; @@ -289,12 +303,12 @@ class _LiveTvScreenState extends State final allChannels = []; final seenChannels = {}; - _favoriteSourceByLiveServer.clear(); - _favoriteSourceByChannel.clear(); - _favoriteStoreByLiveServer.clear(); - _favoriteStoreByChannel.clear(); - _favoriteStoreBySource.clear(); - _favoriteModeByStore.clear(); + final favoriteSourceByLiveServer = {}; + final favoriteSourceByChannel = {}; + final favoriteStoreByLiveServer = {}; + final favoriteStoreByChannel = {}; + final favoriteStoreBySource = {}; + final favoriteModeByStore = {}; appLogger.d( 'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}', @@ -319,10 +333,10 @@ class _LiveTvScreenState extends State final sourceTitle = _sourceTitleForServerInfo(serverInfo); final storeKey = liveTv.favoriteStoreKey; final liveServerKey = _liveServerScopeKey(serverInfo); - _favoriteSourceByLiveServer[liveServerKey] = source; - _favoriteStoreByLiveServer[liveServerKey] = storeKey; - _favoriteStoreBySource[source] = storeKey; - _favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode; + favoriteSourceByLiveServer[liveServerKey] = source; + favoriteStoreByLiveServer[liveServerKey] = storeKey; + favoriteStoreBySource[source] = storeKey; + favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode; final channels = await genericClient.liveTv.fetchChannels(lineup: serverInfo.lineup); // Plex's DVR exposes a separate enabled-channel mapping; Jellyfin @@ -342,8 +356,8 @@ class _LiveTvScreenState extends State final dedupKey = liveTvChannelScopeKey(scopedChannel); if (seenChannels.add(dedupKey)) { final scopeKey = liveTvChannelScopeKey(scopedChannel); - _favoriteSourceByChannel[scopeKey] = source; - _favoriteStoreByChannel[scopeKey] = storeKey; + favoriteSourceByChannel[scopeKey] = source; + favoriteStoreByChannel[scopeKey] = storeKey; allChannels.add(scopedChannel); } } @@ -364,6 +378,24 @@ class _LiveTvScreenState extends State setState(() { _channels = allChannels; + _favoriteSourceByLiveServer + ..clear() + ..addAll(favoriteSourceByLiveServer); + _favoriteSourceByChannel + ..clear() + ..addAll(favoriteSourceByChannel); + _favoriteStoreByLiveServer + ..clear() + ..addAll(favoriteStoreByLiveServer); + _favoriteStoreByChannel + ..clear() + ..addAll(favoriteStoreByChannel); + _favoriteStoreBySource + ..clear() + ..addAll(favoriteStoreBySource); + _favoriteModeByStore + ..clear() + ..addAll(favoriteModeByStore); _isLoading = false; }); @@ -389,10 +421,13 @@ class _LiveTvScreenState extends State } Future _loadFavorites(MultiServerProvider multiServer) async { + final loadGeneration = ++_favoritesLoadGeneration; + final mutationGeneration = _favoritesMutationGeneration; try { - _favoriteSourceByLiveServer.clear(); - _favoriteStoreBySource.clear(); - _favoriteModeByStore.clear(); + final sourceByLiveServer = Map.of(_favoriteSourceByLiveServer); + final storeByLiveServer = Map.of(_favoriteStoreByLiveServer); + final storeBySource = Map.of(_favoriteStoreBySource); + final modeByStore = Map.of(_favoriteModeByStore); final merged = []; final fetchedStores = {}; final seenFavorites = {}; @@ -403,20 +438,36 @@ class _LiveTvScreenState extends State final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); final storeKey = liveTv.favoriteStoreKey; final liveServerKey = _liveServerScopeKey(serverInfo); - _favoriteSourceByLiveServer[liveServerKey] = source; - _favoriteStoreByLiveServer[liveServerKey] = storeKey; - _favoriteStoreBySource[source] = storeKey; - _favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode; + sourceByLiveServer[liveServerKey] = source; + storeByLiveServer[liveServerKey] = storeKey; + storeBySource[source] = storeKey; + modeByStore[storeKey] = liveTv.favoritePersistenceMode; if (!fetchedStores.add(storeKey)) continue; final serverFavorites = await liveTv.fetchFavoriteChannels(); for (final favorite in serverFavorites) { - _favoriteStoreBySource[favorite.source] = storeKey; + storeBySource[favorite.source] = storeKey; if (seenFavorites.add(favorite.stableKey)) merged.add(favorite); } } - if (!mounted) return; + if (!mounted || + loadGeneration != _favoritesLoadGeneration || + mutationGeneration != _favoritesMutationGeneration) { + return; + } setState(() { + _favoriteSourceByLiveServer + ..clear() + ..addAll(sourceByLiveServer); + _favoriteStoreByLiveServer + ..clear() + ..addAll(storeByLiveServer); + _favoriteStoreBySource + ..clear() + ..addAll(storeBySource); + _favoriteModeByStore + ..clear() + ..addAll(modeByStore); _favoriteChannels = merged; _refreshFavoriteKeys(); }); @@ -433,6 +484,7 @@ class _LiveTvScreenState extends State } void _toggleFavorite(LiveTvChannel channel) { + ++_favoritesMutationGeneration; final source = _sourceForChannel(channel); final favoriteKey = favoriteChannelKey(source, channel.key); final scopeKey = liveTvChannelScopeKey(channel); @@ -460,6 +512,7 @@ class _LiveTvScreenState extends State favorites: List.from(_favoriteChannels), channelMap: channelMap, onReorder: (reordered) { + ++_favoritesMutationGeneration; setState(() { _favoriteChannels = reordered; _refreshFavoriteKeys(); @@ -467,6 +520,7 @@ class _LiveTvScreenState extends State _persistFavorites(); }, onRemove: (removed) { + ++_favoritesMutationGeneration; setState(() { _favoriteChannels = _favoriteChannels.where((f) => f.stableKey != removed.stableKey).toList(); _refreshFavoriteKeys(); diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 8beb6826..52287cb3 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -198,9 +198,9 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi switch (state) { case AppLifecycleState.paused: case AppLifecycleState.hidden: - _hiddenSince ??= DateTime.now(); + pauseRefresh(); case AppLifecycleState.resumed: - _catchUpIfStale(); + if (_isGuideVisible) resumeRefresh(); case AppLifecycleState.inactive: case AppLifecycleState.detached: break; @@ -1103,6 +1103,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi }), ], ); + if (!mounted) return; if (value == null) { _guideFocusNode.requestFocus(); return; diff --git a/lib/screens/livetv/tabs/recordings_tab.dart b/lib/screens/livetv/tabs/recordings_tab.dart index 650cd1ab..618a9a96 100644 --- a/lib/screens/livetv/tabs/recordings_tab.dart +++ b/lib/screens/livetv/tabs/recordings_tab.dart @@ -58,26 +58,45 @@ class _RuleEntry { enum _RuleAction { edit, delete } -class RecordingsTabState extends State { +class RecordingsTabState extends State with WidgetsBindingObserver { List<_ServerRecordings> _serverRecordings = []; bool _isLoading = true; bool _adminBlocked = false; String? _error; Timer? _refreshTimer; bool _pendingFocus = false; + bool _refreshRequested = true; + bool _tickerEnabled = false; + bool _appResumed = true; final _firstTileFocusNode = FocusNode(debugLabel: 'recordings_tab_first_tile'); @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _load(); - _refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) { - if (mounted) _load(); - }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final enabled = TickerMode.valuesOf(context).enabled; + if (enabled == _tickerEnabled) return; + _tickerEnabled = enabled; + _syncRefreshTimer(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final resumed = state == AppLifecycleState.resumed; + if (resumed == _appResumed) return; + _appResumed = resumed; + _syncRefreshTimer(); } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _refreshTimer?.cancel(); _firstTileFocusNode.dispose(); super.dispose(); @@ -94,14 +113,22 @@ class RecordingsTabState extends State { } } - void pauseRefresh() => _refreshTimer?.cancel(); + void pauseRefresh() { + _refreshRequested = false; + _syncRefreshTimer(); + } void resumeRefresh() { + _refreshRequested = true; + _syncRefreshTimer(reload: true); + } + + void _syncRefreshTimer({bool reload = false}) { _refreshTimer?.cancel(); - _refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) { - if (mounted) _load(); - }); - _load(); + _refreshTimer = null; + if (!_refreshRequested || !_tickerEnabled || !_appResumed || !mounted) return; + _refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) => _load()); + if (reload) unawaited(_load()); } /// Public reload helper for the parent screen's refresh action. diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 0e46f45d..0c3df39e 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -28,6 +28,7 @@ import '../../../widgets/overlay_sheet.dart'; import '../../../utils/scroll_utils.dart'; import '../../../widgets/horizontal_scroll_with_arrows.dart'; import '../../../widgets/optimized_media_image.dart'; +import '../../../widgets/sliver_child_memo.dart'; import '../live_tv_actions_mixin.dart'; import '../live_tv_show_schedule_screen.dart'; @@ -42,11 +43,16 @@ class WhatsOnTab extends StatefulWidget { State createState() => WhatsOnTabState(); } -class WhatsOnTabState extends State with LiveTvActionsMixin, MountedSetStateMixin { +class WhatsOnTabState extends State + with LiveTvActionsMixin, MountedSetStateMixin, WidgetsBindingObserver { List _hubs = []; bool _isLoading = true; Timer? _refreshTimer; + final Map> _hubKeysById = {}; List> _hubKeys = []; + bool _refreshRequested = true; + bool _tickerEnabled = false; + bool _appResumed = true; @override List get liveTvChannels => widget.channels; @@ -54,23 +60,47 @@ class WhatsOnTabState extends State with LiveTvActionsMixin _refreshTimer?.cancel(); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final enabled = TickerMode.valuesOf(context).enabled; + if (enabled == _tickerEnabled) return; + _tickerEnabled = enabled; + _syncRefreshTimer(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final resumed = state == AppLifecycleState.resumed; + if (resumed == _appResumed) return; + _appResumed = resumed; + _syncRefreshTimer(); + } + + void pauseRefresh() { + _refreshRequested = false; + _syncRefreshTimer(); + } void resumeRefresh() { + _refreshRequested = true; + _syncRefreshTimer(); + } + + void _syncRefreshTimer() { _refreshTimer?.cancel(); - _refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) { - if (mounted) _loadHubs(); - }); + _refreshTimer = null; + if (!_refreshRequested || !_tickerEnabled || !_appResumed || !mounted) return; + _refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) => _loadHubs()); } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _refreshTimer?.cancel(); super.dispose(); } @@ -83,6 +113,7 @@ class WhatsOnTabState extends State with LiveTvActionsMixin(); final liveTvServers = multiServer.liveTvServers; final allHubs = []; + final allHubIds = []; final queriedServers = {}; for (final serverInfo in liveTvServers) { @@ -93,16 +124,23 @@ class WhatsOnTabState extends State with LiveTvActionsMixin !hubIds.contains(id)); setState(() { _hubs = allHubs; - _hubKeys = List.generate(allHubs.length, (_) => GlobalKey<_LiveTvHubSectionState>()); + _hubKeys = [ + for (final hubId in allHubIds) _hubKeysById.putIfAbsent(hubId, () => GlobalKey<_LiveTvHubSectionState>()), + ]; _isLoading = false; }); } catch (e) { @@ -224,6 +262,7 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> with MountedSetSta static const double _leadingPadding = 12.0; final _selectLongPress = DpadSelectLongPressController(); + final SliverChildMemo _childMemo = SliverChildMemo(); @override void initState() { @@ -442,22 +481,27 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> with MountedSetSta itemBuilder: (context, index) { final entry = widget.hub.entries[index]; final isItemFocused = hasFocus && index == _focusedIndex; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: _LiveTvPosterCard( - entry: entry, - width: cardWidth, - posterHeight: posterHeight, - isFocused: isItemFocused, - onTap: () { - _onItemTapped(index); - widget.onTap(entry); - }, - onLongPress: () { - _onItemTapped(index); - widget.onLongPress(entry); - }, + return _childMemo.widgetFor( + index, + entry, + epoch: (cardWidth, posterHeight, widget.hub.entries.length), + salt: isItemFocused, + build: () => Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: _LiveTvPosterCard( + entry: entry, + width: cardWidth, + posterHeight: posterHeight, + isFocused: isItemFocused, + onTap: () { + _onItemTapped(index); + widget.onTap(entry); + }, + onLongPress: () { + _onItemTapped(index); + widget.onLongPress(entry); + }, + ), ), ); },