fix: update watch state properly

This commit is contained in:
edde746
2025-10-29 02:35:00 +01:00
parent 12ee134191
commit 1d29281143
8 changed files with 208 additions and 39 deletions
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
/// Mixin for screens that need to update individual items after watch state changes
///
/// This provides a standard implementation for fetching updated metadata
/// and replacing items in lists, while allowing each screen to customize
/// which lists should be updated.
mixin ItemUpdatable<T extends StatefulWidget> on State<T> {
/// The Plex client to use for fetching updated metadata
/// Each screen must provide access to their client
PlexClient get client;
/// Updates a single item in the screen's list(s) after watch state changes
///
/// Fetches the latest metadata with images (including clearLogo) and
/// calls [updateItemInLists] to update the appropriate list(s).
///
/// If the fetch fails, the error is silently caught and the item will
/// be updated on the next full refresh.
Future<void> updateItem(String ratingKey) async {
try {
final updatedMetadata = await client.getMetadataWithImages(ratingKey);
if (updatedMetadata != null) {
setState(() {
updateItemInLists(ratingKey, updatedMetadata);
});
}
} catch (e) {
// Silently fail - the item will update on next full refresh
}
}
/// Override this method to specify which list(s) should be updated
///
/// This method is called within [setState], so you should directly
/// modify your list(s) without calling setState again.
///
/// Example:
/// ```dart
/// @override
/// void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
/// final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
/// if (index != -1) {
/// _items[index] = updatedMetadata;
/// }
/// }
/// ```
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata);
}
+22 -2
View File
@@ -11,6 +11,7 @@ import '../widgets/media_card.dart';
import '../widgets/desktop_app_bar.dart'; import '../widgets/desktop_app_bar.dart';
import '../widgets/server_list_tile.dart'; import '../widgets/server_list_tile.dart';
import '../mixins/refreshable.dart'; import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import 'video_player_screen.dart'; import 'video_player_screen.dart';
import 'main_screen.dart'; import 'main_screen.dart';
@@ -32,7 +33,10 @@ class DiscoverScreen extends StatefulWidget {
State<DiscoverScreen> createState() => _DiscoverScreenState(); State<DiscoverScreen> createState() => _DiscoverScreenState();
} }
class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable { class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable, ItemUpdatable {
@override
PlexClient get client => widget.client;
List<PlexMetadata> _onDeck = []; List<PlexMetadata> _onDeck = [];
List<PlexMetadata> _recentlyAdded = []; List<PlexMetadata> _recentlyAdded = [];
bool _isLoading = true; bool _isLoading = true;
@@ -110,6 +114,21 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable {
_loadContent(); _loadContent();
} }
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
// Check and update in _onDeck list
final onDeckIndex = _onDeck.indexWhere((item) => item.ratingKey == ratingKey);
if (onDeckIndex != -1) {
_onDeck[onDeckIndex] = updatedMetadata;
}
// Check and update in _recentlyAdded list
final recentlyAddedIndex = _recentlyAdded.indexWhere((item) => item.ratingKey == ratingKey);
if (recentlyAddedIndex != -1) {
_recentlyAdded[recentlyAddedIndex] = updatedMetadata;
}
}
Future<void> _handleSwitchServer() async { Future<void> _handleSwitchServer() async {
final storage = await StorageService.getInstance(); final storage = await StorageService.getInstance();
final plexToken = storage.getPlexToken(); final plexToken = storage.getPlexToken();
@@ -808,11 +827,12 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(horizontal: 2),
child: MediaCard( child: MediaCard(
key: Key(item.ratingKey),
client: widget.client, client: widget.client,
item: item, item: item,
width: cardWidth, width: cardWidth,
height: cardHeight, height: cardHeight,
onRefresh: _loadContent, onRefresh: updateItem,
userProfile: widget.userProfile, userProfile: widget.userProfile,
), ),
); );
+15 -2
View File
@@ -9,6 +9,7 @@ import '../widgets/desktop_app_bar.dart';
import '../widgets/app_bar_back_button.dart'; import '../widgets/app_bar_back_button.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
import '../mixins/refreshable.dart'; import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
class LibrariesScreen extends StatefulWidget { class LibrariesScreen extends StatefulWidget {
final PlexClient client; final PlexClient client;
@@ -20,7 +21,10 @@ class LibrariesScreen extends StatefulWidget {
State<LibrariesScreen> createState() => _LibrariesScreenState(); State<LibrariesScreen> createState() => _LibrariesScreenState();
} }
class _LibrariesScreenState extends State<LibrariesScreen> with Refreshable { class _LibrariesScreenState extends State<LibrariesScreen> with Refreshable, ItemUpdatable {
@override
PlexClient get client => widget.client;
List<PlexLibrary> _libraries = []; List<PlexLibrary> _libraries = [];
List<PlexMetadata> _items = []; List<PlexMetadata> _items = [];
List<PlexFilter> _filters = []; List<PlexFilter> _filters = [];
@@ -167,6 +171,14 @@ class _LibrariesScreenState extends State<LibrariesScreen> with Refreshable {
} }
} }
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
if (index != -1) {
_items[index] = updatedMetadata;
}
}
// Public method to refresh content // Public method to refresh content
@override @override
void refresh() { void refresh() {
@@ -374,9 +386,10 @@ class _LibrariesScreenState extends State<LibrariesScreen> with Refreshable {
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final item = _items[index]; final item = _items[index];
return MediaCard( return MediaCard(
key: Key(item.ratingKey),
client: widget.client, client: widget.client,
item: item, item: item,
onRefresh: _applyFilters, onRefresh: updateItem,
userProfile: widget.userProfile, userProfile: widget.userProfile,
); );
}, childCount: _items.length), }, childCount: _items.length),
+55 -8
View File
@@ -32,13 +32,22 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
PlexMetadata? _fullMetadata; PlexMetadata? _fullMetadata;
PlexMetadata? _onDeckEpisode; PlexMetadata? _onDeckEpisode;
bool _isLoadingMetadata = true; bool _isLoadingMetadata = true;
late final ScrollController _scrollController;
bool _watchStateChanged = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController = ScrollController();
_loadFullMetadata(); _loadFullMetadata();
} }
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _loadFullMetadata() async { Future<void> _loadFullMetadata() async {
setState(() { setState(() {
_isLoadingMetadata = true; _isLoadingMetadata = true;
@@ -108,6 +117,33 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
} }
} }
/// Update watch state without full screen rebuild
/// This preserves scroll position and only updates watch-related data
Future<void> _updateWatchState() async {
try {
final metadata = await widget.client.getMetadataWithImages(widget.metadata.ratingKey);
if (metadata != null) {
// For shows, also refetch seasons to update their watch counts
List<PlexMetadata>? updatedSeasons;
if (metadata.type.toLowerCase() == 'show') {
updatedSeasons = await widget.client.getChildren(widget.metadata.ratingKey);
}
// Single setState to minimize rebuilds - scroll position is preserved by controller
setState(() {
_fullMetadata = metadata;
if (updatedSeasons != null) {
_seasons = updatedSeasons;
}
});
}
} catch (e) {
appLogger.e('Failed to update watch state', error: e);
// Silently fail - user can manually refresh if needed
}
}
Future<void> _playFirstEpisode() async { Future<void> _playFirstEpisode() async {
try { try {
// If seasons aren't loaded yet, wait for them or load them // If seasons aren't loaded yet, wait for them or load them
@@ -192,13 +228,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
controller: _scrollController,
slivers: [ slivers: [
// Hero header with background art // Hero header with background art
DesktopSliverAppBar( DesktopSliverAppBar(
expandedHeight: headerHeight, expandedHeight: headerHeight,
pinned: true, pinned: true,
leading: const AppBarBackButton( leading: AppBarBackButton(
style: BackButtonStyle.circular, style: BackButtonStyle.circular,
onPressed: () => Navigator.pop(context, _watchStateChanged),
), ),
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
background: Stack( background: Stack(
@@ -495,13 +533,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
metadata.ratingKey, metadata.ratingKey,
); );
if (context.mounted) { if (context.mounted) {
_watchStateChanged = true;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Marked as watched'), content: Text('Marked as watched'),
), ),
); );
// Refresh metadata to update UI // Update watch state without full rebuild
_loadFullMetadata(); _updateWatchState();
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
@@ -527,13 +566,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
metadata.ratingKey, metadata.ratingKey,
); );
if (context.mounted) { if (context.mounted) {
_watchStateChanged = true;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Marked as unwatched'), content: Text('Marked as unwatched'),
), ),
); );
// Refresh metadata to update UI // Update watch state without full rebuild
_loadFullMetadata(); _updateWatchState();
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
@@ -642,15 +682,22 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
child: MediaContextMenu( child: MediaContextMenu(
client: widget.client, client: widget.client,
metadata: season, metadata: season,
onRefresh: _loadFullMetadata, onRefresh: (ratingKey) {
onTap: () { _watchStateChanged = true;
Navigator.push( _updateWatchState();
},
onTap: () async {
final watchStateChanged = await Navigator.push<bool>(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => builder: (context) =>
SeasonDetailScreen(client: widget.client, season: season), SeasonDetailScreen(client: widget.client, season: season),
), ),
); );
if (watchStateChanged == true) {
_watchStateChanged = true;
_updateWatchState();
}
}, },
child: InkWell( child: InkWell(
child: Padding( child: Padding(
+15 -2
View File
@@ -6,6 +6,7 @@ import '../models/plex_user_profile.dart';
import '../widgets/media_card.dart'; import '../widgets/media_card.dart';
import '../widgets/desktop_app_bar.dart'; import '../widgets/desktop_app_bar.dart';
import '../mixins/refreshable.dart'; import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
class SearchScreen extends StatefulWidget { class SearchScreen extends StatefulWidget {
final PlexClient client; final PlexClient client;
@@ -17,7 +18,10 @@ class SearchScreen extends StatefulWidget {
State<SearchScreen> createState() => _SearchScreenState(); State<SearchScreen> createState() => _SearchScreenState();
} }
class _SearchScreenState extends State<SearchScreen> with Refreshable { class _SearchScreenState extends State<SearchScreen> with Refreshable, ItemUpdatable {
@override
PlexClient get client => widget.client;
final _searchController = TextEditingController(); final _searchController = TextEditingController();
List<PlexMetadata> _searchResults = []; List<PlexMetadata> _searchResults = [];
bool _isSearching = false; bool _isSearching = false;
@@ -101,6 +105,14 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable {
} }
} }
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
final index = _searchResults.indexWhere((item) => item.ratingKey == ratingKey);
if (index != -1) {
_searchResults[index] = updatedMetadata;
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -196,9 +208,10 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable {
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final item = _searchResults[index]; final item = _searchResults[index];
return MediaCard( return MediaCard(
key: Key(item.ratingKey),
client: widget.client, client: widget.client,
item: item, item: item,
onRefresh: refresh, onRefresh: updateItem,
userProfile: widget.userProfile, userProfile: widget.userProfile,
); );
}, childCount: _searchResults.length), }, childCount: _searchResults.length),
+43 -18
View File
@@ -5,6 +5,8 @@ import '../models/plex_metadata.dart';
import '../models/plex_user_profile.dart'; import '../models/plex_user_profile.dart';
import '../widgets/desktop_app_bar.dart'; import '../widgets/desktop_app_bar.dart';
import '../widgets/app_bar_back_button.dart'; import '../widgets/app_bar_back_button.dart';
import '../widgets/media_context_menu.dart';
import '../mixins/item_updatable.dart';
import 'video_player_screen.dart'; import 'video_player_screen.dart';
class SeasonDetailScreen extends StatefulWidget { class SeasonDetailScreen extends StatefulWidget {
@@ -23,9 +25,13 @@ class SeasonDetailScreen extends StatefulWidget {
State<SeasonDetailScreen> createState() => _SeasonDetailScreenState(); State<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
} }
class _SeasonDetailScreenState extends State<SeasonDetailScreen> { class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdatable {
@override
PlexClient get client => widget.client;
List<PlexMetadata> _episodes = []; List<PlexMetadata> _episodes = [];
bool _isLoadingEpisodes = false; bool _isLoadingEpisodes = false;
bool _watchStateChanged = false;
@override @override
void initState() { void initState() {
@@ -51,6 +57,20 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> {
} }
} }
@override
Future<void> updateItem(String ratingKey) async {
_watchStateChanged = true;
await super.updateItem(ratingKey);
}
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
final index = _episodes.indexWhere((item) => item.ratingKey == ratingKey);
if (index != -1) {
_episodes[index] = updatedMetadata;
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -59,8 +79,9 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> {
DesktopSliverAppBar( DesktopSliverAppBar(
title: Text(widget.season.title), title: Text(widget.season.title),
pinned: true, pinned: true,
leading: const AppBarBackButton( leading: AppBarBackButton(
style: BackButtonStyle.circular, style: BackButtonStyle.circular,
onPressed: () => Navigator.pop(context, _watchStateChanged),
), ),
), ),
if (_isLoadingEpisodes) if (_isLoadingEpisodes)
@@ -117,23 +138,27 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> {
? episode.viewOffset! / episode.duration! ? episode.viewOffset! / episode.duration!
: 0.0; : 0.0;
return Card( return MediaContextMenu(
clipBehavior: Clip.antiAlias, client: widget.client,
child: InkWell( metadata: episode,
onTap: () async { onRefresh: updateItem,
await Navigator.push( onTap: () async {
context, await Navigator.push(
MaterialPageRoute( context,
builder: (context) => VideoPlayerScreen( MaterialPageRoute(
client: widget.client, builder: (context) => VideoPlayerScreen(
metadata: episode, client: widget.client,
userProfile: widget.userProfile, metadata: episode,
), userProfile: widget.userProfile,
), ),
); ),
// Refresh episodes when returning from video player );
_loadEpisodes(); // Refresh episodes when returning from video player
}, _loadEpisodes();
},
child: Card(
key: Key(episode.ratingKey),
clipBehavior: Clip.antiAlias,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Row( child: Row(
+4 -4
View File
@@ -13,7 +13,7 @@ class MediaCard extends StatefulWidget {
final PlexMetadata item; final PlexMetadata item;
final double? width; final double? width;
final double? height; final double? height;
final VoidCallback? onRefresh; final void Function(String ratingKey)? onRefresh;
final PlexUserProfile? userProfile; final PlexUserProfile? userProfile;
const MediaCard({ const MediaCard({
@@ -48,7 +48,7 @@ class _MediaCardState extends State<MediaCard> {
); );
// Refresh parent screen if result indicates it's needed // Refresh parent screen if result indicates it's needed
if (result == true) { if (result == true) {
widget.onRefresh?.call(); widget.onRefresh?.call(widget.item.ratingKey);
} }
} else if (itemType == 'season') { } else if (itemType == 'season') {
// For seasons, show season detail screen // For seasons, show season detail screen
@@ -63,7 +63,7 @@ class _MediaCardState extends State<MediaCard> {
), ),
); );
// Season screen doesn't return a refresh flag, but we can refresh anyway // Season screen doesn't return a refresh flag, but we can refresh anyway
widget.onRefresh?.call(); widget.onRefresh?.call(widget.item.ratingKey);
} else { } else {
// For all other types (shows, movies), show detail screen // For all other types (shows, movies), show detail screen
final result = await Navigator.push<bool>( final result = await Navigator.push<bool>(
@@ -78,7 +78,7 @@ class _MediaCardState extends State<MediaCard> {
); );
// Refresh parent screen if result indicates it's needed // Refresh parent screen if result indicates it's needed
if (result == true) { if (result == true) {
widget.onRefresh?.call(); widget.onRefresh?.call(widget.item.ratingKey);
} }
} }
} }
+3 -3
View File
@@ -23,7 +23,7 @@ class _MenuAction {
class MediaContextMenu extends StatefulWidget { class MediaContextMenu extends StatefulWidget {
final PlexClient client; final PlexClient client;
final PlexMetadata metadata; final PlexMetadata metadata;
final VoidCallback? onRefresh; final void Function(String ratingKey)? onRefresh;
final VoidCallback? onTap; final VoidCallback? onTap;
final Widget child; final Widget child;
@@ -228,7 +228,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(successMessage)), SnackBar(content: Text(successMessage)),
); );
widget.onRefresh?.call(); widget.onRefresh?.call(widget.metadata.ratingKey);
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
@@ -255,7 +255,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
context, context,
MaterialPageRoute(builder: (context) => screenBuilder(metadata)), MaterialPageRoute(builder: (context) => screenBuilder(metadata)),
); );
widget.onRefresh?.call(); widget.onRefresh?.call(widget.metadata.ratingKey);
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {