From 959e6e93984f9ec648f0f1a8c1f1420b8695b2c8 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:49:46 +0200 Subject: [PATCH] refactor(watch): single watched-progress predicate --- lib/media/watch_progress.dart | 8 ++++++ lib/services/external_player_service.dart | 7 ++++- lib/services/offline_watch_sync_service.dart | 4 +-- lib/utils/watch_state_notifier.dart | 3 ++- test/media/watch_progress_test.dart | 27 ++++++++++++++++++++ 5 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 lib/media/watch_progress.dart create mode 100644 test/media/watch_progress_test.dart diff --git a/lib/media/watch_progress.dart b/lib/media/watch_progress.dart new file mode 100644 index 00000000..80e1bf72 --- /dev/null +++ b/lib/media/watch_progress.dart @@ -0,0 +1,8 @@ +/// Single source of truth for the "progress crossed the watched threshold" +/// decision. All comparison sites (online progress events, external-player +/// returns, offline queueing) must route through this so edge-case handling +/// (zero/unknown duration, exact-threshold) can't drift between paths. +bool isWatchedProgress({required int positionMs, required int durationMs, required double threshold}) { + if (durationMs <= 0) return false; + return positionMs / durationMs >= threshold; +} diff --git a/lib/services/external_player_service.dart b/lib/services/external_player_service.dart index 7cfaabaf..805b647c 100644 --- a/lib/services/external_player_service.dart +++ b/lib/services/external_player_service.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import '../media/media_item.dart'; import '../media/media_server_client.dart'; +import '../media/watch_progress.dart'; import '../models/external_player_models.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; @@ -207,7 +208,11 @@ class ExternalPlayerService { watchedThreshold: client.watchedThreshold, ); - if (position.inMilliseconds / duration.inMilliseconds >= client.watchedThreshold) { + if (isWatchedProgress( + positionMs: position.inMilliseconds, + durationMs: duration.inMilliseconds, + threshold: client.watchedThreshold, + )) { try { // reportPlaybackStopped above marks the item played on backends that // support it (Jellyfin); markWatchedFromPlaybackStop then only emits the diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index 01bcd150..ef932d6f 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -11,6 +11,7 @@ import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../media/playback_report_metadata.dart'; +import '../media/watch_progress.dart'; import '../utils/app_logger.dart'; import '../utils/global_key_utils.dart'; import 'offline_mode_source.dart'; @@ -275,9 +276,8 @@ class OfflineWatchSyncService extends ChangeNotifier { /// Check if an item should be considered watched based on progress percentage. bool isWatchedByProgress(int viewOffset, int duration, {ServerId? serverId}) { - if (duration == 0) return false; final threshold = serverId != null ? getWatchedThreshold(serverId) : 0.9; - return (viewOffset / duration) >= threshold; + return isWatchedProgress(positionMs: viewOffset, durationMs: duration, threshold: threshold); } /// Get the local watch status for a media item. diff --git a/lib/utils/watch_state_notifier.dart b/lib/utils/watch_state_notifier.dart index 99e43236..16826a28 100644 --- a/lib/utils/watch_state_notifier.dart +++ b/lib/utils/watch_state_notifier.dart @@ -1,5 +1,6 @@ import '../media/media_item.dart'; import '../media/ids.dart'; +import '../media/watch_progress.dart'; import 'app_logger.dart'; import 'base_notifier.dart'; import 'global_key_utils.dart'; @@ -130,7 +131,7 @@ class WatchStateNotifier extends BaseNotifier { appLogger.w('WatchStateNotifier: missing serverId for ${item.id}, skipping progress event'); return; } - final isNowWatched = duration > 0 && (viewOffset / duration) >= watchedThreshold; + final isNowWatched = isWatchedProgress(positionMs: viewOffset, durationMs: duration, threshold: watchedThreshold); notify( WatchStateEvent( diff --git a/test/media/watch_progress_test.dart b/test/media/watch_progress_test.dart new file mode 100644 index 00000000..656a4763 --- /dev/null +++ b/test/media/watch_progress_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/watch_progress.dart'; + +void main() { + group('isWatchedProgress', () { + test('false for zero or negative duration', () { + expect(isWatchedProgress(positionMs: 1000, durationMs: 0, threshold: 0.9), isFalse); + expect(isWatchedProgress(positionMs: 1000, durationMs: -1, threshold: 0.9), isFalse); + }); + + test('exact threshold counts as watched', () { + expect(isWatchedProgress(positionMs: 90, durationMs: 100, threshold: 0.9), isTrue); + }); + + test('just below threshold is not watched', () { + expect(isWatchedProgress(positionMs: 89, durationMs: 100, threshold: 0.9), isFalse); + }); + + test('position past duration is watched', () { + expect(isWatchedProgress(positionMs: 110, durationMs: 100, threshold: 0.9), isTrue); + }); + + test('zero position is not watched', () { + expect(isWatchedProgress(positionMs: 0, durationMs: 100, threshold: 0.9), isFalse); + }); + }); +}