fix(ui): refresh stale watch state
This commit is contained in:
@@ -48,6 +48,7 @@ import 'providers/playback_state_provider.dart';
|
||||
import 'providers/download_provider.dart';
|
||||
import 'providers/offline_mode_provider.dart';
|
||||
import 'providers/offline_watch_provider.dart';
|
||||
import 'providers/watch_state_overlay_provider.dart';
|
||||
import 'providers/companion_remote_provider.dart';
|
||||
import 'providers/shader_provider.dart';
|
||||
import 'utils/snackbar_helper.dart';
|
||||
@@ -709,6 +710,14 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
return provider;
|
||||
},
|
||||
),
|
||||
ChangeNotifierProxyProvider<ActiveProfileProvider, WatchStateOverlayProvider>(
|
||||
create: (_) => WatchStateOverlayProvider(),
|
||||
update: (_, activeProfile, previous) {
|
||||
final provider = previous ?? WatchStateOverlayProvider();
|
||||
provider.setActiveProfileId(activeProfile.activeId);
|
||||
return provider;
|
||||
},
|
||||
),
|
||||
ChangeNotifierProxyProvider<ActiveProfileProvider, OfflineWatchSyncService>(
|
||||
create: (context) {
|
||||
final offlineModeProvider = context.read<OfflineModeProvider>();
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../media/media_item.dart';
|
||||
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
|
||||
@immutable
|
||||
class WatchStateOverlayPatch {
|
||||
final bool? isWatched;
|
||||
final bool hasViewOffsetMs;
|
||||
final int? viewOffsetMs;
|
||||
|
||||
const WatchStateOverlayPatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is WatchStateOverlayPatch &&
|
||||
other.isWatched == isWatched &&
|
||||
other.hasViewOffsetMs == hasViewOffsetMs &&
|
||||
other.viewOffsetMs == viewOffsetMs;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs);
|
||||
}
|
||||
|
||||
/// Session-local watch-state overlay for immediate UI freshness.
|
||||
///
|
||||
/// Server fetches remain the source of truth; this only patches stale
|
||||
/// [MediaItem] snapshots while a screen waits for its next refresh.
|
||||
class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
||||
WatchStateOverlayProvider() {
|
||||
_subscription = WatchStateNotifier().stream.listen(_onWatchStateEvent);
|
||||
}
|
||||
|
||||
StreamSubscription<WatchStateEvent>? _subscription;
|
||||
final Map<String, WatchStateOverlayPatch> _patches = {};
|
||||
String? _activeProfileId;
|
||||
|
||||
WatchStateOverlayPatch? patchForGlobalKey(String globalKey) => _patches[globalKey];
|
||||
|
||||
WatchStateOverlayPatch? patchForItem(MediaItem item) => patchForGlobalKey(item.globalKey);
|
||||
|
||||
MediaItem apply(MediaItem item) {
|
||||
return applyPatch(item, patchForItem(item));
|
||||
}
|
||||
|
||||
static MediaItem applyPatch(MediaItem item, WatchStateOverlayPatch? patch) {
|
||||
if (patch == null) return item;
|
||||
|
||||
return item.copyWith(
|
||||
viewCount: patch.isWatched == null ? null : (patch.isWatched! ? 1 : 0),
|
||||
viewOffsetMs: patch.hasViewOffsetMs ? patch.viewOffsetMs : null,
|
||||
);
|
||||
}
|
||||
|
||||
void setActiveProfileId(String? profileId) {
|
||||
if (_activeProfileId == profileId) return;
|
||||
_activeProfileId = profileId;
|
||||
if (_patches.isEmpty) return;
|
||||
_patches.clear();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
|
||||
void _onWatchStateEvent(WatchStateEvent event) {
|
||||
final patch = switch (event.changeType) {
|
||||
WatchStateChangeType.watched => const WatchStateOverlayPatch(
|
||||
isWatched: true,
|
||||
hasViewOffsetMs: true,
|
||||
viewOffsetMs: 0,
|
||||
),
|
||||
WatchStateChangeType.unwatched => const WatchStateOverlayPatch(
|
||||
isWatched: false,
|
||||
hasViewOffsetMs: true,
|
||||
viewOffsetMs: 0,
|
||||
),
|
||||
WatchStateChangeType.progressUpdate => WatchStateOverlayPatch(
|
||||
hasViewOffsetMs: event.viewOffset != null,
|
||||
viewOffsetMs: event.viewOffset,
|
||||
),
|
||||
WatchStateChangeType.removedFromContinueWatching => null,
|
||||
};
|
||||
|
||||
if (patch == null) return;
|
||||
|
||||
if (_patches[event.globalKey] == patch) return;
|
||||
_patches[event.globalKey] = patch;
|
||||
safeNotifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,9 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||
serverName: widget.serverName,
|
||||
);
|
||||
|
||||
@override
|
||||
String? get itemServerId => widget.serverId;
|
||||
|
||||
@override
|
||||
String get title => widget.actorName;
|
||||
|
||||
|
||||
@@ -98,13 +98,9 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State
|
||||
|
||||
@override
|
||||
void updateItemInLists(String itemId, MediaItem updatedItem) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
final index = items.indexWhere((it) => it.id == itemId);
|
||||
if (index != -1) {
|
||||
items[index] = updatedItem;
|
||||
}
|
||||
});
|
||||
final index = items.indexWhere((it) => it.id == itemId);
|
||||
if (index != -1) {
|
||||
items[index] = updatedItem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
@override
|
||||
MediaItem get mediaItem => widget.collection;
|
||||
|
||||
@override
|
||||
String? get itemServerId => widget.collection.serverId;
|
||||
|
||||
@override
|
||||
String get title => widget.collection.title!;
|
||||
|
||||
|
||||
@@ -164,8 +164,20 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
@override
|
||||
void onWatchStateChanged(WatchStateEvent event) {
|
||||
if (event.changeType == WatchStateChangeType.removedFromContinueWatching) {
|
||||
_removeContinueWatchingItem(event.itemId);
|
||||
unawaited(_refreshContinueWatching());
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh continue watching when any relevant item changes
|
||||
_refreshContinueWatching();
|
||||
unawaited(_refreshContinueWatching());
|
||||
}
|
||||
|
||||
void _removeContinueWatchingItem(String itemId) {
|
||||
setState(() {
|
||||
_onDeck.removeWhere((item) => item.id == itemId);
|
||||
});
|
||||
}
|
||||
|
||||
// Track initial load so we can focus hero when content first appears
|
||||
|
||||
@@ -256,13 +256,31 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void _handleItemRefresh(String ratingKey) {
|
||||
setState(() {
|
||||
final index = _items.indexWhere((item) => item.id == ratingKey);
|
||||
if (index != -1) {
|
||||
appLogger.d('Item refresh requested for: $ratingKey');
|
||||
}
|
||||
});
|
||||
Future<void> _handleItemRefresh(String ratingKey) async {
|
||||
final itemIndex = _items.indexWhere((item) => item.id == ratingKey);
|
||||
final filteredIndex = _filteredItems.indexWhere((item) => item.id == ratingKey);
|
||||
final existing = itemIndex != -1
|
||||
? _items[itemIndex]
|
||||
: filteredIndex != -1
|
||||
? _filteredItems[filteredIndex]
|
||||
: null;
|
||||
if (existing == null) return;
|
||||
final serverId = existing.serverId ?? widget.hub.serverId;
|
||||
if (serverId == null) return;
|
||||
|
||||
try {
|
||||
final updated = await context.tryGetMediaClientForServer(serverId)?.fetchItem(ratingKey);
|
||||
if (updated == null || !mounted) return;
|
||||
setState(() {
|
||||
final currentItemIndex = _items.indexWhere((item) => item.id == ratingKey);
|
||||
if (currentItemIndex != -1) _items[currentItemIndex] = updated;
|
||||
final currentFilteredIndex = _filteredItems.indexWhere((item) => item.id == ratingKey);
|
||||
if (currentFilteredIndex != -1) _filteredItems[currentFilteredIndex] = updated;
|
||||
});
|
||||
if (_selectedSort != null) _applySort();
|
||||
} catch (e) {
|
||||
appLogger.d('Item refresh skipped for: $ratingKey', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -46,11 +46,13 @@ import '../../../services/storage_service.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../mixins/grid_focus_node_mixin.dart';
|
||||
import '../../../mixins/item_updatable.dart';
|
||||
import '../../../mixins/watch_state_aware.dart';
|
||||
import '../../../mixins/deletion_aware.dart';
|
||||
import '../../../mixins/paginated_item_loader.dart';
|
||||
import '../../../widgets/skeleton_media_card.dart';
|
||||
import '../../../utils/deletion_notifier.dart';
|
||||
import '../../../utils/global_key_utils.dart';
|
||||
import '../../../utils/watch_state_notifier.dart';
|
||||
import '../../../utils/platform_detector.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../main_screen.dart';
|
||||
@@ -81,7 +83,13 @@ class LibraryBrowseTab extends BaseLibraryTab<MediaItem> {
|
||||
}
|
||||
|
||||
class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrowseTab>
|
||||
with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin, DeletionAware, PaginatedItemLoader<LibraryBrowseTab> {
|
||||
with
|
||||
ItemUpdatable,
|
||||
LibraryTabFocusMixin,
|
||||
GridFocusNodeMixin,
|
||||
WatchStateAware,
|
||||
DeletionAware,
|
||||
PaginatedItemLoader<LibraryBrowseTab> {
|
||||
@override
|
||||
String? get itemServerId => widget.library.serverId;
|
||||
|
||||
@@ -91,6 +99,25 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
@override
|
||||
String? get deletionServerId => widget.library.serverId;
|
||||
|
||||
@override
|
||||
String? get watchStateServerId => widget.library.serverId;
|
||||
|
||||
@override
|
||||
Set<String>? get watchedIds => loadedItems.values.map((e) => e.id).toSet();
|
||||
|
||||
@override
|
||||
Set<String>? get watchedGlobalKeys {
|
||||
if (loadedItems.isEmpty) return <String>{};
|
||||
|
||||
final keys = <String>{};
|
||||
for (final item in loadedItems.values) {
|
||||
final serverId = item.serverId ?? widget.library.serverId;
|
||||
if (serverId == null) return null;
|
||||
keys.add(_toGlobalKey(item.id, serverId: serverId));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<String>? get deletionIds => loadedItems.values.map((e) => e.id).toSet();
|
||||
|
||||
@@ -107,6 +134,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
return keys;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWatchStateChanged(WatchStateEvent event) {
|
||||
if (event.changeType == WatchStateChangeType.progressUpdate ||
|
||||
event.changeType == WatchStateChangeType.removedFromContinueWatching) {
|
||||
return;
|
||||
}
|
||||
|
||||
final affectedIds = {event.itemId, ...event.parentChain};
|
||||
for (final item in loadedItems.values) {
|
||||
if (affectedIds.contains(item.id)) {
|
||||
unawaited(updateItem(item.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onDeletionEvent(DeletionEvent event) {
|
||||
// If we have an item that matches the rating key exactly, remove it and rebuild indices
|
||||
@@ -154,14 +196,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
|
||||
@override
|
||||
void updateItemInLists(String itemId, MediaItem updatedMetadata) {
|
||||
setState(() {
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.value.id == itemId) {
|
||||
loadedItems[entry.key] = updatedMetadata;
|
||||
break;
|
||||
}
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.value.id == itemId) {
|
||||
loadedItems[entry.key] = updatedMetadata;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Browse-specific state (not in base class)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
@@ -5,7 +7,10 @@ import '../../../i18n/strings.g.dart';
|
||||
import '../../../media/media_hub.dart';
|
||||
import '../../../media/media_item.dart';
|
||||
import '../../../mixins/item_updatable.dart';
|
||||
import '../../../mixins/watch_state_aware.dart';
|
||||
import '../../../utils/global_key_utils.dart';
|
||||
import '../../../utils/provider_extensions.dart';
|
||||
import '../../../utils/watch_state_notifier.dart';
|
||||
import '../../../widgets/hub_section.dart';
|
||||
import '../../main_screen.dart';
|
||||
import 'base_library_tab.dart';
|
||||
@@ -26,13 +31,45 @@ class LibraryRecommendedTab extends BaseLibraryTab<MediaHub> {
|
||||
State<LibraryRecommendedTab> createState() => _LibraryRecommendedTabState();
|
||||
}
|
||||
|
||||
class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab> with ItemUpdatable {
|
||||
class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab>
|
||||
with ItemUpdatable, WatchStateAware {
|
||||
/// GlobalKeys for each hub section to enable vertical navigation
|
||||
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
||||
|
||||
@override
|
||||
String? get itemServerId => widget.library.serverId;
|
||||
|
||||
@override
|
||||
String? get watchStateServerId => widget.library.serverId;
|
||||
|
||||
@override
|
||||
Set<String>? get watchedIds {
|
||||
final keys = <String>{};
|
||||
for (final hub in items) {
|
||||
for (final item in hub.items) {
|
||||
keys.add(item.id);
|
||||
if (item.parentId != null) keys.add(item.parentId!);
|
||||
if (item.grandparentId != null) keys.add(item.grandparentId!);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<String>? get watchedGlobalKeys {
|
||||
final keys = <String>{};
|
||||
for (final hub in items) {
|
||||
for (final item in hub.items) {
|
||||
final serverId = item.serverId ?? widget.library.serverId;
|
||||
if (serverId == null) return null;
|
||||
keys.add(buildGlobalKey(serverId, item.id));
|
||||
if (item.parentId != null) keys.add(buildGlobalKey(serverId, item.parentId!));
|
||||
if (item.grandparentId != null) keys.add(buildGlobalKey(serverId, item.grandparentId!));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String itemId, MediaItem updatedItem) {
|
||||
// Update the item in any hub that contains it. MediaHub items are
|
||||
@@ -48,6 +85,43 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWatchStateChanged(WatchStateEvent event) {
|
||||
if (event.changeType == WatchStateChangeType.progressUpdate) return;
|
||||
|
||||
if (event.changeType == WatchStateChangeType.removedFromContinueWatching) {
|
||||
_removeContinueWatchingItem(event.itemId);
|
||||
unawaited(loadItems());
|
||||
return;
|
||||
}
|
||||
|
||||
final affectedIds = {event.itemId, ...event.parentChain};
|
||||
final refreshIds = <String>{};
|
||||
for (final hub in items) {
|
||||
for (final item in hub.items) {
|
||||
if (affectedIds.contains(item.id)) {
|
||||
refreshIds.add(item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (final itemId in refreshIds) {
|
||||
unawaited(updateItem(itemId));
|
||||
}
|
||||
}
|
||||
|
||||
void _removeContinueWatchingItem(String itemId) {
|
||||
setState(() {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
final hub = items[i];
|
||||
if (!_isContinueWatchingHub(hub)) continue;
|
||||
final newItems = hub.items.where((item) => item.id != itemId).toList();
|
||||
if (newItems.length != hub.items.length) {
|
||||
items[i] = hub.copyWith(items: newItems, size: newItems.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
IconData get emptyIcon => Symbols.recommend_rounded;
|
||||
|
||||
|
||||
@@ -174,8 +174,6 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
context,
|
||||
isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline,
|
||||
);
|
||||
unawaited(_updateWatchStateOffline());
|
||||
unawaited(_loadOfflineOnDeckEpisode());
|
||||
}
|
||||
} else {
|
||||
// Online mode: dispatch via the right backend's neutral method so
|
||||
|
||||
@@ -34,7 +34,6 @@ import '../utils/media_image_helper.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../services/media_list_playback_launcher.dart';
|
||||
import '../services/offline_watch_sync_service.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/rating_utils.dart';
|
||||
import '../models/download_models.dart';
|
||||
@@ -192,41 +191,61 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
@override
|
||||
void onWatchStateChanged(WatchStateEvent event) {
|
||||
_watchStateChanged = true;
|
||||
final epIndex = _episodes.indexWhere((e) => e.id == event.itemId);
|
||||
|
||||
if (event.changeType == WatchStateChangeType.progressUpdate && event.viewOffset != null) {
|
||||
_patchLocalProgress(event.itemId, event.viewOffset!, epIndex: epIndex);
|
||||
} else {
|
||||
_localProgressById.remove(event.itemId);
|
||||
if (event.changeType == WatchStateChangeType.removedFromContinueWatching) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.changeType == WatchStateChangeType.progressUpdate) {
|
||||
if (event.viewOffset != null) {
|
||||
_patchLocalProgress(event.itemId, event.viewOffset!, epIndex: epIndex);
|
||||
}
|
||||
if (event.isNowWatched != true) return;
|
||||
}
|
||||
|
||||
_localProgressById.remove(event.itemId);
|
||||
_patchWatchedStateFromEvent(
|
||||
event,
|
||||
epIndex: epIndex,
|
||||
clearWatchedProgress: !widget.isOffline || event.changeType == WatchStateChangeType.progressUpdate,
|
||||
);
|
||||
|
||||
if (widget.isOffline) {
|
||||
// Offline: skip network refetch — patch the affected episode (or
|
||||
// the show metadata) in-memory using the local watch flag the event
|
||||
// already carries. The sync service drains queued actions to the
|
||||
// server when the device reconnects.
|
||||
if (epIndex != -1 && event.isNowWatched != null) {
|
||||
setStateIfMounted(() {
|
||||
final updated = _episodes[epIndex].copyWith(
|
||||
viewCount: event.isNowWatched! ? 1 : 0,
|
||||
viewOffsetMs: event.isNowWatched! ? _episodes[epIndex].viewOffsetMs : 0,
|
||||
);
|
||||
_episodes[epIndex] = updated;
|
||||
_syncEpisodeToCache(epIndex, updated);
|
||||
});
|
||||
} else if (event.itemId == _metadata.id) {
|
||||
unawaited(_updateWatchStateOffline());
|
||||
if (_metadata.isShow) {
|
||||
unawaited(_loadOfflineOnDeckEpisode());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Online: re-fetch the affected row so server-derived counters
|
||||
// (parent leafCounts, lastViewedAt) refresh too.
|
||||
if (epIndex != -1) {
|
||||
_updateEpisodeWatchState(event.itemId);
|
||||
} else {
|
||||
_refreshWatchState();
|
||||
}
|
||||
// Online: refresh server-derived counters and on-deck state. A watched
|
||||
// episode can change the hero play target even when the episode row itself
|
||||
// was already visible and patched locally.
|
||||
unawaited(_refreshWatchState());
|
||||
}
|
||||
|
||||
void _patchWatchedStateFromEvent(WatchStateEvent event, {required int epIndex, required bool clearWatchedProgress}) {
|
||||
final isWatched = event.isNowWatched;
|
||||
if (isWatched == null) return;
|
||||
final viewOffsetMs = isWatched && !clearWatchedProgress ? null : 0;
|
||||
setStateIfMounted(() {
|
||||
final base = _fullMetadata ?? widget.metadata;
|
||||
if (base.id == event.itemId) {
|
||||
_fullMetadata = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs);
|
||||
}
|
||||
|
||||
final onDeckEpisode = _onDeckEpisode;
|
||||
if (onDeckEpisode != null && onDeckEpisode.id == event.itemId) {
|
||||
_onDeckEpisode = onDeckEpisode.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs);
|
||||
}
|
||||
|
||||
if (epIndex != -1) {
|
||||
final updated = _episodes[epIndex].copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs);
|
||||
_episodes[epIndex] = updated;
|
||||
_syncEpisodeToCache(epIndex, updated);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _patchLocalProgress(String itemId, int viewOffset, {int? epIndex}) {
|
||||
@@ -377,9 +396,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (metadata != null) {
|
||||
setStateIfMounted(() {
|
||||
_fullMetadata = _applyLocalProgress(metadata.copyWith(serverId: serverId, serverName: serverName));
|
||||
if (onDeckEpisode != null) {
|
||||
_onDeckEpisode = _applyLocalProgress(onDeckEpisode.copyWith(serverId: serverId, serverName: serverName));
|
||||
}
|
||||
_onDeckEpisode = onDeckEpisode == null
|
||||
? null
|
||||
: _applyLocalProgress(onDeckEpisode.copyWith(serverId: serverId, serverName: serverName));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -403,30 +422,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a single episode's watch state without refetching everything.
|
||||
/// Backend-neutral so Jellyfin items refresh in place when their
|
||||
/// watched flag changes (the previous Plex-only path no-op'd for
|
||||
/// Jellyfin and left the row stale).
|
||||
Future<void> _updateEpisodeWatchState(String ratingKey) async {
|
||||
final mediaClient = _getMediaClientForMetadata(context);
|
||||
if (mediaClient == null) return;
|
||||
try {
|
||||
final refreshed = await mediaClient.fetchItem(ratingKey);
|
||||
if (refreshed != null) {
|
||||
setStateIfMounted(() {
|
||||
final i = _episodes.indexWhere((e) => e.id == ratingKey);
|
||||
if (i != -1) {
|
||||
final updated = _applyLocalProgress(refreshed);
|
||||
_episodes[i] = updated;
|
||||
_syncEpisodeToCache(i, updated);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.d('Episode cache sync skipped', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -2009,34 +2004,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final offlineWatchProvider = context.read<OfflineWatchProvider>();
|
||||
final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(_metadata.id);
|
||||
|
||||
setStateIfMounted(() {
|
||||
_onDeckEpisode = nextEpisode == null ? null : _applyLocalProgress(nextEpisode);
|
||||
});
|
||||
|
||||
if (nextEpisode != null) {
|
||||
setStateIfMounted(() {
|
||||
_onDeckEpisode = _applyLocalProgress(nextEpisode);
|
||||
});
|
||||
appLogger.d('Offline OnDeck: S${nextEpisode.parentIndex}E${nextEpisode.index} - ${nextEpisode.title}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Offline: patch the in-memory metadata so the UI reflects a queued
|
||||
/// watch/unwatch action immediately. The sync service holds the truth
|
||||
/// for offline state and will reconcile with the server on reconnect,
|
||||
/// so we don't need to round-trip through the per-backend cache here.
|
||||
Future<void> _updateWatchStateOffline() async {
|
||||
final serverId = _metadata.serverId;
|
||||
if (serverId == null) return;
|
||||
final localStatus = await context.read<OfflineWatchSyncService>().getLocalWatchStatus('$serverId:${_metadata.id}');
|
||||
if (localStatus == null) return;
|
||||
setStateIfMounted(() {
|
||||
final base = _fullMetadata ?? _metadata;
|
||||
_fullMetadata = base.copyWith(
|
||||
viewCount: localStatus ? 1 : 0,
|
||||
// Reset the resume position when transitioning to unwatched, mirroring
|
||||
// the previous Plex cache-mutation behavior.
|
||||
viewOffsetMs: localStatus ? base.viewOffsetMs : 0,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _playFirstEpisode() async {
|
||||
try {
|
||||
// If seasons aren't loaded yet, wait for them or load them
|
||||
|
||||
@@ -43,6 +43,9 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
@override
|
||||
Object get mediaItem => widget.playlist;
|
||||
|
||||
@override
|
||||
String? get itemServerId => widget.playlist.serverId;
|
||||
|
||||
@override
|
||||
String get title => widget.playlist.title;
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../media/media_item.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../mixins/context_menu_tap_mixin.dart';
|
||||
import '../../providers/watch_state_overlay_provider.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
@@ -44,8 +46,20 @@ class PlaylistItemCard extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTapMixin<PlaylistItemCard> {
|
||||
MediaItem _effectiveItem(BuildContext context) {
|
||||
try {
|
||||
final patch = context.select<WatchStateOverlayProvider, WatchStateOverlayPatch?>(
|
||||
(provider) => provider.patchForGlobalKey(widget.item.globalKey),
|
||||
);
|
||||
return WatchStateOverlayProvider.applyPatch(widget.item, patch);
|
||||
} on ProviderNotFoundException {
|
||||
return widget.item;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final item = _effectiveItem(context);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Determine if row is focused (main content area)
|
||||
@@ -71,7 +85,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
|
||||
return MediaContextMenu(
|
||||
key: contextMenuKey,
|
||||
item: widget.item,
|
||||
item: item,
|
||||
onRefresh: widget.onRefresh,
|
||||
onTap: widget.onTap,
|
||||
child: Card(
|
||||
@@ -118,7 +132,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
),
|
||||
|
||||
// Poster thumbnail
|
||||
_buildPosterImage(context),
|
||||
_buildPosterImage(context, item),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
@@ -130,7 +144,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
widget.item.displayTitle,
|
||||
item.displayTitle,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -140,19 +154,19 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
|
||||
// Subtitle (episode info or type)
|
||||
Text(
|
||||
_buildSubtitle(),
|
||||
_buildSubtitle(item),
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
// Progress indicator if partially watched
|
||||
if (widget.item.viewOffsetMs != null && widget.item.durationMs != null)
|
||||
if (item.viewOffsetMs != null && item.durationMs != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: MediaProgressBar(
|
||||
viewOffset: widget.item.viewOffsetMs!,
|
||||
duration: widget.item.durationMs!,
|
||||
viewOffset: item.viewOffsetMs!,
|
||||
duration: item.durationMs!,
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
@@ -163,9 +177,9 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Duration
|
||||
if (widget.item.durationMs != null)
|
||||
if (item.durationMs != null)
|
||||
Text(
|
||||
formatDurationTextual(widget.item.durationMs!),
|
||||
formatDurationTextual(item.durationMs!),
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
|
||||
),
|
||||
|
||||
@@ -194,14 +208,14 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPosterImage(BuildContext context) {
|
||||
final posterUrl = widget.item.posterThumb();
|
||||
Widget _buildPosterImage(BuildContext context, MediaItem item) {
|
||||
final posterUrl = item.posterThumb();
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
child: OptimizedMediaImage.poster(
|
||||
// Backend-neutral lookup so Jellyfin items render via their own
|
||||
// image transcoder; null falls through to the placeholder below.
|
||||
client: context.tryGetMediaClientWithFallback(widget.item.serverId),
|
||||
client: context.tryGetMediaClientWithFallback(item.serverId),
|
||||
imagePath: posterUrl,
|
||||
width: 60,
|
||||
height: 90,
|
||||
@@ -221,21 +235,21 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
);
|
||||
}
|
||||
|
||||
String _buildSubtitle() {
|
||||
final kind = widget.item.kind;
|
||||
String _buildSubtitle(MediaItem item) {
|
||||
final kind = item.kind;
|
||||
|
||||
if (kind == MediaKind.episode) {
|
||||
// For episodes, show "S#E# - Episode Title"
|
||||
final season = widget.item.parentIndex;
|
||||
final episode = widget.item.index;
|
||||
final season = item.parentIndex;
|
||||
final episode = item.index;
|
||||
if (season != null && episode != null) {
|
||||
return 'S${season}E$episode${widget.item.displaySubtitle != null ? ' - ${widget.item.displaySubtitle}' : ''}';
|
||||
return 'S${season}E$episode${item.displaySubtitle != null ? ' - ${item.displaySubtitle}' : ''}';
|
||||
}
|
||||
return widget.item.displaySubtitle ?? t.discover.tvShow;
|
||||
return item.displaySubtitle ?? t.discover.tvShow;
|
||||
} else if (kind == MediaKind.movie) {
|
||||
// For movies, show year and edition (edition is Plex-only; null elsewhere)
|
||||
final year = widget.item.year?.toString();
|
||||
final edition = widget.item.editionTitle;
|
||||
final year = item.year?.toString();
|
||||
final edition = item.editionTitle;
|
||||
if (year != null && edition != null) {
|
||||
return '$year · $edition';
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
forceListMode: true,
|
||||
disableScale: true,
|
||||
focusNode: index == 0 ? _firstResultFocusNode : null,
|
||||
onRefresh: updateItem,
|
||||
onListRefresh: () => updateItem(item.id),
|
||||
onNavigateLeft: _navigateToSidebar,
|
||||
onNavigateUp: index == 0 ? focusSearchInput : null,
|
||||
|
||||
@@ -69,6 +69,10 @@ class PlaybackProgressTracker {
|
||||
/// Whether the final stopped progress event was already emitted locally.
|
||||
bool _stopProgressNotified = false;
|
||||
|
||||
Duration? _lastProgressNotifiedPosition;
|
||||
|
||||
static const Duration _progressNotifyDelta = Duration(seconds: 30);
|
||||
|
||||
final PlaybackReportSession? _reportSession;
|
||||
|
||||
PlaybackProgressTracker({
|
||||
@@ -165,16 +169,23 @@ class PlaybackProgressTracker {
|
||||
if (isOffline) {
|
||||
// Queue progress update for later sync
|
||||
await _sendOfflineProgress(position, duration);
|
||||
_notifyProgressIfNeeded(position, duration, force: state == 'stopped');
|
||||
} else if (state == 'stopped') {
|
||||
// Stopped must complete before disposal
|
||||
await _sendOnlineProgress(state, position, duration);
|
||||
final accepted = await _sendOnlineProgress(state, position, duration);
|
||||
_resetBackoff();
|
||||
if (accepted) {
|
||||
_notifyProgressIfNeeded(position, duration, force: true);
|
||||
}
|
||||
} else {
|
||||
// Fire-and-forget for playing/paused — avoid blocking the Dart event loop
|
||||
unawaited(
|
||||
_sendOnlineProgress(state, position, duration)
|
||||
.then((_) {
|
||||
.then((accepted) {
|
||||
_resetBackoff();
|
||||
if (accepted) {
|
||||
_notifyProgressIfNeeded(position, duration);
|
||||
}
|
||||
})
|
||||
.catchError((Object e) {
|
||||
_consecutiveFailures++;
|
||||
@@ -188,18 +199,6 @@ class PlaybackProgressTracker {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Emit watch state event on stop for UI updates across screens.
|
||||
// Skip if already scrobbled — markWatched already emitted a watched event.
|
||||
if (state == 'stopped' && position.inMilliseconds > 0 && !_scrobbled && !_stopProgressNotified) {
|
||||
_stopProgressNotified = true;
|
||||
WatchStateNotifier().notifyProgress(
|
||||
item: metadata,
|
||||
viewOffset: position.inMilliseconds,
|
||||
duration: duration.inMilliseconds,
|
||||
watchedThreshold: client?.watchedThreshold ?? 0.9,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isOffline) {
|
||||
_consecutiveFailures++;
|
||||
@@ -222,12 +221,32 @@ class PlaybackProgressTracker {
|
||||
}
|
||||
}
|
||||
|
||||
void _notifyProgressIfNeeded(Duration position, Duration duration, {bool force = false}) {
|
||||
if (_scrobbled) return;
|
||||
if (position.inMilliseconds <= 0 || duration.inMilliseconds <= 0) return;
|
||||
if (force) {
|
||||
if (_stopProgressNotified) return;
|
||||
_stopProgressNotified = true;
|
||||
} else {
|
||||
final last = _lastProgressNotifiedPosition;
|
||||
if (last != null && (position - last).abs() < _progressNotifyDelta) return;
|
||||
}
|
||||
|
||||
_lastProgressNotifiedPosition = position;
|
||||
WatchStateNotifier().notifyProgress(
|
||||
item: metadata,
|
||||
viewOffset: position.inMilliseconds,
|
||||
duration: duration.inMilliseconds,
|
||||
watchedThreshold: client?.watchedThreshold ?? 0.9,
|
||||
);
|
||||
}
|
||||
|
||||
/// Send progress update to the active server through the unified
|
||||
/// [MediaServerClient.reportPlayback*] surface.
|
||||
Future<void> _sendOnlineProgress(String state, Duration position, Duration duration) async {
|
||||
Future<bool> _sendOnlineProgress(String state, Duration position, Duration duration) async {
|
||||
final c = client;
|
||||
final session = _reportSession;
|
||||
if (c == null || session == null) return;
|
||||
if (c == null || session == null) return false;
|
||||
|
||||
final accepted = await session.report(
|
||||
PlaybackReportSnapshot(
|
||||
@@ -243,6 +262,7 @@ class PlaybackProgressTracker {
|
||||
if (accepted) {
|
||||
await _maybeScrobble(c, position, duration);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
PlaybackStreamSelection _currentStreamSelectionForStopped() {
|
||||
|
||||
@@ -3068,7 +3068,10 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements
|
||||
Future<void> markUnwatched(MediaItem item) => markAsUnwatched(item.id, item: item);
|
||||
|
||||
@override
|
||||
Future<void> removeFromContinueWatching(MediaItem item) => removeFromOnDeck(item.id);
|
||||
Future<void> removeFromContinueWatching(MediaItem item) async {
|
||||
await removeFromOnDeck(item.id);
|
||||
WatchStateNotifier().notifyRemovedFromContinueWatching(item: item);
|
||||
}
|
||||
|
||||
/// Rate a media item (0.0-10.0 scale, where each integer = half a star).
|
||||
/// Pass `-1` to clear an existing rating. Throws [MediaServerHttpException]
|
||||
|
||||
@@ -109,7 +109,7 @@ class TraktSyncService {
|
||||
|
||||
Future<void> _onWatchStateEvent(WatchStateEvent event) async {
|
||||
if (!_canPush) return;
|
||||
if (event.changeType == WatchStateChangeType.progressUpdate) return;
|
||||
if (event.changeType != WatchStateChangeType.watched && event.changeType != WatchStateChangeType.unwatched) return;
|
||||
|
||||
final kind = TraktMediaKind.tryFromMediaKindId(event.mediaType);
|
||||
if (kind == null) return;
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'base_notifier.dart';
|
||||
import 'global_key_utils.dart';
|
||||
import 'hierarchical_event_mixin.dart';
|
||||
|
||||
enum WatchStateChangeType { watched, unwatched, progressUpdate }
|
||||
enum WatchStateChangeType { watched, unwatched, progressUpdate, removedFromContinueWatching }
|
||||
|
||||
/// Event representing a watch state change with parent chain for hierarchical invalidation
|
||||
class WatchStateEvent with HierarchicalEventMixin {
|
||||
@@ -132,4 +132,18 @@ class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to emit a Continue Watching removal event.
|
||||
void notifyRemovedFromContinueWatching({required MediaItem item}) {
|
||||
notify(
|
||||
WatchStateEvent(
|
||||
itemId: item.id,
|
||||
serverId: item.serverId ?? '',
|
||||
changeType: WatchStateChangeType.removedFromContinueWatching,
|
||||
parentChain: item.parentChain,
|
||||
mediaType: item.kind.id,
|
||||
librarySectionID: item.libraryId,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../focus/focusable_wrapper.dart';
|
||||
import '../mixins/context_menu_tap_mixin.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/watch_state_overlay_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/settings_service.dart';
|
||||
@@ -56,7 +57,18 @@ class EpisodeCard extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<EpisodeCard> {
|
||||
Widget _buildEpisodeMetaRow(BuildContext context) {
|
||||
MediaItem _effectiveEpisode(BuildContext context) {
|
||||
try {
|
||||
final patch = context.select<WatchStateOverlayProvider, WatchStateOverlayPatch?>(
|
||||
(provider) => provider.patchForGlobalKey(widget.episode.globalKey),
|
||||
);
|
||||
return WatchStateOverlayProvider.applyPatch(widget.episode, patch);
|
||||
} on ProviderNotFoundException {
|
||||
return widget.episode;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEpisodeMetaRow(BuildContext context, MediaItem episode) {
|
||||
final mutedStyle = Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12);
|
||||
final dot = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
@@ -64,13 +76,13 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
);
|
||||
return Row(
|
||||
children: [
|
||||
if (widget.episode.durationMs != null)
|
||||
Text(formatDurationTimestamp(Duration(milliseconds: widget.episode.durationMs!)), style: mutedStyle),
|
||||
if (widget.episode.originallyAvailableAt != null) ...[
|
||||
if (episode.durationMs != null)
|
||||
Text(formatDurationTimestamp(Duration(milliseconds: episode.durationMs!)), style: mutedStyle),
|
||||
if (episode.originallyAvailableAt != null) ...[
|
||||
dot,
|
||||
Text(formatFullDate(widget.episode.originallyAvailableAt!), style: mutedStyle),
|
||||
Text(formatFullDate(episode.originallyAvailableAt!), style: mutedStyle),
|
||||
],
|
||||
if (widget.episode.userRating != null && widget.episode.userRating! > 0) ...[
|
||||
if (episode.userRating != null && episode.userRating! > 0) ...[
|
||||
dot,
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
@@ -78,9 +90,9 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
(widget.episode.userRating! / 2) == (widget.episode.userRating! / 2).truncateToDouble()
|
||||
? '${(widget.episode.userRating! / 2).toInt()}'
|
||||
: formatRating(widget.episode.userRating! / 2),
|
||||
(episode.userRating! / 2) == (episode.userRating! / 2).truncateToDouble()
|
||||
? '${(episode.userRating! / 2).toInt()}'
|
||||
: formatRating(episode.userRating! / 2),
|
||||
style: mutedStyle,
|
||||
),
|
||||
],
|
||||
@@ -97,17 +109,15 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, {required bool hideSpoilers}) {
|
||||
final shouldBlur = hideSpoilers && widget.episode.shouldHideSpoiler;
|
||||
final episode = _effectiveEpisode(context);
|
||||
final shouldBlur = hideSpoilers && episode.shouldHideSpoiler;
|
||||
|
||||
// Hide progress when offline (not tracked)
|
||||
final hasProgress =
|
||||
!widget.isOffline &&
|
||||
widget.episode.viewOffsetMs != null &&
|
||||
widget.episode.durationMs != null &&
|
||||
widget.episode.viewOffsetMs! > 0;
|
||||
final progress = hasProgress ? widget.episode.viewOffsetMs! / widget.episode.durationMs! : 0.0;
|
||||
!widget.isOffline && episode.viewOffsetMs != null && episode.durationMs != null && episode.viewOffsetMs! > 0;
|
||||
final progress = hasProgress ? episode.viewOffsetMs! / episode.durationMs! : 0.0;
|
||||
|
||||
final hasActiveProgress = hasProgress && widget.episode.viewOffsetMs! < widget.episode.durationMs!;
|
||||
final hasActiveProgress = hasProgress && episode.viewOffsetMs! < episode.durationMs!;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
@@ -121,12 +131,12 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
disableScale: true,
|
||||
child: MediaContextMenu(
|
||||
key: contextMenuKey,
|
||||
item: widget.episode,
|
||||
item: episode,
|
||||
onRefresh: widget.onRefresh,
|
||||
onListRefresh: widget.onListRefresh,
|
||||
onTap: widget.onTap,
|
||||
child: InkWell(
|
||||
key: Key(widget.episode.id),
|
||||
key: Key(episode.id),
|
||||
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
|
||||
onTap: widget.onTap,
|
||||
canRequestFocus: false,
|
||||
@@ -156,10 +166,10 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
? ClipRect(
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||||
child: _buildEpisodeThumbnail(),
|
||||
child: _buildEpisodeThumbnail(episode),
|
||||
),
|
||||
)
|
||||
: _buildEpisodeThumbnail(),
|
||||
: _buildEpisodeThumbnail(episode),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -209,7 +219,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
),
|
||||
),
|
||||
|
||||
if (widget.episode.isWatched && !hasActiveProgress)
|
||||
if (episode.isWatched && !hasActiveProgress)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
@@ -234,15 +244,13 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Selector<DownloadProvider, _DownloadSlice>(
|
||||
selector: (_, p) => _DownloadSlice.from(
|
||||
p.getProgress(widget.episode.globalKey),
|
||||
p.isQueueing(widget.episode.globalKey),
|
||||
),
|
||||
selector: (_, p) =>
|
||||
_DownloadSlice.from(p.getProgress(episode.globalKey), p.isQueueing(episode.globalKey)),
|
||||
builder: (context, slice, _) {
|
||||
Widget? downloadStatusIcon;
|
||||
|
||||
// Only show download status in online mode
|
||||
if (!widget.isOffline && widget.episode.serverId != null) {
|
||||
if (!widget.isOffline && episode.serverId != null) {
|
||||
final status = slice.status;
|
||||
final mutedBase = tokens(context).textMuted;
|
||||
|
||||
@@ -263,7 +271,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (widget.episode.index != null)
|
||||
if (episode.index != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
@@ -271,7 +279,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
borderRadius: const BorderRadius.all(Radius.circular(3)),
|
||||
),
|
||||
child: Text(
|
||||
'E${widget.episode.index}',
|
||||
'E${episode.index}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontSize: 11,
|
||||
@@ -283,7 +291,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.episode.title!,
|
||||
episode.title!,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
@@ -296,11 +304,11 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
},
|
||||
),
|
||||
|
||||
if (!shouldBlur && widget.episode.summary != null && widget.episode.summary!.isNotEmpty) ...[
|
||||
if (!shouldBlur && episode.summary != null && episode.summary!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
if (PlatformDetector.isTV())
|
||||
Text(
|
||||
widget.episode.summary!,
|
||||
episode.summary!,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3),
|
||||
@@ -309,7 +317,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
)
|
||||
else
|
||||
CollapsibleText(
|
||||
text: widget.episode.summary!,
|
||||
text: episode.summary!,
|
||||
maxLines: 3,
|
||||
small: true,
|
||||
style: Theme.of(
|
||||
@@ -319,7 +327,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
],
|
||||
|
||||
const SizedBox(height: 8),
|
||||
_buildEpisodeMetaRow(context),
|
||||
_buildEpisodeMetaRow(context, episode),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -332,7 +340,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEpisodeThumbnail() {
|
||||
Widget _buildEpisodeThumbnail(MediaItem episode) {
|
||||
if (widget.isOffline && widget.localPosterPath != null) {
|
||||
return OptimizedMediaImage.thumb(
|
||||
client: null,
|
||||
@@ -343,10 +351,10 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)),
|
||||
);
|
||||
}
|
||||
if (widget.episode.thumbPath != null) {
|
||||
if (episode.thumbPath != null) {
|
||||
return OptimizedMediaImage.thumb(
|
||||
client: widget.client,
|
||||
imagePath: widget.episode.thumbPath,
|
||||
imagePath: episode.thumbPath,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const PlaceholderContainer(),
|
||||
|
||||
+38
-17
@@ -11,6 +11,7 @@ import '../media/media_kind.dart';
|
||||
import '../media/media_playlist.dart';
|
||||
import '../mixins/context_menu_tap_mixin.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/watch_state_overlay_provider.dart';
|
||||
import '../services/download_storage_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import 'settings_builder.dart';
|
||||
@@ -82,12 +83,33 @@ class MediaCard extends StatefulWidget {
|
||||
class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard> {
|
||||
/// Public method to trigger tap action (for keyboard/gamepad SELECT)
|
||||
void handleTap() {
|
||||
_handleTap(context);
|
||||
_handleTap(context, _effectiveItemForAction(context));
|
||||
}
|
||||
|
||||
String _buildSemanticLabel() {
|
||||
Object _effectiveItem(BuildContext context) {
|
||||
final item = widget.item;
|
||||
if (item is! MediaItem) return item;
|
||||
try {
|
||||
final patch = context.select<WatchStateOverlayProvider, WatchStateOverlayPatch?>(
|
||||
(provider) => provider.patchForGlobalKey(item.globalKey),
|
||||
);
|
||||
return WatchStateOverlayProvider.applyPatch(item, patch);
|
||||
} on ProviderNotFoundException {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
Object _effectiveItemForAction(BuildContext context) {
|
||||
final item = widget.item;
|
||||
if (item is! MediaItem) return item;
|
||||
try {
|
||||
return context.read<WatchStateOverlayProvider>().apply(item);
|
||||
} on ProviderNotFoundException {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
String _buildSemanticLabel(Object item) {
|
||||
// Playlists don't expose kind, so build a simple localized label and exit early
|
||||
if (item is MediaPlaylist) {
|
||||
final count = item.leafCount;
|
||||
@@ -132,7 +154,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
return baseLabel;
|
||||
}
|
||||
|
||||
void _handleTap(BuildContext context) async {
|
||||
void _handleTap(BuildContext context, Object item) async {
|
||||
// Ignore taps while context menu is open to avoid double-activating
|
||||
if (contextMenuKey.currentState?.isContextMenuOpen == true) {
|
||||
return;
|
||||
@@ -140,7 +162,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
|
||||
final result = await navigateToMediaItem(
|
||||
context,
|
||||
widget.item,
|
||||
item,
|
||||
onRefresh: widget.onRefresh,
|
||||
isOffline: widget.isOffline,
|
||||
playDirectly: widget.isInContinueWatching,
|
||||
@@ -161,11 +183,10 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
}
|
||||
|
||||
/// Get the local poster path for offline mode
|
||||
String? _getLocalPosterPath(BuildContext context) {
|
||||
String? _getLocalPosterPath(BuildContext context, Object item) {
|
||||
if (!widget.isOffline) return null;
|
||||
if (widget.item is! MediaItem) return null;
|
||||
if (item is! MediaItem) return null;
|
||||
|
||||
final item = widget.item as MediaItem;
|
||||
if (item.serverId == null) return null;
|
||||
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
@@ -192,6 +213,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
final item = _effectiveItem(context);
|
||||
final ViewMode viewMode;
|
||||
if (widget.forceListMode) {
|
||||
viewMode = ViewMode.list;
|
||||
@@ -201,15 +223,15 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
viewMode = SettingsService.instanceOrNull!.read(SettingsService.viewMode);
|
||||
}
|
||||
|
||||
final semanticLabel = _buildSemanticLabel();
|
||||
final localPosterPath = _getLocalPosterPath(context);
|
||||
final semanticLabel = _buildSemanticLabel(item);
|
||||
final localPosterPath = _getLocalPosterPath(context, item);
|
||||
|
||||
final cardWidget = viewMode == ViewMode.grid
|
||||
? _buildGridCard(context, semanticLabel, localPosterPath)
|
||||
? _buildGridCard(context, item, semanticLabel, localPosterPath)
|
||||
: _MediaCardList(
|
||||
item: widget.item,
|
||||
item: item,
|
||||
semanticLabel: semanticLabel,
|
||||
onTap: () => _handleTap(context),
|
||||
onTap: () => _handleTap(context, item),
|
||||
onTapDown: storeTapPosition,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
onSecondaryTapDown: storeTapPosition,
|
||||
@@ -224,11 +246,11 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
// programmatic context menu access; gesture callbacks are on InkWell directly.
|
||||
return MediaContextMenu(
|
||||
key: contextMenuKey,
|
||||
item: widget.item,
|
||||
item: item,
|
||||
onRefresh: widget.onRefresh,
|
||||
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
|
||||
onListRefresh: widget.onListRefresh,
|
||||
onTap: () => _handleTap(context),
|
||||
onTap: () => _handleTap(context, item),
|
||||
isInContinueWatching: widget.isInContinueWatching,
|
||||
collectionId: widget.collectionId,
|
||||
child: cardWidget,
|
||||
@@ -237,8 +259,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
|
||||
/// Grid layout — inlined from former _MediaCardGrid, _PosterOverlay, and
|
||||
/// flattened Column. Semantics removed (InkWell provides button semantics).
|
||||
Widget _buildGridCard(BuildContext context, String semanticLabel, String? localPosterPath) {
|
||||
final item = widget.item;
|
||||
Widget _buildGridCard(BuildContext context, Object item, String semanticLabel, String? localPosterPath) {
|
||||
// Compute actual poster dimensions from card dimensions
|
||||
final posterWidth = widget.width != null ? widget.width! - 6 : null; // 3px padding each side
|
||||
final posterHeight = widget.height;
|
||||
@@ -247,7 +268,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
width: widget.width,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: () => _handleTap(context),
|
||||
onTap: () => _handleTap(context, item),
|
||||
onTapDown: storeTapPosition,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
onSecondaryTapDown: storeTapPosition,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/providers/watch_state_overlay_provider.dart';
|
||||
import 'package:plezy/utils/watch_state_notifier.dart';
|
||||
|
||||
MediaItem _item({String id = '1', int? viewOffsetMs, int? viewCount = 0}) {
|
||||
return MediaItem(
|
||||
id: id,
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Movie',
|
||||
serverId: 'server',
|
||||
durationMs: 100000,
|
||||
viewOffsetMs: viewOffsetMs,
|
||||
viewCount: viewCount,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _drainEvents() => Future<void>.delayed(Duration.zero);
|
||||
|
||||
void main() {
|
||||
group('WatchStateOverlayProvider', () {
|
||||
test('applies watched patches immediately', () async {
|
||||
final provider = WatchStateOverlayProvider();
|
||||
addTearDown(provider.dispose);
|
||||
final item = _item(viewOffsetMs: 40000);
|
||||
|
||||
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true);
|
||||
await _drainEvents();
|
||||
|
||||
final patched = provider.apply(item);
|
||||
expect(patched.isWatched, isTrue);
|
||||
expect(patched.viewOffsetMs, 0);
|
||||
});
|
||||
|
||||
test('applies progress patches without changing watched state', () async {
|
||||
final provider = WatchStateOverlayProvider();
|
||||
addTearDown(provider.dispose);
|
||||
final item = _item(viewCount: 1);
|
||||
|
||||
WatchStateNotifier().notifyProgress(item: item, viewOffset: 30000, duration: 100000);
|
||||
await _drainEvents();
|
||||
|
||||
final patched = provider.apply(item);
|
||||
expect(patched.isWatched, isTrue);
|
||||
expect(patched.viewOffsetMs, 30000);
|
||||
});
|
||||
|
||||
test('clears patches when active profile changes', () async {
|
||||
final provider = WatchStateOverlayProvider();
|
||||
addTearDown(provider.dispose);
|
||||
final item = _item();
|
||||
|
||||
provider.setActiveProfileId('a');
|
||||
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true);
|
||||
await _drainEvents();
|
||||
expect(provider.apply(item).isWatched, isTrue);
|
||||
|
||||
provider.setActiveProfileId('b');
|
||||
expect(provider.apply(item).isWatched, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user