From 1ca8e4889a21900ba36f58f76caec29d2741cd68 Mon Sep 17 00:00:00 2001 From: rang <378694192@qq.com> Date: Mon, 1 Dec 2025 15:33:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=92=AD=E6=94=BE=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/playback_reporter.dart | 332 ++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 lib/features/playback_reporting/core/playback_reporter.dart diff --git a/lib/features/playback_reporting/core/playback_reporter.dart b/lib/features/playback_reporting/core/playback_reporter.dart new file mode 100644 index 0000000..b2be336 --- /dev/null +++ b/lib/features/playback_reporting/core/playback_reporter.dart @@ -0,0 +1,332 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; +import 'package:vaani/features/player/core/audiobook_player.dart'; +import 'package:vaani/shared/extensions/obfuscation.dart'; + +final _logger = Logger('PlaybackReporter'); + +/// this playback reporter will watch the player and report to the server +/// +/// it will by default report every 10 seconds +/// and also report when the player is paused/stopped/finished/playing +class PlaybackReporter { + /// The player to watch + final AudiobookPlayer player; + + /// the api to report to + final AudiobookshelfApi authenticatedApi; + + /// The stopwatch to keep track of the time since the last report + /// + /// this should only run when media is playing + final _stopwatch = Stopwatch(); + + /// subscriptions to listen and then cancel when disposing + final List _subscriptions = []; + + Duration _reportingInterval; + + /// the duration to wait before reporting + Duration get reportingInterval => _reportingInterval; + set reportingInterval(Duration value) { + _reportingInterval = value; + _cancelReportTimer(); + _setReportTimerIfNotAlready(); + _logger.info('set interval: $value'); + } + + /// the minimum duration to report + final Duration reportingDurationThreshold; + + /// the duration to wait before starting the reporting + /// this is to ignore the initial duration in case user is browsing + final Duration? minimumPositionForReporting; + + /// the duration to mark the book as complete when the time left is less than this + final Duration markCompleteWhenTimeLeft; + + /// timer to report every 10 seconds + /// tracking the time since the last report + Timer? _reportTimer; + + /// metadata to report + String? deviceName; + String? deviceModel; + String? deviceSdkVersion; + String? deviceClientName; + String? deviceClientVersion; + String? deviceManufacturer; + + PlaybackReporter( + this.player, + this.authenticatedApi, { + this.deviceName, + this.deviceModel, + this.deviceSdkVersion, + this.deviceClientName, + this.deviceClientVersion, + this.deviceManufacturer, + this.reportingDurationThreshold = const Duration(seconds: 1), + Duration reportingInterval = const Duration(seconds: 10), + this.minimumPositionForReporting, + this.markCompleteWhenTimeLeft = const Duration(seconds: 5), + }) : _reportingInterval = reportingInterval { + // initial conditions + if (player.playing) { + _stopwatch.start(); + _setReportTimerIfNotAlready(); + _logger.fine('starting stopwatch'); + } else { + _logger.fine('not starting stopwatch'); + } + + _subscriptions.add( + player.playerStateStream.listen((state) async { + // set timer if any book is playing and cancel if not + if (player.book != null) { + if (state.playing) { + _setReportTimerIfNotAlready(); + } else { + _cancelReportTimer(); + } + } else if (player.book == null && _reportTimer != null) { + _logger.info('book is null, closing session'); + await closeSession(); + _cancelReportTimer(); + } + + // start or stop the stopwatch based on the playing state + if (state.playing) { + _stopwatch.start(); + _logger.fine( + 'player state observed, starting stopwatch at ${_stopwatch.elapsed}', + ); + } else if (!state.playing) { + _stopwatch.stop(); + _logger.fine( + 'player state observed, stopping stopwatch at ${_stopwatch.elapsed}', + ); + await tryReportPlayback(null); + } + }), + ); + + _logger.fine( + 'initialized with reportingInterval: $reportingInterval, reportingDurationThreshold: $reportingDurationThreshold', + ); + _logger.fine( + 'initialized with minimumPositionForReporting: $minimumPositionForReporting, markCompleteWhenTimeLeft: $markCompleteWhenTimeLeft', + ); + _logger.fine( + 'initialized with deviceModel: $deviceModel, deviceSdkVersion: $deviceSdkVersion, deviceClientName: $deviceClientName, deviceClientVersion: $deviceClientVersion, deviceManufacturer: $deviceManufacturer', + ); + } + + Future tryReportPlayback(_) async { + _logger.fine( + 'callback called when elapsed ${_stopwatch.elapsed}', + ); + if (player.book != null && + player.positionInBook >= + player.book!.duration - markCompleteWhenTimeLeft) { + _logger.info( + 'marking complete as time left is less than $markCompleteWhenTimeLeft', + ); + await markComplete(); + return; + } + if (_stopwatch.elapsed > reportingDurationThreshold) { + _logger.fine( + 'reporting now with elapsed ${_stopwatch.elapsed} > threshold $reportingDurationThreshold', + ); + await syncCurrentPosition(); + } + } + + /// dispose the timer + Future dispose() async { + for (var sub in _subscriptions) { + sub.cancel(); + } + await closeSession(); + _stopwatch.stop(); + _reportTimer?.cancel(); + + _logger.fine('disposed'); + } + + /// current sessionId + /// this is used to report the playback + PlaybackSession? _session; + String? get sessionId => _session?.id; + + Future startSession() async { + if (_session != null) { + return _session!; + } + if (player.book == null) { + _logger.warning('No audiobook playing to start session'); + return null; + } + _session = await authenticatedApi.items.play( + libraryItemId: player.book!.libraryItemId, + parameters: PlayItemReqParams( + deviceInfo: await _getDeviceInfo(), + forceDirectPlay: false, + forceTranscode: false, + ), + responseErrorHandler: _responseErrorHandler, + ); + _logger.info('Started session: $sessionId'); + return _session; + } + + Future markComplete() async { + if (player.book == null) { + throw NoAudiobookPlayingError(); + } + await authenticatedApi.me.createUpdateMediaProgress( + libraryItemId: player.book!.libraryItemId, + parameters: CreateUpdateProgressReqParams( + isFinished: true, + currentTime: player.positionInBook, + duration: player.book!.duration, + ), + responseErrorHandler: _responseErrorHandler, + ); + _logger.info('Marked complete for book: ${player.book!.libraryItemId}'); + } + + Future syncCurrentPosition() async { + final data = _getSyncData(); + if (data == null) { + await closeSession(); + } + try { + _session ??= await startSession(); + } on Error catch (e) { + _logger.warning('Error starting session: $e'); + } + if (_session == null) { + _logger.warning('No session to sync position'); + return; + } + final currentPosition = player.positionInBook; + + await authenticatedApi.sessions.syncOpen( + sessionId: sessionId!, + parameters: _getSyncData()!, + responseErrorHandler: _responseErrorHandler, + ); + + _logger.fine( + 'Synced position: $currentPosition with timeListened: ${_stopwatch.elapsed} for session: $sessionId', + ); + + // reset the stopwatch + _stopwatch.reset(); + } + + Future closeSession() async { + if (sessionId == null) { + _logger.warning('No session to close'); + return; + } + + await authenticatedApi.sessions.closeOpen( + sessionId: sessionId!, + parameters: _getSyncData(), + responseErrorHandler: _responseErrorHandler, + ); + _session = null; + _logger.info('Closed session'); + } + + void _setReportTimerIfNotAlready() { + if (_reportTimer != null) return; + _reportTimer = Timer.periodic(_reportingInterval, tryReportPlayback); + _logger.fine('set timer with interval: $_reportingInterval'); + } + + void _cancelReportTimer() { + _reportTimer?.cancel(); + _reportTimer = null; + _logger.fine('cancelled timer'); + } + + void _responseErrorHandler(http.Response response, [error]) { + if (response.statusCode != 200) { + _logger.severe('Error with api: ${response.obfuscate()}, $error'); + throw PlaybackSyncError( + 'Error syncing position: ${response.body}, $error', + ); + } + } + + SyncSessionReqParams? _getSyncData() { + if (player.book?.libraryItemId != _session?.libraryItemId) { + _logger.info( + 'Book changed, not syncing position for session: $sessionId', + ); + return null; + } + + // if in the ignore duration, don't sync + if (minimumPositionForReporting != null && + player.positionInBook < minimumPositionForReporting!) { + // but if elapsed time is more than the minimumPositionForReporting, sync + if (_stopwatch.elapsed > minimumPositionForReporting!) { + _logger.info( + 'Syncing position despite being less than minimumPositionForReporting as elapsed time is more: ${_stopwatch.elapsed}', + ); + } else { + _logger.info( + 'Ignoring sync for position: ${player.positionInBook} < $minimumPositionForReporting', + ); + return null; + } + } + + return SyncSessionReqParams( + currentTime: player.positionInBook, + timeListened: _stopwatch.elapsed, + duration: player.book?.duration ?? Duration.zero, + ); + } + + Future _getDeviceInfo() async { + return DeviceInfoReqParams( + clientVersion: deviceClientVersion, + manufacturer: deviceManufacturer, + model: deviceModel, + sdkVersion: deviceSdkVersion, + clientName: deviceClientName, + deviceName: deviceName, + ); + } +} + +class PlaybackSyncError implements Exception { + String message; + + PlaybackSyncError([this.message = 'Error syncing playback']); + + @override + String toString() { + return 'PlaybackSyncError: $message'; + } +} + +class NoAudiobookPlayingError implements Exception { + String message; + + NoAudiobookPlayingError([this.message = 'No audiobook is playing']); + + @override + String toString() { + return 'NoAudiobookPlayingError: $message'; + } +}