refactor(watch): single watched-progress predicate

This commit is contained in:
edde746
2026-06-12 13:49:46 +02:00
parent 18e47d2327
commit 959e6e9398
5 changed files with 45 additions and 4 deletions
+8
View File
@@ -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;
}
+6 -1
View File
@@ -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
+2 -2
View File
@@ -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.
+2 -1
View File
@@ -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<WatchStateEvent> {
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(
+27
View File
@@ -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);
});
});
}