Focus chrome was implemented twice, once in the focusable wrapper and once in the focus builders; both now go through FocusChrome. TvColorPicker's channel row was a copy of TvNumberSpinner and is now that widget in compact density. Also trims unused helpers and fields and simplifies the Jellyfin browse paths.
245 lines
8.4 KiB
Dart
245 lines
8.4 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import '../media/ids.dart';
|
|
|
|
import '../i18n/strings.g.dart';
|
|
import '../media/episode_collection.dart';
|
|
import '../media/media_item.dart';
|
|
import '../media/media_item_types.dart';
|
|
import '../mixins/disposable_change_notifier_mixin.dart';
|
|
import '../models/download_models.dart';
|
|
import '../services/offline_watch_sync_service.dart';
|
|
import '../services/settings_service.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/snackbar_helper.dart';
|
|
import '../utils/watch_state_notifier.dart';
|
|
import 'download_provider.dart';
|
|
import '../utils/global_key_utils.dart';
|
|
|
|
/// Provider for offline watch status UI state.
|
|
///
|
|
/// Provides:
|
|
/// - Effective watch status (local changes + cached server data)
|
|
/// - Offline "OnDeck" calculation for shows
|
|
/// - Manual mark watched/unwatched while offline
|
|
class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
|
final OfflineWatchSyncService _syncService;
|
|
final DownloadProvider _downloadProvider;
|
|
|
|
OfflineWatchProvider({required this._syncService, required this._downloadProvider}) {
|
|
// Listen to sync service changes to update UI
|
|
_syncService.addListener(_onSyncServiceChanged);
|
|
}
|
|
|
|
void _onSyncServiceChanged() {
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
/// Whether a sync is in progress
|
|
bool get isSyncing => _syncService.isSyncing;
|
|
|
|
/// Get count of pending sync items
|
|
Future<int> getPendingSyncCount() => _syncService.getPendingSyncCount();
|
|
|
|
/// Get the effective watch status for a media item.
|
|
///
|
|
/// Priority:
|
|
/// 1. Local offline action (if exists)
|
|
/// 2. Cached server data from API cache
|
|
/// 3. Metadata from download provider
|
|
///
|
|
/// Returns true if watched, false otherwise.
|
|
Future<bool> isWatched(String globalKey) async {
|
|
// First check local offline action
|
|
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
|
|
if (localStatus != null) {
|
|
return localStatus;
|
|
}
|
|
|
|
// Fall back to cached metadata
|
|
final metadata = _downloadProvider.getMetadata(globalKey);
|
|
if (metadata != null) {
|
|
return metadata.isWatched;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// Get the effective view offset (resume position) for a media item.
|
|
///
|
|
/// Priority:
|
|
/// 1. Local offline progress (if exists)
|
|
/// 2. Metadata from download provider
|
|
///
|
|
/// Returns null if no position is available.
|
|
@visibleForTesting
|
|
Future<int?> getViewOffset(String globalKey) async {
|
|
// First check local offline progress
|
|
final localOffset = await _syncService.getLocalViewOffset(globalKey);
|
|
if (localOffset != null) {
|
|
return localOffset;
|
|
}
|
|
|
|
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
|
|
if (localStatus == true) return null;
|
|
|
|
// Fall back to cached metadata
|
|
final metadata = _downloadProvider.getMetadata(globalKey);
|
|
return metadata?.viewOffsetMs;
|
|
}
|
|
|
|
/// Get sorted episodes for a show: regular seasons first, Specials last,
|
|
/// then season then episode — the shared [sortEpisodesByWatchOrder] order,
|
|
/// so the offline watch order matches what "download next N" selects (#1414).
|
|
List<MediaItem> _getSortedEpisodes(String showId) {
|
|
final episodes = _downloadProvider.getDownloadedEpisodesForShow(showId);
|
|
if (episodes.isEmpty) return episodes;
|
|
sortEpisodesByWatchOrder(episodes);
|
|
return episodes;
|
|
}
|
|
|
|
/// Batch resolve watch statuses for a list of episodes.
|
|
///
|
|
/// Returns a map of globalKey -> isWatched for each episode.
|
|
Future<Map<String, bool>> _resolveEpisodeWatchStatuses(List<MediaItem> episodes) async {
|
|
if (episodes.isEmpty) return {};
|
|
|
|
final globalKeys = episodes.map((e) => e.globalKey).toSet();
|
|
final localStatuses = await _syncService.getLocalWatchStatusesBatched(globalKeys);
|
|
|
|
return {
|
|
for (final episode in episodes)
|
|
episode.globalKey:
|
|
localStatuses[episode.globalKey] ?? _downloadProvider.getMetadata(episode.globalKey)?.isWatched ?? false,
|
|
};
|
|
}
|
|
|
|
/// Find the next unwatched downloaded episode for a show.
|
|
///
|
|
/// This is the "offline OnDeck" calculation - finds the first
|
|
/// episode that hasn't been watched (or is in progress).
|
|
///
|
|
/// Episodes are sorted by season number, then episode number.
|
|
///
|
|
/// Returns the next unwatched episode, or the first episode if all watched.
|
|
Future<MediaItem?> getNextUnwatchedEpisode(String showId) async {
|
|
final episodes = _getSortedEpisodes(showId);
|
|
if (episodes.isEmpty) return null;
|
|
|
|
final watchStatuses = await _resolveEpisodeWatchStatuses(episodes);
|
|
|
|
// Find first unwatched episode
|
|
for (final episode in episodes) {
|
|
if (!watchStatuses[episode.globalKey]!) {
|
|
return episode;
|
|
}
|
|
}
|
|
|
|
// All episodes watched - return first episode for replay
|
|
return episodes.firstOrNull;
|
|
}
|
|
|
|
/// Emit a watch state change event for immediate UI update.
|
|
void _emitWatchStateChange({
|
|
required ServerId serverId,
|
|
required String itemId,
|
|
required bool isNowWatched,
|
|
required WatchStateChangeType changeType,
|
|
String? cacheServerId,
|
|
}) {
|
|
final globalKey = buildGlobalKey(ServerId(serverId), itemId);
|
|
final metadata = _downloadProvider.getMetadata(globalKey);
|
|
if (metadata != null) {
|
|
WatchStateNotifier().notifyWatched(item: metadata, isNowWatched: isNowWatched, cacheServerId: cacheServerId);
|
|
} else {
|
|
// Fallback: emit minimal event without parent chain.
|
|
WatchStateNotifier().notify(
|
|
WatchStateEvent(
|
|
itemId: itemId,
|
|
serverId: serverId,
|
|
cacheServerId: cacheServerId,
|
|
changeType: changeType,
|
|
parentChain: [],
|
|
mediaType: 'unknown',
|
|
isNowWatched: isNowWatched,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Mark an item as watched while offline.
|
|
///
|
|
/// This queues the action for sync when online and emits a [WatchStateEvent].
|
|
Future<void> markAsWatched({required ServerId serverId, required String itemId}) async {
|
|
final cacheServerId = await _syncService.queueMarkWatched(serverId: serverId, itemId: itemId);
|
|
_emitWatchStateChange(
|
|
serverId: serverId,
|
|
itemId: itemId,
|
|
isNowWatched: true,
|
|
changeType: WatchStateChangeType.watched,
|
|
cacheServerId: cacheServerId,
|
|
);
|
|
safeNotifyListeners();
|
|
_autoDeleteIfWatched(serverId, itemId);
|
|
}
|
|
|
|
/// Auto-delete a download if the auto-remove setting is enabled.
|
|
void _autoDeleteIfWatched(ServerId serverId, String itemId) {
|
|
final settings = SettingsService.instanceOrNull;
|
|
if (settings == null || !settings.read(SettingsService.autoRemoveWatchedDownloads)) return;
|
|
|
|
final globalKey = buildGlobalKey(ServerId(serverId), itemId);
|
|
final meta = _downloadProvider.getMetadata(globalKey);
|
|
if (meta == null) return;
|
|
if (!meta.isEpisode && !meta.isMovie) return;
|
|
|
|
final progress = _downloadProvider.downloads[globalKey];
|
|
if (progress?.status != DownloadStatus.completed) return;
|
|
|
|
appLogger.i('Auto-deleting locally-watched download: ${meta.title} ($globalKey)');
|
|
_downloadProvider
|
|
.deleteDownload(globalKey)
|
|
.then(
|
|
(_) {
|
|
showMainSnackBar(t.messages.autoRemovedWatchedDownload(title: meta.title ?? 'Unknown'));
|
|
},
|
|
onError: (e) {
|
|
appLogger.w('Failed to auto-delete locally-watched download $globalKey: $e');
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Mark an item as unwatched while offline.
|
|
///
|
|
/// This queues the action for sync when online and emits a [WatchStateEvent].
|
|
Future<void> markAsUnwatched({required ServerId serverId, required String itemId}) async {
|
|
final cacheServerId = await _syncService.queueMarkUnwatched(serverId: serverId, itemId: itemId);
|
|
_emitWatchStateChange(
|
|
serverId: serverId,
|
|
itemId: itemId,
|
|
isNowWatched: false,
|
|
changeType: WatchStateChangeType.unwatched,
|
|
cacheServerId: cacheServerId,
|
|
);
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
/// Get downloaded episodes for a show with their watch status.
|
|
///
|
|
/// Returns a list of (episode, isWatched) pairs.
|
|
/// Uses batched database query for efficiency.
|
|
Future<List<(MediaItem episode, bool isWatched)>> getEpisodesWithWatchStatus(String showId) async {
|
|
final episodes = _downloadProvider.getDownloadedEpisodesForShow(showId);
|
|
if (episodes.isEmpty) return [];
|
|
|
|
final watchStatuses = await _resolveEpisodeWatchStatuses(episodes);
|
|
|
|
return [for (final episode in episodes) (episode, watchStatuses[episode.globalKey]!)];
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_syncService.removeListener(_onSyncServiceChanged);
|
|
super.dispose();
|
|
}
|
|
}
|