playback reporting

This commit is contained in:
Dr-Blank 2024-06-15 23:43:08 -04:00
parent fbd789f989
commit be7f5daa88
No known key found for this signature in database
GPG key ID: 7452CC63F210A266
14 changed files with 751 additions and 10 deletions

View file

@ -0,0 +1,252 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:whispering_pages/features/player/core/audiobook_player.dart';
/// 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
final _stopwatch = Stopwatch();
/// subscriptions
final List<StreamSubscription> _subscriptions = [];
Duration _reportingInterval;
/// the duration to wait before reporting
Duration get reportingInterval => _reportingInterval;
set reportingInterval(Duration value) {
_reportingInterval = value;
_cancelReportTimer();
_setReportTimer();
debugPrint('PlaybackReporter set interval: $value');
}
/// the minimum duration to report
final Duration reportingDurationThreshold;
/// timer to report every 10 seconds
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),
}) : _reportingInterval = reportingInterval {
// initial conditions
if (player.playing) {
_stopwatch.start();
_setReportTimer();
debugPrint('PlaybackReporter starting stopwatch');
} else {
debugPrint('PlaybackReporter 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 && _reportTimer == null) {
_setReportTimer();
} else if (player.book == null && _reportTimer != null) {
debugPrint('PlaybackReporter book is null, closing session');
await closeSession();
_cancelReportTimer();
}
// start or stop the stopwatch based on the playing state
if (state.playing) {
_stopwatch.start();
debugPrint(
'PlaybackReporter player state observed, starting stopwatch at ${_stopwatch.elapsed}',
);
} else if (!state.playing) {
_stopwatch.stop();
debugPrint(
'PlaybackReporter player state observed, stopping stopwatch at ${_stopwatch.elapsed}',
);
await syncCurrentPosition();
}
}),
);
debugPrint(
'PlaybackReporter initialized with interval: $reportingInterval, threshold: $reportingDurationThreshold',
);
debugPrint(
'PlaybackReporter initialized with deviceModel: $deviceModel, deviceSdkVersion: $deviceSdkVersion, deviceClientName: $deviceClientName, deviceClientVersion: $deviceClientVersion, deviceManufacturer: $deviceManufacturer',
);
}
void tryReportPlayback(_) async {
debugPrint(
'PlaybackReporter callback called when elapsed ${_stopwatch.elapsed}',
);
if (_stopwatch.elapsed > reportingDurationThreshold) {
debugPrint(
'PlaybackReporter reporting now with elapsed ${_stopwatch.elapsed} > threshold $reportingDurationThreshold',
);
await syncCurrentPosition();
}
}
/// dispose the timer
Future<void> dispose() async {
for (var sub in _subscriptions) {
sub.cancel();
}
await closeSession();
_stopwatch.stop();
_reportTimer?.cancel();
debugPrint('PlaybackReporter disposed');
}
/// current sessionId
/// this is used to report the playback
String? sessionId;
Future<String?> startSession() async {
if (sessionId != null) {
return sessionId!;
}
if (player.book == null) {
throw NoAudiobookPlayingError();
}
final session = await authenticatedApi.items.play(
libraryItemId: player.book!.libraryItemId,
parameters: PlayItemReqParams(
deviceInfo: await _getDeviceInfo(),
forceDirectPlay: false,
forceTranscode: false,
),
responseErrorHandler: _responseErrorHandler,
);
sessionId = session!.id;
debugPrint('PlaybackReporter Started session: $sessionId');
return sessionId;
}
Future<void> syncCurrentPosition() async {
try {
sessionId ??= await startSession();
} on NoAudiobookPlayingError {
debugPrint('PlaybackReporter No audiobook playing to sync position');
return;
}
final currentPosition = player.position;
await authenticatedApi.sessions.syncOpen(
sessionId: sessionId!,
parameters: _getSyncData(),
responseErrorHandler: _responseErrorHandler,
);
debugPrint(
'PlaybackReporter Synced position: $currentPosition with timeListened: ${_stopwatch.elapsed} for session: $sessionId',
);
// reset the stopwatch
_stopwatch.reset();
}
Future<void> closeSession() async {
if (sessionId == null) {
debugPrint('PlaybackReporter No session to close');
return;
}
await authenticatedApi.sessions.closeOpen(
sessionId: sessionId!,
parameters: _getSyncData(),
responseErrorHandler: _responseErrorHandler,
);
sessionId = null;
debugPrint('PlaybackReporter Closed session');
}
void _setReportTimer() {
_reportTimer = Timer.periodic(_reportingInterval, tryReportPlayback);
debugPrint('PlaybackReporter set timer with interval: $_reportingInterval');
}
void _cancelReportTimer() {
_reportTimer?.cancel();
_reportTimer = null;
debugPrint('PlaybackReporter cancelled timer');
}
void _responseErrorHandler(response, [error]) {
if (response.statusCode != 200) {
debugPrint('PlaybackReporter Error with api: $response, $error');
throw PlaybackSyncError(
'Error syncing position: ${response.body}, $error',
);
}
}
SyncSessionReqParams _getSyncData() {
return SyncSessionReqParams(
currentTime: player.position,
timeListened: _stopwatch.elapsed,
duration: player.book?.duration ?? Duration.zero,
);
}
Future<DeviceInfoReqParams> _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';
}
}

View file

@ -0,0 +1,40 @@
import 'package:package_info_plus/package_info_plus.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:whispering_pages/api/api_provider.dart';
import 'package:whispering_pages/features/playback_reporting/core/playback_reporter.dart'
as core;
import 'package:whispering_pages/features/player/providers/audiobook_player.dart';
import 'package:whispering_pages/settings/app_settings_provider.dart';
import 'package:whispering_pages/settings/metadata/metadata_provider.dart';
part 'playback_reporter_provider.g.dart';
@Riverpod(keepAlive: true)
class PlaybackReporter extends _$PlaybackReporter {
@override
Future<core.PlaybackReporter> build() async {
final appSettings = ref.watch(appSettingsProvider);
final player = ref.watch(simpleAudiobookPlayerProvider);
final packageInfo = await PackageInfo.fromPlatform();
final api = ref.watch(authenticatedApiProvider);
final deviceName = await ref.watch(deviceNameProvider.future);
final deviceModel = await ref.watch(deviceModelProvider.future);
final deviceSdkVersion = await ref.watch(deviceSdkVersionProvider.future);
final deviceManufacturer =
await ref.watch(deviceManufacturerProvider.future);
final reporter = core.PlaybackReporter(
player,
api,
reportingInterval: appSettings.playerSettings.playbackReportInterval,
deviceName: deviceName,
deviceModel: deviceModel,
deviceSdkVersion: deviceSdkVersion,
deviceClientName: packageInfo.appName,
deviceClientVersion: packageInfo.version,
deviceManufacturer: deviceManufacturer,
);
ref.onDispose(reporter.dispose);
return reporter;
}
}

View file

@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'playback_reporter_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$playbackReporterHash() => r'c210b7286d9c151fd59a9ead9eb4a28d1cffdc7c';
/// See also [PlaybackReporter].
@ProviderFor(PlaybackReporter)
final playbackReporterProvider =
AsyncNotifierProvider<PlaybackReporter, core.PlaybackReporter>.internal(
PlaybackReporter.new,
name: r'playbackReporterProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$playbackReporterHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$PlaybackReporter = AsyncNotifier<core.PlaybackReporter>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member