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/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<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> _recentlyAdded = [];
bool _isLoading = true;
@@ -110,6 +114,21 @@ class _DiscoverScreenState extends State<DiscoverScreen> 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<void> _handleSwitchServer() async {
final storage = await StorageService.getInstance();
final plexToken = storage.getPlexToken();
@@ -808,11 +827,12 @@ class _DiscoverScreenState extends State<DiscoverScreen> 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,
),
);
+15 -2
View File
@@ -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<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<PlexMetadata> _items = [];
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
@override
void refresh() {
@@ -374,9 +386,10 @@ class _LibrariesScreenState extends State<LibrariesScreen> 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),
+55 -8
View File
@@ -32,13 +32,22 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
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<void> _loadFullMetadata() async {
setState(() {
_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 {
try {
// If seasons aren't loaded yet, wait for them or load them
@@ -192,13 +228,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
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<MediaDetailScreen> {
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<MediaDetailScreen> {
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<MediaDetailScreen> {
child: MediaContextMenu(
client: widget.client,
metadata: season,
onRefresh: _loadFullMetadata,
onTap: () {
Navigator.push(
onRefresh: (ratingKey) {
_watchStateChanged = true;
_updateWatchState();
},
onTap: () async {
final watchStateChanged = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) =>
SeasonDetailScreen(client: widget.client, season: season),
),
);
if (watchStateChanged == true) {
_watchStateChanged = true;
_updateWatchState();
}
},
child: InkWell(
child: Padding(
+15 -2
View File
@@ -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<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();
List<PlexMetadata> _searchResults = [];
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
Widget build(BuildContext context) {
return Scaffold(
@@ -196,9 +208,10 @@ class _SearchScreenState extends State<SearchScreen> 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),
+43 -18
View File
@@ -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<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
}
class _SeasonDetailScreenState extends State<SeasonDetailScreen> {
class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdatable {
@override
PlexClient get client => widget.client;
List<PlexMetadata> _episodes = [];
bool _isLoadingEpisodes = false;
bool _watchStateChanged = false;
@override
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
Widget build(BuildContext context) {
return Scaffold(
@@ -59,8 +79,9 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> {
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<SeasonDetailScreen> {
? 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(
+4 -4
View File
@@ -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<MediaCard> {
);
// 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<MediaCard> {
),
);
// 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<bool>(
@@ -78,7 +78,7 @@ class _MediaCardState extends State<MediaCard> {
);
// Refresh parent screen if result indicates it's needed
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 {
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<MediaContextMenu> {
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<MediaContextMenu> {
context,
MaterialPageRoute(builder: (context) => screenBuilder(metadata)),
);
widget.onRefresh?.call();
widget.onRefresh?.call(widget.metadata.ratingKey);
}
} catch (e) {
if (context.mounted) {