Files
plezy/lib/providers/offline_watch_provider.dart
T
edde746 369c6279d6 fix(i18n): translate the player, downloads and server-setup text left in English
A Portuguese user reported "Skip Intro" rendering in English on Android TV.
The locale files were not the problem - all 22 were structurally complete.
skip_marker_button.dart simply never imported strings.g.dart and assigned
'Skip Intro' / 'Skip Credits' / 'Next Episode' as plain literals. An audit of
lib/ found ~120 more sites in the same state, in four shapes that need
different fixes:

A literal in a file that never imported the i18n layer is the easy one -
skip_marker_button, performance_stats, track_label_builder and codec_utils all
render text with no `t` in the file at all. TrackLabelBuilder._compose now takes
a fallbackLabel builder instead of an English fallbackPrefix, so the caller
supplies t.audioTracks.track / t.videoControls.subtitleTrack and every unnamed
audio and subtitle row in the track menus is localized.

English reaching the user through an exception message is the widest one, and
it needs care: MediaServerException.message feeds both toString() - logs and
Sentry grouping - and verbatim UI display. Localizing it in place would make
bug-report logs follow the user's locale and split one Sentry issue into 22.
The MediaServer and Seerr families instead gain a nullable `display` alongside
the English `message`, and the six screens that print these errors read
`display ?? message`. PlaybackException keeps the opposite rule, because it
already carries a PlaybackFailureReason for logic and classifyPlaybackFailure
already builds it from t.messages: its stragglers are localized at the throw
site. That also removes the literal "Exception: " prefix Live TV users saw on
a tune failure, since PlaybackException.toString() returns the bare message.

Localized parts hand-concatenated with bare English are the shape no search for
Text('...') can find: '${t.common.pause} auto-scroll' on the home carousel,
'${day} at ${time}' on the Live TV schedule row, and an actor-screen count that
hand-rolled its plural as `n == 1 ? 'title' : 'titles'` - wrong for ru and pl
regardless of translation, now a real Slang plural.

Finally a literal assigned to provider state that a widget renders later:
DownloadProgress.errorMessage, and the four background_downloader notification
bodies, which sit inside a plugin config call where no widget-shaped search
reaches them.

Two things surfaced while converting. track_chapter_controls compared a track
label against 'Audio Track N' to swap in a localized version; once the builder
localized its own fallback that branch became unreachable, so it and the
orphaned _joinTrackLabel are gone. And discovery_view's PeerError fallback arm
looks like a leak but is not - its producers already localize, and a test says
so - so it stays as it is.

All 21 non-base locales are translated, including the 21 keys left empty by
earlier commits that were falling back to English. No locale has an empty value.

scripts/check_hardcoded_strings.py guards the three shapes a structural check
can see, and runs in ci_checks.sh after translation hygiene. Its first draft
passed its own tests while missing this very bug, because 'Skip Intro' is bound
to a local rather than handed to Text(); the name-bound rule that closes that
gap is restricted to phrase-shaped literals, or it cannot tell copy from the
identifiers this codebase binds constantly ('cast_row', 'auto', 'liveTv'). It
cannot see English inside a throw or assigned to a provider field - neither is
distinguishable from a log message without dataflow analysis - and the docstring
says so. label: and actionLabel: are deliberately unscanned: here they name a
diagnostic operation, and a check that is chronically red is a check that gets
switched off.

One commit rather than one per area: the keys, the 22 locale files and the
generated output are a single unit, and any partial split fails the repo's own
unused-key scan on the way through.

close #1856
2026-08-10 15:32:43 +02:00

254 lines
8.8 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,
required WatchPatchId patchId,
String? cacheServerId,
}) {
final globalKey = buildGlobalKey(ServerId(serverId), itemId);
final metadata = _downloadProvider.getMetadata(globalKey);
if (metadata != null) {
WatchStateNotifier().notifyWatched(
item: metadata,
isNowWatched: isNowWatched,
cacheServerId: cacheServerId,
patchId: patchId,
);
} else {
// Fallback: emit minimal event without parent chain.
WatchStateNotifier().notify(
WatchStateEvent(
itemId: itemId,
serverId: serverId,
cacheServerId: cacheServerId,
changeType: changeType,
parentChain: [],
mediaType: 'unknown',
isNowWatched: isNowWatched,
patchId: patchId,
),
);
}
}
/// 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 queued = await _syncService.queueMarkWatched(serverId: serverId, itemId: itemId);
_emitWatchStateChange(
serverId: serverId,
itemId: itemId,
isNowWatched: true,
changeType: WatchStateChangeType.watched,
cacheServerId: queued.clientScopeId,
patchId: WatchPatchId.offlineAction(profileId: queued.profileId, rowId: queued.rowId, revision: queued.revision),
);
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 ?? t.common.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 queued = await _syncService.queueMarkUnwatched(serverId: serverId, itemId: itemId);
_emitWatchStateChange(
serverId: serverId,
itemId: itemId,
isNowWatched: false,
changeType: WatchStateChangeType.unwatched,
cacheServerId: queued.clientScopeId,
patchId: WatchPatchId.offlineAction(profileId: queued.profileId, rowId: queued.rowId, revision: queued.revision),
);
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();
}
}