更改播放逻辑

This commit is contained in:
rang 2025-12-08 17:54:08 +08:00
parent 290b68336f
commit 420438c0df
29 changed files with 810 additions and 514 deletions

View file

@ -1,27 +1,152 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:collection/collection.dart';
import 'package:just_audio/just_audio.dart';
import 'package:logging/logging.dart';
import 'package:rxdart/rxdart.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/features/player/core/audiobook_player.dart';
import 'package:vaani/features/settings/app_settings_provider.dart';
import 'package:vaani/features/settings/models/app_settings.dart';
import 'package:vaani/shared/extensions/chapter.dart';
import 'package:vaani/shared/extensions/model_conversions.dart';
final offset = Duration(milliseconds: 10);
final _logger = Logger('AbsAudioPlayer');
abstract class AbsAudioPlayer {
AbsAudioPlayer._();
final _mediaItemController = BehaviorSubject<MediaItem?>.seeded(null);
final playerStateSubject =
BehaviorSubject.seeded(PlayerState(false, ProcessingState.idle));
final _bookStreamController = BehaviorSubject<BookExpanded?>.seeded(null);
final _chapterStreamController = BehaviorSubject<BookChapter?>.seeded(null);
BookExpanded? _book;
BookExpanded? get book => _bookStreamController.nvalue;
BookChapter? get currentChapter => _chapterStreamController.nvalue;
PlayerState get playerState => playerStateSubject.value;
Stream<MediaItem?> get mediaItemStream => _mediaItemController.stream;
Stream<PlayerState> get playerStateStream => playerStateSubject.stream;
BookExpanded? get book => _book;
Future<void> load(
BookExpanded book, {
required Uri baseUrl,
required String token,
Duration? initialPosition,
List<Uri>? downloadedUris,
}) async {
if (_bookStreamController.nvalue == book) {
_logger.info('Book is the same, doing nothing');
return;
}
_bookStreamController.add(book);
final appSettings = loadOrCreateAppSettings();
final currentTrack = book.findTrackAtTime(initialPosition ?? Duration.zero);
final indexTrack = book.tracks.indexOf(currentTrack);
final positionInTrack = initialPosition != null
? initialPosition - currentTrack.startOffset
: null;
final title = appSettings.notificationSettings.primaryTitle
.formatNotificationTitle(book);
final artist = appSettings.notificationSettings.secondaryTitle
.formatNotificationTitle(book);
_chapterStreamController
.add(book.findChapterAtTime(initialPosition ?? Duration.zero));
final item = MediaItem(
id: book.libraryItemId,
title: title,
artist: artist,
duration: currentChapter?.duration ?? book.duration,
artUri: Uri.parse(
'$baseUrl/api/items/${book.libraryItemId}/cover?token=$token',
),
);
_mediaItemController.sink.add(item);
final playlist = book.tracks
.map(
(track) => _getUri(currentTrack, downloadedUris,
baseUrl: baseUrl, token: token),
)
.toList();
setPlayList(playlist, index: indexTrack, position: positionInTrack);
}
Future<void> setPlayList(
List<Uri> playlist, {
int? index,
Duration? position,
});
Future<void> play();
Future<void> pause();
Future<void> playOrPause();
Future<void> next();
Future<void> previous();
//
Future<void> next() async {
final chapter = currentChapter;
if (book == null || chapter == null) {
return;
}
final chapterIndex = book!.chapters.indexOf(chapter);
if (chapterIndex < book!.chapters.length - 1) {
final nextChapter = book!.chapters[chapterIndex + 1];
await switchChapter(nextChapter.id);
}
}
//
Future<void> previous() async {
final chapter = currentChapter;
if (book == null || chapter == null) {
return;
}
final currentIndex = book!.chapters.indexOf(chapter);
if (currentIndex > 0) {
final prevChapter = book!.chapters[currentIndex - 1];
await switchChapter(prevChapter.id);
} else {
//
await seekInBook(Duration.zero);
}
}
Future<void> seek(Duration position, {int? index});
Future<void> seekInBook(Duration position);
Future<void> seekInBook(Duration position) async {
if (book == null) return;
//
final track = book!.findTrackAtTime(position);
final index = book!.tracks.indexOf(track);
Duration positionInTrack = position - track.startOffset;
if (positionInTrack <= Duration.zero) {
positionInTrack = offset;
}
//
await seek(positionInTrack, index: index);
}
Future<void> setSpeed(double speed);
Future<void> setVolume(double volume);
Future<void> switchChapter(int chapterId);
Future<void> switchChapter(int chapterId) async {
if (book == null) return;
final chapter = book!.chapters.firstWhere(
(ch) => ch.id == chapterId,
orElse: () => throw Exception('Chapter not found'),
);
await seekInBook(chapter.start + offset);
}
Stream<bool> get playingStream;
Stream<BookExpanded?> get bookStream => _bookStreamController.stream;
Stream<BookChapter?> get chapterStream => _chapterStreamController.stream;
int get currentIndex;
double get speed;
Duration get position;
Stream<Duration> get positionStream;
Duration get positionInChapter {
final globalPosition = positionInBook;
@ -32,10 +157,179 @@ abstract class AbsAudioPlayer {
Duration get positionInBook =>
position + (book?.tracks[currentIndex].startOffset ?? Duration.zero);
Stream<Duration> get positionStream;
Stream<Duration> get positionInChapterStream =>
positionStream.map((position) {
return positionInChapter;
});
Stream<Duration> get positionInChapterStream;
Stream<Duration> get positionInBookStream => positionStream.map((position) {
return positionInBook;
});
Stream<Duration> get positionInBookStream;
Stream<Duration> get bufferedPositionInBookStream;
Duration get bufferedPosition;
Stream<Duration> get bufferedPositionStream;
Duration get bufferedPositionInBook =>
bufferedPosition +
(book?.tracks[currentIndex].startOffset ?? Duration.zero);
Stream<Duration> get bufferedPositionInBookStream =>
bufferedPositionStream.map((position) {
return bufferedPositionInBook;
});
dispose() {
_mediaItemController.close();
playerStateSubject.close();
_bookStreamController.close();
_chapterStreamController.close();
}
}
/// Enumerates the different processing states of a player.
enum ProcessingState {
/// The player has not loaded an [AudioSource].
idle,
/// The player is loading an [AudioSource].
loading,
/// The player is buffering audio and unable to play.
buffering,
/// The player is has enough audio buffered and is able to play.
ready,
/// The player has reached the end of the audio.
completed,
}
/// Encapsulates the playing and processing states. These two states vary
/// orthogonally, and so if [processingState] is [ProcessingState.buffering],
/// you can check [playing] to determine whether the buffering occurred while
/// the player was playing or while the player was paused.
class PlayerState {
/// Whether the player will play when [processingState] is
/// [ProcessingState.ready].
final bool playing;
/// The current processing state of the player.
final ProcessingState processingState;
PlayerState(this.playing, this.processingState);
@override
String toString() => 'playing=$playing,processingState=$processingState';
@override
int get hashCode => Object.hash(playing, processingState);
@override
bool operator ==(Object other) =>
other.runtimeType == runtimeType &&
other is PlayerState &&
other.playing == playing &&
other.processingState == processingState;
PlayerState copyWith({
bool? playing,
ProcessingState? processingState,
}) {
return PlayerState(
playing ?? this.playing,
processingState ?? this.processingState,
);
}
}
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');
}
/// Backwards compatible extensions on rxdart's ValueStream
extension _ValueStreamExtension<T> on ValueStream<T> {
/// Backwards compatible version of valueOrNull.
T? get nvalue => hasValue ? value : null;
}
extension FormatNotificationTitle on String {
String formatNotificationTitle(BookExpanded book) {
return replaceAllMapped(
RegExp(r'\$(\w+)'),
(match) {
final type = match.group(1);
return NotificationTitleType.values
.firstWhere((element) => element.name == type)
.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;
}
}
}
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,
);
}
int findTrackIndexAtTime(Duration position) {
return tracks.indexWhere((element) {
return element.startOffset <= position &&
element.startOffset + element.duration >= position + offset;
});
}
Duration getTrackStartOffset(int index) {
return tracks[index].startOffset;
}
}

View file

@ -0,0 +1,107 @@
import 'dart:async';
import 'package:media_kit/media_kit.dart' hide PlayerState;
import 'package:vaani/shared/audio_player.dart';
class AbsMpvAudioPlayer extends AbsAudioPlayer {
final player = Player();
AbsMpvAudioPlayer() {
player.stream.playing.listen((playing) {
final state = playerState;
playerStateSubject.add(
state.copyWith(
playing: playing,
processingState: playing
? state.processingState == ProcessingState.idle
? ProcessingState.ready
: state.processingState
: player.state.buffering
? ProcessingState.buffering
: player.state.completed
? ProcessingState.completed
: ProcessingState.ready,
),
);
});
}
@override
Stream<Duration> get bufferedPositionInBookStream => player.stream.buffer;
@override
int get currentIndex => player.state.playlist.index;
@override
Future<void> pause() async {
await player.pause();
}
@override
Future<void> play() async {
await player.play();
}
@override
Future<void> playOrPause() async {
await player.playOrPause();
}
@override
Duration get position => player.state.position;
@override
Stream<Duration> get positionStream => player.stream.position;
@override
Future<void> seek(Duration position, {int? index}) async {
if (index != null) {
await player.jump(index);
}
await player.seek(position);
}
@override
Future<void> setPlayList(
List<Uri> playlist, {
int? index,
Duration? position,
}) async {
await player.open(
Playlist(
playlist.map((uri) => Media(uri.toString())).toList(),
index: index ?? 0,
),
play: false,
);
// open方法加载完成
// ignore: unnecessary_null_comparison
await player.stream.duration.firstWhere((d) => d != null);
if (position != null) {
await player.seek(position);
}
}
@override
Future<void> setSpeed(double speed) async {
await player.setRate(speed);
}
@override
Future<void> setVolume(double volume) async {
await player.setVolume(volume);
}
@override
Stream<bool> get playingStream => player.stream.playing;
@override
// TODO: implement speed
double get speed => player.state.rate;
@override
// TODO: implement bufferedPosition
Duration get bufferedPosition => player.state.buffer;
@override
// TODO: implement bufferedPositionStream
Stream<Duration> get bufferedPositionStream => player.stream.buffer;
}

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:vaani/features/you/view/server_manager.dart';
import 'package:vaani/router/router.dart';
@ -26,12 +25,12 @@ class MyDrawer extends StatelessWidget {
ListTile(
title: const Text('server Settings'),
onTap: () {
Navigator.of(context).push(
platformPageRoute(
context: context,
builder: (context) => const ServerManagerPage(),
),
);
// Navigator.of(context).push(
// PageRoute(
// context: context,
// builder: (context) => const ServerManagerPage(),
// ),
// );
},
),
ListTile(

View file

@ -12,12 +12,9 @@ import 'package:vaani/api/library_item_provider.dart' show libraryItemProvider;
import 'package:vaani/constants/hero_tag_conventions.dart';
import 'package:vaani/features/item_viewer/view/library_item_actions.dart';
import 'package:vaani/features/player/providers/abs_provider.dart';
import 'package:vaani/features/player/providers/currently_playing_provider.dart';
import 'package:vaani/features/player/providers/player_status_provider.dart';
import 'package:vaani/features/player/providers/audiobook_player.dart';
import 'package:vaani/features/settings/app_settings_provider.dart';
import 'package:vaani/router/models/library_item_extras.dart';
import 'package:vaani/router/router.dart';
import 'package:vaani/features/settings/app_settings_provider.dart';
import 'package:vaani/shared/extensions/model_conversions.dart';
import 'package:vaani/shared/widgets/shelves/home_shelf.dart';
import 'package:vaani/theme/providers/theme_from_cover_provider.dart';
@ -215,11 +212,8 @@ class _BookOnShelfPlayButton extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final me = ref.watch(meProvider);
// final player = ref.watch(audiobookPlayerProvider);
final currentBook = ref.watch(absStateProvider.select((v) => v.book));
final playing = ref.watch(absStateProvider.select((v) => v.playing));
// final playerStatus = ref.watch(playerStatusProvider);
// final isLoading = playerStatus.isLoading(libraryItemId);
final currentBook = ref.watch(currentBookProvider);
final playing = ref.watch(playerStateProvider.select((v) => v.playing));
final isCurrentBookSetInPlayer =
currentBook?.libraryItemId == libraryItemId;
final isPlayingThisBook = playing && isCurrentBookSetInPlayer;
@ -300,9 +294,9 @@ class _BookOnShelfPlayButton extends HookConsumerWidget {
// book.media.asBookExpanded,
// userProgress?.currentTime,
// );
ref.read(absStateProvider.notifier).load(
ref.read(absAudioPlayerProvider.notifier).load(
book.media.asBookExpanded,
userProgress?.currentTime,
initialPosition: userProgress?.currentTime,
);
},
icon: Hero(
@ -355,7 +349,7 @@ class BookCoverWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentBook = ref.watch(absStateProvider.select((v) => v.book));
final currentBook = ref.watch(currentBookProvider);
if (currentBook == null) {
return const BookCoverSkeleton();
}

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:tray_manager/tray_manager.dart';
import 'package:vaani/features/player/providers/audiobook_player.dart';
import 'package:vaani/features/player/providers/abs_provider.dart';
import 'package:vaani/globals.dart';
import 'package:vaani/shared/utils/helper.dart';
import 'package:window_manager/window_manager.dart';
@ -46,17 +46,17 @@ class _TrayManagerState extends ConsumerState<TrayManager>
MenuItem(
key: 'play_pause',
label: '播放/暂停',
onClick: (menuItem) => ref.read(playerProvider).togglePlayPause(),
onClick: (menuItem) => ref.read(absAudioPlayerProvider).playOrPause(),
),
MenuItem(
key: 'previous',
label: '上一个',
onClick: (menuItem) => ref.read(playerProvider).skipToPrevious(),
onClick: (menuItem) => ref.read(absAudioPlayerProvider).previous(),
),
MenuItem(
key: 'next',
label: '下一个',
onClick: (menuItem) => ref.read(playerProvider).skipToNext(),
onClick: (menuItem) => ref.read(absAudioPlayerProvider).next(),
),
MenuItem.separator(),
MenuItem(