diff --git a/lib/mixins/item_updatable.dart b/lib/mixins/item_updatable.dart new file mode 100644 index 00000000..d26b3515 --- /dev/null +++ b/lib/mixins/item_updatable.dart @@ -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 on State { + /// 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 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); +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 39cdb4b5..405e0f5c 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -11,6 +11,7 @@ import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/server_list_tile.dart'; import '../mixins/refreshable.dart'; +import '../mixins/item_updatable.dart'; import '../utils/app_logger.dart'; import 'video_player_screen.dart'; import 'main_screen.dart'; @@ -32,7 +33,10 @@ class DiscoverScreen extends StatefulWidget { State createState() => _DiscoverScreenState(); } -class _DiscoverScreenState extends State with Refreshable { +class _DiscoverScreenState extends State with Refreshable, ItemUpdatable { + @override + PlexClient get client => widget.client; + List _onDeck = []; List _recentlyAdded = []; bool _isLoading = true; @@ -110,6 +114,21 @@ class _DiscoverScreenState extends State with Refreshable { _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 _handleSwitchServer() async { final storage = await StorageService.getInstance(); final plexToken = storage.getPlexToken(); @@ -808,11 +827,12 @@ class _DiscoverScreenState extends State with Refreshable { return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: MediaCard( + key: Key(item.ratingKey), client: widget.client, item: item, width: cardWidth, height: cardHeight, - onRefresh: _loadContent, + onRefresh: updateItem, userProfile: widget.userProfile, ), ); diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 94c69661..2b0b76e7 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -9,6 +9,7 @@ import '../widgets/desktop_app_bar.dart'; import '../widgets/app_bar_back_button.dart'; import '../services/storage_service.dart'; import '../mixins/refreshable.dart'; +import '../mixins/item_updatable.dart'; class LibrariesScreen extends StatefulWidget { final PlexClient client; @@ -20,7 +21,10 @@ class LibrariesScreen extends StatefulWidget { State createState() => _LibrariesScreenState(); } -class _LibrariesScreenState extends State with Refreshable { +class _LibrariesScreenState extends State with Refreshable, ItemUpdatable { + @override + PlexClient get client => widget.client; + List _libraries = []; List _items = []; List _filters = []; @@ -167,6 +171,14 @@ class _LibrariesScreenState extends State 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 @override void refresh() { @@ -374,9 +386,10 @@ class _LibrariesScreenState extends State with Refreshable { delegate: SliverChildBuilderDelegate((context, index) { final item = _items[index]; return MediaCard( + key: Key(item.ratingKey), client: widget.client, item: item, - onRefresh: _applyFilters, + onRefresh: updateItem, userProfile: widget.userProfile, ); }, childCount: _items.length), diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index b693f15e..1b5c91b6 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -32,13 +32,22 @@ class _MediaDetailScreenState extends State { PlexMetadata? _fullMetadata; PlexMetadata? _onDeckEpisode; bool _isLoadingMetadata = true; + late final ScrollController _scrollController; + bool _watchStateChanged = false; @override void initState() { super.initState(); + _scrollController = ScrollController(); _loadFullMetadata(); } + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + Future _loadFullMetadata() async { setState(() { _isLoadingMetadata = true; @@ -108,6 +117,33 @@ class _MediaDetailScreenState extends State { } } + /// Update watch state without full screen rebuild + /// This preserves scroll position and only updates watch-related data + Future _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? 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 _playFirstEpisode() async { try { // If seasons aren't loaded yet, wait for them or load them @@ -192,13 +228,15 @@ class _MediaDetailScreenState extends State { return Scaffold( body: CustomScrollView( + controller: _scrollController, slivers: [ // Hero header with background art DesktopSliverAppBar( expandedHeight: headerHeight, pinned: true, - leading: const AppBarBackButton( + leading: AppBarBackButton( style: BackButtonStyle.circular, + onPressed: () => Navigator.pop(context, _watchStateChanged), ), flexibleSpace: FlexibleSpaceBar( background: Stack( @@ -495,13 +533,14 @@ class _MediaDetailScreenState extends State { metadata.ratingKey, ); if (context.mounted) { + _watchStateChanged = true; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Marked as watched'), ), ); - // Refresh metadata to update UI - _loadFullMetadata(); + // Update watch state without full rebuild + _updateWatchState(); } } catch (e) { if (context.mounted) { @@ -527,13 +566,14 @@ class _MediaDetailScreenState extends State { metadata.ratingKey, ); if (context.mounted) { + _watchStateChanged = true; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Marked as unwatched'), ), ); - // Refresh metadata to update UI - _loadFullMetadata(); + // Update watch state without full rebuild + _updateWatchState(); } } catch (e) { if (context.mounted) { @@ -642,15 +682,22 @@ class _MediaDetailScreenState extends State { child: MediaContextMenu( client: widget.client, metadata: season, - onRefresh: _loadFullMetadata, - onTap: () { - Navigator.push( + onRefresh: (ratingKey) { + _watchStateChanged = true; + _updateWatchState(); + }, + onTap: () async { + final watchStateChanged = await Navigator.push( context, MaterialPageRoute( builder: (context) => SeasonDetailScreen(client: widget.client, season: season), ), ); + if (watchStateChanged == true) { + _watchStateChanged = true; + _updateWatchState(); + } }, child: InkWell( child: Padding( diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 8e576921..c37905a1 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -6,6 +6,7 @@ import '../models/plex_user_profile.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../mixins/refreshable.dart'; +import '../mixins/item_updatable.dart'; class SearchScreen extends StatefulWidget { final PlexClient client; @@ -17,7 +18,10 @@ class SearchScreen extends StatefulWidget { State createState() => _SearchScreenState(); } -class _SearchScreenState extends State with Refreshable { +class _SearchScreenState extends State with Refreshable, ItemUpdatable { + @override + PlexClient get client => widget.client; + final _searchController = TextEditingController(); List _searchResults = []; bool _isSearching = false; @@ -101,6 +105,14 @@ class _SearchScreenState extends State with Refreshable { } } + @override + void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + final index = _searchResults.indexWhere((item) => item.ratingKey == ratingKey); + if (index != -1) { + _searchResults[index] = updatedMetadata; + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -196,9 +208,10 @@ class _SearchScreenState extends State with Refreshable { delegate: SliverChildBuilderDelegate((context, index) { final item = _searchResults[index]; return MediaCard( + key: Key(item.ratingKey), client: widget.client, item: item, - onRefresh: refresh, + onRefresh: updateItem, userProfile: widget.userProfile, ); }, childCount: _searchResults.length), diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 53333820..01ff9a8d 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -5,6 +5,8 @@ import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/app_bar_back_button.dart'; +import '../widgets/media_context_menu.dart'; +import '../mixins/item_updatable.dart'; import 'video_player_screen.dart'; class SeasonDetailScreen extends StatefulWidget { @@ -23,9 +25,13 @@ class SeasonDetailScreen extends StatefulWidget { State createState() => _SeasonDetailScreenState(); } -class _SeasonDetailScreenState extends State { +class _SeasonDetailScreenState extends State with ItemUpdatable { + @override + PlexClient get client => widget.client; + List _episodes = []; bool _isLoadingEpisodes = false; + bool _watchStateChanged = false; @override void initState() { @@ -51,6 +57,20 @@ class _SeasonDetailScreenState extends State { } } + @override + Future 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 Widget build(BuildContext context) { return Scaffold( @@ -59,8 +79,9 @@ class _SeasonDetailScreenState extends State { DesktopSliverAppBar( title: Text(widget.season.title), pinned: true, - leading: const AppBarBackButton( + leading: AppBarBackButton( style: BackButtonStyle.circular, + onPressed: () => Navigator.pop(context, _watchStateChanged), ), ), if (_isLoadingEpisodes) @@ -117,23 +138,27 @@ class _SeasonDetailScreenState extends State { ? episode.viewOffset! / episode.duration! : 0.0; - return Card( - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => VideoPlayerScreen( - client: widget.client, - metadata: episode, - userProfile: widget.userProfile, - ), + return MediaContextMenu( + client: widget.client, + metadata: episode, + onRefresh: updateItem, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + 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( padding: const EdgeInsets.all(12), child: Row( diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index db15fa14..64cb8413 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -13,7 +13,7 @@ class MediaCard extends StatefulWidget { final PlexMetadata item; final double? width; final double? height; - final VoidCallback? onRefresh; + final void Function(String ratingKey)? onRefresh; final PlexUserProfile? userProfile; const MediaCard({ @@ -48,7 +48,7 @@ class _MediaCardState extends State { ); // Refresh parent screen if result indicates it's needed if (result == true) { - widget.onRefresh?.call(); + widget.onRefresh?.call(widget.item.ratingKey); } } else if (itemType == 'season') { // For seasons, show season detail screen @@ -63,7 +63,7 @@ class _MediaCardState extends State { ), ); // Season screen doesn't return a refresh flag, but we can refresh anyway - widget.onRefresh?.call(); + widget.onRefresh?.call(widget.item.ratingKey); } else { // For all other types (shows, movies), show detail screen final result = await Navigator.push( @@ -78,7 +78,7 @@ class _MediaCardState extends State { ); // Refresh parent screen if result indicates it's needed if (result == true) { - widget.onRefresh?.call(); + widget.onRefresh?.call(widget.item.ratingKey); } } } diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index c4eeda72..09d9eedd 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -23,7 +23,7 @@ class _MenuAction { class MediaContextMenu extends StatefulWidget { final PlexClient client; final PlexMetadata metadata; - final VoidCallback? onRefresh; + final void Function(String ratingKey)? onRefresh; final VoidCallback? onTap; final Widget child; @@ -228,7 +228,7 @@ class _MediaContextMenuState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(successMessage)), ); - widget.onRefresh?.call(); + widget.onRefresh?.call(widget.metadata.ratingKey); } } catch (e) { if (context.mounted) { @@ -255,7 +255,7 @@ class _MediaContextMenuState extends State { context, MaterialPageRoute(builder: (context) => screenBuilder(metadata)), ); - widget.onRefresh?.call(); + widget.onRefresh?.call(widget.metadata.ratingKey); } } catch (e) { if (context.mounted) {