替换miniPlayer

This commit is contained in:
rang 2025-11-13 17:53:23 +08:00
parent eb9b8f3b94
commit e67d045da6
34 changed files with 1777 additions and 1078 deletions

View file

@ -8,13 +8,19 @@ import 'package:just_audio/just_audio.dart';
import 'package:just_audio_background/just_audio_background.dart';
import 'package:logging/logging.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/features/per_book_settings/providers/book_settings_provider.dart';
import 'package:vaani/settings/app_settings_provider.dart';
import 'package:vaani/settings/models/app_settings.dart';
import 'package:vaani/shared/extensions/model_conversions.dart';
final _logger = Logger('AudiobookPlayer');
// add a small offset so the display does not show the previous chapter for a split second
final offset = Duration(milliseconds: 10);
/// time into the current chapter to determine if we should go to the previous chapter or the start of the current chapter
final doNotSeekBackIfLessThan = Duration(seconds: 5);
/// returns the sum of the duration of all the previous tracks before the [index]
Duration sumOfTracks(BookExpanded book, int? index) {
_logger.fine('Calculating sum of tracks for index: $index');
@ -31,31 +37,17 @@ Duration sumOfTracks(BookExpanded book, int? index) {
return total;
}
/// returns the [AudioTrack] to play based on the [position] in the [book]
AudioTrack getTrackToPlay(BookExpanded book, Duration position) {
_logger.fine('Getting track to play for position: $position');
final track = book.tracks.firstWhere(
(element) {
return element.startOffset <= position &&
(element.startOffset + element.duration) >= position;
},
orElse: () => book.tracks.last,
);
_logger.fine('Track to play for position: $position is $track');
return track;
}
/// will manage the audio player instance
class AudiobookPlayer extends AudioPlayer {
// constructor which takes in the BookExpanded object
AudiobookPlayer(this.token, this.baseUrl) : super() {
// set the source of the player to the first track in the book
_logger.config('Setting up audiobook player');
playerStateStream.listen((playerState) {
if (playerState.processingState == ProcessingState.completed) {
Future.microtask(seekToNext);
}
});
// playerStateStream.listen((playerState) {
// if (playerState.processingState == ProcessingState.completed) {
// Future.microtask(seekToNext);
// }
// });
}
/// the [BookExpanded] being played
@ -76,17 +68,16 @@ class AudiobookPlayer extends AudioPlayer {
final Uri baseUrl;
// the current index of the audio file in the [book]
int _currentIndex = 0;
// int _currentIndex = 0;
// available audio tracks
int? get availableTracks => _book?.tracks.length;
List<Uri>? _downloadedUris;
/// sets the current [AudioTrack] as the source of the player
Future<void> setSourceAudiobook(
BookExpanded? book, {
bool preload = true,
// int? initialIndex,
int? initialIndex,
Duration? initialPosition,
List<Uri>? downloadedUris,
Uri? artworkUri,
@ -94,7 +85,7 @@ class AudiobookPlayer extends AudioPlayer {
_logger.finer(
'Initial position: $initialPosition, Downloaded URIs: $downloadedUris',
);
// final appSettings = loadOrCreateAppSettings();
final appSettings = loadOrCreateAppSettings();
if (book == null) {
_book = null;
_logger.info('Book is null, stopping player');
@ -111,103 +102,52 @@ class AudiobookPlayer extends AudioPlayer {
await stop();
_book = book;
_downloadedUris = downloadedUris;
// 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
// initialPosition ;
final trackToPlay = getTrackToPlay(book, initialPosition ?? Duration.zero);
final trackToPlay =
_book!.findTrackAtTime(initialPosition ?? Duration.zero);
final initialIndex = book.tracks.indexOf(trackToPlay);
final initialPositionInTrack = initialPosition != null
? initialPosition - trackToPlay.startOffset
: null;
await setAudioSourceTrack(
initialIndex,
initialPosition: initialPositionInTrack,
);
// _logger.finer('Setting audioSource');
// await setAudioSource(
// preload: preload,
// initialIndex: initialIndex,
// initialPosition: initialPositionInTrack,
// ConcatenatingAudioSource(
// useLazyPreparation: true,
// children: book.tracks.map((track) {
// final retrievedUri = _getUri(track, downloadedUris, baseUrl: baseUrl, token: token);
// _logger.fine(
// 'Setting source for track: ${track.title}, URI: ${retrievedUri.obfuscate()}',
// );
// return AudioSource.uri(
// retrievedUri,
// tag: MediaItem(
// // Specify a unique ID for each media item:
// id: book.libraryItemId + track.index.toString(),
// // Metadata to display in the notification:
// title: appSettings.notificationSettings.primaryTitle.formatNotificationTitle(book),
// album: appSettings.notificationSettings.secondaryTitle.formatNotificationTitle(book),
// artUri: artworkUri ??
// Uri.parse(
// '$baseUrl/api/items/${book.libraryItemId}/cover?token=$token&width=800',
// ),
// ),
// );
// }).toList(),
// ),
// ).catchError((error) {
// _logger.shout('Error in setting audio source: $error');
// });
}
Future<void> setAudioSourceTrack(
int index, {
Duration? initialPosition,
}) async {
if (_book == null) {
return stop();
}
if (_currentIndex != 0 && index == _currentIndex) {
if (initialPosition != null) {
seek(initialPosition);
}
return;
}
_currentIndex = index;
AudioTrack track = _book!.tracks[index];
final appSettings = loadOrCreateAppSettings();
final playerSettings =
readFromBoxOrCreate(_book!.libraryItemId).playerSettings;
if (initialPosition == null || initialPosition <= Duration(seconds: 1)) {
initialPosition = playerSettings.skipChapterStart;
}
final retrievedUri =
_getUri(track, _downloadedUris, baseUrl: baseUrl, token: token);
await setAudioSource(
initialPosition: initialPosition,
ClippingAudioSource(
end: track.duration - playerSettings.skipChapterEnd,
child: AudioSource.uri(
retrievedUri,
),
_logger.finer('Setting audioSource');
final playlist = book.tracks.map((track) {
final retrievedUri =
_getUri(track, downloadedUris, baseUrl: baseUrl, token: token);
// _logger.fine(
// 'Setting source for track: ${track.title}, URI: ${retrievedUri.obfuscate()}',
// );
return AudioSource.uri(
retrievedUri,
tag: MediaItem(
// Specify a unique ID for each media item:
id: '${book?.libraryItemId}${track.index}',
id: book.libraryItemId + track.index.toString(),
// Metadata to display in the notification:
title: appSettings.notificationSettings.primaryTitle
.formatNotificationTitle(book!),
.formatNotificationTitle(book),
album: appSettings.notificationSettings.secondaryTitle
.formatNotificationTitle(book!),
artUri: Uri.parse(
'$baseUrl/api/items/${book?.libraryItemId}/cover?token=$token&width=800',
),
.formatNotificationTitle(book),
artUri: artworkUri ??
Uri.parse(
'$baseUrl/api/items/${book.libraryItemId}/cover?token=$token&width=800',
),
),
),
);
);
}).toList();
await setAudioSources(
playlist,
preload: preload,
initialIndex: initialIndex,
initialPosition: initialPositionInTrack,
).catchError((error) {
_logger.shout('Error in setting audio source: $error');
return null;
});
}
/// toggles the player between play and pause
@ -223,145 +163,136 @@ class AudiobookPlayer extends AudioPlayer {
};
}
// @override
// Future<void> seek(Duration? positionInBook, {int? index, bool b = true}) async {
// if (!b) {
// return super.seek(positionInBook, index: index);
// }
// if (_book == null) {
// _logger.warning('No book is set, not seeking');
// return;
// }
// if (positionInBook == null) {
// _logger.warning('Position given is null, not seeking');
// return;
// }
// final tracks = _book!.tracks;
// final trackToPlay = getTrackToPlay(_book!, positionInBook);
// final i = tracks.indexOf(trackToPlay);
// final positionInTrack = positionInBook - trackToPlay.startOffset;
// return super.seek(positionInTrack, index: i);
// }
/// 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
Future<void> seekInBook(Duration? positionInBook, {int? index}) async {
Future<void> seekInBook(Duration globalPosition) async {
if (_book == null) {
_logger.warning('No book is set, not seeking');
return;
}
if (positionInBook == null) {
_logger.warning('Position given is null, not seeking');
return;
//
final track = _book!.findTrackAtTime(globalPosition);
final index = _book!.tracks.indexOf(track);
Duration positionInTrack = globalPosition - track.startOffset;
if (positionInTrack <= Duration.zero) {
positionInTrack = offset;
}
final tracks = _book!.tracks;
final trackToPlay = getTrackToPlay(_book!, positionInBook);
final i = tracks.indexOf(trackToPlay);
final positionInTrack = positionInBook - trackToPlay.startOffset;
return setAudioSourceTrack(i, initialPosition: positionInTrack);
// return super.seek(positionInTrack, index: i);
//
if (index != currentIndex) {
await seek(positionInTrack, index: index);
}
await seek(positionInTrack);
}
// add a small offset so the display does not show the previous chapter for a split second
final offset = Duration(milliseconds: 10);
//
Future<void> skipToChapter(int chapterId, {Duration? position}) async {
if (_book == null) return;
/// time into the current chapter to determine if we should go to the previous chapter or the start of the current chapter
final doNotSeekBackIfLessThan = Duration(seconds: 5);
/// seek forward to the next chapter
void seekForward() {
seekInBook(currentChapter!.end + offset);
// final index = _book!.chapters.indexOf(currentChapter!);
// if (index < _book!.chapters.length - 1) {
// super.seek(
// _book!.chapters[index + 1].start + offset,
// );
// } else {
// super.seek(currentChapter!.end);
// }
}
/// seek backward to the previous chapter or the start of the current chapter
void seekBackward() {
final currentPlayingChapterIndex = _book!.chapters.indexOf(currentChapter!);
if (position > doNotSeekBackIfLessThan || currentPlayingChapterIndex <= 0) {
seekInBook(currentChapter!.start + offset);
final chapter = _book!.chapters.firstWhere(
(ch) => ch.id == chapterId,
orElse: () => throw Exception('Chapter not found'),
);
if (position != null) {
print('章节开头: ${chapter.start}');
print('章节开头: ${chapter.start + position}');
await seekInBook(chapter.start + position);
return;
}
BookChapter chapterToSeekTo =
_book!.chapters[currentPlayingChapterIndex - 1];
seekInBook(chapterToSeekTo.start + offset);
// final currentPlayingChapterIndex = _book!.chapters.indexOf(currentChapter!);
// final chapterPosition = positionInBook - currentChapter!.start;
// BookChapter chapterToSeekTo;
// // if player position is less than 5 seconds into the chapter, go to the previous chapter
// if (chapterPosition < doNotSeekBackIfLessThan && currentPlayingChapterIndex > 0) {
// chapterToSeekTo = _book!.chapters[currentPlayingChapterIndex - 1];
// } else {
// chapterToSeekTo = currentChapter!;
// }
// super.seek(
// chapterToSeekTo.start + offset,
// );
await seekInBook(chapter.start + offset);
}
@override
Future<void> seekToNext() {
if (_currentIndex >= availableTracks!) {
return super.seek(duration);
Future<void> seekToNext() async {
if (_book == null) {
// 退
return super.seekToNext();
}
final chapter = currentChapter;
if (chapter == null) {
// 退
return super.seekToNext();
}
final currentIndex = _book!.chapters.indexOf(chapter);
if (currentIndex < _book!.chapters.length - 1) {
//
final nextChapter = _book!.chapters[currentIndex + 1];
await skipToChapter(nextChapter.id);
}
return setAudioSourceTrack(_currentIndex + 1);
}
@override
Future<void> seekToPrevious() {
if (_currentIndex == 0) {
return super.seek(Duration());
Future<void> seekToPrevious() async {
if (_book == null) {
return super.seekToPrevious();
}
final chapter = currentChapter;
if (chapter == null) {
return super.seekToPrevious();
}
final currentIndex = _book!.chapters.indexOf(chapter);
if (currentIndex > 0) {
//
final prevChapter = _book!.chapters[currentIndex - 1];
await skipToChapter(prevChapter.id);
} else {
//
await seekInBook(Duration.zero);
}
return setAudioSourceTrack(_currentIndex - 1);
}
/// a convenience method to get position in the book instead of the current track position
Duration get positionInBook {
if (_book == null) {
if (_book == null || currentIndex == null) {
return Duration.zero;
}
return position + _book!.tracks[_currentIndex].startOffset;
return position + _book!.tracks[currentIndex!].startOffset;
// 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) {
if (_book == null || currentIndex == null) {
return Duration.zero;
}
return bufferedPosition + _book!.tracks[_currentIndex].startOffset;
return bufferedPosition + _book!.tracks[currentIndex!].startOffset;
// return bufferedPosition + _book!.tracks[sequenceState!.currentIndex].startOffset;
}
//
Stream<Duration> get positionStreamInChapter {
return super.positionStream.map((position) {
if (_book == null || currentIndex == null) {
return Duration.zero;
}
final globalPosition =
position + _book!.tracks[currentIndex!].startOffset;
final chapter = _book!.findChapterAtTime(globalPosition);
return globalPosition - chapter.start;
});
}
/// streams to override to suit the book instead of the current track
// - positionStream
// - bufferedPositionStream
Stream<Duration> get positionStreamInBook {
// return the positionInBook stream
return super.positionStream.map((position) {
if (_book == null) {
if (_book == null || currentIndex == null) {
return Duration.zero;
}
return position + _book!.tracks[_currentIndex].startOffset;
return position + _book!.tracks[currentIndex!].startOffset;
// return position + _book!.tracks[sequenceState!.currentIndex].startOffset;
});
}
Stream<Duration> get bufferedPositionStreamInBook {
return super.bufferedPositionStream.map((position) {
if (_book == null) {
if (_book == null || currentIndex == null) {
return Duration.zero;
}
return position + _book!.tracks[_currentIndex].startOffset;
return position + _book!.tracks[currentIndex!].startOffset;
// return position + _book!.tracks[sequenceState!.currentIndex].startOffset;
});
}
@ -375,10 +306,10 @@ class AudiobookPlayer extends AudioPlayer {
);
// now we need to map the position to the book instead of the current track
return superPositionStream.map((position) {
if (_book == null) {
if (_book == null || currentIndex == null) {
return Duration.zero;
}
return position + _book!.tracks[_currentIndex].startOffset;
return position + _book!.tracks[currentIndex!].startOffset;
// return position + _book!.tracks[sequenceState!.currentIndex].startOffset;
});
}
@ -388,17 +319,7 @@ class AudiobookPlayer extends AudioPlayer {
if (_book == null) {
return null;
}
// if the list is empty, return null
if (_book!.chapters.isEmpty) {
return null;
}
return _book!.chapters.firstWhere(
(element) {
return element.start <= positionInBook &&
element.end >= positionInBook + offset;
},
orElse: () => _book!.chapters.first,
);
return _book!.findChapterAtTime(positionInBook);
}
}
@ -457,3 +378,28 @@ extension NotificationTitleUtils on NotificationTitleType {
}
}
}
extension BookExpandedExtension on BookExpanded {
BookChapter findChapterAtTime(Duration position) {
return chapters.firstWhere(
(element) {
return element.start <= position && element.end >= position + offset;
},
orElse: () => chapters.first,
);
}
AudioTrack findTrackAtTime(Duration position) {
return tracks.firstWhere(
(element) {
return element.startOffset <= position &&
element.startOffset + element.duration >= position + offset;
},
orElse: () => tracks.first,
);
}
Duration getTrackStartOffset(int index) {
return tracks[index].startOffset;
}
}

View file

@ -0,0 +1,272 @@
// my_audio_handler.dart
import 'package:audio_service/audio_service.dart';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
// add a small offset so the display does not show the previous chapter for a split second
final offset = Duration(milliseconds: 10);
class HookAudioHandler extends BaseAudioHandler {
final AudioPlayer _player = AudioPlayer();
final List<AudioSource> _playlist = [];
final Ref ref;
BookExpanded? _book;
/// the authentication token to access the [AudioTrack.contentUrl]
final String token;
/// the base url for the audio files
final Uri baseUrl;
HookAudioHandler(this.ref, {required this.token, required this.baseUrl}) {
_setupAudioPlayer();
}
void _setupAudioPlayer() {
_player.setAudioSources(_playlist);
// //
// _player.positionStream.listen((position) {
// // _updateGlobalPosition(position);
// });
// //
// _player.currentIndexStream.listen((index) {
// if (index != null) {
// _onTrackChanged(index);
// }
// });
//
_player.playbackEventStream.map(_transformEvent).pipe(playbackState);
}
//
Future<void> setSourceAudiobook(
BookExpanded audiobook, {
Duration? initialPosition,
List<Uri>? downloadedUris,
}) async {
_book = audiobook;
//
_playlist.clear();
//
for (final track in audiobook.tracks) {
final audioSource = ProgressiveAudioSource(
_getUri(track, downloadedUris, baseUrl: baseUrl, token: token),
tag: MediaItem(
id: '${audiobook.libraryItemId}${track.index}',
title: track.title,
duration: track.duration,
),
);
_playlist.add(audioSource);
}
//
final mediaItems = audiobook.tracks
.map(
(track) => MediaItem(
id: '${audiobook.libraryItemId}${track.index}',
title: track.title,
duration: track.duration,
),
)
.toList();
queue.add(mediaItems);
//
if (initialPosition != null) {
await seekToPosition(initialPosition);
}
}
// //
// void _onTrackChanged(int trackIndex) {
// if (_book == null) return;
// //
// // print('切换到音轨: ${_book!.tracks[trackIndex].title}');
// }
//
Future<void> skipToChapter(int chapterId) async {
if (_book == null) return;
final chapter = _book!.chapters.firstWhere(
(ch) => ch.id == chapterId,
orElse: () => throw Exception('Chapter not found'),
);
await seekToPosition(chapter.start + offset);
}
Duration get positionInBook {
if (_book != null && _player.currentIndex != null) {
return _book!.tracks[_player.currentIndex!].startOffset +
_player.position;
}
return Duration.zero;
}
//
AudioTrack? get currentTrack {
if (_book == null) {
return null;
}
return _book!.findTrackAtTime(positionInBook);
}
//
BookChapter? get currentChapter {
if (_book == null) {
return null;
}
return _book!.findChapterAtTime(positionInBook);
}
//
@override
Future<void> play() => _player.play();
@override
Future<void> pause() => _player.pause();
// /
@override
Future<void> skipToNext() async {
if (_book == null) {
// 退
return _player.seekToNext();
}
final chapter = currentChapter;
if (chapter == null) {
// 退
return _player.seekToNext();
}
final currentIndex = _book!.chapters.indexOf(chapter);
if (currentIndex < _book!.chapters.length - 1) {
//
final nextChapter = _book!.chapters[currentIndex + 1];
await skipToChapter(nextChapter.id);
}
}
@override
Future<void> skipToPrevious() async {
if (_book == null) {
return _player.seekToPrevious();
}
final chapter = currentChapter;
if (chapter == null) {
return _player.seekToPrevious();
}
final currentIndex = _book!.chapters.indexOf(chapter);
if (currentIndex > 0) {
//
final prevChapter = _book!.chapters[currentIndex - 1];
await skipToChapter(prevChapter.id);
} else {
//
await seekToPosition(Duration.zero);
}
}
@override
Future<void> seek(Duration position) async {
// position 使
//
final track = currentTrack;
Duration startOffset = Duration.zero;
if (track != null) {
startOffset = track.startOffset;
}
await seekToPosition(startOffset + position);
}
//
Future<void> seekToPosition(Duration globalPosition) async {
if (_book == null) return;
//
final track = _book!.findTrackAtTime(globalPosition);
final index = _book!.tracks.indexOf(track);
Duration positionInTrack = globalPosition - track.startOffset;
if (positionInTrack <= Duration.zero) {
positionInTrack = offset;
}
//
await _player.seek(positionInTrack, index: index);
}
PlaybackState _transformEvent(PlaybackEvent event) {
return PlaybackState(
controls: [
MediaControl.skipToPrevious,
if (_player.playing) MediaControl.pause else MediaControl.play,
MediaControl.skipToNext,
],
processingState: const {
ProcessingState.idle: AudioProcessingState.idle,
ProcessingState.loading: AudioProcessingState.loading,
ProcessingState.buffering: AudioProcessingState.buffering,
ProcessingState.ready: AudioProcessingState.ready,
ProcessingState.completed: AudioProcessingState.completed,
}[_player.processingState] ??
AudioProcessingState.idle,
playing: _player.playing,
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
queueIndex: event.currentIndex,
);
}
}
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');
}
extension BookExpandedExtension on BookExpanded {
BookChapter findChapterAtTime(Duration position) {
return chapters.firstWhere(
(element) {
return element.start <= position && element.end >= position + offset;
},
orElse: () => chapters.first,
);
}
AudioTrack findTrackAtTime(Duration position) {
return tracks.firstWhere(
(element) {
return element.startOffset <= position &&
element.startOffset + element.duration >= position + offset;
},
orElse: () => tracks.first,
);
}
Duration getTrackStartOffset(int index) {
return tracks[index].startOffset;
}
}

View file

@ -9,7 +9,7 @@ import 'package:vaani/settings/models/app_settings.dart';
Future<void> configurePlayer() async {
// for playing audio on windows, linux
JustAudioMediaKit.ensureInitialized(windows: false);
JustAudioMediaKit.ensureInitialized();
// for configuring how this app will interact with other audio apps
final session = await AudioSession.instance;