2024-05-14 06:13:16 -04:00
|
|
|
/// a wrapper around the audioplayers package to manage the audio player instance
|
|
|
|
|
///
|
|
|
|
|
/// this is needed as audiobook can be a list of audio files instead of a single file
|
|
|
|
|
library;
|
|
|
|
|
|
2024-08-20 08:36:39 -04:00
|
|
|
import 'package:collection/collection.dart';
|
2024-05-17 11:04:20 -04:00
|
|
|
import 'package:just_audio/just_audio.dart';
|
|
|
|
|
import 'package:just_audio_background/just_audio_background.dart';
|
2024-08-20 08:36:39 -04:00
|
|
|
import 'package:logging/logging.dart';
|
2024-05-14 06:13:16 -04:00
|
|
|
import 'package:shelfsdk/audiobookshelf_api.dart';
|
2024-09-25 03:13:42 -04:00
|
|
|
import 'package:vaani/settings/app_settings_provider.dart';
|
|
|
|
|
import 'package:vaani/settings/models/app_settings.dart';
|
|
|
|
|
import 'package:vaani/shared/extensions/model_conversions.dart';
|
2024-05-14 06:13:16 -04:00
|
|
|
|
2024-08-20 08:36:39 -04:00
|
|
|
final _logger = Logger('AudiobookPlayer');
|
|
|
|
|
|
2024-05-19 08:53:21 -04:00
|
|
|
/// returns the sum of the duration of all the previous tracks before the [index]
|
|
|
|
|
Duration sumOfTracks(BookExpanded book, int? index) {
|
|
|
|
|
// return 0 if index is less than 0
|
|
|
|
|
if (index == null || index < 0) {
|
|
|
|
|
return Duration.zero;
|
|
|
|
|
}
|
|
|
|
|
return book.tracks.sublist(0, index).fold<Duration>(Duration.zero,
|
|
|
|
|
(previousValue, element) {
|
|
|
|
|
return previousValue + element.duration;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// returns the [AudioTrack] to play based on the [position] in the [book]
|
|
|
|
|
AudioTrack getTrackToPlay(BookExpanded book, Duration position) {
|
2024-05-19 09:45:41 -04:00
|
|
|
// var totalDuration = Duration.zero;
|
|
|
|
|
// for (var track in book.tracks) {
|
|
|
|
|
// totalDuration += track.duration;
|
|
|
|
|
// if (totalDuration >= position) {
|
|
|
|
|
// return track;
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// return book.tracks.last;
|
|
|
|
|
return book.tracks.firstWhere(
|
|
|
|
|
(element) {
|
|
|
|
|
return element.startOffset <= position &&
|
|
|
|
|
(element.startOffset + element.duration) >= position;
|
|
|
|
|
},
|
|
|
|
|
orElse: () => book.tracks.last,
|
|
|
|
|
);
|
2024-05-19 08:53:21 -04:00
|
|
|
}
|
|
|
|
|
|
2024-05-14 06:13:16 -04:00
|
|
|
/// will manage the audio player instance
|
|
|
|
|
class AudiobookPlayer extends AudioPlayer {
|
|
|
|
|
// constructor which takes in the BookExpanded object
|
2024-05-17 11:04:20 -04:00
|
|
|
AudiobookPlayer(this.token, this.baseUrl) : super() {
|
2024-05-14 06:13:16 -04:00
|
|
|
// set the source of the player to the first track in the book
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// the [BookExpanded] being played
|
|
|
|
|
BookExpanded? _book;
|
|
|
|
|
|
2024-05-19 08:53:21 -04:00
|
|
|
// /// the [BookExpanded] trying to be played
|
|
|
|
|
// BookExpanded? _intended_book;
|
|
|
|
|
|
2024-05-14 06:13:16 -04:00
|
|
|
/// the [BookExpanded] being played
|
|
|
|
|
///
|
2024-08-20 08:36:39 -04:00
|
|
|
/// to set the book, use [setSourceAudiobook]
|
2024-05-14 06:13:16 -04:00
|
|
|
BookExpanded? get book => _book;
|
|
|
|
|
|
|
|
|
|
/// the authentication token to access the [AudioTrack.contentUrl]
|
|
|
|
|
final String token;
|
|
|
|
|
|
|
|
|
|
/// the base url for the audio files
|
|
|
|
|
final Uri baseUrl;
|
|
|
|
|
|
|
|
|
|
// the current index of the audio file in the [book]
|
2024-05-17 11:04:20 -04:00
|
|
|
// final int _currentIndex = 0;
|
2024-05-14 06:13:16 -04:00
|
|
|
|
2024-05-15 02:27:05 -04:00
|
|
|
// available audio tracks
|
|
|
|
|
int? get availableTracks => _book?.tracks.length;
|
|
|
|
|
|
2024-05-14 06:13:16 -04:00
|
|
|
/// sets the current [AudioTrack] as the source of the player
|
2024-08-20 08:36:39 -04:00
|
|
|
Future<void> setSourceAudiobook(
|
2024-05-19 08:53:21 -04:00
|
|
|
BookExpanded? book, {
|
|
|
|
|
bool preload = true,
|
|
|
|
|
// int? initialIndex,
|
|
|
|
|
Duration? initialPosition,
|
2024-08-20 08:36:39 -04:00
|
|
|
List<Uri>? downloadedUris,
|
|
|
|
|
Uri? artworkUri,
|
2024-05-19 08:53:21 -04:00
|
|
|
}) async {
|
2024-09-25 03:13:42 -04:00
|
|
|
final appSettings = loadOrCreateAppSettings();
|
2024-05-14 10:11:25 -04:00
|
|
|
// if the book is null, stop the player
|
|
|
|
|
if (book == null) {
|
|
|
|
|
_book = null;
|
2024-08-20 08:36:39 -04:00
|
|
|
_logger.info('Book is null, stopping player');
|
2024-05-14 10:11:25 -04:00
|
|
|
return stop();
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-14 06:13:16 -04:00
|
|
|
// see if the book is the same as the current book
|
|
|
|
|
if (_book == book) {
|
2024-08-20 08:36:39 -04:00
|
|
|
_logger.info('Book is the same, doing nothing');
|
2024-05-14 06:13:16 -04:00
|
|
|
return;
|
|
|
|
|
}
|
2024-05-17 11:04:20 -04:00
|
|
|
// first stop the player and clear the source
|
2024-05-14 10:11:25 -04:00
|
|
|
await stop();
|
2024-05-14 06:13:16 -04:00
|
|
|
|
2024-05-19 08:53:21 -04:00
|
|
|
_book = book;
|
|
|
|
|
|
|
|
|
|
// some calculations to set the initial index and position
|
|
|
|
|
// initialPosition is of the entire book not just the current track
|
|
|
|
|
// hence first we need to calculate the current track which will be used to set the initial position
|
|
|
|
|
// then we set the initial index to the current track index and position as the remaining duration from the position
|
|
|
|
|
// after subtracting the duration of all the previous tracks
|
2024-05-19 09:45:41 -04:00
|
|
|
// initialPosition ;
|
2024-05-19 08:53:21 -04:00
|
|
|
final trackToPlay = getTrackToPlay(book, initialPosition ?? Duration.zero);
|
|
|
|
|
final initialIndex = book.tracks.indexOf(trackToPlay);
|
|
|
|
|
final initialPositionInTrack = initialPosition != null
|
2024-05-19 09:45:41 -04:00
|
|
|
? initialPosition - trackToPlay.startOffset
|
2024-05-19 08:53:21 -04:00
|
|
|
: null;
|
|
|
|
|
|
2024-05-17 11:04:20 -04:00
|
|
|
await setAudioSource(
|
2024-05-19 08:53:21 -04:00
|
|
|
preload: preload,
|
|
|
|
|
initialIndex: initialIndex,
|
|
|
|
|
initialPosition: initialPositionInTrack,
|
2024-05-17 11:04:20 -04:00
|
|
|
ConcatenatingAudioSource(
|
|
|
|
|
useLazyPreparation: true,
|
|
|
|
|
children: book.tracks.map((track) {
|
2024-08-20 08:36:39 -04:00
|
|
|
final retrievedUri =
|
|
|
|
|
_getUri(track, downloadedUris, baseUrl: baseUrl, token: token);
|
|
|
|
|
_logger.fine(
|
|
|
|
|
'Setting source for track: ${track.title}, URI: $retrievedUri',
|
|
|
|
|
);
|
2024-05-17 11:04:20 -04:00
|
|
|
return AudioSource.uri(
|
2024-08-20 08:36:39 -04:00
|
|
|
retrievedUri,
|
2024-05-17 11:04:20 -04:00
|
|
|
tag: MediaItem(
|
|
|
|
|
// Specify a unique ID for each media item:
|
|
|
|
|
id: book.libraryItemId + track.index.toString(),
|
|
|
|
|
// Metadata to display in the notification:
|
2024-09-25 03:13:42 -04:00
|
|
|
title: appSettings.notificationSettings.primaryTitle
|
|
|
|
|
.formatNotificationTitle(book),
|
|
|
|
|
album: appSettings.notificationSettings.secondaryTitle
|
|
|
|
|
.formatNotificationTitle(book),
|
2024-08-20 08:36:39 -04:00
|
|
|
artUri: artworkUri ??
|
|
|
|
|
Uri.parse(
|
|
|
|
|
'$baseUrl/api/items/${book.libraryItemId}/cover?token=$token&width=800',
|
|
|
|
|
),
|
2024-05-17 11:04:20 -04:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}).toList(),
|
|
|
|
|
),
|
|
|
|
|
).catchError((error) {
|
2024-08-20 08:36:39 -04:00
|
|
|
_logger.shout('Error: $error');
|
2024-05-17 11:04:20 -04:00
|
|
|
});
|
2024-05-14 06:13:16 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// toggles the player between play and pause
|
|
|
|
|
Future<void> togglePlayPause() {
|
|
|
|
|
// check if book is set
|
|
|
|
|
if (_book == null) {
|
|
|
|
|
throw StateError('No book is set');
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-19 09:45:41 -04:00
|
|
|
// TODO refactor this to cover all the states
|
2024-05-17 11:04:20 -04:00
|
|
|
return switch (playerState) {
|
|
|
|
|
PlayerState(playing: var isPlaying) => isPlaying ? pause() : play(),
|
|
|
|
|
};
|
2024-05-14 10:11:25 -04:00
|
|
|
}
|
|
|
|
|
|
2024-05-15 02:27:05 -04:00
|
|
|
/// need to override getDuration and getCurrentPosition to return according to the book instead of the current track
|
|
|
|
|
/// this is because the book can be a list of audio files and the player is only aware of the current track
|
|
|
|
|
/// so we need to calculate the duration and current position based on the book
|
2024-05-19 08:53:21 -04:00
|
|
|
|
|
|
|
|
@override
|
2024-05-19 09:45:41 -04:00
|
|
|
Future<void> seek(Duration? positionInBook, {int? index}) async {
|
2024-05-19 08:53:21 -04:00
|
|
|
if (_book == null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2024-05-19 09:45:41 -04:00
|
|
|
if (positionInBook == null) {
|
2024-05-19 08:53:21 -04:00
|
|
|
return;
|
|
|
|
|
}
|
2024-05-19 09:45:41 -04:00
|
|
|
final tracks = _book!.tracks;
|
|
|
|
|
final trackToPlay = getTrackToPlay(_book!, positionInBook);
|
|
|
|
|
final index = tracks.indexOf(trackToPlay);
|
|
|
|
|
final positionInTrack = positionInBook - trackToPlay.startOffset;
|
2024-05-19 08:53:21 -04:00
|
|
|
return super.seek(positionInTrack, index: index);
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-19 09:45:41 -04:00
|
|
|
/// a convenience method to get position in the book instead of the current track position
|
|
|
|
|
Duration get positionInBook {
|
|
|
|
|
if (_book == null) {
|
|
|
|
|
return Duration.zero;
|
|
|
|
|
}
|
|
|
|
|
return position + _book!.tracks[sequenceState!.currentIndex].startOffset;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// a convenience method to get the buffered position in the book instead of the current track position
|
|
|
|
|
Duration get bufferedPositionInBook {
|
|
|
|
|
if (_book == null) {
|
|
|
|
|
return Duration.zero;
|
|
|
|
|
}
|
2024-08-20 08:36:39 -04:00
|
|
|
return bufferedPosition +
|
|
|
|
|
_book!.tracks[sequenceState!.currentIndex].startOffset;
|
2024-05-19 09:45:41 -04:00
|
|
|
}
|
|
|
|
|
|
2024-05-19 08:53:21 -04:00
|
|
|
/// streams to override to suit the book instead of the current track
|
|
|
|
|
// - positionStream
|
|
|
|
|
// - bufferedPositionStream
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Stream<Duration> get positionStream {
|
2024-09-25 03:13:42 -04:00
|
|
|
// return the positionInBook stream
|
2024-05-19 08:53:21 -04:00
|
|
|
return super.positionStream.map((position) {
|
|
|
|
|
if (_book == null) {
|
|
|
|
|
return Duration.zero;
|
|
|
|
|
}
|
2024-05-19 09:45:41 -04:00
|
|
|
return position + _book!.tracks[sequenceState!.currentIndex].startOffset;
|
2024-05-19 08:53:21 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Stream<Duration> get bufferedPositionStream {
|
|
|
|
|
return super.bufferedPositionStream.map((position) {
|
|
|
|
|
if (_book == null) {
|
|
|
|
|
return Duration.zero;
|
|
|
|
|
}
|
2024-05-19 09:45:41 -04:00
|
|
|
return position + _book!.tracks[sequenceState!.currentIndex].startOffset;
|
2024-05-19 08:53:21 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-20 07:45:06 -04:00
|
|
|
/// a convenience getter for slow position stream
|
|
|
|
|
Stream<Duration> get slowPositionStream {
|
|
|
|
|
final superPositionStream = createPositionStream(
|
|
|
|
|
steps: 100,
|
|
|
|
|
minPeriod: const Duration(milliseconds: 500),
|
|
|
|
|
maxPeriod: const Duration(seconds: 1),
|
|
|
|
|
);
|
|
|
|
|
// now we need to map the position to the book instead of the current track
|
|
|
|
|
return superPositionStream.map((position) {
|
|
|
|
|
if (_book == null) {
|
|
|
|
|
return Duration.zero;
|
|
|
|
|
}
|
|
|
|
|
return position + _book!.tracks[sequenceState!.currentIndex].startOffset;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-19 08:53:21 -04:00
|
|
|
/// get current chapter
|
|
|
|
|
BookChapter? get currentChapter {
|
|
|
|
|
if (_book == null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2024-05-21 10:50:02 -04:00
|
|
|
// if the list is empty, return null
|
|
|
|
|
if (_book!.chapters.isEmpty) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2024-05-19 08:53:21 -04:00
|
|
|
return _book!.chapters.firstWhere(
|
|
|
|
|
(element) {
|
2024-05-19 09:45:41 -04:00
|
|
|
return element.start <= positionInBook && element.end >= positionInBook;
|
2024-05-19 08:53:21 -04:00
|
|
|
},
|
|
|
|
|
orElse: () => _book!.chapters.first,
|
|
|
|
|
);
|
|
|
|
|
}
|
2024-05-15 02:27:05 -04:00
|
|
|
}
|
2024-08-20 08:36:39 -04:00
|
|
|
|
|
|
|
|
Uri _getUri(
|
|
|
|
|
AudioTrack track,
|
|
|
|
|
List<Uri>? downloadedUris, {
|
|
|
|
|
required Uri baseUrl,
|
|
|
|
|
required String token,
|
|
|
|
|
}) {
|
|
|
|
|
// check if the track is in the downloadedUris
|
|
|
|
|
final uri = downloadedUris?.firstWhereOrNull(
|
|
|
|
|
(element) {
|
|
|
|
|
return element.pathSegments.last == track.metadata?.filename;
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return uri ??
|
|
|
|
|
Uri.parse('${baseUrl.toString()}${track.contentUrl}?token=$token');
|
|
|
|
|
}
|
2024-09-25 03:13:42 -04:00
|
|
|
|
|
|
|
|
extension FormatNotificationTitle on String {
|
|
|
|
|
String formatNotificationTitle(BookExpanded book) {
|
|
|
|
|
return replaceAllMapped(
|
|
|
|
|
RegExp(r'\$(\w+)'),
|
|
|
|
|
(match) {
|
|
|
|
|
final type = match.group(1);
|
|
|
|
|
return NotificationTitleType.values
|
2024-09-28 01:27:56 -04:00
|
|
|
.firstWhere((element) => element.name == type)
|
2024-09-25 03:13:42 -04:00
|
|
|
.extractFrom(book) ??
|
|
|
|
|
match.group(0) ??
|
|
|
|
|
'';
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension NotificationTitleUtils on NotificationTitleType {
|
|
|
|
|
String? extractFrom(BookExpanded book) {
|
|
|
|
|
var bookMetadataExpanded = book.metadata.asBookMetadataExpanded;
|
|
|
|
|
switch (this) {
|
|
|
|
|
case NotificationTitleType.bookTitle:
|
|
|
|
|
return bookMetadataExpanded.title;
|
|
|
|
|
case NotificationTitleType.chapterTitle:
|
|
|
|
|
// TODO: implement chapter title; depends on https://github.com/Dr-Blank/Vaani/issues/2
|
|
|
|
|
return bookMetadataExpanded.title;
|
|
|
|
|
case NotificationTitleType.author:
|
|
|
|
|
return bookMetadataExpanded.authorName;
|
|
|
|
|
case NotificationTitleType.narrator:
|
|
|
|
|
return bookMetadataExpanded.narratorName;
|
|
|
|
|
case NotificationTitleType.series:
|
|
|
|
|
return bookMetadataExpanded.seriesName;
|
|
|
|
|
case NotificationTitleType.subtitle:
|
|
|
|
|
return bookMetadataExpanded.subtitle;
|
|
|
|
|
case NotificationTitleType.year:
|
|
|
|
|
return bookMetadataExpanded.publishedYear;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|