fix: preserve live TV state across refreshes

This commit is contained in:
edde746
2026-07-12 18:59:42 +02:00
parent 0ef1e773bb
commit b4575c9780
4 changed files with 185 additions and 59 deletions
+76 -22
View File
@@ -72,6 +72,9 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final Map<String, String> _favoriteStoreByChannel = {}; final Map<String, String> _favoriteStoreByChannel = {};
final Map<String, String> _favoriteStoreBySource = {}; final Map<String, String> _favoriteStoreBySource = {};
final Map<String, FavoriteChannelPersistenceMode> _favoriteModeByStore = {}; final Map<String, FavoriteChannelPersistenceMode> _favoriteModeByStore = {};
Future<void>? _channelsLoadFuture;
int _favoritesLoadGeneration = 0;
int _favoritesMutationGeneration = 0;
List<LiveTvChannel> get _filteredChannels => filterLiveTvChannelsForFavorites( List<LiveTvChannel> get _filteredChannels => filterLiveTvChannelsForFavorites(
channels: _channels, channels: _channels,
@@ -268,7 +271,18 @@ class _LiveTvScreenState extends State<LiveTvScreen>
return trimmed == null || trimmed.isEmpty ? null : trimmed; return trimmed == null || trimmed.isEmpty ? null : trimmed;
} }
Future<void> _loadChannels() async { Future<void> _loadChannels() {
final inFlight = _channelsLoadFuture;
if (inFlight != null) return inFlight;
late final Future<void> load;
load = _loadChannelsOnce().whenComplete(() {
if (identical(_channelsLoadFuture, load)) _channelsLoadFuture = null;
});
_channelsLoadFuture = load;
return load;
}
Future<void> _loadChannelsOnce() async {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@@ -289,12 +303,12 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final allChannels = <LiveTvChannel>[]; final allChannels = <LiveTvChannel>[];
final seenChannels = <String>{}; final seenChannels = <String>{};
_favoriteSourceByLiveServer.clear(); final favoriteSourceByLiveServer = <String, String>{};
_favoriteSourceByChannel.clear(); final favoriteSourceByChannel = <String, String>{};
_favoriteStoreByLiveServer.clear(); final favoriteStoreByLiveServer = <String, String>{};
_favoriteStoreByChannel.clear(); final favoriteStoreByChannel = <String, String>{};
_favoriteStoreBySource.clear(); final favoriteStoreBySource = <String, String>{};
_favoriteModeByStore.clear(); final favoriteModeByStore = <String, FavoriteChannelPersistenceMode>{};
appLogger.d( appLogger.d(
'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}', 'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}',
@@ -319,10 +333,10 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final sourceTitle = _sourceTitleForServerInfo(serverInfo); final sourceTitle = _sourceTitleForServerInfo(serverInfo);
final storeKey = liveTv.favoriteStoreKey; final storeKey = liveTv.favoriteStoreKey;
final liveServerKey = _liveServerScopeKey(serverInfo); final liveServerKey = _liveServerScopeKey(serverInfo);
_favoriteSourceByLiveServer[liveServerKey] = source; favoriteSourceByLiveServer[liveServerKey] = source;
_favoriteStoreByLiveServer[liveServerKey] = storeKey; favoriteStoreByLiveServer[liveServerKey] = storeKey;
_favoriteStoreBySource[source] = storeKey; favoriteStoreBySource[source] = storeKey;
_favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode; favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode;
final channels = await genericClient.liveTv.fetchChannels(lineup: serverInfo.lineup); final channels = await genericClient.liveTv.fetchChannels(lineup: serverInfo.lineup);
// Plex's DVR exposes a separate enabled-channel mapping; Jellyfin // Plex's DVR exposes a separate enabled-channel mapping; Jellyfin
@@ -342,8 +356,8 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final dedupKey = liveTvChannelScopeKey(scopedChannel); final dedupKey = liveTvChannelScopeKey(scopedChannel);
if (seenChannels.add(dedupKey)) { if (seenChannels.add(dedupKey)) {
final scopeKey = liveTvChannelScopeKey(scopedChannel); final scopeKey = liveTvChannelScopeKey(scopedChannel);
_favoriteSourceByChannel[scopeKey] = source; favoriteSourceByChannel[scopeKey] = source;
_favoriteStoreByChannel[scopeKey] = storeKey; favoriteStoreByChannel[scopeKey] = storeKey;
allChannels.add(scopedChannel); allChannels.add(scopedChannel);
} }
} }
@@ -364,6 +378,24 @@ class _LiveTvScreenState extends State<LiveTvScreen>
setState(() { setState(() {
_channels = allChannels; _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; _isLoading = false;
}); });
@@ -389,10 +421,13 @@ class _LiveTvScreenState extends State<LiveTvScreen>
} }
Future<void> _loadFavorites(MultiServerProvider multiServer) async { Future<void> _loadFavorites(MultiServerProvider multiServer) async {
final loadGeneration = ++_favoritesLoadGeneration;
final mutationGeneration = _favoritesMutationGeneration;
try { try {
_favoriteSourceByLiveServer.clear(); final sourceByLiveServer = Map<String, String>.of(_favoriteSourceByLiveServer);
_favoriteStoreBySource.clear(); final storeByLiveServer = Map<String, String>.of(_favoriteStoreByLiveServer);
_favoriteModeByStore.clear(); final storeBySource = Map<String, String>.of(_favoriteStoreBySource);
final modeByStore = Map<String, FavoriteChannelPersistenceMode>.of(_favoriteModeByStore);
final merged = <FavoriteChannel>[]; final merged = <FavoriteChannel>[];
final fetchedStores = <String>{}; final fetchedStores = <String>{};
final seenFavorites = <String>{}; final seenFavorites = <String>{};
@@ -403,20 +438,36 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup);
final storeKey = liveTv.favoriteStoreKey; final storeKey = liveTv.favoriteStoreKey;
final liveServerKey = _liveServerScopeKey(serverInfo); final liveServerKey = _liveServerScopeKey(serverInfo);
_favoriteSourceByLiveServer[liveServerKey] = source; sourceByLiveServer[liveServerKey] = source;
_favoriteStoreByLiveServer[liveServerKey] = storeKey; storeByLiveServer[liveServerKey] = storeKey;
_favoriteStoreBySource[source] = storeKey; storeBySource[source] = storeKey;
_favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode; modeByStore[storeKey] = liveTv.favoritePersistenceMode;
if (!fetchedStores.add(storeKey)) continue; if (!fetchedStores.add(storeKey)) continue;
final serverFavorites = await liveTv.fetchFavoriteChannels(); final serverFavorites = await liveTv.fetchFavoriteChannels();
for (final favorite in serverFavorites) { for (final favorite in serverFavorites) {
_favoriteStoreBySource[favorite.source] = storeKey; storeBySource[favorite.source] = storeKey;
if (seenFavorites.add(favorite.stableKey)) merged.add(favorite); if (seenFavorites.add(favorite.stableKey)) merged.add(favorite);
} }
} }
if (!mounted) return; if (!mounted ||
loadGeneration != _favoritesLoadGeneration ||
mutationGeneration != _favoritesMutationGeneration) {
return;
}
setState(() { setState(() {
_favoriteSourceByLiveServer
..clear()
..addAll(sourceByLiveServer);
_favoriteStoreByLiveServer
..clear()
..addAll(storeByLiveServer);
_favoriteStoreBySource
..clear()
..addAll(storeBySource);
_favoriteModeByStore
..clear()
..addAll(modeByStore);
_favoriteChannels = merged; _favoriteChannels = merged;
_refreshFavoriteKeys(); _refreshFavoriteKeys();
}); });
@@ -433,6 +484,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
} }
void _toggleFavorite(LiveTvChannel channel) { void _toggleFavorite(LiveTvChannel channel) {
++_favoritesMutationGeneration;
final source = _sourceForChannel(channel); final source = _sourceForChannel(channel);
final favoriteKey = favoriteChannelKey(source, channel.key); final favoriteKey = favoriteChannelKey(source, channel.key);
final scopeKey = liveTvChannelScopeKey(channel); final scopeKey = liveTvChannelScopeKey(channel);
@@ -460,6 +512,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
favorites: List.from(_favoriteChannels), favorites: List.from(_favoriteChannels),
channelMap: channelMap, channelMap: channelMap,
onReorder: (reordered) { onReorder: (reordered) {
++_favoritesMutationGeneration;
setState(() { setState(() {
_favoriteChannels = reordered; _favoriteChannels = reordered;
_refreshFavoriteKeys(); _refreshFavoriteKeys();
@@ -467,6 +520,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
_persistFavorites(); _persistFavorites();
}, },
onRemove: (removed) { onRemove: (removed) {
++_favoritesMutationGeneration;
setState(() { setState(() {
_favoriteChannels = _favoriteChannels.where((f) => f.stableKey != removed.stableKey).toList(); _favoriteChannels = _favoriteChannels.where((f) => f.stableKey != removed.stableKey).toList();
_refreshFavoriteKeys(); _refreshFavoriteKeys();
+3 -2
View File
@@ -198,9 +198,9 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
switch (state) { switch (state) {
case AppLifecycleState.paused: case AppLifecycleState.paused:
case AppLifecycleState.hidden: case AppLifecycleState.hidden:
_hiddenSince ??= DateTime.now(); pauseRefresh();
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
_catchUpIfStale(); if (_isGuideVisible) resumeRefresh();
case AppLifecycleState.inactive: case AppLifecycleState.inactive:
case AppLifecycleState.detached: case AppLifecycleState.detached:
break; break;
@@ -1103,6 +1103,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
}), }),
], ],
); );
if (!mounted) return;
if (value == null) { if (value == null) {
_guideFocusNode.requestFocus(); _guideFocusNode.requestFocus();
return; return;
+36 -9
View File
@@ -58,26 +58,45 @@ class _RuleEntry {
enum _RuleAction { edit, delete } enum _RuleAction { edit, delete }
class RecordingsTabState extends State<RecordingsTab> { class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserver {
List<_ServerRecordings> _serverRecordings = []; List<_ServerRecordings> _serverRecordings = [];
bool _isLoading = true; bool _isLoading = true;
bool _adminBlocked = false; bool _adminBlocked = false;
String? _error; String? _error;
Timer? _refreshTimer; Timer? _refreshTimer;
bool _pendingFocus = false; bool _pendingFocus = false;
bool _refreshRequested = true;
bool _tickerEnabled = false;
bool _appResumed = true;
final _firstTileFocusNode = FocusNode(debugLabel: 'recordings_tab_first_tile'); final _firstTileFocusNode = FocusNode(debugLabel: 'recordings_tab_first_tile');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_load(); _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 @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_refreshTimer?.cancel(); _refreshTimer?.cancel();
_firstTileFocusNode.dispose(); _firstTileFocusNode.dispose();
super.dispose(); super.dispose();
@@ -94,14 +113,22 @@ class RecordingsTabState extends State<RecordingsTab> {
} }
} }
void pauseRefresh() => _refreshTimer?.cancel(); void pauseRefresh() {
_refreshRequested = false;
_syncRefreshTimer();
}
void resumeRefresh() { void resumeRefresh() {
_refreshRequested = true;
_syncRefreshTimer(reload: true);
}
void _syncRefreshTimer({bool reload = false}) {
_refreshTimer?.cancel(); _refreshTimer?.cancel();
_refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) { _refreshTimer = null;
if (mounted) _load(); if (!_refreshRequested || !_tickerEnabled || !_appResumed || !mounted) return;
}); _refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) => _load());
_load(); if (reload) unawaited(_load());
} }
/// Public reload helper for the parent screen's refresh action. /// Public reload helper for the parent screen's refresh action.
+70 -26
View File
@@ -28,6 +28,7 @@ import '../../../widgets/overlay_sheet.dart';
import '../../../utils/scroll_utils.dart'; import '../../../utils/scroll_utils.dart';
import '../../../widgets/horizontal_scroll_with_arrows.dart'; import '../../../widgets/horizontal_scroll_with_arrows.dart';
import '../../../widgets/optimized_media_image.dart'; import '../../../widgets/optimized_media_image.dart';
import '../../../widgets/sliver_child_memo.dart';
import '../live_tv_actions_mixin.dart'; import '../live_tv_actions_mixin.dart';
import '../live_tv_show_schedule_screen.dart'; import '../live_tv_show_schedule_screen.dart';
@@ -42,11 +43,16 @@ class WhatsOnTab extends StatefulWidget {
State<WhatsOnTab> createState() => WhatsOnTabState(); State<WhatsOnTab> createState() => WhatsOnTabState();
} }
class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnTab>, MountedSetStateMixin { class WhatsOnTabState extends State<WhatsOnTab>
with LiveTvActionsMixin<WhatsOnTab>, MountedSetStateMixin, WidgetsBindingObserver {
List<LiveTvHubResult> _hubs = []; List<LiveTvHubResult> _hubs = [];
bool _isLoading = true; bool _isLoading = true;
Timer? _refreshTimer; Timer? _refreshTimer;
final Map<String, GlobalKey<_LiveTvHubSectionState>> _hubKeysById = {};
List<GlobalKey<_LiveTvHubSectionState>> _hubKeys = []; List<GlobalKey<_LiveTvHubSectionState>> _hubKeys = [];
bool _refreshRequested = true;
bool _tickerEnabled = false;
bool _appResumed = true;
@override @override
List<LiveTvChannel> get liveTvChannels => widget.channels; List<LiveTvChannel> get liveTvChannels => widget.channels;
@@ -54,23 +60,47 @@ class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnT
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_loadHubs(); _loadHubs();
_refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) {
if (mounted) _loadHubs();
});
} }
void pauseRefresh() => _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() { void resumeRefresh() {
_refreshRequested = true;
_syncRefreshTimer();
}
void _syncRefreshTimer() {
_refreshTimer?.cancel(); _refreshTimer?.cancel();
_refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) { _refreshTimer = null;
if (mounted) _loadHubs(); if (!_refreshRequested || !_tickerEnabled || !_appResumed || !mounted) return;
}); _refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) => _loadHubs());
} }
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_refreshTimer?.cancel(); _refreshTimer?.cancel();
super.dispose(); super.dispose();
} }
@@ -83,6 +113,7 @@ class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnT
final multiServer = context.read<MultiServerProvider>(); final multiServer = context.read<MultiServerProvider>();
final liveTvServers = multiServer.liveTvServers; final liveTvServers = multiServer.liveTvServers;
final allHubs = <LiveTvHubResult>[]; final allHubs = <LiveTvHubResult>[];
final allHubIds = <String>[];
final queriedServers = <String>{}; final queriedServers = <String>{};
for (final serverInfo in liveTvServers) { for (final serverInfo in liveTvServers) {
@@ -93,16 +124,23 @@ class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnT
if (client == null) continue; if (client == null) continue;
final hubs = await client.getLiveTvHubs(); final hubs = await client.getLiveTvHubs();
allHubs.addAll(hubs); for (final hub in hubs) {
allHubs.add(hub);
allHubIds.add('${serverInfo.serverId}\u0000${hub.hubKey}');
}
} catch (e) { } catch (e) {
appLogger.e('Failed to load hubs from server ${serverInfo.serverId}', error: e); appLogger.e('Failed to load hubs from server ${serverInfo.serverId}', error: e);
} }
} }
if (!mounted) return; if (!mounted) return;
final hubIds = allHubIds.toSet();
_hubKeysById.removeWhere((id, _) => !hubIds.contains(id));
setState(() { setState(() {
_hubs = allHubs; _hubs = allHubs;
_hubKeys = List.generate(allHubs.length, (_) => GlobalKey<_LiveTvHubSectionState>()); _hubKeys = [
for (final hubId in allHubIds) _hubKeysById.putIfAbsent(hubId, () => GlobalKey<_LiveTvHubSectionState>()),
];
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } catch (e) {
@@ -224,6 +262,7 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> with MountedSetSta
static const double _leadingPadding = 12.0; static const double _leadingPadding = 12.0;
final _selectLongPress = DpadSelectLongPressController(); final _selectLongPress = DpadSelectLongPressController();
final SliverChildMemo<LiveTvHubEntry> _childMemo = SliverChildMemo<LiveTvHubEntry>();
@override @override
void initState() { void initState() {
@@ -442,22 +481,27 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> with MountedSetSta
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = widget.hub.entries[index]; final entry = widget.hub.entries[index];
final isItemFocused = hasFocus && index == _focusedIndex; final isItemFocused = hasFocus && index == _focusedIndex;
return _childMemo.widgetFor(
return Padding( index,
padding: const EdgeInsets.symmetric(horizontal: 2), entry,
child: _LiveTvPosterCard( epoch: (cardWidth, posterHeight, widget.hub.entries.length),
entry: entry, salt: isItemFocused,
width: cardWidth, build: () => Padding(
posterHeight: posterHeight, padding: const EdgeInsets.symmetric(horizontal: 2),
isFocused: isItemFocused, child: _LiveTvPosterCard(
onTap: () { entry: entry,
_onItemTapped(index); width: cardWidth,
widget.onTap(entry); posterHeight: posterHeight,
}, isFocused: isItemFocused,
onLongPress: () { onTap: () {
_onItemTapped(index); _onItemTapped(index);
widget.onLongPress(entry); widget.onTap(entry);
}, },
onLongPress: () {
_onItemTapped(index);
widget.onLongPress(entry);
},
),
), ),
); );
}, },