diff --git a/lib/constants/sizes.dart b/lib/constants/sizes.dart index ad45272..b8f3c20 100644 --- a/lib/constants/sizes.dart +++ b/lib/constants/sizes.dart @@ -12,4 +12,6 @@ class AppElementSizes { static const double iconSizeRegular = 48.0; static const double iconSizeSmall = 36.0; static const double iconSizeLarge = 64.0; + + static const double barHeight = 3.0; } diff --git a/lib/features/player/core/audiobook_player.dart b/lib/features/player/core/audiobook_player.dart index 568630b..9aa349b 100644 --- a/lib/features/player/core/audiobook_player.dart +++ b/lib/features/player/core/audiobook_player.dart @@ -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? _downloadedUris; /// sets the current [AudioTrack] as the source of the player Future setSourceAudiobook( BookExpanded? book, { bool preload = true, - // int? initialIndex, + int? initialIndex, Duration? initialPosition, List? 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 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 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 seekInBook(Duration? positionInBook, {int? index}) async { + Future 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 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 seekToNext() { - if (_currentIndex >= availableTracks!) { - return super.seek(duration); + Future 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 seekToPrevious() { - if (_currentIndex == 0) { - return super.seek(Duration()); + Future 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 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 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 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; + } +} diff --git a/lib/features/player/core/audiobook_player_session.dart b/lib/features/player/core/audiobook_player_session.dart new file mode 100644 index 0000000..eba1622 --- /dev/null +++ b/lib/features/player/core/audiobook_player_session.dart @@ -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 _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 setSourceAudiobook( + BookExpanded audiobook, { + Duration? initialPosition, + List? 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 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 play() => _player.play(); + + @override + Future pause() => _player.pause(); + + // 重写上一曲/下一曲为章节导航 + @override + Future 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 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 seek(Duration position) async { + // 这个 position 是当前音轨内的位置,我们不直接使用 + // 而是通过全局位置来控制 + final track = currentTrack; + Duration startOffset = Duration.zero; + if (track != null) { + startOffset = track.startOffset; + } + await seekToPosition(startOffset + position); + } + + // 核心功能:跳转到全局时间位置 + Future 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? 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; + } +} diff --git a/lib/features/player/core/init.dart b/lib/features/player/core/init.dart index 7b6c0f7..abcf013 100644 --- a/lib/features/player/core/init.dart +++ b/lib/features/player/core/init.dart @@ -9,7 +9,7 @@ import 'package:vaani/settings/models/app_settings.dart'; Future 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; diff --git a/lib/features/player/providers/audiobook_player.dart b/lib/features/player/providers/audiobook_player.dart index 91b6483..98eb38b 100644 --- a/lib/features/player/providers/audiobook_player.dart +++ b/lib/features/player/providers/audiobook_player.dart @@ -1,3 +1,4 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:vaani/api/api_provider.dart'; @@ -51,3 +52,12 @@ class AudiobookPlayer extends _$AudiobookPlayer { ref.notifyListeners(); } } + +@riverpod +bool isPlayerPlaying( + Ref ref, +) { + final player = ref.watch(audiobookPlayerProvider); + print("playing: ${player.playing}"); + return player.playing; +} diff --git a/lib/features/player/providers/audiobook_player.g.dart b/lib/features/player/providers/audiobook_player.g.dart index d38dd30..a3af070 100644 --- a/lib/features/player/providers/audiobook_player.g.dart +++ b/lib/features/player/providers/audiobook_player.g.dart @@ -6,6 +6,23 @@ part of 'audiobook_player.dart'; // RiverpodGenerator // ************************************************************************** +String _$isPlayerPlayingHash() => r'b81fa9cfb51c88c8d9e8f5c1f4f6a12d9e5a0cc1'; + +/// See also [isPlayerPlaying]. +@ProviderFor(isPlayerPlaying) +final isPlayerPlayingProvider = AutoDisposeProvider.internal( + isPlayerPlaying, + name: r'isPlayerPlayingProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$isPlayerPlayingHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef IsPlayerPlayingRef = AutoDisposeProviderRef; String _$simpleAudiobookPlayerHash() => r'5e94bbff4314adceb5affa704fc4d079d4016afa'; diff --git a/lib/features/player/providers/player_form.dart b/lib/features/player/providers/player_form.dart index 10376ee..5c47ac1 100644 --- a/lib/features/player/providers/player_form.dart +++ b/lib/features/player/providers/player_form.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:miniplayer/miniplayer.dart'; +// import 'package:miniplayer/miniplayer.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:vaani/features/player/providers/audiobook_player.dart'; @@ -60,7 +60,7 @@ double playerHeight( return playerExpandProgress.value; } -final audioBookMiniplayerController = MiniplayerController(); +// final audioBookMiniplayerController = MiniplayerController(); @Riverpod(keepAlive: true) bool isPlayerActive( diff --git a/lib/features/player/providers/player_providers.dart b/lib/features/player/providers/player_providers.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/features/player/providers/player_providers.dart @@ -0,0 +1 @@ + diff --git a/lib/features/player/view/audiobook_player.dart b/lib/features/player/view/audiobook_player.dart index 134cd04..df1b105 100644 --- a/lib/features/player/view/audiobook_player.dart +++ b/lib/features/player/view/audiobook_player.dart @@ -1,271 +1,195 @@ -import 'dart:math'; +// import 'dart:math'; -import 'package:audio_video_progress_bar/audio_video_progress_bar.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:miniplayer/miniplayer.dart'; -import 'package:vaani/api/image_provider.dart'; -import 'package:vaani/api/library_item_provider.dart'; -import 'package:vaani/features/player/providers/audiobook_player.dart'; -import 'package:vaani/features/player/providers/currently_playing_provider.dart'; -import 'package:vaani/features/player/providers/player_form.dart'; -import 'package:vaani/settings/app_settings_provider.dart'; -import 'package:vaani/shared/extensions/inverse_lerp.dart'; -import 'package:vaani/shared/widgets/shelves/book_shelf.dart'; -import 'package:vaani/theme/providers/theme_from_cover_provider.dart'; +// import 'package:flutter/material.dart'; +// import 'package:flutter_hooks/flutter_hooks.dart'; +// import 'package:hooks_riverpod/hooks_riverpod.dart'; +// import 'package:just_audio/just_audio.dart'; +// import 'package:miniplayer/miniplayer.dart'; +// import 'package:vaani/api/image_provider.dart'; +// import 'package:vaani/api/library_item_provider.dart'; +// import 'package:vaani/features/player/providers/audiobook_player.dart'; +// import 'package:vaani/features/player/providers/currently_playing_provider.dart'; +// import 'package:vaani/features/player/providers/player_form.dart'; +// import 'package:vaani/settings/app_settings_provider.dart'; +// import 'package:vaani/shared/extensions/inverse_lerp.dart'; +// import 'package:vaani/shared/widgets/shelves/book_shelf.dart'; +// import 'package:vaani/theme/providers/theme_from_cover_provider.dart'; -import 'player_when_expanded.dart'; -import 'player_when_minimized.dart'; +// import 'player_when_expanded.dart'; +// import 'player_when_minimized.dart'; -const playerMaxHeightPercentOfScreen = 0.8; +// const playerMaxHeightPercentOfScreen = 0.8; -class AudiobookPlayer extends HookConsumerWidget { - const AudiobookPlayer({super.key}); +// class AudiobookPlayer extends HookConsumerWidget { +// const AudiobookPlayer({super.key}); - @override - Widget build(BuildContext context, WidgetRef ref) { - final appSettings = ref.watch(appSettingsProvider); - final currentBook = ref.watch(currentlyPlayingBookProvider); - if (currentBook == null) { - return const SizedBox.shrink(); - } - final itemBeingPlayed = - ref.watch(libraryItemProvider(currentBook.libraryItemId)); - final player = ref.watch(audiobookPlayerProvider); - final imageOfItemBeingPlayed = itemBeingPlayed.valueOrNull != null - ? ref.watch( - coverImageProvider(itemBeingPlayed.valueOrNull!.id), - ) - : null; - final imgWidget = imageOfItemBeingPlayed?.valueOrNull != null - ? Image.memory( - imageOfItemBeingPlayed!.valueOrNull!, - fit: BoxFit.cover, - ) - : const BookCoverSkeleton(); +// @override +// Widget build(BuildContext context, WidgetRef ref) { +// final appSettings = ref.watch(appSettingsProvider); +// final currentBook = ref.watch(currentlyPlayingBookProvider); +// if (currentBook == null) { +// return const SizedBox.shrink(); +// } +// final itemBeingPlayed = +// ref.watch(libraryItemProvider(currentBook.libraryItemId)); +// final player = ref.watch(audiobookPlayerProvider); +// final imageOfItemBeingPlayed = itemBeingPlayed.valueOrNull != null +// ? ref.watch( +// coverImageProvider(itemBeingPlayed.valueOrNull!.id), +// ) +// : null; +// final imgWidget = imageOfItemBeingPlayed?.valueOrNull != null +// ? Image.memory( +// imageOfItemBeingPlayed!.valueOrNull!, +// fit: BoxFit.cover, +// ) +// : const BookCoverSkeleton(); - final playPauseController = useAnimationController( - duration: const Duration(milliseconds: 200), - initialValue: 1, - ); +// final playPauseController = useAnimationController( +// duration: const Duration(milliseconds: 200), +// initialValue: 1, +// ); - // add controller to the player state listener - player.playerStateStream.listen((state) { - if (state.playing) { - playPauseController.forward(); - } else { - playPauseController.reverse(); - } - }); +// // add controller to the player state listener +// player.playerStateStream.listen((state) { +// if (state.playing) { +// playPauseController.forward(); +// } else { +// playPauseController.reverse(); +// } +// }); - // theme from image - final imageTheme = ref.watch( - themeOfLibraryItemProvider( - itemBeingPlayed.valueOrNull?.id, - brightness: Theme.of(context).brightness, - highContrast: appSettings.themeSettings.highContrast || - MediaQuery.of(context).highContrast, - ), - ); +// // theme from image +// final imageTheme = ref.watch( +// themeOfLibraryItemProvider( +// itemBeingPlayed.valueOrNull?.id, +// brightness: Theme.of(context).brightness, +// highContrast: appSettings.themeSettings.highContrast || +// MediaQuery.of(context).highContrast, +// ), +// ); - // max height of the player is the height of the screen - final playerMaxHeight = MediaQuery.of(context).size.height; +// // max height of the player is the height of the screen +// final playerMaxHeight = MediaQuery.of(context).size.height; - final availWidth = MediaQuery.of(context).size.width; +// final availWidth = MediaQuery.of(context).size.width; - // the image width when the player is expanded - final maxImgSize = min(playerMaxHeight * 0.5, availWidth * 0.9); +// // the image width when the player is expanded +// final maxImgSize = min(playerMaxHeight * 0.5, availWidth * 0.9); - final preferredVolume = appSettings.playerSettings.preferredDefaultVolume; - return Theme( - data: ThemeData( - colorScheme: imageTheme.valueOrNull ?? Theme.of(context).colorScheme, - ), - child: Miniplayer( - valueNotifier: ref.watch(playerExpandProgressNotifierProvider), - onDragDown: (percentage) async { - // preferred volume - // set volume to 0 when dragging down - await player - .setVolume(preferredVolume * (1 - percentage.clamp(0, .75))); - }, - minHeight: playerMinHeight, - // subtract the height of notches and other system UI - maxHeight: playerMaxHeight, - controller: audioBookMiniplayerController, - elevation: 4, - // duration: Duration(seconds: 3), - onDismissed: () { - // add a delay before closing the player - // to allow the user to see the player closing - Future.delayed(const Duration(milliseconds: 300), () { - player.setSourceAudiobook(null); - }); - }, - curve: Curves.linear, - builder: (height, percentage) { - // at what point should the player switch from miniplayer to expanded player - // also at this point the image should be at its max size and in the center of the player - final miniplayerPercentageDeclaration = - (maxImgSize - playerMinHeight) / - (playerMaxHeight - playerMinHeight); - final bool isFormMiniplayer = - percentage < miniplayerPercentageDeclaration; +// final preferredVolume = appSettings.playerSettings.preferredDefaultVolume; +// return Theme( +// data: ThemeData( +// colorScheme: imageTheme.valueOrNull ?? Theme.of(context).colorScheme, +// ), +// child: Miniplayer( +// valueNotifier: ref.watch(playerExpandProgressNotifierProvider), +// onDragDown: (percentage) async { +// // preferred volume +// // set volume to 0 when dragging down +// await player +// .setVolume(preferredVolume * (1 - percentage.clamp(0, .75))); +// }, +// minHeight: playerMinHeight, +// // subtract the height of notches and other system UI +// maxHeight: playerMaxHeight, +// controller: audioBookMiniplayerController, +// elevation: 4, +// // duration: Duration(seconds: 3), +// onDismissed: () { +// // add a delay before closing the player +// // to allow the user to see the player closing +// Future.delayed(const Duration(milliseconds: 300), () { +// player.setSourceAudiobook(null); +// }); +// }, +// curve: Curves.linear, +// builder: (height, percentage) { +// // at what point should the player switch from miniplayer to expanded player +// // also at this point the image should be at its max size and in the center of the player +// final miniplayerPercentageDeclaration = +// (maxImgSize - playerMinHeight) / +// (playerMaxHeight - playerMinHeight); +// final bool isFormMiniplayer = +// percentage < miniplayerPercentageDeclaration; - if (!isFormMiniplayer) { - // this calculation needs a refactor - var percentageExpandedPlayer = percentage - .inverseLerp( - miniplayerPercentageDeclaration, - 1, - ) - .clamp(0.0, 1.0); +// if (!isFormMiniplayer) { +// // this calculation needs a refactor +// var percentageExpandedPlayer = percentage +// .inverseLerp( +// miniplayerPercentageDeclaration, +// 1, +// ) +// .clamp(0.0, 1.0); - return PlayerWhenExpanded( - imageSize: maxImgSize, - img: imgWidget, - percentageExpandedPlayer: percentageExpandedPlayer, - playPauseController: playPauseController, - ); - } +// return PlayerWhenExpanded( +// imageSize: maxImgSize, +// img: imgWidget, +// percentageExpandedPlayer: percentageExpandedPlayer, +// playPauseController: playPauseController, +// ); +// } - //Miniplayer - final percentageMiniplayer = percentage.inverseLerp( - 0, - miniplayerPercentageDeclaration, - ); +// //Miniplayer +// final percentageMiniplayer = percentage.inverseLerp( +// 0, +// miniplayerPercentageDeclaration, +// ); - return PlayerWhenMinimized( - maxImgSize: maxImgSize, - availWidth: availWidth, - imgWidget: imgWidget, - playPauseController: playPauseController, - percentageMiniplayer: percentageMiniplayer, - ); - }, - ), - ); - } -} +// return PlayerWhenMinimized( +// maxImgSize: maxImgSize, +// availWidth: availWidth, +// imgWidget: imgWidget, +// playPauseController: playPauseController, +// percentageMiniplayer: percentageMiniplayer, +// ); +// }, +// ), +// ); +// } +// } -class AudiobookPlayerPlayPauseButton extends HookConsumerWidget { - const AudiobookPlayerPlayPauseButton({ - super.key, - required this.playPauseController, - this.iconSize = 48.0, - }); +// class AudiobookPlayerPlayPauseButton extends HookConsumerWidget { +// const AudiobookPlayerPlayPauseButton({ +// super.key, +// required this.playPauseController, +// this.iconSize = 48.0, +// }); - final double iconSize; - final AnimationController playPauseController; - @override - Widget build(BuildContext context, WidgetRef ref) { - final player = ref.watch(audiobookPlayerProvider); +// final double iconSize; +// final AnimationController playPauseController; +// @override +// Widget build(BuildContext context, WidgetRef ref) { +// final player = ref.watch(audiobookPlayerProvider); - return switch (player.processingState) { - ProcessingState.loading || ProcessingState.buffering => const Padding( - padding: EdgeInsets.all(8.0), - child: CircularProgressIndicator(), - ), - ProcessingState.completed => IconButton( - onPressed: () async { - await player.seekInBook(const Duration(seconds: 0)); - await player.play(); - }, - icon: const Icon( - Icons.replay, - ), - ), - ProcessingState.ready => IconButton( - onPressed: () async { - await player.togglePlayPause(); - }, - iconSize: iconSize, - icon: AnimatedIcon( - icon: AnimatedIcons.play_pause, - progress: playPauseController, - ), - ), - ProcessingState.idle => const SizedBox.shrink(), - }; - } -} +// return switch (player.processingState) { +// ProcessingState.loading || ProcessingState.buffering => const Padding( +// padding: EdgeInsets.all(8.0), +// child: CircularProgressIndicator(), +// ), +// ProcessingState.completed => IconButton( +// onPressed: () async { +// await player.seekInBook(const Duration(seconds: 0)); +// await player.play(); +// }, +// icon: const Icon( +// Icons.replay, +// ), +// ), +// ProcessingState.ready => IconButton( +// onPressed: () async { +// await player.togglePlayPause(); +// }, +// iconSize: iconSize, +// icon: AnimatedIcon( +// icon: AnimatedIcons.play_pause, +// progress: playPauseController, +// ), +// ), +// ProcessingState.idle => const SizedBox.shrink(), +// }; +// } +// } -class AudiobookChapterProgressBar extends HookConsumerWidget { - const AudiobookChapterProgressBar({ - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final player = ref.watch(audiobookPlayerProvider); - final currentChapter = ref.watch(currentPlayingChapterProvider); - final position = useStream( - player.positionStreamInBook, - initialData: const Duration(seconds: 0), - ); - final buffered = useStream( - player.bufferedPositionStreamInBook, - initialData: const Duration(seconds: 0), - ); - - // now find the chapter that corresponds to the current time - // and calculate the progress of the current chapter - final currentChapterProgress = currentChapter == null - ? null - : (player.positionInBook - currentChapter.start); - - final currentChapterBuffered = currentChapter == null - ? null - : (player.bufferedPositionInBook - currentChapter.start); - - return ProgressBar( - progress: - currentChapterProgress ?? position.data ?? const Duration(seconds: 0), - total: currentChapter == null - ? player.book?.duration ?? const Duration(seconds: 0) - : currentChapter.end - currentChapter.start, - // ! TODO add onSeek - onSeek: (duration) { - player.seekInBook( - duration + (currentChapter?.start ?? const Duration(seconds: 0)), - ); - // player.seek(duration); - }, - thumbRadius: 8, - buffered: - currentChapterBuffered ?? buffered.data ?? const Duration(seconds: 0), - bufferedBarColor: Theme.of(context).colorScheme.secondary, - timeLabelType: TimeLabelType.remainingTime, - timeLabelLocation: TimeLabelLocation.below, - ); - } -} - -class AudiobookProgressBar extends HookConsumerWidget { - const AudiobookProgressBar({ - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final player = ref.watch(audiobookPlayerProvider); - final position = useStream( - player.slowPositionStreamInBook, - initialData: const Duration(seconds: 0), - ); - - return ProgressBar( - progress: position.data ?? const Duration(seconds: 0), - total: player.book?.duration ?? const Duration(seconds: 0), - thumbRadius: 8, - bufferedBarColor: Theme.of(context).colorScheme.secondary, - timeLabelType: TimeLabelType.remainingTime, - timeLabelLocation: TimeLabelLocation.below, - ); - } -} - -// ! TODO remove onTap -void onTap() {} +// // ! TODO remove onTap +// void onTap() {} diff --git a/lib/features/player/view/player_expanded.dart b/lib/features/player/view/player_expanded.dart new file mode 100644 index 0000000..b60213f --- /dev/null +++ b/lib/features/player/view/player_expanded.dart @@ -0,0 +1,227 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:vaani/api/image_provider.dart'; +import 'package:vaani/api/library_item_provider.dart'; +import 'package:vaani/constants/sizes.dart'; +import 'package:vaani/features/player/providers/currently_playing_provider.dart'; +import 'package:vaani/features/player/view/widgets/player_player_pause_button.dart'; +import 'package:vaani/features/player/view/widgets/player_progress_bar.dart'; +import 'package:vaani/features/skip_start_end/player_skip_chapter_start_end.dart'; +import 'package:vaani/features/sleep_timer/view/sleep_timer_button.dart'; +import 'package:vaani/shared/widgets/not_implemented.dart'; +import 'package:vaani/shared/widgets/shelves/book_shelf.dart'; + +import 'widgets/audiobook_player_seek_button.dart'; +import 'widgets/audiobook_player_seek_chapter_button.dart'; +import 'widgets/chapter_selection_button.dart'; +import 'widgets/player_speed_adjust_button.dart'; + +var pendingPlayerModals = 0; + +class PlayerExpanded extends HookConsumerWidget { + const PlayerExpanded({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + /// all the properties that help in building the widget are calculated from the [percentageExpandedPlayer] + /// however, some properties need to start later than 0% and end before 100% + final currentBook = ref.watch(currentlyPlayingBookProvider); + if (currentBook == null) { + return const SizedBox.shrink(); + } + final currentChapter = ref.watch(currentPlayingChapterProvider); + final currentBookMetadata = ref.watch(currentBookMetadataProvider); + // max height of the player is the height of the screen + final playerMaxHeight = MediaQuery.of(context).size.height; + final availWidth = MediaQuery.of(context).size.width; + // the image width when the player is expanded + final imageSize = min(playerMaxHeight * 0.5, availWidth * 0.9); + final itemBeingPlayed = + ref.watch(libraryItemProvider(currentBook.libraryItemId)); + final imageOfItemBeingPlayed = itemBeingPlayed.valueOrNull != null + ? ref.watch( + coverImageProvider(itemBeingPlayed.valueOrNull!.id), + ) + : null; + final imgWidget = imageOfItemBeingPlayed?.valueOrNull != null + ? Image.memory( + imageOfItemBeingPlayed!.valueOrNull!, + fit: BoxFit.cover, + ) + : const BookCoverSkeleton(); + return Scaffold( + appBar: AppBar( + leading: IconButton( + iconSize: 30, + icon: const Icon(Icons.keyboard_arrow_down), + onPressed: () => context.pop(), + ), + actions: [ + IconButton( + icon: const Icon(Icons.cast), + onPressed: () { + showNotImplementedToast(context); + }, + ), + ], + ), + body: Column( + children: [ + // sized box for system status bar; not needed as not full screen + SizedBox( + height: MediaQuery.of(context).padding.top, + ), + + // the image + Padding( + padding: EdgeInsets.only(top: AppElementSizes.paddingLarge), + child: Align( + alignment: Alignment.center, + // add a shadow to the image elevation hovering effect + child: Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.1), + blurRadius: 32, + spreadRadius: 8, + ), + ], + ), + child: SizedBox( + height: imageSize, + child: InkWell( + onTap: () {}, + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppElementSizes.borderRadiusRegular, + ), + child: imgWidget, + ), + ), + ), + ), + ), + ), + + // the chapter title + Expanded( + child: Padding( + padding: EdgeInsets.only(top: AppElementSizes.paddingRegular), + child: currentChapter == null + ? const SizedBox() + : Text( + currentChapter.title, + style: Theme.of(context).textTheme.titleLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + + // the book name and author + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: AppElementSizes.paddingRegular), + child: Text( + [ + currentBookMetadata?.title ?? '', + currentBookMetadata?.authorName ?? '', + ].join(' - '), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + + // the progress bar + Expanded( + child: SizedBox( + width: imageSize, + child: Padding( + padding: EdgeInsets.only( + left: AppElementSizes.paddingRegular, + right: AppElementSizes.paddingRegular, + ), + child: const AudiobookChapterProgressBar(), + ), + ), + ), + + Expanded( + child: SizedBox( + width: imageSize, + child: Padding( + padding: EdgeInsets.only( + left: AppElementSizes.paddingRegular, + right: AppElementSizes.paddingRegular, + ), + child: const AudiobookProgressBar(), + ), + ), + ), + + // the chapter skip buttons, seek 30 seconds back and forward, and play/pause button + Expanded( + flex: 2, + child: SizedBox( + width: imageSize, + height: AppElementSizes.iconSizeRegular, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // previous chapter + const AudiobookPlayerSeekChapterButton(isForward: false), + // buttonSkipBackwards + const AudiobookPlayerSeekButton(isForward: false), + AudiobookPlayerPlayPauseButton(), + // buttonSkipForwards + const AudiobookPlayerSeekButton(isForward: true), + // next chapter + const AudiobookPlayerSeekChapterButton(isForward: true), + ], + ), + ), + ), + + // speed control, sleep timer, chapter list, and settings + Expanded( + child: SizedBox( + width: imageSize, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // speed control + const PlayerSpeedAdjustButton(), + const Spacer(), + // sleep timer + const SleepTimerButton(), + const Spacer(), + // chapter list + const ChapterSelectionButton(), + const Spacer(), + // 跳过片头片尾 + SkipChapterStartEndButton(), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/player/view/player_minimized.dart b/lib/features/player/view/player_minimized.dart new file mode 100644 index 0000000..409176e --- /dev/null +++ b/lib/features/player/view/player_minimized.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/src/widgets/framework.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:vaani/api/image_provider.dart'; +import 'package:vaani/api/library_item_provider.dart'; +import 'package:vaani/constants/sizes.dart'; +import 'package:vaani/features/player/providers/audiobook_player.dart'; +import 'package:vaani/features/player/providers/currently_playing_provider.dart'; +import 'package:vaani/features/player/view/widgets/player_player_pause_button.dart'; +import 'package:vaani/router/router.dart'; +import 'package:vaani/shared/widgets/shelves/book_shelf.dart'; + +/// The height of the player when it is minimized +const double playerMinimizedHeight = 70; + +class PlayerMinimized extends HookConsumerWidget { + const PlayerMinimized({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentBook = ref.watch(currentlyPlayingBookProvider); + if (currentBook == null) { + return const SizedBox.shrink(); + } + final itemBeingPlayed = + ref.watch(libraryItemProvider(currentBook.libraryItemId)); + final imageOfItemBeingPlayed = itemBeingPlayed.valueOrNull != null + ? ref.watch( + coverImageProvider(itemBeingPlayed.valueOrNull!.id), + ) + : null; + final imgWidget = imageOfItemBeingPlayed?.valueOrNull != null + ? Image.memory( + imageOfItemBeingPlayed!.valueOrNull!, + fit: BoxFit.cover, + ) + : const BookCoverSkeleton(); + final bookMetaExpanded = ref.watch(currentBookMetadataProvider); + final currentChapter = ref.watch(currentPlayingChapterProvider); + + return PlayerMinimizedFramework( + children: [ + // image + Padding( + padding: EdgeInsets.all(AppElementSizes.paddingSmall), + child: InkWell( + onTap: () { + // navigate to item page + context.pushNamed( + Routes.libraryItem.name, + pathParameters: { + Routes.libraryItem.pathParamName!: currentBook.libraryItemId, + }, + ); + }, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: playerMinimizedHeight, + ), + child: imgWidget, + ), + ), + ), + // author and title of the book + Expanded( + child: Padding( + padding: const EdgeInsets.only( + left: AppElementSizes.paddingRegular, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + // AutoScrollText( + Text( + '${bookMetaExpanded?.title ?? ''} - ${currentChapter?.title ?? ''}', + maxLines: 1, overflow: TextOverflow.ellipsis, + // velocity: + // const Velocity(pixelsPerSecond: Offset(16, 0)), + style: Theme.of(context).textTheme.bodyLarge, + ), + Text( + bookMetaExpanded?.authorName ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ), + + // rewind button + Padding( + padding: const EdgeInsets.only(left: 8), + child: IconButton( + icon: const Icon( + Icons.replay_30, + size: AppElementSizes.iconSizeSmall, + ), + onPressed: () {}, + ), + ), + + // play/pause button + Padding( + padding: const EdgeInsets.only(right: 8), + child: AudiobookPlayerPlayPauseButton(), + ), + ], + ); + } +} + +class PlayerMinimizedFramework extends HookConsumerWidget { + final List children; + + const PlayerMinimizedFramework({super.key, required this.children}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = ref.watch(audiobookPlayerProvider); + final progress = + useStream(player.positionStream, initialData: Duration.zero); + return GestureDetector( + onTap: () => context.pushNamed(Routes.player.name), + child: Container( + height: playerMinimizedHeight, + color: Theme.of(context).colorScheme.surface, + child: Stack( + alignment: Alignment.topCenter, + children: [ + Row( + children: children, + ), + SizedBox( + height: AppElementSizes.barHeight, + child: LinearProgressIndicator( + // value: (progress.data ?? Duration.zero).inSeconds / + // player.book!.duration.inSeconds, + value: (progress.data ?? Duration.zero).inSeconds / + (player.duration?.inSeconds ?? 1), + color: Theme.of(context).colorScheme.onPrimaryContainer, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/player/view/player_when_expanded.dart b/lib/features/player/view/player_when_expanded.dart index bf437ae..2e021b7 100644 --- a/lib/features/player/view/player_when_expanded.dart +++ b/lib/features/player/view/player_when_expanded.dart @@ -1,292 +1,293 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:miniplayer/miniplayer.dart'; -import 'package:vaani/constants/sizes.dart'; -import 'package:vaani/features/player/providers/currently_playing_provider.dart'; -import 'package:vaani/features/player/providers/player_form.dart'; -import 'package:vaani/features/player/view/audiobook_player.dart'; -import 'package:vaani/features/skip_start_end/player_skip_chapter_start_end.dart'; -import 'package:vaani/features/sleep_timer/view/sleep_timer_button.dart'; -import 'package:vaani/shared/extensions/inverse_lerp.dart'; -import 'package:vaani/shared/widgets/not_implemented.dart'; +// import 'package:flutter/material.dart'; +// import 'package:hooks_riverpod/hooks_riverpod.dart'; +// import 'package:miniplayer/miniplayer.dart'; +// import 'package:vaani/constants/sizes.dart'; +// import 'package:vaani/features/player/providers/currently_playing_provider.dart'; +// import 'package:vaani/features/player/providers/player_form.dart'; +// import 'package:vaani/features/player/view/audiobook_player.dart'; +// import 'package:vaani/features/player/view/widgets/player_progress_bar.dart'; +// import 'package:vaani/features/skip_start_end/player_skip_chapter_start_end.dart'; +// import 'package:vaani/features/sleep_timer/view/sleep_timer_button.dart'; +// import 'package:vaani/shared/extensions/inverse_lerp.dart'; +// import 'package:vaani/shared/widgets/not_implemented.dart'; -import 'widgets/audiobook_player_seek_button.dart'; -import 'widgets/audiobook_player_seek_chapter_button.dart'; -import 'widgets/chapter_selection_button.dart'; -import 'widgets/player_speed_adjust_button.dart'; +// import 'widgets/audiobook_player_seek_button.dart'; +// import 'widgets/audiobook_player_seek_chapter_button.dart'; +// import 'widgets/chapter_selection_button.dart'; +// import 'widgets/player_speed_adjust_button.dart'; -var pendingPlayerModals = 0; +// var pendingPlayerModals = 0; -class PlayerWhenExpanded extends HookConsumerWidget { - const PlayerWhenExpanded({ - super.key, - required this.imageSize, - required this.img, - required this.percentageExpandedPlayer, - required this.playPauseController, - }); +// class PlayerWhenExpanded extends HookConsumerWidget { +// const PlayerWhenExpanded({ +// super.key, +// required this.imageSize, +// required this.img, +// required this.percentageExpandedPlayer, +// required this.playPauseController, +// }); - /// padding values control the position of the image - final double imageSize; - final Widget img; - final double percentageExpandedPlayer; - final AnimationController playPauseController; +// /// padding values control the position of the image +// final double imageSize; +// final Widget img; +// final double percentageExpandedPlayer; +// final AnimationController playPauseController; - @override - Widget build(BuildContext context, WidgetRef ref) { - /// all the properties that help in building the widget are calculated from the [percentageExpandedPlayer] - /// however, some properties need to start later than 0% and end before 100% - const lateStart = 0.4; - const earlyEnd = 1; - final earlyPercentage = percentageExpandedPlayer - .inverseLerp( - lateStart, - earlyEnd, - ) - .clamp(0.0, 1.0); - final currentChapter = ref.watch(currentPlayingChapterProvider); - final currentBookMetadata = ref.watch(currentBookMetadataProvider); +// @override +// Widget build(BuildContext context, WidgetRef ref) { +// /// all the properties that help in building the widget are calculated from the [percentageExpandedPlayer] +// /// however, some properties need to start later than 0% and end before 100% +// const lateStart = 0.4; +// const earlyEnd = 1; +// final earlyPercentage = percentageExpandedPlayer +// .inverseLerp( +// lateStart, +// earlyEnd, +// ) +// .clamp(0.0, 1.0); +// final currentChapter = ref.watch(currentPlayingChapterProvider); +// final currentBookMetadata = ref.watch(currentBookMetadataProvider); - return Column( - children: [ - // sized box for system status bar; not needed as not full screen - SizedBox( - height: MediaQuery.of(context).padding.top * earlyPercentage, - ), +// return Column( +// children: [ +// // sized box for system status bar; not needed as not full screen +// SizedBox( +// height: MediaQuery.of(context).padding.top * earlyPercentage, +// ), - // a row with a down arrow to minimize the player, a pill shaped container to drag the player, and a cast button - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: 100 * earlyPercentage, - ), - child: Opacity( - opacity: earlyPercentage, - child: Padding( - padding: EdgeInsets.only(top: 8.0 * earlyPercentage), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // the down arrow - IconButton( - iconSize: 30, - icon: const Icon(Icons.keyboard_arrow_down), - onPressed: () { - // minimize the player - audioBookMiniplayerController.animateToHeight( - state: PanelState.MIN, - ); - }, - ), +// // a row with a down arrow to minimize the player, a pill shaped container to drag the player, and a cast button +// ConstrainedBox( +// constraints: BoxConstraints( +// maxHeight: 100 * earlyPercentage, +// ), +// child: Opacity( +// opacity: earlyPercentage, +// child: Padding( +// padding: EdgeInsets.only(top: 8.0 * earlyPercentage), +// child: Row( +// crossAxisAlignment: CrossAxisAlignment.center, +// mainAxisSize: MainAxisSize.max, +// mainAxisAlignment: MainAxisAlignment.spaceBetween, +// children: [ +// // the down arrow +// IconButton( +// iconSize: 30, +// icon: const Icon(Icons.keyboard_arrow_down), +// onPressed: () { +// // minimize the player +// audioBookMiniplayerController.animateToHeight( +// state: PanelState.MIN, +// ); +// }, +// ), - // the cast button - IconButton( - icon: const Icon(Icons.cast), - onPressed: () { - showNotImplementedToast(context); - }, - ), - ], - ), - ), - ), - ), +// // the cast button +// IconButton( +// icon: const Icon(Icons.cast), +// onPressed: () { +// showNotImplementedToast(context); +// }, +// ), +// ], +// ), +// ), +// ), +// ), - // the image - Padding( - padding: EdgeInsets.only( - top: AppElementSizes.paddingLarge * earlyPercentage, - ), - child: Align( - alignment: Alignment.center, - // add a shadow to the image elevation hovering effect - child: Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: 0.1), - blurRadius: 32 * earlyPercentage, - spreadRadius: 8 * earlyPercentage, - // offset: Offset(0, 16 * earlyPercentage), - ), - ], - ), - child: SizedBox( - height: imageSize, - child: InkWell( - onTap: () {}, - child: ClipRRect( - borderRadius: BorderRadius.circular( - AppElementSizes.borderRadiusRegular * earlyPercentage, - ), - child: img, - ), - ), - ), - ), - ), - ), +// // the image +// Padding( +// padding: EdgeInsets.only( +// top: AppElementSizes.paddingLarge * earlyPercentage, +// ), +// child: Align( +// alignment: Alignment.center, +// // add a shadow to the image elevation hovering effect +// child: Container( +// decoration: BoxDecoration( +// boxShadow: [ +// BoxShadow( +// color: Theme.of(context) +// .colorScheme +// .primary +// .withValues(alpha: 0.1), +// blurRadius: 32 * earlyPercentage, +// spreadRadius: 8 * earlyPercentage, +// // offset: Offset(0, 16 * earlyPercentage), +// ), +// ], +// ), +// child: SizedBox( +// height: imageSize, +// child: InkWell( +// onTap: () {}, +// child: ClipRRect( +// borderRadius: BorderRadius.circular( +// AppElementSizes.borderRadiusRegular * earlyPercentage, +// ), +// child: img, +// ), +// ), +// ), +// ), +// ), +// ), - // the chapter title - Expanded( - child: Opacity( - opacity: earlyPercentage, - child: Padding( - padding: EdgeInsets.only( - top: AppElementSizes.paddingRegular * earlyPercentage, - // horizontal: 16.0, - ), - // child: SizedBox( - // same as the image width - // width: imageSize, - child: currentChapter == null - ? const SizedBox() - : Text( - currentChapter.title, - style: Theme.of(context).textTheme.titleLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - // ), - ), - ), - ), +// // the chapter title +// Expanded( +// child: Opacity( +// opacity: earlyPercentage, +// child: Padding( +// padding: EdgeInsets.only( +// top: AppElementSizes.paddingRegular * earlyPercentage, +// // horizontal: 16.0, +// ), +// // child: SizedBox( +// // same as the image width +// // width: imageSize, +// child: currentChapter == null +// ? const SizedBox() +// : Text( +// currentChapter.title, +// style: Theme.of(context).textTheme.titleLarge, +// maxLines: 1, +// overflow: TextOverflow.ellipsis, +// ), +// // ), +// ), +// ), +// ), - // the book name and author - Expanded( - child: Opacity( - opacity: earlyPercentage, - child: Padding( - padding: EdgeInsets.only( - bottom: AppElementSizes.paddingRegular * earlyPercentage, - // horizontal: 16.0, - ), - // child: SizedBox( - // same as the image width - // width: imageSize, - child: Text( - [ - currentBookMetadata?.title ?? '', - currentBookMetadata?.authorName ?? '', - ].join(' - '), - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.7), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - // ), - ), - ), - ), +// // the book name and author +// Expanded( +// child: Opacity( +// opacity: earlyPercentage, +// child: Padding( +// padding: EdgeInsets.only( +// bottom: AppElementSizes.paddingRegular * earlyPercentage, +// // horizontal: 16.0, +// ), +// // child: SizedBox( +// // same as the image width +// // width: imageSize, +// child: Text( +// [ +// currentBookMetadata?.title ?? '', +// currentBookMetadata?.authorName ?? '', +// ].join(' - '), +// style: Theme.of(context).textTheme.titleMedium?.copyWith( +// color: Theme.of(context) +// .colorScheme +// .onSurface +// .withValues(alpha: 0.7), +// ), +// maxLines: 1, +// overflow: TextOverflow.ellipsis, +// ), +// // ), +// ), +// ), +// ), - // the progress bar - Expanded( - child: Opacity( - opacity: earlyPercentage, - child: SizedBox( - width: imageSize, - child: Padding( - padding: EdgeInsets.only( - // top: AppElementSizes.paddingRegular * earlyPercentage, - left: AppElementSizes.paddingRegular * earlyPercentage, - right: AppElementSizes.paddingRegular * earlyPercentage, - ), - child: const AudiobookChapterProgressBar(), - ), - ), - ), - ), +// // the progress bar +// Expanded( +// child: Opacity( +// opacity: earlyPercentage, +// child: SizedBox( +// width: imageSize, +// child: Padding( +// padding: EdgeInsets.only( +// // top: AppElementSizes.paddingRegular * earlyPercentage, +// left: AppElementSizes.paddingRegular * earlyPercentage, +// right: AppElementSizes.paddingRegular * earlyPercentage, +// ), +// child: const AudiobookChapterProgressBar(), +// ), +// ), +// ), +// ), - Expanded( - child: Opacity( - opacity: earlyPercentage, - child: SizedBox( - width: imageSize, - child: Padding( - padding: EdgeInsets.only( - // top: AppElementSizes.paddingRegular * earlyPercentage, - left: AppElementSizes.paddingRegular * earlyPercentage, - right: AppElementSizes.paddingRegular * earlyPercentage, - ), - child: const AudiobookProgressBar(), - ), - ), - ), - ), +// Expanded( +// child: Opacity( +// opacity: earlyPercentage, +// child: SizedBox( +// width: imageSize, +// child: Padding( +// padding: EdgeInsets.only( +// // top: AppElementSizes.paddingRegular * earlyPercentage, +// left: AppElementSizes.paddingRegular * earlyPercentage, +// right: AppElementSizes.paddingRegular * earlyPercentage, +// ), +// child: const AudiobookProgressBar(), +// ), +// ), +// ), +// ), - // the chapter skip buttons, seek 30 seconds back and forward, and play/pause button - Expanded( - flex: 2, - child: Opacity( - opacity: earlyPercentage, - child: SizedBox( - width: imageSize, - height: AppElementSizes.iconSizeRegular, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // previous chapter - const AudiobookPlayerSeekChapterButton(isForward: false), - // buttonSkipBackwards - const AudiobookPlayerSeekButton(isForward: false), - AudiobookPlayerPlayPauseButton( - playPauseController: playPauseController, - ), - // buttonSkipForwards - const AudiobookPlayerSeekButton(isForward: true), - // next chapter - const AudiobookPlayerSeekChapterButton(isForward: true), - ], - ), - ), - ), - ), +// // the chapter skip buttons, seek 30 seconds back and forward, and play/pause button +// Expanded( +// flex: 2, +// child: Opacity( +// opacity: earlyPercentage, +// child: SizedBox( +// width: imageSize, +// height: AppElementSizes.iconSizeRegular, +// child: Row( +// mainAxisAlignment: MainAxisAlignment.spaceBetween, +// children: [ +// // previous chapter +// const AudiobookPlayerSeekChapterButton(isForward: false), +// // buttonSkipBackwards +// const AudiobookPlayerSeekButton(isForward: false), +// AudiobookPlayerPlayPauseButton( +// playPauseController: playPauseController, +// ), +// // buttonSkipForwards +// const AudiobookPlayerSeekButton(isForward: true), +// // next chapter +// const AudiobookPlayerSeekChapterButton(isForward: true), +// ], +// ), +// ), +// ), +// ), - // speed control, sleep timer, chapter list, and settings - Expanded( - child: Opacity( - opacity: earlyPercentage, - child: SizedBox( - // padding: EdgeInsets.only( - // bottom: AppElementSizes.paddingRegular * 4 * earlyPercentage, - // ), - width: imageSize, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - // speed control - const PlayerSpeedAdjustButton(), - const Spacer(), - // sleep timer - const SleepTimerButton(), - const Spacer(), - // chapter list - const ChapterSelectionButton(), - const Spacer(), - // 跳过片头片尾 - SkipChapterStartEndButton(), - // settings - // IconButton( - // icon: const Icon(Icons.more_horiz), - // onPressed: () { - // // show toast - // showNotImplementedToast(context); - // }, - // ), - ], - ), - ), - ), - ), - ], - ); - } -} +// // speed control, sleep timer, chapter list, and settings +// Expanded( +// child: Opacity( +// opacity: earlyPercentage, +// child: SizedBox( +// // padding: EdgeInsets.only( +// // bottom: AppElementSizes.paddingRegular * 4 * earlyPercentage, +// // ), +// width: imageSize, +// child: Row( +// mainAxisAlignment: MainAxisAlignment.spaceEvenly, +// children: [ +// // speed control +// const PlayerSpeedAdjustButton(), +// const Spacer(), +// // sleep timer +// const SleepTimerButton(), +// const Spacer(), +// // chapter list +// const ChapterSelectionButton(), +// const Spacer(), +// // 跳过片头片尾 +// SkipChapterStartEndButton(), +// // settings +// // IconButton( +// // icon: const Icon(Icons.more_horiz), +// // onPressed: () { +// // // show toast +// // showNotImplementedToast(context); +// // }, +// // ), +// ], +// ), +// ), +// ), +// ), +// ], +// ); +// } +// } diff --git a/lib/features/player/view/player_when_minimized.dart b/lib/features/player/view/player_when_minimized.dart index c8ff90c..e090a6a 100644 --- a/lib/features/player/view/player_when_minimized.dart +++ b/lib/features/player/view/player_when_minimized.dart @@ -1,155 +1,155 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:vaani/constants/sizes.dart'; -import 'package:vaani/features/player/providers/audiobook_player.dart'; -import 'package:vaani/features/player/providers/currently_playing_provider.dart'; -import 'package:vaani/features/player/view/audiobook_player.dart'; -import 'package:vaani/router/router.dart'; +// import 'package:flutter/material.dart'; +// import 'package:flutter_hooks/flutter_hooks.dart'; +// import 'package:go_router/go_router.dart'; +// import 'package:hooks_riverpod/hooks_riverpod.dart'; +// import 'package:vaani/constants/sizes.dart'; +// import 'package:vaani/features/player/providers/audiobook_player.dart'; +// import 'package:vaani/features/player/providers/currently_playing_provider.dart'; +// import 'package:vaani/features/player/view/audiobook_player.dart'; +// import 'package:vaani/router/router.dart'; -class PlayerWhenMinimized extends HookConsumerWidget { - const PlayerWhenMinimized({ - super.key, - required this.availWidth, - required this.maxImgSize, - required this.imgWidget, - required this.playPauseController, - required this.percentageMiniplayer, - }); +// class PlayerWhenMinimized extends HookConsumerWidget { +// const PlayerWhenMinimized({ +// super.key, +// required this.availWidth, +// required this.maxImgSize, +// required this.imgWidget, +// required this.playPauseController, +// required this.percentageMiniplayer, +// }); - final double availWidth; - final double maxImgSize; - final Widget imgWidget; - final AnimationController playPauseController; +// final double availWidth; +// final double maxImgSize; +// final Widget imgWidget; +// final AnimationController playPauseController; - /// 0 - 1, from minimized to when switched to expanded player - /// - /// by the time 1 is reached only image should be visible in the center of the widget - final double percentageMiniplayer; +// /// 0 - 1, from minimized to when switched to expanded player +// /// +// /// by the time 1 is reached only image should be visible in the center of the widget +// final double percentageMiniplayer; - @override - Widget build(BuildContext context, WidgetRef ref) { - final player = ref.watch(audiobookPlayerProvider); - final currentChapter = ref.watch(currentPlayingChapterProvider); +// @override +// Widget build(BuildContext context, WidgetRef ref) { +// final player = ref.watch(audiobookPlayerProvider); +// final currentChapter = ref.watch(currentPlayingChapterProvider); - final vanishingPercentage = 1 - percentageMiniplayer; - // final progress = - // useStream(player.slowPositionStreamInBook, initialData: Duration.zero); - final progress = - useStream(player.positionStream, initialData: Duration.zero); +// final vanishingPercentage = 1 - percentageMiniplayer; +// // final progress = +// // useStream(player.slowPositionStreamInBook, initialData: Duration.zero); +// final progress = +// useStream(player.positionStream, initialData: Duration.zero); - final bookMetaExpanded = ref.watch(currentBookMetadataProvider); +// final bookMetaExpanded = ref.watch(currentBookMetadataProvider); - var barHeight = vanishingPercentage * 3; +// var barHeight = vanishingPercentage * 3; - return Stack( - alignment: Alignment.topCenter, - children: [ - Row( - children: [ - // image - Padding( - padding: EdgeInsets.only( - left: ((availWidth - maxImgSize) / 2) * percentageMiniplayer, - ), - child: InkWell( - onTap: () { - // navigate to item page - context.pushNamed( - Routes.libraryItem.name, - pathParameters: { - Routes.libraryItem.pathParamName!: - player.book!.libraryItemId, - }, - ); - }, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: maxImgSize, - ), - child: imgWidget, - ), - ), - ), - // author and title of the book - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - // AutoScrollText( - Text( - '${bookMetaExpanded?.title ?? ''} - ${currentChapter?.title ?? ''}', - maxLines: 1, overflow: TextOverflow.ellipsis, - // velocity: - // const Velocity(pixelsPerSecond: Offset(16, 0)), - style: Theme.of(context).textTheme.bodyLarge, - ), - Text( - bookMetaExpanded?.authorName ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.7), - ), - ), - ], - ), - ), - ), - // IconButton( - // icon: const Icon(Icons.fullscreen), - // onPressed: () { - // controller.animateToHeight(state: PanelState.MAX); - // }, - // ), +// return Stack( +// alignment: Alignment.topCenter, +// children: [ +// Row( +// children: [ +// // image +// Padding( +// padding: EdgeInsets.only( +// left: ((availWidth - maxImgSize) / 2) * percentageMiniplayer, +// ), +// child: InkWell( +// onTap: () { +// // navigate to item page +// context.pushNamed( +// Routes.libraryItem.name, +// pathParameters: { +// Routes.libraryItem.pathParamName!: +// player.book!.libraryItemId, +// }, +// ); +// }, +// child: ConstrainedBox( +// constraints: BoxConstraints( +// maxWidth: maxImgSize, +// ), +// child: imgWidget, +// ), +// ), +// ), +// // author and title of the book +// Expanded( +// child: Padding( +// padding: const EdgeInsets.only(left: 8), +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// mainAxisAlignment: MainAxisAlignment.center, +// mainAxisSize: MainAxisSize.min, +// children: [ +// // AutoScrollText( +// Text( +// '${bookMetaExpanded?.title ?? ''} - ${currentChapter?.title ?? ''}', +// maxLines: 1, overflow: TextOverflow.ellipsis, +// // velocity: +// // const Velocity(pixelsPerSecond: Offset(16, 0)), +// style: Theme.of(context).textTheme.bodyLarge, +// ), +// Text( +// bookMetaExpanded?.authorName ?? '', +// maxLines: 1, +// overflow: TextOverflow.ellipsis, +// style: Theme.of(context).textTheme.bodyMedium!.copyWith( +// color: Theme.of(context) +// .colorScheme +// .onSurface +// .withValues(alpha: 0.7), +// ), +// ), +// ], +// ), +// ), +// ), +// // IconButton( +// // icon: const Icon(Icons.fullscreen), +// // onPressed: () { +// // controller.animateToHeight(state: PanelState.MAX); +// // }, +// // ), - // rewind button - Opacity( - opacity: vanishingPercentage, - child: Padding( - padding: const EdgeInsets.only(left: 8), - child: IconButton( - icon: const Icon( - Icons.replay_30, - size: AppElementSizes.iconSizeSmall, - ), - onPressed: () {}, - ), - ), - ), +// // rewind button +// Opacity( +// opacity: vanishingPercentage, +// child: Padding( +// padding: const EdgeInsets.only(left: 8), +// child: IconButton( +// icon: const Icon( +// Icons.replay_30, +// size: AppElementSizes.iconSizeSmall, +// ), +// onPressed: () {}, +// ), +// ), +// ), - // play/pause button - Opacity( - opacity: vanishingPercentage, - child: Padding( - padding: const EdgeInsets.only(right: 8), - child: AudiobookPlayerPlayPauseButton( - playPauseController: playPauseController, - ), - ), - ), - ], - ), - SizedBox( - height: barHeight, - child: LinearProgressIndicator( - // value: (progress.data ?? Duration.zero).inSeconds / - // player.book!.duration.inSeconds, - value: (progress.data ?? Duration.zero).inSeconds / - (player.duration?.inSeconds ?? 1), - color: Theme.of(context).colorScheme.onPrimaryContainer, - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - ), - ), - ], - ); - } -} +// // play/pause button +// Opacity( +// opacity: vanishingPercentage, +// child: Padding( +// padding: const EdgeInsets.only(right: 8), +// child: AudiobookPlayerPlayPauseButton( +// playPauseController: playPauseController, +// ), +// ), +// ), +// ], +// ), +// SizedBox( +// height: barHeight, +// child: LinearProgressIndicator( +// // value: (progress.data ?? Duration.zero).inSeconds / +// // player.book!.duration.inSeconds, +// value: (progress.data ?? Duration.zero).inSeconds / +// (player.duration?.inSeconds ?? 1), +// color: Theme.of(context).colorScheme.onPrimaryContainer, +// backgroundColor: Theme.of(context).colorScheme.primaryContainer, +// ), +// ), +// ], +// ); +// } +// } diff --git a/lib/features/player/view/widgets/audiobook_player_seek_chapter_button.dart b/lib/features/player/view/widgets/audiobook_player_seek_chapter_button.dart index 47536f3..749c66b 100644 --- a/lib/features/player/view/widgets/audiobook_player_seek_chapter_button.dart +++ b/lib/features/player/view/widgets/audiobook_player_seek_chapter_button.dart @@ -68,9 +68,9 @@ class AudiobookPlayerSeekChapterButton extends HookConsumerWidget { return; } if (isForward) { - player.seekForward(); + player.seekToNext(); } else { - player.seekBackward(); + player.seekToPrevious(); } }, ); diff --git a/lib/features/player/view/widgets/chapter_selection_button.dart b/lib/features/player/view/widgets/chapter_selection_button.dart index 58ca33a..a3d8740 100644 --- a/lib/features/player/view/widgets/chapter_selection_button.dart +++ b/lib/features/player/view/widgets/chapter_selection_button.dart @@ -5,7 +5,7 @@ import 'package:vaani/features/player/providers/audiobook_player.dart' show audiobookPlayerProvider; import 'package:vaani/features/player/providers/currently_playing_provider.dart' show currentPlayingChapterProvider, currentlyPlayingBookProvider; -import 'package:vaani/features/player/view/player_when_expanded.dart' +import 'package:vaani/features/player/view/player_expanded.dart' show pendingPlayerModals; import 'package:vaani/features/player/view/widgets/playing_indicator_icon.dart'; import 'package:vaani/main.dart' show appLogger; @@ -117,7 +117,8 @@ class ChapterSelectionModal extends HookConsumerWidget { key: isCurrent ? chapterKey : null, onTap: () { Navigator.of(context).pop(); - notifier.seekInBook(chapter.start + 90.ms); + // notifier.seekInBook(chapter.start + 90.ms); + notifier.skipToChapter(chapter.id); notifier.play(); }, ); diff --git a/lib/features/player/view/widgets/player_player_pause_button.dart b/lib/features/player/view/widgets/player_player_pause_button.dart new file mode 100644 index 0000000..ab26b65 --- /dev/null +++ b/lib/features/player/view/widgets/player_player_pause_button.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:vaani/constants/sizes.dart'; +import 'package:vaani/features/player/providers/audiobook_player.dart'; + +class AudiobookPlayerPlayPauseButton extends HookConsumerWidget { + const AudiobookPlayerPlayPauseButton({ + super.key, + this.iconSize = 48.0, + }); + + final double iconSize; + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = ref.watch(audiobookPlayerProvider); + final playing = ref.watch(isPlayerPlayingProvider); + final playPauseController = useAnimationController( + duration: const Duration(milliseconds: 200), + initialValue: 1, + ); + if (playing) { + playPauseController.forward(); + } else { + playPauseController.reverse(); + } + return switch (player.processingState) { + ProcessingState.loading || ProcessingState.buffering => const Padding( + padding: EdgeInsets.all(AppElementSizes.paddingRegular), + child: CircularProgressIndicator(), + ), + ProcessingState.completed => IconButton( + onPressed: () async { + await player.seekInBook(const Duration(seconds: 0)); + await player.play(); + }, + icon: const Icon( + Icons.replay, + ), + ), + ProcessingState.ready => IconButton( + onPressed: () async { + await player.togglePlayPause(); + }, + iconSize: iconSize, + icon: AnimatedIcon( + icon: AnimatedIcons.play_pause, + progress: playPauseController, + ), + ), + ProcessingState.idle => const SizedBox.shrink(), + }; + } +} diff --git a/lib/features/player/view/widgets/player_progress_bar.dart b/lib/features/player/view/widgets/player_progress_bar.dart new file mode 100644 index 0000000..d5f7818 --- /dev/null +++ b/lib/features/player/view/widgets/player_progress_bar.dart @@ -0,0 +1,82 @@ +import 'package:audio_video_progress_bar/audio_video_progress_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:vaani/features/player/providers/audiobook_player.dart'; +import 'package:vaani/features/player/providers/currently_playing_provider.dart'; + +class AudiobookChapterProgressBar extends HookConsumerWidget { + const AudiobookChapterProgressBar({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = ref.watch(audiobookPlayerProvider); + final currentChapter = ref.watch(currentPlayingChapterProvider); + final position = useStream( + player.positionStreamInBook, + initialData: const Duration(seconds: 0), + ); + final buffered = useStream( + player.bufferedPositionStreamInBook, + initialData: const Duration(seconds: 0), + ); + + // now find the chapter that corresponds to the current time + // and calculate the progress of the current chapter + final currentChapterProgress = currentChapter == null + ? null + : (player.positionInBook - currentChapter.start); + + final currentChapterBuffered = currentChapter == null + ? null + : (player.bufferedPositionInBook - currentChapter.start); + + return ProgressBar( + progress: + currentChapterProgress ?? position.data ?? const Duration(seconds: 0), + total: currentChapter == null + ? player.book?.duration ?? const Duration(seconds: 0) + : currentChapter.end - currentChapter.start, + // ! TODO add onSeek + onSeek: (duration) { + player.seekInBook( + duration + (currentChapter?.start ?? const Duration(seconds: 0)), + ); + // player.seek(duration); + }, + thumbRadius: 8, + buffered: + currentChapterBuffered ?? buffered.data ?? const Duration(seconds: 0), + bufferedBarColor: Theme.of(context).colorScheme.secondary, + timeLabelType: TimeLabelType.remainingTime, + timeLabelLocation: TimeLabelLocation.below, + ); + } +} + +class AudiobookProgressBar extends HookConsumerWidget { + const AudiobookProgressBar({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = ref.watch(audiobookPlayerProvider); + final position = useStream( + player.slowPositionStreamInBook, + initialData: const Duration(seconds: 0), + ); + + return ProgressBar( + progress: position.data ?? const Duration(seconds: 0), + total: player.book?.duration ?? const Duration(seconds: 0), + thumbRadius: 8, + bufferedBarColor: Theme.of(context).colorScheme.secondary, + timeLabelType: TimeLabelType.remainingTime, + timeLabelLocation: TimeLabelLocation.below, + ); + } +} diff --git a/lib/features/player/view/widgets/player_speed_adjust_button.dart b/lib/features/player/view/widgets/player_speed_adjust_button.dart index 36b7cd7..963ddd2 100644 --- a/lib/features/player/view/widgets/player_speed_adjust_button.dart +++ b/lib/features/player/view/widgets/player_speed_adjust_button.dart @@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:vaani/features/per_book_settings/providers/book_settings_provider.dart'; import 'package:vaani/features/player/providers/audiobook_player.dart'; -import 'package:vaani/features/player/view/player_when_expanded.dart'; +import 'package:vaani/features/player/view/player_expanded.dart'; import 'package:vaani/features/player/view/widgets/speed_selector.dart'; import 'package:vaani/settings/app_settings_provider.dart'; diff --git a/lib/features/skip_start_end/player_skip_chapter_start_end.dart b/lib/features/skip_start_end/player_skip_chapter_start_end.dart index b573579..9712bdc 100644 --- a/lib/features/skip_start_end/player_skip_chapter_start_end.dart +++ b/lib/features/skip_start_end/player_skip_chapter_start_end.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:vaani/features/per_book_settings/providers/book_settings_provider.dart'; import 'package:vaani/features/player/providers/audiobook_player.dart'; -import 'package:vaani/features/player/view/player_when_expanded.dart'; +import 'package:vaani/features/player/view/player_expanded.dart'; import 'package:vaani/settings/view/notification_settings_page.dart'; class SkipChapterStartEndButton extends HookConsumerWidget { diff --git a/lib/features/skip_start_end/skip_start_end.dart b/lib/features/skip_start_end/skip_start_end.dart index 3b130c9..fcdb9ef 100644 --- a/lib/features/skip_start_end/skip_start_end.dart +++ b/lib/features/skip_start_end/skip_start_end.dart @@ -1,18 +1,28 @@ import 'dart:async'; +import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:vaani/features/player/core/audiobook_player.dart'; +import 'package:vaani/shared/extensions/chapter.dart'; +import 'package:vaani/shared/utils/throttler.dart'; class SkipStartEnd { final Duration start; final Duration end; final AudiobookPlayer player; + // 当前章节的id + int? chapterId; // int _index; final List _subscriptions = []; final throttler = Throttler(delay: Duration(seconds: 3)); // final StreamController _playbackController = // StreamController.broadcast(); - SkipStartEnd({required this.start, required this.end, required this.player}) { + SkipStartEnd({ + required this.start, + required this.end, + required this.player, + this.chapterId, + }) { // if (start > Duration()) { // _subscriptions.add( // player.currentIndexStream.listen((index) { @@ -25,25 +35,81 @@ class SkipStartEnd { // }), // ); // } - if (end > Duration()) { + // if (end > Duration()) { + // _subscriptions.add( + // player.positionStream.distinct().listen((position) { + // if (player.duration != null && + // player.duration!.inMilliseconds - player.position.inMilliseconds < + // end.inMilliseconds) { + // throttler.call(() { + // print('跳过片尾'); + // Future.microtask(() async { + // await player.stop(); + // player.seekToNext(); + // }); + // }); + // } + // }), + // ); + // } + if (start > Duration.zero || end > Duration.zero) { _subscriptions.add( - player.positionStream.distinct().listen((position) { - if (player.duration != null && - player.duration!.inMilliseconds - player.position.inMilliseconds < - end.inMilliseconds) { - throttler.call(() { - print('跳过片尾'); - Future.microtask(() async { - await player.stop(); - player.seekToNext(); + player.positionStream.listen((position) { + final chapter = player.currentChapter; + if (chapter == null) { + return; + } + if (chapter.id == chapterId) { + if (end > Duration.zero && + chapter.duration - (player.positionInBook - chapter.start) < + end) { + throttler.call(() { + Future.microtask(() => skipEnd(chapter)); }); - }); + } + } + if (chapter.id != chapterId) { + if (start > Duration.zero && + player.positionInBook - chapter.start < Duration(seconds: 1)) { + throttler.call(() { + Future.microtask(() => skipStart(chapter)); + }); + } + + chapterId = chapter.id; } }), ); } } + void skipStart(BookChapter chapter) { + print('跳过片头'); + final globalPosition = player.positionInBook; + if (globalPosition - chapter.start < Duration(seconds: 1)) { + player.seekInBook(chapter.start + start); + } + } + + void skipEnd(chapter) { + print('跳过片尾'); + final book = player.book; + if (book == null) { + return; + } + if (start > Duration.zero) { + final currentIndex = book.chapters.indexOf(chapter); + if (currentIndex < book.chapters.length - 1) { + final nextChapter = book.chapters[currentIndex + 1]; + // 跳过片头+片尾 + print('跳过片头+片尾'); + player.skipToChapter(nextChapter.id, position: start); + } + } else { + player.seekToPrevious(); + } + } + /// dispose the timer void dispose() { for (var sub in _subscriptions) { @@ -53,38 +119,3 @@ class SkipStartEnd { // _playbackController.close(); } } - -class Throttler { - final Duration delay; - Timer? _timer; - DateTime? _lastRun; - - Throttler({required this.delay}); - - void call(void Function() callback) { - // 如果是第一次调用,立即执行 - if (_lastRun == null) { - callback(); - _lastRun = DateTime.now(); - return; - } - - // 如果距离上次执行已经超过延迟时间,立即执行 - if (DateTime.now().difference(_lastRun!) > delay) { - callback(); - _lastRun = DateTime.now(); - } - // 否则,安排在下个周期执行 - else { - _timer?.cancel(); - _timer = Timer(delay, () { - callback(); - _lastRun = DateTime.now(); - }); - } - } - - void dispose() { - _timer?.cancel(); - } -} diff --git a/lib/features/skip_start_end/skip_start_end_provider.dart b/lib/features/skip_start_end/skip_start_end_provider.dart index d00e361..9eed663 100644 --- a/lib/features/skip_start_end/skip_start_end_provider.dart +++ b/lib/features/skip_start_end/skip_start_end_provider.dart @@ -5,12 +5,13 @@ import 'package:vaani/features/skip_start_end/skip_start_end.dart' as core; part 'skip_start_end_provider.g.dart'; -@Riverpod(keepAlive: true) +@riverpod class SkipStartEnd extends _$SkipStartEnd { @override core.SkipStartEnd? build() { - final player = ref.watch(audiobookPlayerProvider); - final bookId = player.book?.libraryItemId ?? '_'; + final player = ref.watch(simpleAudiobookPlayerProvider); + final book = ref.watch(audiobookPlayerProvider.select((v) => v.book)); + final bookId = book?.libraryItemId ?? '_'; if (bookId == '_') { return null; } @@ -18,6 +19,13 @@ class SkipStartEnd extends _$SkipStartEnd { final start = bookSettings.playerSettings.skipChapterStart; final end = bookSettings.playerSettings.skipChapterEnd; - return core.SkipStartEnd(start: start, end: end, player: player); + final skipStartEnd = core.SkipStartEnd( + start: start, + end: end, + player: player, + chapterId: player.currentChapter?.id, + ); + ref.onDispose(skipStartEnd.dispose); + return skipStartEnd; } } diff --git a/lib/features/skip_start_end/skip_start_end_provider.g.dart b/lib/features/skip_start_end/skip_start_end_provider.g.dart index ef202f2..59771ef 100644 --- a/lib/features/skip_start_end/skip_start_end_provider.g.dart +++ b/lib/features/skip_start_end/skip_start_end_provider.g.dart @@ -6,12 +6,12 @@ part of 'skip_start_end_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$skipStartEndHash() => r'202cfb36fdb3d3fa12debfb188f87650473a88a9'; +String _$skipStartEndHash() => r'857b448eac9bb9ab85cea9217775712e660bc990'; /// See also [SkipStartEnd]. @ProviderFor(SkipStartEnd) final skipStartEndProvider = - NotifierProvider.internal( + AutoDisposeNotifierProvider.internal( SkipStartEnd.new, name: r'skipStartEndProvider', debugGetCreateSourceHash: @@ -20,6 +20,6 @@ final skipStartEndProvider = allTransitiveDependencies: null, ); -typedef _$SkipStartEnd = Notifier; +typedef _$SkipStartEnd = AutoDisposeNotifier; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/sleep_timer/view/sleep_timer_button.dart b/lib/features/sleep_timer/view/sleep_timer_button.dart index 9712813..99bad80 100644 --- a/lib/features/sleep_timer/view/sleep_timer_button.dart +++ b/lib/features/sleep_timer/view/sleep_timer_button.dart @@ -2,8 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:list_wheel_scroll_view_nls/list_wheel_scroll_view_nls.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:vaani/features/player/view/player_when_expanded.dart'; +import 'package:vaani/features/player/view/player_expanded.dart'; import 'package:vaani/features/player/view/widgets/speed_selector.dart'; import 'package:vaani/features/sleep_timer/core/sleep_timer.dart'; import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart' @@ -54,7 +53,7 @@ class SleepTimerButton extends HookConsumerWidget { duration: const Duration(milliseconds: 300), child: sleepTimer == null ? Icon( - Symbols.bedtime, + Icons.bedtime_outlined, color: Theme.of(context).colorScheme.onSurface, ) : RemainingSleepTimeDisplay( @@ -153,7 +152,7 @@ class SleepTimerBottomSheet extends HookConsumerWidget { onDurationSelected?.call(null); Navigator.of(context).pop(); }, - icon: const Icon(Symbols.bedtime_off), + icon: const Icon(Icons.bedtime_off_outlined), label: const Text('Cancel Sleep Timer'), ), ), diff --git a/lib/main.dart b/lib/main.dart index 4d4c52d..6135cd4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:vaani/features/player/core/init.dart'; import 'package:vaani/features/player/providers/audiobook_player.dart' show audiobookPlayerProvider, simpleAudiobookPlayerProvider; import 'package:vaani/features/shake_detection/providers/shake_detector.dart'; +import 'package:vaani/features/skip_start_end/skip_start_end_provider.dart'; import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart'; import 'package:vaani/generated/l10n.dart'; import 'package:vaani/models/tray.dart'; @@ -189,7 +190,7 @@ class _EagerInitialization extends ConsumerWidget { ref.watch(playbackReporterProvider); ref.watch(simpleDownloadManagerProvider); ref.watch(shakeDetectorProvider); - // ref.watch(skipStartEndProvider); + ref.watch(skipStartEndProvider); } catch (e) { debugPrintStack(stackTrace: StackTrace.current, label: e.toString()); appLogger.severe(e.toString()); diff --git a/lib/router/constants.dart b/lib/router/constants.dart index 79c4556..7c71855 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -43,7 +43,7 @@ class Routes { parentRoute: settings, ); static const playerSettings = _SimpleRoute( - pathName: 'player', + pathName: 'playerSettings', name: 'playerSettings', parentRoute: settings, ); @@ -101,6 +101,12 @@ class Routes { parentRoute: onboarding, ); + // player page + static const player = _SimpleRoute( + pathName: 'player', + name: 'player', + ); + // logs page static const logs = _SimpleRoute( pathName: 'logs', diff --git a/lib/router/router.dart b/lib/router/router.dart index eda348e..31abd0f 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -8,6 +8,7 @@ import 'package:vaani/features/library_browser/view/library_browser_page.dart'; import 'package:vaani/features/logging/view/logs_page.dart'; import 'package:vaani/features/onboarding/view/callback_page.dart'; import 'package:vaani/features/onboarding/view/onboarding_single_page.dart'; +import 'package:vaani/features/player/view/player_expanded.dart'; import 'package:vaani/features/you/view/server_manager.dart'; import 'package:vaani/features/you/view/you_page.dart'; import 'package:vaani/main.dart'; @@ -234,6 +235,13 @@ class MyAppRouter { ], ), + // loggers page + GoRoute( + path: Routes.player.localPath, + name: Routes.player.name, + pageBuilder: defaultPageBuilder(const PlayerExpanded()), + ), + // loggers page GoRoute( path: Routes.logs.localPath, diff --git a/lib/router/scaffold_with_nav_bar.dart b/lib/router/scaffold_with_nav_bar.dart index 32d9dd3..a28c532 100644 --- a/lib/router/scaffold_with_nav_bar.dart +++ b/lib/router/scaffold_with_nav_bar.dart @@ -1,19 +1,16 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:miniplayer/miniplayer.dart'; import 'package:vaani/api/library_provider.dart' show currentLibraryProvider; import 'package:vaani/features/explore/providers/search_controller.dart'; import 'package:vaani/features/player/providers/player_form.dart'; -import 'package:vaani/features/player/view/audiobook_player.dart'; -import 'package:vaani/features/player/view/player_when_expanded.dart'; +import 'package:vaani/features/player/view/player_minimized.dart'; import 'package:vaani/features/you/view/widgets/library_switch_chip.dart'; import 'package:vaani/generated/l10n.dart'; import 'package:vaani/main.dart'; import 'package:vaani/router/router.dart'; import 'package:vaani/shared/icons/abs_icons.dart' show AbsIcons; +import 'package:vaani/shared/utils/utils.dart'; // stack to track changes in navigationShell.currentIndex // home is always at index 0 and at the start and should be the last before popping @@ -37,73 +34,21 @@ class ScaffoldWithNavBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final size = MediaQuery.of(context).size; - final playerProgress = ref.watch(playerHeightProvider); - final isMobile = Platform.isAndroid || Platform.isIOS || Platform.isFuchsia; final isVertical = size.height > size.width; - onBackButtonPressed() async { - final isPlayerExpanded = playerProgress != playerMinHeight; - appLogger.fine( - 'BackButtonListener: Back button pressed, isPlayerExpanded: $isPlayerExpanded, stack: $navigationShellStack, pendingPlayerModals: $pendingPlayerModals', - ); - - // close miniplayer if it is open - if (isPlayerExpanded && pendingPlayerModals == 0) { - appLogger.fine( - 'BackButtonListener: closing the player', - ); - audioBookMiniplayerController.animateToHeight(state: PanelState.MIN); - return true; - } - - // do the the following only if the current branch has nothing to pop - final canPop = GoRouter.of(context).canPop(); - - if (canPop) { - appLogger.fine( - 'BackButtonListener: passing it to the router as canPop is true', - ); - return false; - } - - if (navigationShellStack.isNotEmpty) { - // pop the last index from the stack and navigate to it - final index = navigationShellStack.last; - navigationShellStack.remove(index); - appLogger.fine('BackButtonListener: popping the stack, index: $index'); - - // if the stack is empty, navigate to home else navigate to the last index - if (navigationShellStack.isNotEmpty) { - navigationShell.goBranch(navigationShellStack.last); - return true; - } - } - if (navigationShell.currentIndex != 0) { - // if the stack is empty and the current branch is not home, navigate to home - appLogger.fine('BackButtonListener: navigating to home'); - navigationShell.goBranch(0); - return true; - } - - appLogger.fine('BackButtonListener: passing it to the router'); - return false; - } - - // TODO: Implement a better way to handle back button presses to minimize player - return BackButtonListener( - onBackButtonPressed: onBackButtonPressed, - child: Scaffold( - body: Stack( - children: [ - isMobile || isVertical - ? navigationShell - : buildNavLeft(context, ref), - const AudiobookPlayer(), - ], - ), - bottomNavigationBar: - isMobile || isVertical ? buildNavBottom(context, ref) : null, + return Scaffold( + body: Stack( + alignment: Alignment.bottomCenter, + children: [ + Utils.isMobile() || isVertical + ? navigationShell + : buildNavLeft(context, ref), + // const AudiobookPlayer(), + const PlayerMinimized(), + ], ), + bottomNavigationBar: + Utils.isMobile() || isVertical ? buildNavBottom(context, ref) : null, ); } @@ -116,7 +61,7 @@ class ScaffoldWithNavBar extends HookConsumerWidget { SafeArea( child: NavigationRail( minWidth: 60, - minExtendedWidth: 120, + minExtendedWidth: 180, extended: MediaQuery.of(context).size.width > 640, // extended: false, destinations: _navigationItems(context).map((item) { diff --git a/lib/settings/view/app_settings_page.dart b/lib/settings/view/app_settings_page.dart index 5ce7e56..37c1cd5 100644 --- a/lib/settings/view/app_settings_page.dart +++ b/lib/settings/view/app_settings_page.dart @@ -6,7 +6,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_settings_ui/flutter_settings_ui.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:material_symbols_icons/symbols.dart'; import 'package:vaani/generated/l10n.dart'; import 'package:vaani/router/router.dart'; import 'package:vaani/settings/app_settings_provider.dart'; @@ -71,8 +70,8 @@ class AppSettingsPage extends HookConsumerWidget { title: Text(S.of(context).autoTurnOnSleepTimer), description: Text(S.of(context).automaticallyDescription), leading: sleepTimerSettings.autoTurnOnTimer - ? const Icon(Symbols.time_auto, fill: 1) - : const Icon(Symbols.timer_off, fill: 1), + ? const Icon(Icons.timer, fill: 1) + : const Icon(Icons.timer_off, fill: 1), onPressed: (context) { context.pushNamed(Routes.autoSleepTimerSettings.name); }, diff --git a/lib/settings/view/auto_sleep_timer_settings_page.dart b/lib/settings/view/auto_sleep_timer_settings_page.dart index 4eccb51..8dd0179 100644 --- a/lib/settings/view/auto_sleep_timer_settings_page.dart +++ b/lib/settings/view/auto_sleep_timer_settings_page.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_settings_ui/flutter_settings_ui.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:material_symbols_icons/symbols.dart'; import 'package:vaani/generated/l10n.dart'; import 'package:vaani/settings/app_settings_provider.dart'; import 'package:vaani/settings/view/simple_settings_page.dart'; @@ -38,8 +37,8 @@ class AutoSleepTimerSettingsPage extends HookConsumerWidget { S.of(context).autoTurnOnTimerDescription, ), leading: sleepTimerSettings.autoTurnOnTimer - ? const Icon(Symbols.time_auto) - : const Icon(Symbols.timer_off), + ? const Icon(Icons.timer_outlined) + : const Icon(Icons.timer_off_outlined), onToggle: (value) { ref.read(appSettingsProvider.notifier).update( appSettings.copyWith.sleepTimerSettings( @@ -52,7 +51,7 @@ class AutoSleepTimerSettingsPage extends HookConsumerWidget { // auto turn on time settings, enabled only when autoTurnOnTimer is enabled SettingsTile.navigation( enabled: enabled, - leading: const Icon(Symbols.timer_play), + leading: const Icon(Icons.play_circle), title: Text(S.of(context).autoTurnOnTimerFrom), description: Text( S.of(context).autoTurnOnTimerFromDescription, @@ -78,7 +77,7 @@ class AutoSleepTimerSettingsPage extends HookConsumerWidget { ), SettingsTile.navigation( enabled: enabled, - leading: const Icon(Symbols.timer_pause), + leading: const Icon(Icons.pause_circle), title: Text(S.of(context).autoTurnOnTimerUntil), description: Text( S.of(context).autoTurnOnTimerUntilDescription, @@ -107,7 +106,7 @@ class AutoSleepTimerSettingsPage extends HookConsumerWidget { // switch tile for always auto turn on timer no matter what SettingsTile.switchTile( - leading: const Icon(Symbols.all_inclusive), + leading: const Icon(Icons.all_inclusive), title: Text(S.of(context).autoTurnOnTimerAlways), description: Text( S.of(context).autoTurnOnTimerAlwaysDescription, diff --git a/lib/shared/utils/throttler.dart b/lib/shared/utils/throttler.dart new file mode 100644 index 0000000..91b7bae --- /dev/null +++ b/lib/shared/utils/throttler.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +// 节流器 +class Throttler { + final Duration delay; + Timer? _timer; + DateTime? _lastRun; + + Throttler({required this.delay}); + + void call(void Function() callback) { + // 如果是第一次调用,立即执行 + if (_lastRun == null) { + callback(); + _lastRun = DateTime.now(); + return; + } + + // 如果距离上次执行已经超过延迟时间,立即执行 + if (DateTime.now().difference(_lastRun!) > delay) { + callback(); + _lastRun = DateTime.now(); + } + // 否则,安排在下个周期执行 + else { + _timer?.cancel(); + _timer = Timer(delay, () { + callback(); + _lastRun = DateTime.now(); + }); + } + } + + void dispose() { + _timer?.cancel(); + } +} diff --git a/pubspec.lock b/pubspec.lock index 85fc208..dc0a054 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -222,14 +222,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - chalkdart: - dependency: transitive - description: - name: chalkdart - sha256: "7ffc6bd39c81453fb9ba8dbce042a9c960219b75ea1c07196a7fa41c2fab9e86" - url: "https://pub.dev" - source: hosted - version: "3.0.5" characters: dependency: transitive description: @@ -326,14 +318,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" custom_lint: dependency: "direct dev" description: @@ -803,14 +787,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.16" - just_audio_windows: - dependency: "direct main" - description: - name: just_audio_windows - sha256: b1ba5305d841c0e3883644e20fc11aaa23f28cfdd43ec20236d1e119a402ef29 - url: "https://pub.dev" - source: hosted - version: "0.2.2" leak_tracker: dependency: transitive description: @@ -907,14 +883,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" - material_symbols_icons: - dependency: "direct main" - description: - name: material_symbols_icons - sha256: "9a7de58ffc299c8e362b4e860e36e1d198fa0981a894376fe1b6bfe52773e15b" - url: "https://pub.dev" - source: hosted - version: "4.2874.0" media_kit: dependency: transitive description: @@ -931,6 +899,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + media_kit_libs_windows_audio: + dependency: "direct main" + description: + name: media_kit_libs_windows_audio + sha256: c2fd558cc87b9d89a801141fcdffe02e338a3b21a41a18fbd63d5b221a1b8e53 + url: "https://pub.dev" + source: hosted + version: "1.0.9" menu_base: dependency: transitive description: @@ -955,15 +931,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - miniplayer: - dependency: "direct main" - description: - path: "." - ref: feat-notifier-for-percent-dismissed - resolved-ref: "480f7933deaf0225ceb3a97162efca53610ba840" - url: "https://github.com/Dr-Blank/miniplayer.git" - source: git - version: "1.0.3" numberpicker: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index be26903..f5f2441 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,7 +42,7 @@ dependencies: cached_network_image: ^3.3.1 coast: ^2.0.2 collection: ^1.18.0 - cupertino_icons: ^1.0.6 + # cupertino_icons: ^1.0.6 device_info_plus: ^11.3.3 duration_picker: ^1.2.0 dynamic_color: ^1.7.0 @@ -63,26 +63,26 @@ dependencies: isar_flutter_libs: ^4.0.0-dev.13 json_annotation: ^4.9.0 just_audio: ^0.10.5 - just_audio_background: + just_audio_background: # TODO Remove git dep when https://github.com/ryanheise/just_audio/issues/912 is closed git: url: https://github.com/Dr-Blank/just_audio ref: media-notification-config path: just_audio_background - just_audio_windows: ^0.2.2 + # just_audio_windows: ^0.2.2 just_audio_media_kit: ^2.0.4 media_kit_libs_linux: any - # media_kit_libs_windows_audio: any + media_kit_libs_windows_audio: any list_wheel_scroll_view_nls: ^0.0.3 logging: ^1.2.0 logging_appenders: ^1.3.1 lottie: ^3.1.0 material_color_utilities: ^0.11.1 - material_symbols_icons: ^4.2785.1 - miniplayer: - git: - url: https://github.com/Dr-Blank/miniplayer.git - ref: feat-notifier-for-percent-dismissed + # material_symbols_icons: ^4.2785.1 + # miniplayer: + # git: + # url: https://github.com/Dr-Blank/miniplayer.git + # ref: feat-notifier-for-percent-dismissed numberpicker: ^2.1.2 package_info_plus: ^8.0.0 path: ^1.9.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index db5c691..4f18e92 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,7 +8,7 @@ #include #include -#include +#include #include #include #include @@ -21,8 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); IsarFlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("IsarFlutterLibsPlugin")); - JustAudioWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("JustAudioWindowsPlugin")); + MediaKitLibsWindowsAudioPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsAudioPluginCApi")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index cc33937..ed05458 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,7 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color isar_flutter_libs - just_audio_windows + media_kit_libs_windows_audio permission_handler_windows screen_retriever_windows share_plus