fix: update watch state properly
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user