mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2026-02-16 14:29:35 +00:00
一堆乱七八糟的修改
播放页面增加桌面版
This commit is contained in:
parent
aee1fbde88
commit
3ba35b31b8
116 changed files with 1238 additions and 2592 deletions
|
|
@ -5,8 +5,8 @@ import 'dart:io';
|
|||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart';
|
||||
import 'package:vaani/globals.dart';
|
||||
import 'package:vaani/shared/extensions/model_conversions.dart';
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart';
|
||||
|
||||
|
|
@ -72,11 +72,10 @@ class AudiobookDownloadManager {
|
|||
) async {
|
||||
_logger.info('queuing download for item: ${item.id}');
|
||||
// create a download task for each file in the item
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
for (final file in item.libraryFiles) {
|
||||
// check if the file is already downloaded
|
||||
if (isFileDownloaded(
|
||||
constructFilePath(directory, item, file),
|
||||
constructFilePath(item, file),
|
||||
)) {
|
||||
_logger.info('file already downloaded: ${file.metadata.filename}');
|
||||
continue;
|
||||
|
|
@ -102,11 +101,10 @@ class AudiobookDownloadManager {
|
|||
}
|
||||
|
||||
String constructFilePath(
|
||||
Directory directory,
|
||||
LibraryItemExpanded item,
|
||||
LibraryFile file,
|
||||
) =>
|
||||
'${directory.path}/${item.relPath}/${file.metadata.filename}';
|
||||
'${appSupportDir.path}/${item.relPath}/${file.metadata.filename}';
|
||||
|
||||
void dispose() {
|
||||
_updatesSubscription.cancel();
|
||||
|
|
@ -125,10 +123,9 @@ class AudiobookDownloadManager {
|
|||
Future<List<LibraryFile>> getDownloadedFilesMetadata(
|
||||
LibraryItemExpanded item,
|
||||
) async {
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
final downloadedFiles = <LibraryFile>[];
|
||||
for (final file in item.libraryFiles) {
|
||||
final filePath = constructFilePath(directory, item, file);
|
||||
final filePath = constructFilePath(item, file);
|
||||
if (isFileDownloaded(filePath)) {
|
||||
downloadedFiles.add(file);
|
||||
}
|
||||
|
|
@ -146,9 +143,8 @@ class AudiobookDownloadManager {
|
|||
}
|
||||
|
||||
Future<bool> isItemDownloaded(LibraryItemExpanded item) async {
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
for (final file in item.libraryFiles) {
|
||||
if (!isFileDownloaded(constructFilePath(directory, item, file))) {
|
||||
if (!isFileDownloaded(constructFilePath(item, file))) {
|
||||
_logger.info('file not downloaded: ${file.metadata.filename}');
|
||||
return false;
|
||||
}
|
||||
|
|
@ -159,9 +155,8 @@ class AudiobookDownloadManager {
|
|||
|
||||
Future<void> deleteDownloadedItem(LibraryItemExpanded item) async {
|
||||
_logger.info('deleting downloaded item with id: ${item.id}');
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
for (final file in item.libraryFiles) {
|
||||
final filePath = constructFilePath(directory, item, file);
|
||||
final filePath = constructFilePath(item, file);
|
||||
if (isFileDownloaded(filePath)) {
|
||||
File(filePath).deleteSync();
|
||||
}
|
||||
|
|
@ -170,10 +165,9 @@ class AudiobookDownloadManager {
|
|||
}
|
||||
|
||||
Future<List<Uri>> getDownloadedFilesUri(LibraryItemExpanded item) async {
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
final files = <Uri>[];
|
||||
for (final file in item.libraryFiles) {
|
||||
final filePath = constructFilePath(directory, item, file);
|
||||
final filePath = constructFilePath(item, file);
|
||||
if (isFileDownloaded(filePath)) {
|
||||
files.add(Uri.file(filePath));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import 'package:shelfsdk/audiobookshelf_api.dart';
|
|||
import 'package:vaani/api/api_provider.dart';
|
||||
import 'package:vaani/api/library_item_provider.dart';
|
||||
import 'package:vaani/features/downloads/core/download_manager.dart' as core;
|
||||
import 'package:vaani/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/shared/extensions/item_files.dart';
|
||||
|
||||
part 'download_manager.g.dart';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart';
|
||||
import 'package:vaani/api/api_provider.dart';
|
||||
import 'package:vaani/settings/api_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/api_settings_provider.dart';
|
||||
|
||||
part 'search_result_provider.g.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import 'package:vaani/features/explore/providers/search_controller.dart';
|
|||
import 'package:vaani/features/explore/view/search_result_page.dart';
|
||||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/router/router.dart';
|
||||
import 'package:vaani/settings/api_settings_provider.dart';
|
||||
import 'package:vaani/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/api_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/shared/extensions/model_conversions.dart';
|
||||
import 'package:vaani/shared/widgets/shelves/book_shelf.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -14,13 +14,12 @@ import 'package:vaani/features/downloads/providers/download_manager.dart'
|
|||
isItemDownloadingProvider,
|
||||
itemDownloadProgressProvider;
|
||||
import 'package:vaani/features/item_viewer/view/library_item_page.dart';
|
||||
import 'package:vaani/features/player/providers/player_form.dart';
|
||||
import 'package:vaani/features/player/providers/player_status_provider.dart';
|
||||
import 'package:vaani/features/player/providers/session_provider.dart';
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/globals.dart';
|
||||
import 'package:vaani/router/router.dart';
|
||||
import 'package:vaani/settings/api_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/api_settings_provider.dart';
|
||||
import 'package:vaani/shared/extensions/model_conversions.dart';
|
||||
import 'package:vaani/shared/utils.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import 'package:vaani/constants/hero_tag_conventions.dart';
|
|||
import 'package:vaani/features/item_viewer/view/library_item_page.dart';
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
import 'package:vaani/router/models/library_item_extras.dart';
|
||||
import 'package:vaani/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/shared/extensions/duration_format.dart';
|
||||
import 'package:vaani/shared/extensions/model_conversions.dart';
|
||||
import 'package:vaani/shared/widgets/shelves/book_shelf.dart';
|
||||
|
|
@ -139,20 +139,21 @@ class _LibraryItemProgressIndicator extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
final player = ref.watch(playerProvider);
|
||||
final libraryItem = ref.watch(libraryItemProvider(id)).valueOrNull;
|
||||
if (libraryItem == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final mediaProgress = libraryItem.userMediaProgress;
|
||||
if (mediaProgress == null && player.book?.libraryItemId != libraryItem.id) {
|
||||
if (mediaProgress == null &&
|
||||
player.session?.libraryItemId != libraryItem.id) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
double progress;
|
||||
Duration remainingTime;
|
||||
if (player.book?.libraryItemId == libraryItem.id) {
|
||||
if (player.session?.libraryItemId == libraryItem.id) {
|
||||
// final positionStream = useStream(player.slowPositionStream);
|
||||
progress = (player.positionInBook).inSeconds /
|
||||
libraryItem.media.asBookExpanded.duration.inSeconds;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import 'package:vaani/shared/extensions/duration_format.dart';
|
|||
|
||||
Future<String> getLoggingFilePath() async {
|
||||
// final Directory directory = await getApplicationDocumentsDirectory();
|
||||
return '${appStorageDir.path}/$appName.log';
|
||||
return '${appDocumentsDir.path}/$appName.log';
|
||||
}
|
||||
|
||||
Future<void> initLogging() async {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'dart:io';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:vaani/api/api_provider.dart';
|
||||
import 'package:vaani/models/error_response.dart';
|
||||
import 'package:vaani/shared/utils/error_response.dart';
|
||||
|
||||
import '../models/flow.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'package:go_router/go_router.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/features/onboarding/providers/oauth_provider.dart';
|
||||
import 'package:vaani/features/onboarding/view/user_login_with_password.dart';
|
||||
import 'package:vaani/models/error_response.dart';
|
||||
import 'package:vaani/shared/utils/error_response.dart';
|
||||
import 'package:vaani/router/router.dart';
|
||||
|
||||
class CallbackPage extends HookConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import 'package:vaani/api/api_provider.dart';
|
|||
import 'package:vaani/features/onboarding/view/user_login.dart';
|
||||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/globals.dart';
|
||||
import 'package:vaani/settings/api_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/api_settings_provider.dart';
|
||||
import 'package:vaani/shared/utils.dart';
|
||||
import 'package:vaani/shared/widgets/add_new_server.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@ import 'package:vaani/features/onboarding/view/user_login_with_token.dart'
|
|||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/hacks/fix_autofill_losing_focus.dart'
|
||||
show InactiveFocusScopeObserver;
|
||||
import 'package:vaani/models/error_response.dart' show ErrorResponseHandler;
|
||||
import 'package:vaani/settings/api_settings_provider.dart'
|
||||
import 'package:vaani/shared/utils/error_response.dart'
|
||||
show ErrorResponseHandler;
|
||||
import 'package:vaani/features/settings/api_settings_provider.dart'
|
||||
show apiSettingsProvider;
|
||||
import 'package:vaani/settings/models/models.dart' as model;
|
||||
import 'package:vaani/features/settings/models/models.dart' as model;
|
||||
|
||||
class UserLoginWidget extends HookConsumerWidget {
|
||||
const UserLoginWidget({
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import 'package:vaani/api/api_provider.dart';
|
|||
import 'package:vaani/features/onboarding/providers/oauth_provider.dart';
|
||||
import 'package:vaani/features/onboarding/view/user_login_with_password.dart';
|
||||
import 'package:vaani/globals.dart';
|
||||
import 'package:vaani/models/error_response.dart';
|
||||
import 'package:vaani/shared/utils/error_response.dart';
|
||||
import 'package:vaani/router/router.dart';
|
||||
import 'package:vaani/settings/models/models.dart' as model;
|
||||
import 'package:vaani/features/settings/models/models.dart' as model;
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart';
|
||||
import 'package:vaani/shared/utils.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import 'package:vaani/api/authenticated_users_provider.dart';
|
|||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/globals.dart';
|
||||
import 'package:vaani/hacks/fix_autofill_losing_focus.dart';
|
||||
import 'package:vaani/models/error_response.dart';
|
||||
import 'package:vaani/shared/utils/error_response.dart';
|
||||
import 'package:vaani/router/router.dart';
|
||||
import 'package:vaani/settings/models/models.dart' as model;
|
||||
import 'package:vaani/features/settings/models/models.dart' as model;
|
||||
import 'package:vaani/shared/utils.dart';
|
||||
|
||||
class UserLoginWithPassword extends HookConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import 'package:shelfsdk/audiobookshelf_api.dart';
|
|||
import 'package:vaani/api/api_provider.dart';
|
||||
import 'package:vaani/api/authenticated_users_provider.dart';
|
||||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/models/error_response.dart';
|
||||
import 'package:vaani/shared/utils/error_response.dart';
|
||||
import 'package:vaani/router/router.dart';
|
||||
import 'package:vaani/settings/models/models.dart' as model;
|
||||
import 'package:vaani/features/settings/models/models.dart' as model;
|
||||
|
||||
class UserLoginWithToken extends HookConsumerWidget {
|
||||
UserLoginWithToken({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:vaani/settings/models/app_settings.dart';
|
||||
import 'package:vaani/features/settings/models/app_settings.dart';
|
||||
|
||||
part 'nullable_player_settings.freezed.dart';
|
||||
part 'nullable_player_settings.g.dart';
|
||||
|
|
|
|||
|
|
@ -1,340 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart';
|
||||
import 'package:vaani/features/player/core/audiobook_player.dart';
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart';
|
||||
|
||||
final _logger = Logger('PlaybackReporter');
|
||||
|
||||
/// this playback reporter will watch the player and report to the server
|
||||
///
|
||||
/// it will by default report every 10 seconds
|
||||
/// and also report when the player is paused/stopped/finished/playing
|
||||
class PlaybackReporter {
|
||||
/// The player to watch
|
||||
final AudiobookPlayer player;
|
||||
|
||||
/// the api to report to
|
||||
final AudiobookshelfApi authenticatedApi;
|
||||
|
||||
/// The stopwatch to keep track of the time since the last report
|
||||
///
|
||||
/// this should only run when media is playing
|
||||
final _stopwatch = Stopwatch();
|
||||
|
||||
/// subscriptions to listen and then cancel when disposing
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
|
||||
Duration _reportingInterval;
|
||||
|
||||
/// the duration to wait before reporting
|
||||
Duration get reportingInterval => _reportingInterval;
|
||||
set reportingInterval(Duration value) {
|
||||
_reportingInterval = value;
|
||||
_cancelReportTimer();
|
||||
_setReportTimerIfNotAlready();
|
||||
_logger.info('set interval: $value');
|
||||
}
|
||||
|
||||
/// the minimum duration to report
|
||||
final Duration reportingDurationThreshold;
|
||||
|
||||
/// the duration to wait before starting the reporting
|
||||
/// this is to ignore the initial duration in case user is browsing
|
||||
final Duration? minimumPositionForReporting;
|
||||
|
||||
/// the duration to mark the book as complete when the time left is less than this
|
||||
final Duration markCompleteWhenTimeLeft;
|
||||
|
||||
/// timer to report every 10 seconds
|
||||
/// tracking the time since the last report
|
||||
Timer? _reportTimer;
|
||||
|
||||
/// metadata to report
|
||||
String? deviceName;
|
||||
String? deviceModel;
|
||||
String? deviceSdkVersion;
|
||||
String? deviceClientName;
|
||||
String? deviceClientVersion;
|
||||
String? deviceManufacturer;
|
||||
|
||||
PlaybackReporter(
|
||||
this.player,
|
||||
this.authenticatedApi, {
|
||||
this.deviceName,
|
||||
this.deviceModel,
|
||||
this.deviceSdkVersion,
|
||||
this.deviceClientName,
|
||||
this.deviceClientVersion,
|
||||
this.deviceManufacturer,
|
||||
this.reportingDurationThreshold = const Duration(seconds: 1),
|
||||
Duration reportingInterval = const Duration(seconds: 10),
|
||||
this.minimumPositionForReporting,
|
||||
this.markCompleteWhenTimeLeft = const Duration(seconds: 5),
|
||||
}) : _reportingInterval = reportingInterval {
|
||||
// initial conditions
|
||||
if (player.playing) {
|
||||
_stopwatch.start();
|
||||
_setReportTimerIfNotAlready();
|
||||
_logger.fine('starting stopwatch');
|
||||
} else {
|
||||
_logger.fine('not starting stopwatch');
|
||||
}
|
||||
|
||||
_subscriptions.add(
|
||||
player.playerStateStream.listen((state) async {
|
||||
// set timer if any book is playing and cancel if not
|
||||
if (player.book != null) {
|
||||
if (state.playing) {
|
||||
_setReportTimerIfNotAlready();
|
||||
} else {
|
||||
_cancelReportTimer();
|
||||
}
|
||||
} else if (player.book == null && _reportTimer != null) {
|
||||
_logger.info('book is null, closing session');
|
||||
await closeSession();
|
||||
_cancelReportTimer();
|
||||
}
|
||||
|
||||
// start or stop the stopwatch based on the playing state
|
||||
if (state.playing) {
|
||||
_stopwatch.start();
|
||||
_logger.fine(
|
||||
'player state observed, starting stopwatch at ${_stopwatch.elapsed}',
|
||||
);
|
||||
} else if (!state.playing) {
|
||||
_stopwatch.stop();
|
||||
_logger.fine(
|
||||
'player state observed, stopping stopwatch at ${_stopwatch.elapsed}',
|
||||
);
|
||||
await tryReportPlayback(null);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
_logger.fine(
|
||||
'initialized with reportingInterval: $reportingInterval, reportingDurationThreshold: $reportingDurationThreshold',
|
||||
);
|
||||
_logger.fine(
|
||||
'initialized with minimumPositionForReporting: $minimumPositionForReporting, markCompleteWhenTimeLeft: $markCompleteWhenTimeLeft',
|
||||
);
|
||||
_logger.fine(
|
||||
'initialized with deviceModel: $deviceModel, deviceSdkVersion: $deviceSdkVersion, deviceClientName: $deviceClientName, deviceClientVersion: $deviceClientVersion, deviceManufacturer: $deviceManufacturer',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> tryReportPlayback(_) async {
|
||||
_logger.fine(
|
||||
'callback called when elapsed ${_stopwatch.elapsed}',
|
||||
);
|
||||
if (player.book != null &&
|
||||
player.positionInBook >=
|
||||
player.book!.duration - markCompleteWhenTimeLeft) {
|
||||
_logger.info(
|
||||
'marking complete as time left is less than $markCompleteWhenTimeLeft',
|
||||
);
|
||||
await markComplete();
|
||||
return;
|
||||
}
|
||||
if (_stopwatch.elapsed > reportingDurationThreshold) {
|
||||
_logger.fine(
|
||||
'reporting now with elapsed ${_stopwatch.elapsed} > threshold $reportingDurationThreshold',
|
||||
);
|
||||
await syncCurrentPosition();
|
||||
}
|
||||
}
|
||||
|
||||
/// dispose the timer
|
||||
Future<void> dispose() async {
|
||||
for (var sub in _subscriptions) {
|
||||
sub.cancel();
|
||||
}
|
||||
await closeSession();
|
||||
_stopwatch.stop();
|
||||
_reportTimer?.cancel();
|
||||
|
||||
_logger.fine('disposed');
|
||||
}
|
||||
|
||||
/// current sessionId
|
||||
/// this is used to report the playback
|
||||
PlaybackSession? _session;
|
||||
String? get sessionId => _session?.id;
|
||||
|
||||
Future<PlaybackSession?> startSession() async {
|
||||
if (_session != null) {
|
||||
return _session!;
|
||||
}
|
||||
if (player.book == null) {
|
||||
_logger.warning('No audiobook playing to start session');
|
||||
return null;
|
||||
}
|
||||
_session = await authenticatedApi.items.play(
|
||||
libraryItemId: player.book!.libraryItemId,
|
||||
parameters: PlayItemReqParams(
|
||||
deviceInfo: await _getDeviceInfo(),
|
||||
forceDirectPlay: false,
|
||||
forceTranscode: false,
|
||||
supportedMimeTypes: [
|
||||
"audio/flac",
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
"audio/ogg",
|
||||
"audio/aac",
|
||||
"audio/webm",
|
||||
],
|
||||
),
|
||||
responseErrorHandler: _responseErrorHandler,
|
||||
);
|
||||
_logger.info('Started session: $sessionId');
|
||||
return _session;
|
||||
}
|
||||
|
||||
Future<void> markComplete() async {
|
||||
if (player.book == null) {
|
||||
throw NoAudiobookPlayingError();
|
||||
}
|
||||
await authenticatedApi.me.createUpdateMediaProgress(
|
||||
libraryItemId: player.book!.libraryItemId,
|
||||
parameters: CreateUpdateProgressReqParams(
|
||||
isFinished: true,
|
||||
currentTime: player.positionInBook,
|
||||
duration: player.book!.duration,
|
||||
),
|
||||
responseErrorHandler: _responseErrorHandler,
|
||||
);
|
||||
_logger.info('Marked complete for book: ${player.book!.libraryItemId}');
|
||||
}
|
||||
|
||||
Future<void> syncCurrentPosition() async {
|
||||
final data = _getSyncData();
|
||||
if (data == null) {
|
||||
await closeSession();
|
||||
}
|
||||
try {
|
||||
_session ??= await startSession();
|
||||
} on Error catch (e) {
|
||||
_logger.warning('Error starting session: $e');
|
||||
}
|
||||
if (_session == null) {
|
||||
_logger.warning('No session to sync position');
|
||||
return;
|
||||
}
|
||||
final currentPosition = player.positionInBook;
|
||||
|
||||
await authenticatedApi.sessions.syncOpen(
|
||||
sessionId: sessionId!,
|
||||
parameters: _getSyncData()!,
|
||||
responseErrorHandler: _responseErrorHandler,
|
||||
);
|
||||
|
||||
_logger.fine(
|
||||
'Synced position: $currentPosition with timeListened: ${_stopwatch.elapsed} for session: $sessionId',
|
||||
);
|
||||
|
||||
// reset the stopwatch
|
||||
_stopwatch.reset();
|
||||
}
|
||||
|
||||
Future<void> closeSession() async {
|
||||
if (sessionId == null) {
|
||||
_logger.warning('No session to close');
|
||||
return;
|
||||
}
|
||||
|
||||
await authenticatedApi.sessions.closeOpen(
|
||||
sessionId: sessionId!,
|
||||
parameters: _getSyncData(),
|
||||
responseErrorHandler: _responseErrorHandler,
|
||||
);
|
||||
_session = null;
|
||||
_logger.info('Closed session');
|
||||
}
|
||||
|
||||
void _setReportTimerIfNotAlready() {
|
||||
if (_reportTimer != null) return;
|
||||
_reportTimer = Timer.periodic(_reportingInterval, tryReportPlayback);
|
||||
_logger.fine('set timer with interval: $_reportingInterval');
|
||||
}
|
||||
|
||||
void _cancelReportTimer() {
|
||||
_reportTimer?.cancel();
|
||||
_reportTimer = null;
|
||||
_logger.fine('cancelled timer');
|
||||
}
|
||||
|
||||
void _responseErrorHandler(http.Response response, [error]) {
|
||||
if (response.statusCode != 200) {
|
||||
_logger.severe('Error with api: ${response.obfuscate()}, $error');
|
||||
throw PlaybackSyncError(
|
||||
'Error syncing position: ${response.body}, $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SyncSessionReqParams? _getSyncData() {
|
||||
if (player.book?.libraryItemId != _session?.libraryItemId) {
|
||||
_logger.info(
|
||||
'Book changed, not syncing position for session: $sessionId',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// if in the ignore duration, don't sync
|
||||
if (minimumPositionForReporting != null &&
|
||||
player.positionInBook < minimumPositionForReporting!) {
|
||||
// but if elapsed time is more than the minimumPositionForReporting, sync
|
||||
if (_stopwatch.elapsed > minimumPositionForReporting!) {
|
||||
_logger.info(
|
||||
'Syncing position despite being less than minimumPositionForReporting as elapsed time is more: ${_stopwatch.elapsed}',
|
||||
);
|
||||
} else {
|
||||
_logger.info(
|
||||
'Ignoring sync for position: ${player.positionInBook} < $minimumPositionForReporting',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return SyncSessionReqParams(
|
||||
currentTime: player.positionInBook,
|
||||
timeListened: _stopwatch.elapsed,
|
||||
duration: player.book?.duration ?? Duration.zero,
|
||||
);
|
||||
}
|
||||
|
||||
Future<DeviceInfoReqParams> _getDeviceInfo() async {
|
||||
return DeviceInfoReqParams(
|
||||
clientVersion: deviceClientVersion,
|
||||
manufacturer: deviceManufacturer,
|
||||
model: deviceModel,
|
||||
sdkVersion: deviceSdkVersion,
|
||||
clientName: deviceClientName,
|
||||
deviceName: deviceName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlaybackSyncError implements Exception {
|
||||
String message;
|
||||
|
||||
PlaybackSyncError([this.message = 'Error syncing playback']);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PlaybackSyncError: $message';
|
||||
}
|
||||
}
|
||||
|
||||
class NoAudiobookPlayingError implements Exception {
|
||||
String message;
|
||||
|
||||
NoAudiobookPlayingError([this.message = 'No audiobook is playing']);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'NoAudiobookPlayingError: $message';
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import 'dart:async';
|
|||
import 'package:http/http.dart' as http;
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart';
|
||||
import 'package:vaani/features/player/core/audiobook_player_session.dart';
|
||||
import 'package:vaani/features/player/core/audiobook_player.dart';
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart';
|
||||
|
||||
final _logger = Logger('PlaybackReporter');
|
||||
|
|
|
|||
|
|
@ -1,19 +1,22 @@
|
|||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:vaani/api/api_provider.dart';
|
||||
import 'package:vaani/features/playback_reporting/core/playback_reporter.dart'
|
||||
import 'package:vaani/features/playback_reporting/core/playback_reporter_session.dart'
|
||||
as core;
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
import 'package:vaani/globals.dart';
|
||||
import 'package:vaani/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
|
||||
part 'playback_reporter_provider.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
@riverpod
|
||||
class PlaybackReporter extends _$PlaybackReporter {
|
||||
@override
|
||||
Future<core.PlaybackReporter> build() async {
|
||||
Future<core.PlaybackReporter?> build() async {
|
||||
final session = ref.watch(sessionProvider);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
final playerSettings = ref.watch(appSettingsProvider).playerSettings;
|
||||
final player = ref.watch(simpleAudiobookPlayerProvider);
|
||||
final player = ref.watch(playerProvider);
|
||||
final api = ref.watch(authenticatedApiProvider);
|
||||
|
||||
final reporter = core.PlaybackReporter(
|
||||
|
|
@ -22,12 +25,7 @@ class PlaybackReporter extends _$PlaybackReporter {
|
|||
reportingInterval: playerSettings.playbackReportInterval,
|
||||
markCompleteWhenTimeLeft: playerSettings.markCompleteWhenTimeLeft,
|
||||
minimumPositionForReporting: playerSettings.minimumPositionForReporting,
|
||||
deviceName: deviceName,
|
||||
deviceModel: deviceModel,
|
||||
deviceSdkVersion: deviceSdkVersion,
|
||||
deviceClientName: appName,
|
||||
deviceClientVersion: appVersion,
|
||||
deviceManufacturer: deviceManufacturer,
|
||||
session: session,
|
||||
);
|
||||
ref.onDispose(reporter.dispose);
|
||||
return reporter;
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ part of 'playback_reporter_provider.dart';
|
|||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$playbackReporterHash() => r'43bde2ac163830b6950303a80cdd915ffcb1943b';
|
||||
String _$playbackReporterHash() => r'f9be5d6e4b07815ec669406cede4b00d2278e3af';
|
||||
|
||||
/// See also [PlaybackReporter].
|
||||
@ProviderFor(PlaybackReporter)
|
||||
final playbackReporterProvider =
|
||||
AsyncNotifierProvider<PlaybackReporter, core.PlaybackReporter>.internal(
|
||||
final playbackReporterProvider = AutoDisposeAsyncNotifierProvider<
|
||||
PlaybackReporter, core.PlaybackReporter?>.internal(
|
||||
PlaybackReporter.new,
|
||||
name: r'playbackReporterProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
|
|
@ -21,6 +21,6 @@ final playbackReporterProvider =
|
|||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$PlaybackReporter = AsyncNotifier<core.PlaybackReporter>;
|
||||
typedef _$PlaybackReporter = AutoDisposeAsyncNotifier<core.PlaybackReporter?>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
|
|
|
|||
|
|
@ -1,240 +1,239 @@
|
|||
/// a wrapper around the audioplayers package to manage the audio player instance
|
||||
///
|
||||
/// this is needed as audiobook can be a list of audio files instead of a single file
|
||||
library;
|
||||
// my_audio_handler.dart
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
// import 'package:just_audio_background/just_audio_background.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.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');
|
||||
import 'package:vaani/features/player/core/player_status.dart' as core;
|
||||
import 'package:vaani/features/player/providers/player_status_provider.dart';
|
||||
import 'package:vaani/shared/extensions/chapter.dart';
|
||||
|
||||
// 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);
|
||||
class AbsAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
// final List<AudioSource> _playlist = [];
|
||||
final Ref ref;
|
||||
|
||||
/// 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');
|
||||
// return 0 if index is less than 0
|
||||
if (index == null || index < 0) {
|
||||
_logger.warning('Index is null or less than 0, returning 0');
|
||||
return Duration.zero;
|
||||
}
|
||||
final total = book.tracks.sublist(0, index).fold<Duration>(
|
||||
Duration.zero,
|
||||
(previousValue, element) => previousValue + element.duration,
|
||||
);
|
||||
_logger.fine('Sum of tracks for index: $index is $total');
|
||||
return total;
|
||||
}
|
||||
PlaybackSessionExpanded? _session;
|
||||
|
||||
/// 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);
|
||||
// }
|
||||
// });
|
||||
final _currentChapterObject = BehaviorSubject<BookChapter?>.seeded(null);
|
||||
AbsAudioHandler(this.ref) {
|
||||
_setupAudioPlayer();
|
||||
}
|
||||
|
||||
/// the [BookExpanded] being played
|
||||
BookExpanded? _book;
|
||||
void _setupAudioPlayer() {
|
||||
final statusNotifier = ref.read(playerStatusProvider.notifier);
|
||||
|
||||
// /// the [BookExpanded] trying to be played
|
||||
// BookExpanded? _intended_book;
|
||||
|
||||
/// the [BookExpanded] being played
|
||||
///
|
||||
/// to set the book, use [setSourceAudiobook]
|
||||
BookExpanded? get book => _book;
|
||||
|
||||
/// the authentication token to access the [AudioTrack.contentUrl]
|
||||
final String token;
|
||||
|
||||
/// the base url for the audio files
|
||||
final Uri baseUrl;
|
||||
|
||||
// the current index of the audio file in the [book]
|
||||
// int _currentIndex = 0;
|
||||
|
||||
// available audio tracks
|
||||
int? get availableTracks => _book?.tracks.length;
|
||||
|
||||
/// sets the current [AudioTrack] as the source of the player
|
||||
Future<void> setSourceAudiobook(
|
||||
BookExpanded? book, {
|
||||
bool preload = true,
|
||||
int? initialIndex,
|
||||
Duration? initialPosition,
|
||||
List<Uri>? downloadedUris,
|
||||
Uri? artworkUri,
|
||||
}) async {
|
||||
_logger.finer(
|
||||
'Initial position: $initialPosition, Downloaded URIs: $downloadedUris',
|
||||
);
|
||||
final appSettings = loadOrCreateAppSettings();
|
||||
if (book == null) {
|
||||
_book = null;
|
||||
_logger.info('Book is null, stopping player');
|
||||
return stop();
|
||||
}
|
||||
|
||||
if (_book == book) {
|
||||
_logger.info('Book is the same, doing nothing');
|
||||
return;
|
||||
}
|
||||
_logger.info('Setting source for book: $book');
|
||||
|
||||
_logger.fine('Stopping player');
|
||||
await stop();
|
||||
|
||||
_book = book;
|
||||
// some calculations to set the initial index and position
|
||||
// initialPosition is of the entire book not just the current track
|
||||
// hence first we need to calculate the current track which will be used to set the initial position
|
||||
// then we set the initial index to the current track index and position as the remaining duration from the position
|
||||
// after subtracting the duration of all the previous tracks
|
||||
// initialPosition ;
|
||||
final trackToPlay =
|
||||
_book!.findTrackAtTime(initialPosition ?? Duration.zero);
|
||||
|
||||
final initialIndex = book.tracks.indexOf(trackToPlay);
|
||||
final initialPositionInTrack = initialPosition != null
|
||||
? initialPosition - trackToPlay.startOffset
|
||||
: null;
|
||||
_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.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();
|
||||
await setAudioSources(
|
||||
playlist,
|
||||
preload: preload,
|
||||
initialIndex: initialIndex,
|
||||
initialPosition: initialPositionInTrack,
|
||||
).catchError((error) {
|
||||
_logger.shout('Error in setting audio source: $error');
|
||||
return null;
|
||||
// 转发播放状态
|
||||
_player.playbackEventStream.map(_transformEvent).pipe(playbackState);
|
||||
_player.playerStateStream.listen((event) {
|
||||
if (event.playing) {
|
||||
statusNotifier.setPlayStatusVerify(core.PlayStatus.playing);
|
||||
} else {
|
||||
statusNotifier.setPlayStatusVerify(core.PlayStatus.paused);
|
||||
}
|
||||
});
|
||||
_player.positionStream.distinct().listen((position) {
|
||||
final chapter = _session?.findChapterAtTime(positionInBook);
|
||||
if (chapter != currentChapter) {
|
||||
_currentChapterObject.sink.add(chapter);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// toggles the player between play and pause
|
||||
Future<void> togglePlayPause() {
|
||||
// check if book is set
|
||||
if (_book == null) {
|
||||
_logger.warning('No book is set, not toggling play/pause');
|
||||
// 加载有声书
|
||||
Future<void> setSourceAudiobook(
|
||||
PlaybackSessionExpanded playbackSession, {
|
||||
required Uri baseUrl,
|
||||
required String token,
|
||||
List<Uri>? downloadedUris,
|
||||
}) async {
|
||||
_session = playbackSession;
|
||||
|
||||
// 添加所有音轨
|
||||
List<AudioSource> audioSources = [];
|
||||
for (final track in playbackSession.audioTracks) {
|
||||
audioSources.add(
|
||||
AudioSource.uri(
|
||||
_getUri(track, downloadedUris, baseUrl: baseUrl, token: token),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// TODO refactor this to cover all the states
|
||||
return switch (playerState) {
|
||||
PlayerState(playing: var isPlaying) => isPlaying ? pause() : play(),
|
||||
};
|
||||
playMediaItem(
|
||||
MediaItem(
|
||||
id: playbackSession.libraryItemId,
|
||||
album: playbackSession.mediaMetadata.title,
|
||||
title: playbackSession.displayTitle,
|
||||
displaySubtitle: playbackSession.mediaType == MediaType.book
|
||||
? (playbackSession.mediaMetadata as BookMetadata).subtitle
|
||||
: null,
|
||||
duration: playbackSession.duration,
|
||||
artUri: Uri.parse(
|
||||
'$baseUrl/api/items/${playbackSession.libraryItemId}/cover?token=$token',
|
||||
),
|
||||
),
|
||||
);
|
||||
final track = playbackSession.findTrackAtTime(playbackSession.currentTime);
|
||||
final index = playbackSession.audioTracks.indexOf(track);
|
||||
|
||||
await _player.setAudioSources(
|
||||
audioSources,
|
||||
initialIndex: index,
|
||||
initialPosition: playbackSession.currentTime - track.startOffset,
|
||||
);
|
||||
_player.seek(playbackSession.currentTime - track.startOffset, index: index);
|
||||
await play();
|
||||
// 恢复上次播放位置(如果有)
|
||||
// if (initialPosition != null) {
|
||||
// await seekInBook(initialPosition);
|
||||
// }
|
||||
}
|
||||
|
||||
/// need to override getDuration and getCurrentPosition to return according to the book instead of the current track
|
||||
/// this is because the book can be a list of audio files and the player is only aware of the current track
|
||||
/// so we need to calculate the duration and current position based on the book
|
||||
Future<void> seekInBook(Duration globalPosition) async {
|
||||
if (_book == null) {
|
||||
_logger.warning('No book is set, 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;
|
||||
}
|
||||
// 切换到目标音轨具体位置
|
||||
if (index != currentIndex) {
|
||||
await seek(positionInTrack, index: index);
|
||||
}
|
||||
await seek(positionInTrack);
|
||||
}
|
||||
// // 音轨切换处理
|
||||
// void _onTrackChanged(int trackIndex) {
|
||||
// if (_book == null) return;
|
||||
|
||||
// // 可以在这里处理音轨切换逻辑,比如预加载下一音轨
|
||||
// // print('切换到音轨: ${_book!.tracks[trackIndex].title}');
|
||||
// }
|
||||
|
||||
// 核心功能:跳转到指定章节
|
||||
Future<void> skipToChapter(int chapterId, {Duration? position}) async {
|
||||
if (_book == null) return;
|
||||
Future<void> skipToChapter(int chapterId) async {
|
||||
if (_session == null) return;
|
||||
|
||||
final chapter = _book!.chapters.firstWhere(
|
||||
final chapter = _session!.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;
|
||||
}
|
||||
await seekInBook(chapter.start + offset);
|
||||
}
|
||||
|
||||
PlaybackSessionExpanded? get session => _session;
|
||||
|
||||
// 当前音轨
|
||||
AudioTrack? get currentTrack {
|
||||
if (_session == null || _player.currentIndex == null) {
|
||||
return null;
|
||||
}
|
||||
return _session!.audioTracks[_player.currentIndex!];
|
||||
}
|
||||
|
||||
// 当前章节
|
||||
BookChapter? get currentChapter {
|
||||
return _currentChapterObject.value;
|
||||
}
|
||||
|
||||
Duration get position => _player.position;
|
||||
Duration get positionInChapter {
|
||||
return _player.position +
|
||||
(currentTrack?.startOffset ?? Duration.zero) -
|
||||
(currentChapter?.start ?? Duration.zero);
|
||||
}
|
||||
|
||||
Duration get positionInBook {
|
||||
return _player.position + (currentTrack?.startOffset ?? Duration.zero);
|
||||
}
|
||||
|
||||
Duration get bufferedPositionInBook {
|
||||
return _player.bufferedPosition +
|
||||
(currentTrack?.startOffset ?? Duration.zero);
|
||||
}
|
||||
|
||||
Duration? get chapterDuration => currentChapter?.duration;
|
||||
|
||||
Stream<PlayerState> get playerStateStream => _player.playerStateStream;
|
||||
|
||||
Stream<Duration> get positionStream => _player.positionStream;
|
||||
|
||||
Stream<Duration> get positionStreamInBook {
|
||||
return _player.positionStream.map((position) {
|
||||
return position + (currentTrack?.startOffset ?? Duration.zero);
|
||||
});
|
||||
}
|
||||
|
||||
Stream<Duration> get slowPositionStreamInBook {
|
||||
final superPositionStream = _player.createPositionStream(
|
||||
steps: 100,
|
||||
minPeriod: const Duration(milliseconds: 500),
|
||||
maxPeriod: const Duration(seconds: 1),
|
||||
);
|
||||
return superPositionStream.map((position) {
|
||||
return position + (currentTrack?.startOffset ?? Duration.zero);
|
||||
});
|
||||
}
|
||||
|
||||
Stream<Duration> get bufferedPositionStreamInBook {
|
||||
return _player.bufferedPositionStream.map((position) {
|
||||
return position + (currentTrack?.startOffset ?? Duration.zero);
|
||||
});
|
||||
}
|
||||
|
||||
Stream<Duration> get positionStreamInChapter {
|
||||
return _player.positionStream.distinct().map((position) {
|
||||
return position +
|
||||
(currentTrack?.startOffset ?? Duration.zero) -
|
||||
(currentChapter?.start ?? Duration.zero);
|
||||
});
|
||||
}
|
||||
|
||||
Stream<BookChapter?> get chapterStream => _currentChapterObject.stream;
|
||||
|
||||
Future<void> togglePlayPause() async {
|
||||
// check if book is set
|
||||
if (_session == null) {
|
||||
return Future.value();
|
||||
}
|
||||
_player.playerState.playing ? await pause() : await play();
|
||||
}
|
||||
|
||||
// 播放控制方法
|
||||
@override
|
||||
Future<void> seekToNext() async {
|
||||
if (_book == null) {
|
||||
Future<void> play() async {
|
||||
await _player.play();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
await _player.pause();
|
||||
}
|
||||
|
||||
// 重写上一曲/下一曲为章节导航
|
||||
@override
|
||||
Future<void> skipToNext() async {
|
||||
if (_session == null) {
|
||||
// 回退到默认行为
|
||||
return super.seekToNext();
|
||||
return _player.seekToNext();
|
||||
}
|
||||
final chapter = currentChapter;
|
||||
if (chapter == null) {
|
||||
// 回退到默认行为
|
||||
return super.seekToNext();
|
||||
return _player.seekToNext();
|
||||
}
|
||||
final currentIndex = _book!.chapters.indexOf(chapter);
|
||||
if (currentIndex < _book!.chapters.length - 1) {
|
||||
final chapterIndex = _session!.chapters.indexOf(chapter);
|
||||
if (chapterIndex < _session!.chapters.length - 1) {
|
||||
// 跳到下一章
|
||||
final nextChapter = _book!.chapters[currentIndex + 1];
|
||||
final nextChapter = _session!.chapters[chapterIndex + 1];
|
||||
await skipToChapter(nextChapter.id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seekToPrevious() async {
|
||||
if (_book == null) {
|
||||
return super.seekToPrevious();
|
||||
}
|
||||
|
||||
Future<void> skipToPrevious() async {
|
||||
final chapter = currentChapter;
|
||||
if (chapter == null) {
|
||||
return super.seekToPrevious();
|
||||
if (_session == null || chapter == null) {
|
||||
return _player.seekToPrevious();
|
||||
}
|
||||
final currentIndex = _book!.chapters.indexOf(chapter);
|
||||
final currentIndex = _session!.chapters.indexOf(chapter);
|
||||
if (currentIndex > 0) {
|
||||
// 跳到上一章
|
||||
final prevChapter = _book!.chapters[currentIndex - 1];
|
||||
final prevChapter = _session!.chapters[currentIndex - 1];
|
||||
await skipToChapter(prevChapter.id);
|
||||
} else {
|
||||
// 已经是第一章,回到开头
|
||||
|
|
@ -242,84 +241,77 @@ class AudiobookPlayer extends AudioPlayer {
|
|||
}
|
||||
}
|
||||
|
||||
/// a convenience method to get position in the book instead of the current track position
|
||||
Duration get positionInBook {
|
||||
if (_book == null || currentIndex == null) {
|
||||
return Duration.zero;
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
// 这个 position 是当前音轨内的位置,我们不直接使用
|
||||
// 而是通过全局位置来控制
|
||||
final track = currentTrack;
|
||||
Duration startOffset = Duration.zero;
|
||||
if (track != null) {
|
||||
startOffset = track.startOffset;
|
||||
}
|
||||
return position + _book!.tracks[currentIndex!].startOffset;
|
||||
// return position + _book!.tracks[sequenceState!.currentIndex].startOffset;
|
||||
await seekInBook(startOffset + position);
|
||||
}
|
||||
|
||||
/// a convenience method to get the buffered position in the book instead of the current track position
|
||||
Duration get bufferedPositionInBook {
|
||||
if (_book == null || currentIndex == null) {
|
||||
return Duration.zero;
|
||||
Future<void> setVolume(double volume) async {
|
||||
await _player.setVolume(volume);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setSpeed(double speed) async {
|
||||
await _player.setSpeed(speed);
|
||||
}
|
||||
|
||||
// 核心功能:跳转到全局时间位置
|
||||
Future<void> seekInBook(Duration globalPosition) async {
|
||||
if (_session == null) return;
|
||||
// 找到目标音轨和在音轨内的位置
|
||||
final track = _session!.findTrackAtTime(globalPosition);
|
||||
final index = _session!.audioTracks.indexOf(track);
|
||||
Duration positionInTrack = globalPosition - track.startOffset;
|
||||
if (positionInTrack < Duration.zero) {
|
||||
positionInTrack = Duration.zero;
|
||||
}
|
||||
return bufferedPosition + _book!.tracks[currentIndex!].startOffset;
|
||||
// return bufferedPosition + _book!.tracks[sequenceState!.currentIndex].startOffset;
|
||||
// 切换到目标音轨具体位置
|
||||
await _player.seek(positionInTrack, index: index);
|
||||
}
|
||||
|
||||
// 章节进度
|
||||
Stream<Duration> get positionStreamInChapter {
|
||||
return super.positionStream.map((position) {
|
||||
if (_book == null || currentIndex == null) {
|
||||
return Duration.zero;
|
||||
}
|
||||
final globalPosition =
|
||||
position + _book!.tracks[currentIndex!].startOffset;
|
||||
final chapter = _book!.findChapterAtTime(globalPosition);
|
||||
return globalPosition - chapter.start;
|
||||
});
|
||||
}
|
||||
|
||||
/// streams to override to suit the book instead of the current track
|
||||
// - positionStream
|
||||
// - bufferedPositionStream
|
||||
Stream<Duration> get positionStreamInBook {
|
||||
// return the positionInBook stream
|
||||
return super.positionStream.map((position) {
|
||||
if (_book == null || currentIndex == null) {
|
||||
return Duration.zero;
|
||||
}
|
||||
return position + _book!.tracks[currentIndex!].startOffset;
|
||||
// return position + _book!.tracks[sequenceState!.currentIndex].startOffset;
|
||||
});
|
||||
}
|
||||
|
||||
Stream<Duration> get bufferedPositionStreamInBook {
|
||||
return super.bufferedPositionStream.map((position) {
|
||||
if (_book == null || currentIndex == null) {
|
||||
return Duration.zero;
|
||||
}
|
||||
return position + _book!.tracks[currentIndex!].startOffset;
|
||||
// return position + _book!.tracks[sequenceState!.currentIndex].startOffset;
|
||||
});
|
||||
}
|
||||
|
||||
/// a convenience getter for slow position stream
|
||||
Stream<Duration> get slowPositionStreamInBook {
|
||||
final superPositionStream = createPositionStream(
|
||||
steps: 100,
|
||||
minPeriod: const Duration(milliseconds: 500),
|
||||
maxPeriod: const Duration(seconds: 1),
|
||||
AudioPlayer get player => _player;
|
||||
PlaybackState _transformEvent(PlaybackEvent event) {
|
||||
return PlaybackState(
|
||||
controls: [
|
||||
if (kIsWeb || !Platform.isAndroid) MediaControl.skipToPrevious,
|
||||
MediaControl.rewind,
|
||||
if (_player.playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.stop,
|
||||
MediaControl.fastForward,
|
||||
if (kIsWeb || !Platform.isAndroid) MediaControl.skipToNext,
|
||||
],
|
||||
systemActions: {
|
||||
if (kIsWeb || !Platform.isAndroid) MediaAction.skipToPrevious,
|
||||
MediaAction.rewind,
|
||||
MediaAction.seek,
|
||||
MediaAction.fastForward,
|
||||
MediaAction.stop,
|
||||
MediaAction.setSpeed,
|
||||
if (kIsWeb || !Platform.isAndroid) MediaAction.skipToNext,
|
||||
},
|
||||
androidCompactActionIndices: const [1, 2, 3],
|
||||
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: event.bufferedPosition,
|
||||
speed: _player.speed,
|
||||
queueIndex: event.currentIndex,
|
||||
captioningEnabled: false,
|
||||
);
|
||||
// now we need to map the position to the book instead of the current track
|
||||
return superPositionStream.map((position) {
|
||||
if (_book == null || currentIndex == null) {
|
||||
return Duration.zero;
|
||||
}
|
||||
return position + _book!.tracks[currentIndex!].startOffset;
|
||||
// return position + _book!.tracks[sequenceState!.currentIndex].startOffset;
|
||||
});
|
||||
}
|
||||
|
||||
/// get current chapter
|
||||
BookChapter? get currentChapter {
|
||||
if (_book == null) {
|
||||
return null;
|
||||
}
|
||||
return _book!.findChapterAtTime(positionInBook);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -340,46 +332,7 @@ Uri _getUri(
|
|||
Uri.parse('${baseUrl.toString()}${track.contentUrl}?token=$token');
|
||||
}
|
||||
|
||||
extension FormatNotificationTitle on String {
|
||||
String formatNotificationTitle(BookExpanded book) {
|
||||
return replaceAllMapped(
|
||||
RegExp(r'\$(\w+)'),
|
||||
(match) {
|
||||
final type = match.group(1);
|
||||
return NotificationTitleType.values
|
||||
.firstWhere((element) => element.name == type)
|
||||
.extractFrom(book) ??
|
||||
match.group(0) ??
|
||||
'';
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension NotificationTitleUtils on NotificationTitleType {
|
||||
String? extractFrom(BookExpanded book) {
|
||||
var bookMetadataExpanded = book.metadata.asBookMetadataExpanded;
|
||||
switch (this) {
|
||||
case NotificationTitleType.bookTitle:
|
||||
return bookMetadataExpanded.title;
|
||||
case NotificationTitleType.chapterTitle:
|
||||
// TODO: implement chapter title; depends on https://github.com/Dr-Blank/Vaani/issues/2
|
||||
return bookMetadataExpanded.title;
|
||||
case NotificationTitleType.author:
|
||||
return bookMetadataExpanded.authorName;
|
||||
case NotificationTitleType.narrator:
|
||||
return bookMetadataExpanded.narratorName;
|
||||
case NotificationTitleType.series:
|
||||
return bookMetadataExpanded.seriesName;
|
||||
case NotificationTitleType.subtitle:
|
||||
return bookMetadataExpanded.subtitle;
|
||||
case NotificationTitleType.year:
|
||||
return bookMetadataExpanded.publishedYear;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BookExpandedExtension on BookExpanded {
|
||||
extension PlaybackSessionExpandedExtension on PlaybackSessionExpanded {
|
||||
BookChapter findChapterAtTime(Duration position) {
|
||||
return chapters.firstWhere(
|
||||
(element) {
|
||||
|
|
@ -390,16 +343,23 @@ extension BookExpandedExtension on BookExpanded {
|
|||
}
|
||||
|
||||
AudioTrack findTrackAtTime(Duration position) {
|
||||
return tracks.firstWhere(
|
||||
return audioTracks.firstWhere(
|
||||
(element) {
|
||||
return element.startOffset <= position &&
|
||||
element.startOffset + element.duration >= position + offset;
|
||||
},
|
||||
orElse: () => tracks.first,
|
||||
orElse: () => audioTracks.first,
|
||||
);
|
||||
}
|
||||
|
||||
int findTrackIndexAtTime(Duration position) {
|
||||
return audioTracks.indexWhere((element) {
|
||||
return element.startOffset <= position &&
|
||||
element.startOffset + element.duration >= position + offset;
|
||||
});
|
||||
}
|
||||
|
||||
Duration getTrackStartOffset(int index) {
|
||||
return tracks[index].startOffset;
|
||||
return audioTracks[index].startOffset;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,365 +0,0 @@
|
|||
// my_audio_handler.dart
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart';
|
||||
import 'package:vaani/features/player/core/player_status.dart' as core;
|
||||
import 'package:vaani/features/player/providers/player_status_provider.dart';
|
||||
import 'package:vaani/shared/extensions/chapter.dart';
|
||||
|
||||
// add a small offset so the display does not show the previous chapter for a split second
|
||||
final offset = Duration(milliseconds: 10);
|
||||
|
||||
class AbsAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
// final List<AudioSource> _playlist = [];
|
||||
final Ref ref;
|
||||
|
||||
PlaybackSessionExpanded? _session;
|
||||
|
||||
final _currentChapterObject = BehaviorSubject<BookChapter?>.seeded(null);
|
||||
AbsAudioHandler(this.ref) {
|
||||
_setupAudioPlayer();
|
||||
}
|
||||
|
||||
void _setupAudioPlayer() {
|
||||
final statusNotifier = ref.read(playerStatusProvider.notifier);
|
||||
|
||||
// 转发播放状态
|
||||
_player.playbackEventStream.map(_transformEvent).pipe(playbackState);
|
||||
_player.playerStateStream.listen((event) {
|
||||
if (event.playing) {
|
||||
statusNotifier.setPlayStatusVerify(core.PlayStatus.playing);
|
||||
} else {
|
||||
statusNotifier.setPlayStatusVerify(core.PlayStatus.paused);
|
||||
}
|
||||
});
|
||||
_player.positionStream.distinct().listen((position) {
|
||||
final chapter = _session?.findChapterAtTime(positionInBook);
|
||||
if (chapter != currentChapter) {
|
||||
_currentChapterObject.sink.add(chapter);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 加载有声书
|
||||
Future<void> setSourceAudiobook(
|
||||
PlaybackSessionExpanded playbackSession, {
|
||||
required Uri baseUrl,
|
||||
required String token,
|
||||
List<Uri>? downloadedUris,
|
||||
}) async {
|
||||
_session = playbackSession;
|
||||
|
||||
// 添加所有音轨
|
||||
List<AudioSource> audioSources = [];
|
||||
for (final track in playbackSession.audioTracks) {
|
||||
audioSources.add(
|
||||
AudioSource.uri(
|
||||
_getUri(track, downloadedUris, baseUrl: baseUrl, token: token),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
playMediaItem(
|
||||
MediaItem(
|
||||
id: playbackSession.libraryItemId,
|
||||
album: playbackSession.mediaMetadata.title,
|
||||
title: playbackSession.displayTitle,
|
||||
displaySubtitle: playbackSession.mediaType == MediaType.book
|
||||
? (playbackSession.mediaMetadata as BookMetadata).subtitle
|
||||
: null,
|
||||
duration: playbackSession.duration,
|
||||
artUri: Uri.parse(
|
||||
'$baseUrl/api/items/${playbackSession.libraryItemId}/cover?token=$token',
|
||||
),
|
||||
),
|
||||
);
|
||||
final track = playbackSession.findTrackAtTime(playbackSession.currentTime);
|
||||
final index = playbackSession.audioTracks.indexOf(track);
|
||||
|
||||
await _player.setAudioSources(
|
||||
audioSources,
|
||||
initialIndex: index,
|
||||
initialPosition: playbackSession.currentTime - track.startOffset,
|
||||
);
|
||||
_player.seek(playbackSession.currentTime - track.startOffset, index: index);
|
||||
await play();
|
||||
// 恢复上次播放位置(如果有)
|
||||
// if (initialPosition != null) {
|
||||
// await seekInBook(initialPosition);
|
||||
// }
|
||||
}
|
||||
|
||||
// // 音轨切换处理
|
||||
// void _onTrackChanged(int trackIndex) {
|
||||
// if (_book == null) return;
|
||||
|
||||
// // 可以在这里处理音轨切换逻辑,比如预加载下一音轨
|
||||
// // print('切换到音轨: ${_book!.tracks[trackIndex].title}');
|
||||
// }
|
||||
|
||||
// 核心功能:跳转到指定章节
|
||||
Future<void> skipToChapter(int chapterId) async {
|
||||
if (_session == null) return;
|
||||
|
||||
final chapter = _session!.chapters.firstWhere(
|
||||
(ch) => ch.id == chapterId,
|
||||
orElse: () => throw Exception('Chapter not found'),
|
||||
);
|
||||
await seekInBook(chapter.start + offset);
|
||||
}
|
||||
|
||||
PlaybackSessionExpanded? get session => _session;
|
||||
|
||||
// 当前音轨
|
||||
AudioTrack? get currentTrack {
|
||||
if (_session == null || _player.currentIndex == null) {
|
||||
return null;
|
||||
}
|
||||
return _session!.audioTracks[_player.currentIndex!];
|
||||
}
|
||||
|
||||
// 当前章节
|
||||
BookChapter? get currentChapter {
|
||||
return _currentChapterObject.value;
|
||||
}
|
||||
|
||||
Duration get position => _player.position;
|
||||
Duration get positionInChapter {
|
||||
return _player.position +
|
||||
(currentTrack?.startOffset ?? Duration.zero) -
|
||||
(currentChapter?.start ?? Duration.zero);
|
||||
}
|
||||
|
||||
Duration get positionInBook {
|
||||
return _player.position + (currentTrack?.startOffset ?? Duration.zero);
|
||||
}
|
||||
|
||||
Duration get bufferedPositionInBook {
|
||||
return _player.bufferedPosition +
|
||||
(currentTrack?.startOffset ?? Duration.zero);
|
||||
}
|
||||
|
||||
Duration? get chapterDuration => currentChapter?.duration;
|
||||
|
||||
Stream<PlayerState> get playerStateStream => _player.playerStateStream;
|
||||
|
||||
Stream<Duration> get positionStream => _player.positionStream;
|
||||
|
||||
Stream<Duration> get positionStreamInBook {
|
||||
return _player.positionStream.map((position) {
|
||||
return position + (currentTrack?.startOffset ?? Duration.zero);
|
||||
});
|
||||
}
|
||||
|
||||
Stream<Duration> get slowPositionStreamInBook {
|
||||
final superPositionStream = _player.createPositionStream(
|
||||
steps: 100,
|
||||
minPeriod: const Duration(milliseconds: 500),
|
||||
maxPeriod: const Duration(seconds: 1),
|
||||
);
|
||||
return superPositionStream.map((position) {
|
||||
return position + (currentTrack?.startOffset ?? Duration.zero);
|
||||
});
|
||||
}
|
||||
|
||||
Stream<Duration> get bufferedPositionStreamInBook {
|
||||
return _player.bufferedPositionStream.map((position) {
|
||||
return position + (currentTrack?.startOffset ?? Duration.zero);
|
||||
});
|
||||
}
|
||||
|
||||
Stream<Duration> get positionStreamInChapter {
|
||||
return _player.positionStream.distinct().map((position) {
|
||||
return position +
|
||||
(currentTrack?.startOffset ?? Duration.zero) -
|
||||
(currentChapter?.start ?? Duration.zero);
|
||||
});
|
||||
}
|
||||
|
||||
Stream<BookChapter?> get chapterStream => _currentChapterObject.stream;
|
||||
|
||||
Future<void> togglePlayPause() async {
|
||||
// check if book is set
|
||||
if (_session == null) {
|
||||
return Future.value();
|
||||
}
|
||||
_player.playerState.playing ? await pause() : await play();
|
||||
}
|
||||
|
||||
// 播放控制方法
|
||||
@override
|
||||
Future<void> play() async {
|
||||
await _player.play();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
await _player.pause();
|
||||
}
|
||||
|
||||
// 重写上一曲/下一曲为章节导航
|
||||
@override
|
||||
Future<void> skipToNext() async {
|
||||
if (_session == null) {
|
||||
// 回退到默认行为
|
||||
return _player.seekToNext();
|
||||
}
|
||||
final chapter = currentChapter;
|
||||
if (chapter == null) {
|
||||
// 回退到默认行为
|
||||
return _player.seekToNext();
|
||||
}
|
||||
final chapterIndex = _session!.chapters.indexOf(chapter);
|
||||
if (chapterIndex < _session!.chapters.length - 1) {
|
||||
// 跳到下一章
|
||||
final nextChapter = _session!.chapters[chapterIndex + 1];
|
||||
await skipToChapter(nextChapter.id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToPrevious() async {
|
||||
final chapter = currentChapter;
|
||||
if (_session == null || chapter == null) {
|
||||
return _player.seekToPrevious();
|
||||
}
|
||||
final currentIndex = _session!.chapters.indexOf(chapter);
|
||||
if (currentIndex > 0) {
|
||||
// 跳到上一章
|
||||
final prevChapter = _session!.chapters[currentIndex - 1];
|
||||
await skipToChapter(prevChapter.id);
|
||||
} else {
|
||||
// 已经是第一章,回到开头
|
||||
await seekInBook(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
// 这个 position 是当前音轨内的位置,我们不直接使用
|
||||
// 而是通过全局位置来控制
|
||||
final track = currentTrack;
|
||||
Duration startOffset = Duration.zero;
|
||||
if (track != null) {
|
||||
startOffset = track.startOffset;
|
||||
}
|
||||
await seekInBook(startOffset + position);
|
||||
}
|
||||
|
||||
Future<void> setVolume(double volume) async {
|
||||
await _player.setVolume(volume);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setSpeed(double speed) async {
|
||||
await _player.setSpeed(speed);
|
||||
}
|
||||
|
||||
// 核心功能:跳转到全局时间位置
|
||||
Future<void> seekInBook(Duration globalPosition) async {
|
||||
if (_session == null) return;
|
||||
// 找到目标音轨和在音轨内的位置
|
||||
final track = _session!.findTrackAtTime(globalPosition);
|
||||
final index = _session!.audioTracks.indexOf(track);
|
||||
Duration positionInTrack = globalPosition - track.startOffset;
|
||||
if (positionInTrack < Duration.zero) {
|
||||
positionInTrack = Duration.zero;
|
||||
}
|
||||
// 切换到目标音轨具体位置
|
||||
await _player.seek(positionInTrack, index: index);
|
||||
}
|
||||
|
||||
AudioPlayer get player => _player;
|
||||
PlaybackState _transformEvent(PlaybackEvent event) {
|
||||
return PlaybackState(
|
||||
controls: [
|
||||
if (kIsWeb || !Platform.isAndroid) MediaControl.skipToPrevious,
|
||||
MediaControl.rewind,
|
||||
if (_player.playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.stop,
|
||||
MediaControl.fastForward,
|
||||
if (kIsWeb || !Platform.isAndroid) MediaControl.skipToNext,
|
||||
],
|
||||
systemActions: {
|
||||
if (kIsWeb || !Platform.isAndroid) MediaAction.skipToPrevious,
|
||||
MediaAction.rewind,
|
||||
MediaAction.seek,
|
||||
MediaAction.fastForward,
|
||||
MediaAction.stop,
|
||||
MediaAction.setSpeed,
|
||||
if (kIsWeb || !Platform.isAndroid) MediaAction.skipToNext,
|
||||
},
|
||||
androidCompactActionIndices: const [1, 2, 3],
|
||||
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: event.bufferedPosition,
|
||||
speed: _player.speed,
|
||||
queueIndex: event.currentIndex,
|
||||
captioningEnabled: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Uri _getUri(
|
||||
AudioTrack track,
|
||||
List<Uri>? downloadedUris, {
|
||||
required Uri baseUrl,
|
||||
required String token,
|
||||
}) {
|
||||
// check if the track is in the downloadedUris
|
||||
final uri = downloadedUris?.firstWhereOrNull(
|
||||
(element) {
|
||||
return element.pathSegments.last == track.metadata?.filename;
|
||||
},
|
||||
);
|
||||
|
||||
return uri ??
|
||||
Uri.parse('${baseUrl.toString()}${track.contentUrl}?token=$token');
|
||||
}
|
||||
|
||||
extension PlaybackSessionExpandedExtension on PlaybackSessionExpanded {
|
||||
BookChapter findChapterAtTime(Duration position) {
|
||||
return chapters.firstWhere(
|
||||
(element) {
|
||||
return element.start <= position && element.end >= position + offset;
|
||||
},
|
||||
orElse: () => chapters.first,
|
||||
);
|
||||
}
|
||||
|
||||
AudioTrack findTrackAtTime(Duration position) {
|
||||
return audioTracks.firstWhere(
|
||||
(element) {
|
||||
return element.startOffset <= position &&
|
||||
element.startOffset + element.duration >= position + offset;
|
||||
},
|
||||
orElse: () => audioTracks.first,
|
||||
);
|
||||
}
|
||||
|
||||
int findTrackIndexAtTime(Duration position) {
|
||||
return audioTracks.indexWhere((element) {
|
||||
return element.startOffset <= position &&
|
||||
element.startOffset + element.duration >= position + offset;
|
||||
});
|
||||
}
|
||||
|
||||
Duration getTrackStartOffset(int index) {
|
||||
return audioTracks[index].startOffset;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
// import 'package:audio_service/audio_service.dart';
|
||||
// import 'package:audio_session/audio_session.dart';
|
||||
// import 'package:just_audio_background/just_audio_background.dart'
|
||||
// show JustAudioBackground, NotificationConfig;
|
||||
// import 'package:just_audio_media_kit/just_audio_media_kit.dart'
|
||||
// show JustAudioMediaKit;
|
||||
// import 'package:vaani/settings/app_settings_provider.dart';
|
||||
// import 'package:vaani/settings/models/app_settings.dart';
|
||||
|
||||
// Future<void> configurePlayer() async {
|
||||
// // for playing audio on windows, linux
|
||||
// JustAudioMediaKit.ensureInitialized(windows: false);
|
||||
|
||||
// // for configuring how this app will interact with other audio apps
|
||||
// final session = await AudioSession.instance;
|
||||
// await session.configure(const AudioSessionConfiguration.speech());
|
||||
|
||||
// final appSettings = loadOrCreateAppSettings();
|
||||
|
||||
// // for playing audio in the background
|
||||
// await JustAudioBackground.init(
|
||||
// androidNotificationChannelId: 'com.vaani.bg_demo.channel.audio',
|
||||
// androidNotificationChannelName: 'Audio playback',
|
||||
// androidNotificationOngoing: false,
|
||||
// androidStopForegroundOnPause: false,
|
||||
// androidNotificationChannelDescription: 'Audio playback in the background',
|
||||
// androidNotificationIcon: 'drawable/ic_stat_logo',
|
||||
// rewindInterval: appSettings.notificationSettings.rewindInterval,
|
||||
// fastForwardInterval: appSettings.notificationSettings.fastForwardInterval,
|
||||
// androidShowNotificationBadge: false,
|
||||
// notificationConfigBuilder: (state) {
|
||||
// final controls = [
|
||||
// if (appSettings.notificationSettings.mediaControls
|
||||
// .contains(NotificationMediaControl.skipToPreviousChapter) &&
|
||||
// state.hasPrevious)
|
||||
// MediaControl.skipToPrevious,
|
||||
// if (appSettings.notificationSettings.mediaControls
|
||||
// .contains(NotificationMediaControl.rewind))
|
||||
// MediaControl.rewind,
|
||||
// if (state.playing) MediaControl.pause else MediaControl.play,
|
||||
// if (appSettings.notificationSettings.mediaControls
|
||||
// .contains(NotificationMediaControl.fastForward))
|
||||
// MediaControl.fastForward,
|
||||
// if (appSettings.notificationSettings.mediaControls
|
||||
// .contains(NotificationMediaControl.skipToNextChapter) &&
|
||||
// state.hasNext)
|
||||
// MediaControl.skipToNext,
|
||||
// if (appSettings.notificationSettings.mediaControls
|
||||
// .contains(NotificationMediaControl.stop))
|
||||
// MediaControl.stop,
|
||||
// ];
|
||||
// return NotificationConfig(
|
||||
// controls: controls,
|
||||
// systemActions: const {
|
||||
// MediaAction.seek,
|
||||
// MediaAction.seekForward,
|
||||
// MediaAction.seekBackward,
|
||||
// },
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
|
|
@ -1,71 +1,112 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:just_audio_media_kit/just_audio_media_kit.dart';
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart' as shelfsdk;
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart' as core;
|
||||
import 'package:vaani/api/api_provider.dart';
|
||||
import 'package:vaani/features/player/core/audiobook_player.dart' as core;
|
||||
import 'package:vaani/api/library_item_provider.dart';
|
||||
import 'package:vaani/features/downloads/providers/download_manager.dart';
|
||||
import 'package:vaani/features/per_book_settings/providers/book_settings_provider.dart';
|
||||
import 'package:vaani/features/player/core/audiobook_player.dart';
|
||||
import 'package:vaani/features/player/providers/player_status_provider.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/shared/extensions/model_conversions.dart';
|
||||
import 'package:vaani/shared/utils/helper.dart';
|
||||
|
||||
part 'audiobook_player.g.dart';
|
||||
|
||||
final _logger = Logger('AudiobookPlayerProvider');
|
||||
|
||||
const playerId = 'audiobook_player';
|
||||
|
||||
/// Simple because it doesn't rebuild when the player state changes
|
||||
/// it only rebuilds when the token changes
|
||||
@Riverpod(keepAlive: true)
|
||||
class SimpleAudiobookPlayer extends _$SimpleAudiobookPlayer {
|
||||
Future<AbsAudioHandler> audioHandlerInit(Ref ref) async {
|
||||
if (Helper.isWindows() || Helper.isLinux()) {
|
||||
// JustAudioMediaKit.ensureInitialized(windows: false);
|
||||
JustAudioMediaKit.ensureInitialized();
|
||||
}
|
||||
|
||||
final audioService = await AudioService.init(
|
||||
builder: () => AbsAudioHandler(ref),
|
||||
config: const AudioServiceConfig(
|
||||
androidNotificationChannelId: 'dr.blank.vaani.channel.audio',
|
||||
androidNotificationChannelName: 'ABSPlayback',
|
||||
androidNotificationChannelDescription:
|
||||
'Needed to control audio from lock screen',
|
||||
androidNotificationOngoing: false,
|
||||
androidStopForegroundOnPause: false,
|
||||
androidNotificationIcon: 'drawable/ic_stat_logo',
|
||||
preloadArtwork: true,
|
||||
),
|
||||
);
|
||||
return audioService;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class Player extends _$Player {
|
||||
@override
|
||||
core.AudiobookPlayer build() {
|
||||
final api = ref.watch(authenticatedApiProvider);
|
||||
final player = core.AudiobookPlayer(
|
||||
api.token!,
|
||||
api.baseUrl,
|
||||
);
|
||||
|
||||
ref.onDispose(player.dispose);
|
||||
_logger.finer('created simple player');
|
||||
|
||||
return player;
|
||||
AbsAudioHandler build() {
|
||||
return ref.watch(audioHandlerInitProvider).requireValue;
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class AudiobookPlayer extends _$AudiobookPlayer {
|
||||
class Session extends _$Session {
|
||||
@override
|
||||
core.AudiobookPlayer build() {
|
||||
final player = ref.watch(simpleAudiobookPlayerProvider);
|
||||
|
||||
ref.onDispose(player.dispose);
|
||||
|
||||
// bind notify listeners to the player
|
||||
// player.playerStateStream.listen((_) {
|
||||
// ref.notifyListeners();
|
||||
// });
|
||||
|
||||
_logger.finer('created player');
|
||||
|
||||
return player;
|
||||
core.PlaybackSessionExpanded? build() {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> setSpeed(double speed) async {
|
||||
await state.setSpeed(speed);
|
||||
ref.notifyListeners();
|
||||
}
|
||||
Future<void> load(String id, String? episodeId) async {
|
||||
final audioService = ref.read(playerProvider);
|
||||
await audioService.pause();
|
||||
ref.read(playerStatusProvider.notifier).setLoading(id);
|
||||
final api = ref.read(authenticatedApiProvider);
|
||||
final playBack = await ref.watch(playBackSessionProvider(id).future);
|
||||
if (playBack == null) {
|
||||
return;
|
||||
}
|
||||
state = playBack.asExpanded;
|
||||
final downloadManager = ref.read(simpleDownloadManagerProvider);
|
||||
final libItem =
|
||||
await ref.read(libraryItemProvider(state!.libraryItemId).future);
|
||||
final downloadedUris = await downloadManager.getDownloadedFilesUri(libItem);
|
||||
|
||||
Future<void> setSourceAudiobook({
|
||||
required shelfsdk.BookExpanded book,
|
||||
shelfsdk.MediaProgress? userMediaProgress,
|
||||
}) async {
|
||||
ref.notifyListeners();
|
||||
var bookPlayerSettings =
|
||||
ref.read(bookSettingsProvider(state!.libraryItemId)).playerSettings;
|
||||
var appPlayerSettings = ref.read(appSettingsProvider).playerSettings;
|
||||
|
||||
var configurePlayerForEveryBook =
|
||||
appPlayerSettings.configurePlayerForEveryBook;
|
||||
|
||||
await Future.wait([
|
||||
audioService.setSourceAudiobook(
|
||||
state!.asExpanded,
|
||||
baseUrl: api.baseUrl,
|
||||
token: api.token!,
|
||||
downloadedUris: downloadedUris,
|
||||
),
|
||||
// set the volume
|
||||
audioService.setVolume(
|
||||
configurePlayerForEveryBook
|
||||
? bookPlayerSettings.preferredDefaultVolume ??
|
||||
appPlayerSettings.preferredDefaultVolume
|
||||
: appPlayerSettings.preferredDefaultVolume,
|
||||
),
|
||||
// set the speed
|
||||
audioService.setSpeed(
|
||||
configurePlayerForEveryBook
|
||||
? bookPlayerSettings.preferredDefaultSpeed ??
|
||||
appPlayerSettings.preferredDefaultSpeed
|
||||
: appPlayerSettings.preferredDefaultSpeed,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
bool isPlayerPlaying(
|
||||
Ref ref,
|
||||
) {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
print("playing: ${player.playing}");
|
||||
return player.playing;
|
||||
class PlaybackSyncError implements Exception {
|
||||
String message;
|
||||
|
||||
PlaybackSyncError([this.message = 'Error syncing playback']);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PlaybackSyncError: $message';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,58 +6,51 @@ part of 'audiobook_player.dart';
|
|||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$isPlayerPlayingHash() => r'b81fa9cfb51c88c8d9e8f5c1f4f6a12d9e5a0cc1';
|
||||
String _$audioHandlerInitHash() => r'6e4662a45c1c6e84aa16436f71ffcfecc3d4bdab';
|
||||
|
||||
/// See also [isPlayerPlaying].
|
||||
@ProviderFor(isPlayerPlaying)
|
||||
final isPlayerPlayingProvider = AutoDisposeProvider<bool>.internal(
|
||||
isPlayerPlaying,
|
||||
name: r'isPlayerPlayingProvider',
|
||||
/// See also [audioHandlerInit].
|
||||
@ProviderFor(audioHandlerInit)
|
||||
final audioHandlerInitProvider = FutureProvider<AbsAudioHandler>.internal(
|
||||
audioHandlerInit,
|
||||
name: r'audioHandlerInitProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$isPlayerPlayingHash,
|
||||
: _$audioHandlerInitHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef IsPlayerPlayingRef = AutoDisposeProviderRef<bool>;
|
||||
String _$simpleAudiobookPlayerHash() =>
|
||||
r'5e94bbff4314adceb5affa704fc4d079d4016afa';
|
||||
typedef AudioHandlerInitRef = FutureProviderRef<AbsAudioHandler>;
|
||||
String _$playerHash() => r'9599b094cdd9eca614c27ec5bdf2d5259d20ac5f';
|
||||
|
||||
/// Simple because it doesn't rebuild when the player state changes
|
||||
/// it only rebuilds when the token changes
|
||||
///
|
||||
/// Copied from [SimpleAudiobookPlayer].
|
||||
@ProviderFor(SimpleAudiobookPlayer)
|
||||
final simpleAudiobookPlayerProvider =
|
||||
NotifierProvider<SimpleAudiobookPlayer, core.AudiobookPlayer>.internal(
|
||||
SimpleAudiobookPlayer.new,
|
||||
name: r'simpleAudiobookPlayerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$simpleAudiobookPlayerHash,
|
||||
/// See also [Player].
|
||||
@ProviderFor(Player)
|
||||
final playerProvider = NotifierProvider<Player, AbsAudioHandler>.internal(
|
||||
Player.new,
|
||||
name: r'playerProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$playerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$SimpleAudiobookPlayer = Notifier<core.AudiobookPlayer>;
|
||||
String _$audiobookPlayerHash() => r'04448247e79c5d60b9fd6f98eeeb865f1e8d0ff8';
|
||||
typedef _$Player = Notifier<AbsAudioHandler>;
|
||||
String _$sessionHash() => r'c171809249c3021dc445dc1ba90fe8626a3d3b54';
|
||||
|
||||
/// See also [AudiobookPlayer].
|
||||
@ProviderFor(AudiobookPlayer)
|
||||
final audiobookPlayerProvider =
|
||||
NotifierProvider<AudiobookPlayer, core.AudiobookPlayer>.internal(
|
||||
AudiobookPlayer.new,
|
||||
name: r'audiobookPlayerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$audiobookPlayerHash,
|
||||
/// See also [Session].
|
||||
@ProviderFor(Session)
|
||||
final sessionProvider =
|
||||
NotifierProvider<Session, core.PlaybackSessionExpanded?>.internal(
|
||||
Session.new,
|
||||
name: r'sessionProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$sessionHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AudiobookPlayer = Notifier<core.AudiobookPlayer>;
|
||||
typedef _$Session = Notifier<core.PlaybackSessionExpanded?>;
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -1,46 +1,40 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart' as core;
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
import 'package:vaani/shared/extensions/model_conversions.dart';
|
||||
|
||||
part 'currently_playing_provider.g.dart';
|
||||
|
||||
final _logger = Logger('CurrentlyPlayingProvider');
|
||||
|
||||
@riverpod
|
||||
BookExpanded? currentlyPlayingBook(Ref ref) {
|
||||
try {
|
||||
final book = ref.watch(simpleAudiobookPlayerProvider.select((v) => v.book));
|
||||
return book;
|
||||
} catch (e) {
|
||||
_logger.warning('Error getting currently playing book: $e');
|
||||
return null;
|
||||
class CurrentChapter extends _$CurrentChapter {
|
||||
@override
|
||||
core.BookChapter? build() {
|
||||
final player = ref.watch(playerProvider);
|
||||
player.chapterStream.distinct().listen((chapter) {
|
||||
update(chapter);
|
||||
});
|
||||
return player.currentChapter;
|
||||
}
|
||||
|
||||
void update(core.BookChapter? chapter) {
|
||||
if (state != chapter) {
|
||||
state = chapter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// provided the current chapter of the book being played
|
||||
@riverpod
|
||||
BookChapter? currentPlayingChapter(Ref ref) {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
player.slowPositionStreamInBook.listen((_) {
|
||||
ref.invalidateSelf();
|
||||
});
|
||||
|
||||
return player.currentChapter;
|
||||
List<core.BookChapter> currentChapters(Ref ref) {
|
||||
final session = ref.watch(sessionProvider);
|
||||
if (session == null) {
|
||||
return [];
|
||||
}
|
||||
final currentChapter = ref.watch(currentChapterProvider);
|
||||
if (currentChapter == null) {
|
||||
return [];
|
||||
}
|
||||
final index = session.chapters.indexOf(currentChapter);
|
||||
final total = session.chapters.length;
|
||||
return session.chapters
|
||||
.sublist(index - 3, (total - 3) <= (index + 17) ? total : index + 17);
|
||||
}
|
||||
|
||||
/// provides the book metadata of the currently playing book
|
||||
@riverpod
|
||||
BookMetadataExpanded? currentBookMetadata(Ref ref) {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
if (player.book == null) return null;
|
||||
return player.book!.metadata.asBookMetadataExpanded;
|
||||
}
|
||||
|
||||
// /// volume of the player [0, 1]
|
||||
// @riverpod
|
||||
// double currentVolume(CurrentVolumeRef ref) {
|
||||
// return 1;
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -6,66 +6,39 @@ part of 'currently_playing_provider.dart';
|
|||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$currentlyPlayingBookHash() =>
|
||||
r'f2c47028340d253be9440dc29f835328ff30c0e6';
|
||||
String _$currentChaptersHash() => r'f2cc6ec31b5a3a9471775b1c96b2bfc3a91f1c90';
|
||||
|
||||
/// See also [currentlyPlayingBook].
|
||||
@ProviderFor(currentlyPlayingBook)
|
||||
final currentlyPlayingBookProvider =
|
||||
AutoDisposeProvider<BookExpanded?>.internal(
|
||||
currentlyPlayingBook,
|
||||
name: r'currentlyPlayingBookProvider',
|
||||
/// See also [currentChapters].
|
||||
@ProviderFor(currentChapters)
|
||||
final currentChaptersProvider =
|
||||
AutoDisposeProvider<List<core.BookChapter>>.internal(
|
||||
currentChapters,
|
||||
name: r'currentChaptersProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$currentlyPlayingBookHash,
|
||||
: _$currentChaptersHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef CurrentlyPlayingBookRef = AutoDisposeProviderRef<BookExpanded?>;
|
||||
String _$currentPlayingChapterHash() =>
|
||||
r'4a64157089279c71279ccfdbcfc7b32543ecc88c';
|
||||
typedef CurrentChaptersRef = AutoDisposeProviderRef<List<core.BookChapter>>;
|
||||
String _$currentChapterHash() => r'f5f6d9e49cb7e455d032f7370f364d9ce30b8eb1';
|
||||
|
||||
/// provided the current chapter of the book being played
|
||||
///
|
||||
/// Copied from [currentPlayingChapter].
|
||||
@ProviderFor(currentPlayingChapter)
|
||||
final currentPlayingChapterProvider =
|
||||
AutoDisposeProvider<BookChapter?>.internal(
|
||||
currentPlayingChapter,
|
||||
name: r'currentPlayingChapterProvider',
|
||||
/// See also [CurrentChapter].
|
||||
@ProviderFor(CurrentChapter)
|
||||
final currentChapterProvider =
|
||||
AutoDisposeNotifierProvider<CurrentChapter, core.BookChapter?>.internal(
|
||||
CurrentChapter.new,
|
||||
name: r'currentChapterProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$currentPlayingChapterHash,
|
||||
: _$currentChapterHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef CurrentPlayingChapterRef = AutoDisposeProviderRef<BookChapter?>;
|
||||
String _$currentBookMetadataHash() =>
|
||||
r'f537ef4ef19280bc952de658ecf6520c535ae344';
|
||||
|
||||
/// provides the book metadata of the currently playing book
|
||||
///
|
||||
/// Copied from [currentBookMetadata].
|
||||
@ProviderFor(currentBookMetadata)
|
||||
final currentBookMetadataProvider =
|
||||
AutoDisposeProvider<BookMetadataExpanded?>.internal(
|
||||
currentBookMetadata,
|
||||
name: r'currentBookMetadataProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$currentBookMetadataHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef CurrentBookMetadataRef = AutoDisposeProviderRef<BookMetadataExpanded?>;
|
||||
typedef _$CurrentChapter = AutoDisposeNotifier<core.BookChapter?>;
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
// this provider is used to manage the player form state
|
||||
// it will inform about the percentage of the player expanded
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
// import 'package:miniplayer/miniplayer.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
|
||||
part 'player_form.g.dart';
|
||||
|
||||
/// The height of the player when it is minimized
|
||||
const double playerMinHeight = 70;
|
||||
// const miniplayerPercentageDeclaration = 0.2;
|
||||
|
||||
extension on Ref {
|
||||
// We can move the previous logic to a Ref extension.
|
||||
// This enables reusing the logic between providers
|
||||
T disposeAndListenChangeNotifier<T extends ChangeNotifier>(T notifier) {
|
||||
onDispose(notifier.dispose);
|
||||
notifier.addListener(notifyListeners);
|
||||
// We return the notifier to ease the usage a bit
|
||||
return notifier;
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Raw<ValueNotifier<double>> playerExpandProgressNotifier(
|
||||
Ref ref,
|
||||
) {
|
||||
final ValueNotifier<double> playerExpandProgress =
|
||||
ValueNotifier(playerMinHeight);
|
||||
|
||||
return ref.disposeAndListenChangeNotifier(playerExpandProgress);
|
||||
}
|
||||
|
||||
// @Riverpod(keepAlive: true)
|
||||
// Raw<ValueNotifier<double>> dragDownPercentageNotifier(
|
||||
// DragDownPercentageNotifierRef ref,
|
||||
// ) {
|
||||
// final ValueNotifier<double> notifier = ValueNotifier(0);
|
||||
|
||||
// return ref.disposeAndListenChangeNotifier(notifier);
|
||||
// }
|
||||
|
||||
// a provider that will listen to the playerExpandProgressNotifier and return the percentage of the player expanded
|
||||
@Riverpod(keepAlive: true)
|
||||
double playerHeight(
|
||||
Ref ref,
|
||||
) {
|
||||
final playerExpandProgress = ref.watch(playerExpandProgressNotifierProvider);
|
||||
|
||||
// on change of the playerExpandProgress invalidate
|
||||
playerExpandProgress.addListener(() {
|
||||
ref.invalidateSelf();
|
||||
});
|
||||
|
||||
// listen to the playerExpandProgressNotifier and return the value
|
||||
return playerExpandProgress.value;
|
||||
}
|
||||
|
||||
// final audioBookMiniplayerController = MiniplayerController();
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
bool isPlayerActive(
|
||||
Ref ref,
|
||||
) {
|
||||
try {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
if (player.book != null) {
|
||||
return true;
|
||||
} else {
|
||||
final playerHeight = ref.watch(playerHeightProvider);
|
||||
return playerHeight < playerMinHeight;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'player_form.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$playerExpandProgressNotifierHash() =>
|
||||
r'1ac7172d90a070f96222286edd1a176be197f378';
|
||||
|
||||
/// See also [playerExpandProgressNotifier].
|
||||
@ProviderFor(playerExpandProgressNotifier)
|
||||
final playerExpandProgressNotifierProvider =
|
||||
Provider<Raw<ValueNotifier<double>>>.internal(
|
||||
playerExpandProgressNotifier,
|
||||
name: r'playerExpandProgressNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$playerExpandProgressNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef PlayerExpandProgressNotifierRef
|
||||
= ProviderRef<Raw<ValueNotifier<double>>>;
|
||||
String _$playerHeightHash() => r'3f031eaffdffbb2c6ddf7eb1aba31bf1619260fc';
|
||||
|
||||
/// See also [playerHeight].
|
||||
@ProviderFor(playerHeight)
|
||||
final playerHeightProvider = Provider<double>.internal(
|
||||
playerHeight,
|
||||
name: r'playerHeightProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$playerHeightHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef PlayerHeightRef = ProviderRef<double>;
|
||||
String _$isPlayerActiveHash() => r'2c7ca125423126fb5f0ef218d37bc8fe0ca9ec98';
|
||||
|
||||
/// See also [isPlayerActive].
|
||||
@ProviderFor(isPlayerActive)
|
||||
final isPlayerActiveProvider = Provider<bool>.internal(
|
||||
isPlayerActive,
|
||||
name: r'isPlayerActiveProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$isPlayerActiveHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef IsPlayerActiveRef = ProviderRef<bool>;
|
||||
// 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
|
||||
|
|
@ -1 +0,0 @@
|
|||
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:just_audio_media_kit/just_audio_media_kit.dart';
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart' as core;
|
||||
import 'package:vaani/api/api_provider.dart';
|
||||
import 'package:vaani/api/library_item_provider.dart';
|
||||
import 'package:vaani/features/downloads/providers/download_manager.dart';
|
||||
import 'package:vaani/features/per_book_settings/providers/book_settings_provider.dart';
|
||||
import 'package:vaani/features/playback_reporting/core/playback_reporter_session.dart'
|
||||
as core;
|
||||
import 'package:vaani/features/player/core/audiobook_player_session.dart';
|
||||
import 'package:vaani/features/player/providers/player_status_provider.dart';
|
||||
import 'package:vaani/globals.dart';
|
||||
import 'package:vaani/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart';
|
||||
import 'package:vaani/shared/utils/utils.dart';
|
||||
|
||||
part 'session_provider.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<AbsAudioHandler> audioHandlerInit(Ref ref) async {
|
||||
if (Utils.isWindows() || Utils.isLinux()) {
|
||||
// JustAudioMediaKit.ensureInitialized(windows: false);
|
||||
JustAudioMediaKit.ensureInitialized();
|
||||
}
|
||||
|
||||
final audioService = await AudioService.init(
|
||||
builder: () => AbsAudioHandler(ref),
|
||||
config: const AudioServiceConfig(
|
||||
androidNotificationChannelId: 'com.vaani.rang.channel.audio',
|
||||
androidNotificationChannelName: 'ABSPlayback',
|
||||
androidNotificationChannelDescription:
|
||||
'Needed to control audio from lock screen',
|
||||
androidNotificationOngoing: false,
|
||||
androidStopForegroundOnPause: false,
|
||||
androidNotificationIcon: 'drawable/ic_stat_logo',
|
||||
preloadArtwork: true,
|
||||
),
|
||||
);
|
||||
return audioService;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class Player extends _$Player {
|
||||
@override
|
||||
AbsAudioHandler build() {
|
||||
return ref.watch(audioHandlerInitProvider).requireValue;
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class Session extends _$Session {
|
||||
@override
|
||||
core.PlaybackSessionExpanded? build() {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> load(String id, String? episodeId) async {
|
||||
final audioService = ref.read(playerProvider);
|
||||
await audioService.pause();
|
||||
ref.read(playerStatusProvider.notifier).setLoading(id);
|
||||
final api = ref.read(authenticatedApiProvider);
|
||||
final playBack = await api.items.play(
|
||||
libraryItemId: id,
|
||||
parameters: core.PlayItemReqParams(
|
||||
deviceInfo: core.DeviceInfoReqParams(
|
||||
clientVersion: appVersion,
|
||||
manufacturer: deviceManufacturer,
|
||||
model: deviceModel,
|
||||
sdkVersion: deviceSdkVersion,
|
||||
clientName: appName,
|
||||
deviceName: deviceName,
|
||||
),
|
||||
forceDirectPlay: false,
|
||||
forceTranscode: false,
|
||||
supportedMimeTypes: [
|
||||
"audio/flac",
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
"audio/ogg",
|
||||
"audio/aac",
|
||||
"audio/webm",
|
||||
],
|
||||
),
|
||||
responseErrorHandler: _responseErrorHandler,
|
||||
) as core.PlaybackSessionExpanded;
|
||||
state = playBack;
|
||||
final downloadManager = ref.read(simpleDownloadManagerProvider);
|
||||
final libItem =
|
||||
await ref.read(libraryItemProvider(playBack.libraryItemId).future);
|
||||
final downloadedUris = await downloadManager.getDownloadedFilesUri(libItem);
|
||||
|
||||
var bookPlayerSettings =
|
||||
ref.read(bookSettingsProvider(playBack.libraryItemId)).playerSettings;
|
||||
var appPlayerSettings = ref.read(appSettingsProvider).playerSettings;
|
||||
|
||||
var configurePlayerForEveryBook =
|
||||
appPlayerSettings.configurePlayerForEveryBook;
|
||||
|
||||
await Future.wait([
|
||||
audioService.setSourceAudiobook(
|
||||
playBack,
|
||||
baseUrl: api.baseUrl,
|
||||
token: api.token!,
|
||||
downloadedUris: downloadedUris,
|
||||
),
|
||||
// set the volume
|
||||
audioService.setVolume(
|
||||
configurePlayerForEveryBook
|
||||
? bookPlayerSettings.preferredDefaultVolume ??
|
||||
appPlayerSettings.preferredDefaultVolume
|
||||
: appPlayerSettings.preferredDefaultVolume,
|
||||
),
|
||||
// set the speed
|
||||
audioService.setSpeed(
|
||||
configurePlayerForEveryBook
|
||||
? bookPlayerSettings.preferredDefaultSpeed ??
|
||||
appPlayerSettings.preferredDefaultSpeed
|
||||
: appPlayerSettings.preferredDefaultSpeed,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
void _responseErrorHandler(http.Response response, [error]) {
|
||||
if (response.statusCode != 200) {
|
||||
appLogger.severe('Error with api: ${response.obfuscate()}, $error');
|
||||
throw PlaybackSyncError(
|
||||
'Error syncing position: ${response.body}, $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class CurrentChapter extends _$CurrentChapter {
|
||||
@override
|
||||
core.BookChapter? build() {
|
||||
final player = ref.watch(playerProvider);
|
||||
player.chapterStream.distinct().listen((chapter) {
|
||||
update(chapter);
|
||||
});
|
||||
return player.currentChapter;
|
||||
}
|
||||
|
||||
void update(core.BookChapter? chapter) {
|
||||
if (state != chapter) {
|
||||
state = chapter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class PlaybackReporter extends _$PlaybackReporter {
|
||||
@override
|
||||
Future<core.PlaybackReporter?> build() async {
|
||||
final session = ref.watch(sessionProvider);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
final playerSettings = ref.watch(appSettingsProvider).playerSettings;
|
||||
final player = ref.watch(playerProvider);
|
||||
final api = ref.watch(authenticatedApiProvider);
|
||||
|
||||
final reporter = core.PlaybackReporter(
|
||||
player,
|
||||
api,
|
||||
reportingInterval: playerSettings.playbackReportInterval,
|
||||
markCompleteWhenTimeLeft: playerSettings.markCompleteWhenTimeLeft,
|
||||
minimumPositionForReporting: playerSettings.minimumPositionForReporting,
|
||||
session: session,
|
||||
);
|
||||
ref.onDispose(reporter.dispose);
|
||||
return reporter;
|
||||
}
|
||||
}
|
||||
|
||||
class PlaybackSyncError implements Exception {
|
||||
String message;
|
||||
|
||||
PlaybackSyncError([this.message = 'Error syncing playback']);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PlaybackSyncError: $message';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'session_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$audioHandlerInitHash() => r'c54f17757807f8bc14daff5095c34eb88ff2037b';
|
||||
|
||||
/// See also [audioHandlerInit].
|
||||
@ProviderFor(audioHandlerInit)
|
||||
final audioHandlerInitProvider = FutureProvider<AbsAudioHandler>.internal(
|
||||
audioHandlerInit,
|
||||
name: r'audioHandlerInitProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$audioHandlerInitHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef AudioHandlerInitRef = FutureProviderRef<AbsAudioHandler>;
|
||||
String _$playerHash() => r'9599b094cdd9eca614c27ec5bdf2d5259d20ac5f';
|
||||
|
||||
/// See also [Player].
|
||||
@ProviderFor(Player)
|
||||
final playerProvider = NotifierProvider<Player, AbsAudioHandler>.internal(
|
||||
Player.new,
|
||||
name: r'playerProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$playerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$Player = Notifier<AbsAudioHandler>;
|
||||
String _$sessionHash() => r'ce9405cc0b8247014924a005fa60fbc3bf92d5c6';
|
||||
|
||||
/// See also [Session].
|
||||
@ProviderFor(Session)
|
||||
final sessionProvider =
|
||||
NotifierProvider<Session, core.PlaybackSessionExpanded?>.internal(
|
||||
Session.new,
|
||||
name: r'sessionProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$sessionHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$Session = Notifier<core.PlaybackSessionExpanded?>;
|
||||
String _$currentChapterHash() => r'0be02fbc4f65b30da4ce18964ee457a18c4d1073';
|
||||
|
||||
/// See also [CurrentChapter].
|
||||
@ProviderFor(CurrentChapter)
|
||||
final currentChapterProvider =
|
||||
NotifierProvider<CurrentChapter, core.BookChapter?>.internal(
|
||||
CurrentChapter.new,
|
||||
name: r'currentChapterProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$currentChapterHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$CurrentChapter = Notifier<core.BookChapter?>;
|
||||
String _$playbackReporterHash() => r'b9a2197a12e83f9760bd61ae2a1ad8dff82042e9';
|
||||
|
||||
/// See also [PlaybackReporter].
|
||||
@ProviderFor(PlaybackReporter)
|
||||
final playbackReporterProvider =
|
||||
AsyncNotifierProvider<PlaybackReporter, core.PlaybackReporter?>.internal(
|
||||
PlaybackReporter.new,
|
||||
name: r'playbackReporterProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$playbackReporterHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$PlaybackReporter = AsyncNotifier<core.PlaybackReporter?>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/features/player/providers/player_form.dart';
|
||||
import 'package:vaani/features/player/providers/player_status_provider.dart';
|
||||
import 'package:vaani/globals.dart' show playerMinHeight;
|
||||
|
||||
class MiniPlayerBottomPadding extends HookConsumerWidget {
|
||||
const MiniPlayerBottomPadding({super.key});
|
||||
|
|
@ -8,7 +9,7 @@ class MiniPlayerBottomPadding extends HookConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: ref.watch(isPlayerActiveProvider)
|
||||
child: ref.watch(playerStatusProvider).isPlaying()
|
||||
? const SizedBox(height: playerMinHeight + 8)
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
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/constants/sizes.dart';
|
||||
import 'package:vaani/features/player/providers/session_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/view/widgets/player_player_pause_button.dart';
|
||||
import 'package:vaani/features/player/view/widgets/player_progress_bar.dart';
|
||||
import 'package:vaani/features/player/view/widgets/player_skip_chapter_start_end.dart';
|
||||
import 'package:vaani/features/skip_start_end/view/skip_start_end_button.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';
|
||||
|
|
@ -40,172 +39,162 @@ class PlayerExpanded extends HookConsumerWidget {
|
|||
final availWidth = MediaQuery.of(context).size.width;
|
||||
// the image width when the player is expanded
|
||||
final imageSize = min(playerMaxHeight * 0.5, availWidth * 0.9);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
iconSize: 30,
|
||||
icon: const Icon(Icons.keyboard_arrow_down),
|
||||
onPressed: () => context.pop(),
|
||||
return Column(
|
||||
children: [
|
||||
// sized box for system status bar; not needed as not full screen
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).padding.top,
|
||||
),
|
||||
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: BookCoverWidget(),
|
||||
),
|
||||
// the image
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: AppElementSizes.paddingLarge),
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
// add a shadow to the image elevation hovering effect
|
||||
child: PlayerExpandedImage(imageSize),
|
||||
),
|
||||
),
|
||||
|
||||
// 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(
|
||||
[
|
||||
session.displayTitle,
|
||||
session.displayAuthor,
|
||||
].join(' - '),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.7),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 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(
|
||||
[
|
||||
session.displayTitle,
|
||||
session.displayAuthor,
|
||||
].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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(
|
||||
// the progress bar
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
width: imageSize,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: AppElementSizes.paddingRegular,
|
||||
right: AppElementSizes.paddingRegular,
|
||||
),
|
||||
child: const AudiobookProgressBar(),
|
||||
child: const AudiobookChapterProgressBar(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 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),
|
||||
],
|
||||
),
|
||||
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(),
|
||||
],
|
||||
),
|
||||
// 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerExpandedImage extends StatelessWidget {
|
||||
final double imageSize;
|
||||
|
||||
const PlayerExpandedImage(this.imageSize, {super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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: BookCoverWidget(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
174
lib/features/player/view/player_expanded_desktop.dart
Normal file
174
lib/features/player/view/player_expanded_desktop.dart
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.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/player_expanded.dart'
|
||||
show PlayerExpandedImage;
|
||||
import 'package:vaani/features/player/view/player_minimized.dart';
|
||||
import 'package:vaani/features/player/view/widgets/audiobook_player_seek_button.dart';
|
||||
import 'package:vaani/features/player/view/widgets/audiobook_player_seek_chapter_button.dart';
|
||||
import 'package:vaani/features/player/view/widgets/player_player_pause_button.dart';
|
||||
import 'package:vaani/features/player/view/widgets/player_speed_adjust_button.dart';
|
||||
import 'package:vaani/features/skip_start_end/view/skip_start_end_button.dart';
|
||||
import 'package:vaani/features/sleep_timer/view/sleep_timer_button.dart';
|
||||
import 'package:vaani/globals.dart';
|
||||
import 'package:vaani/shared/extensions/chapter.dart';
|
||||
import 'package:vaani/shared/extensions/duration_format.dart';
|
||||
|
||||
var pendingPlayerModals = 0;
|
||||
|
||||
class PlayerExpandedDesktop extends HookConsumerWidget {
|
||||
const PlayerExpandedDesktop({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final session = ref.watch(sessionProvider);
|
||||
if (session == null) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
|
||||
/// 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 currentChapter = ref.watch(currentChapterProvider);
|
||||
// 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);
|
||||
|
||||
return Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Scaffold(
|
||||
body: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: AppElementSizes.paddingLarge,
|
||||
bottom: playerMinHeight + 40,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 45,
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
// add a shadow to the image elevation hovering effect
|
||||
child: PlayerExpandedImage(imageSize),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// speed control
|
||||
const PlayerSpeedAdjustButton(),
|
||||
const Spacer(),
|
||||
// sleep timer
|
||||
const SleepTimerButton(),
|
||||
const Spacer(),
|
||||
// 跳过片头片尾
|
||||
SkipChapterStartEndButton(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 65,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
currentChapter == null
|
||||
? SizedBox.shrink()
|
||||
: Text(
|
||||
currentChapter.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Expanded(
|
||||
child: ChapterSelection(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Hero(tag: 'player_hero', child: const PlayerMinimized()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChapterSelection extends HookConsumerWidget {
|
||||
const ChapterSelection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentChapter = ref.watch(currentChapterProvider);
|
||||
if (currentChapter == null) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
|
||||
final currentChapters = ref.watch(currentChaptersProvider);
|
||||
final currentChapterIndex = currentChapters.indexOf(currentChapter);
|
||||
final theme = Theme.of(context);
|
||||
return Scrollbar(
|
||||
child: ListView.builder(
|
||||
itemCount: currentChapters.length,
|
||||
itemBuilder: (context, index) {
|
||||
final chapter = currentChapters[index];
|
||||
final isCurrent = currentChapterIndex == index;
|
||||
final isPlayed = index < currentChapterIndex;
|
||||
return ListTile(
|
||||
autofocus: isCurrent,
|
||||
iconColor: isPlayed && !isCurrent ? theme.disabledColor : null,
|
||||
title: Text(
|
||||
chapter.title,
|
||||
style: isPlayed && !isCurrent
|
||||
? TextStyle(color: theme.disabledColor)
|
||||
: null,
|
||||
),
|
||||
subtitle: Text(
|
||||
'(${chapter.duration.smartBinaryFormat})',
|
||||
style: isPlayed && !isCurrent
|
||||
? TextStyle(color: theme.disabledColor)
|
||||
: null,
|
||||
),
|
||||
// trailing: isCurrent
|
||||
// ? const PlayingIndicatorIcon()
|
||||
// : const Icon(Icons.play_arrow),
|
||||
selected: isCurrent,
|
||||
// key: isCurrent ? chapterKey : null,
|
||||
onTap: () {
|
||||
ref.read(playerProvider).skipToChapter(chapter.id);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.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/session_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/view/widgets/player_player_pause_button.dart';
|
||||
import 'package:vaani/router/router.dart';
|
||||
import 'package:vaani/shared/widgets/shelves/book_shelf.dart';
|
||||
|
|
@ -28,7 +29,7 @@ class PlayerMinimized extends HookConsumerWidget {
|
|||
// image
|
||||
Padding(
|
||||
padding: EdgeInsets.all(AppElementSizes.paddingSmall),
|
||||
child: InkWell(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
// navigate to item page
|
||||
context.pushNamed(
|
||||
|
|
@ -114,7 +115,13 @@ class PlayerMinimizedFramework extends HookConsumerWidget {
|
|||
final progress =
|
||||
useStream(player.positionStreamInChapter, initialData: Duration.zero);
|
||||
return GestureDetector(
|
||||
onTap: () => context.pushNamed(Routes.player.name),
|
||||
onTap: () {
|
||||
if (GoRouterState.of(context).topRoute?.name != Routes.player.name) {
|
||||
context.pushNamed(Routes.player.name);
|
||||
} else {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: playerMinimizedHeight,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
|
|
|
|||
|
|
@ -1,293 +0,0 @@
|
|||
// 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';
|
||||
|
||||
// var pendingPlayerModals = 0;
|
||||
|
||||
// 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;
|
||||
|
||||
// @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,
|
||||
// ),
|
||||
|
||||
// // 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 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 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(),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
|
||||
// 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),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
|
||||
// // 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);
|
||||
// // },
|
||||
// // ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
// 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,
|
||||
// });
|
||||
|
||||
// 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;
|
||||
|
||||
// @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 bookMetaExpanded = ref.watch(currentBookMetadataProvider);
|
||||
|
||||
// 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);
|
||||
// // },
|
||||
// // ),
|
||||
|
||||
// // 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,
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/constants/sizes.dart';
|
||||
import 'package:vaani/features/player/providers/session_provider.dart';
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
|
||||
class AudiobookPlayerSeekButton extends HookConsumerWidget {
|
||||
const AudiobookPlayerSeekButton({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/constants/sizes.dart';
|
||||
import 'package:vaani/features/player/providers/session_provider.dart';
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
|
||||
class AudiobookPlayerSeekChapterButton extends HookConsumerWidget {
|
||||
const AudiobookPlayerSeekChapterButton({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/features/player/providers/session_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/view/player_expanded.dart'
|
||||
show pendingPlayerModals;
|
||||
import 'package:vaani/features/player/view/widgets/playing_indicator_icon.dart';
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/features/player/core/player_status.dart';
|
||||
import 'package:vaani/features/player/providers/player_status_provider.dart';
|
||||
import 'package:vaani/features/player/providers/session_provider.dart';
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
|
||||
class AudiobookPlayerPlayPauseButton extends HookConsumerWidget {
|
||||
const AudiobookPlayerPlayPauseButton({
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/constants/sizes.dart';
|
||||
import 'package:vaani/features/player/providers/session_provider.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({
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import 'package:vaani/features/per_book_settings/providers/book_settings_provide
|
|||
import 'package:vaani/features/player/providers/audiobook_player.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';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
|
||||
final _logger = Logger('PlayerSpeedAdjustButton');
|
||||
|
||||
|
|
@ -16,13 +16,12 @@ class PlayerSpeedAdjustButton extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final player = ref.watch(audiobookPlayerProvider);
|
||||
final bookId = player.book?.libraryItemId ?? '_';
|
||||
final player = ref.watch(playerProvider);
|
||||
final bookId = player.session?.libraryItemId ?? '_';
|
||||
final bookSettings = ref.watch(bookSettingsProvider(bookId));
|
||||
final appSettings = ref.watch(appSettingsProvider);
|
||||
final notifier = ref.watch(audiobookPlayerProvider.notifier);
|
||||
return TextButton(
|
||||
child: Text('${player.speed}x'),
|
||||
child: Text('${player.player.speed}x'),
|
||||
onPressed: () async {
|
||||
pendingPlayerModals++;
|
||||
_logger.fine('opening speed selector');
|
||||
|
|
@ -32,7 +31,7 @@ class PlayerSpeedAdjustButton extends HookConsumerWidget {
|
|||
builder: (context) {
|
||||
return SpeedSelector(
|
||||
onSpeedSelected: (speed) {
|
||||
notifier.setSpeed(speed);
|
||||
player.setSpeed(speed);
|
||||
if (appSettings.playerSettings.configurePlayerForEveryBook) {
|
||||
ref
|
||||
.read(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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:vaani/features/player/providers/audiobook_player.dart';
|
||||
import 'package:vaani/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
|
||||
const double itemExtent = 25;
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ class SpeedSelector extends HookConsumerWidget {
|
|||
final appSettings = ref.watch(appSettingsProvider);
|
||||
final playerSettings = appSettings.playerSettings;
|
||||
final speeds = playerSettings.speedOptions;
|
||||
final currentSpeed = ref.watch(audiobookPlayerProvider).speed;
|
||||
final currentSpeed = ref.watch(playerProvider).player.speed;
|
||||
final speedState = useState(currentSpeed);
|
||||
|
||||
// hook the onSpeedSelected function to the state
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart';
|
||||
import 'package:vaani/features/player/playlist.dart';
|
||||
import 'package:vaani/features/playlist/playlist.dart';
|
||||
|
||||
part 'playlist_provider.g.dart';
|
||||
|
||||
63
lib/features/settings/api_settings_provider.dart
Normal file
63
lib/features/settings/api_settings_provider.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// this provider is used to provide the Api settings to the app
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:vaani/db/available_boxes.dart';
|
||||
import 'package:vaani/features/settings/models/api_settings.dart' as model;
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart';
|
||||
|
||||
part 'api_settings_provider.g.dart';
|
||||
|
||||
final _box = AvailableHiveBoxes.apiSettingsBox;
|
||||
|
||||
final _logger = Logger('ApiSettingsProvider');
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ApiSettings extends _$ApiSettings {
|
||||
@override
|
||||
model.ApiSettings build() {
|
||||
state = readFromBoxOrCreate();
|
||||
ref.listenSelf((_, __) {
|
||||
writeToBox();
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
model.ApiSettings readFromBoxOrCreate() {
|
||||
// see if the settings are already in the box
|
||||
if (_box.isNotEmpty) {
|
||||
var foundSettings = _box.getAt(0);
|
||||
// foundSettings.activeServer ??= foundSettings.activeUser?.server;
|
||||
// foundSettings =foundSettings.copyWith(activeServer: foundSettings.activeUser?.server);
|
||||
if (foundSettings.activeServer == null) {
|
||||
foundSettings = foundSettings.copyWith(
|
||||
activeServer: foundSettings.activeUser?.server,
|
||||
);
|
||||
}
|
||||
_logger.fine('found api settings in box: ${foundSettings.obfuscate()}');
|
||||
return foundSettings;
|
||||
} else {
|
||||
// create a new settings object
|
||||
const settings = model.ApiSettings();
|
||||
_logger.fine('created new api settings: $settings');
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
// write the settings to the box
|
||||
void writeToBox() {
|
||||
_box.clear();
|
||||
_box.add(state);
|
||||
_logger.fine('wrote api settings to box: ${state.obfuscate()}');
|
||||
}
|
||||
|
||||
void updateState(model.ApiSettings newSettings, {bool force = false}) {
|
||||
// check if the settings are different
|
||||
|
||||
if (state == newSettings && !force) {
|
||||
return;
|
||||
}
|
||||
state = newSettings;
|
||||
}
|
||||
}
|
||||
25
lib/features/settings/api_settings_provider.g.dart
Normal file
25
lib/features/settings/api_settings_provider.g.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'api_settings_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$apiSettingsHash() => r'5bc1e16e9d72b77fb10637aabadf08e8947da580';
|
||||
|
||||
/// See also [ApiSettings].
|
||||
@ProviderFor(ApiSettings)
|
||||
final apiSettingsProvider =
|
||||
NotifierProvider<ApiSettings, model.ApiSettings>.internal(
|
||||
ApiSettings.new,
|
||||
name: r'apiSettingsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$apiSettingsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ApiSettings = Notifier<model.ApiSettings>;
|
||||
// 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
|
||||
73
lib/features/settings/app_settings_provider.dart
Normal file
73
lib/features/settings/app_settings_provider.dart
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// this provider is used to provide the app settings to the app
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:vaani/db/available_boxes.dart';
|
||||
import 'package:vaani/features/settings/models/app_settings.dart' as model;
|
||||
|
||||
part 'app_settings_provider.g.dart';
|
||||
|
||||
final _box = AvailableHiveBoxes.userPrefsBox;
|
||||
|
||||
final _logger = Logger('AppSettingsProvider');
|
||||
|
||||
model.AppSettings loadOrCreateAppSettings() {
|
||||
// see if the settings are already in the box
|
||||
model.AppSettings? settings;
|
||||
if (_box.isNotEmpty) {
|
||||
try {
|
||||
settings = _box.getAt(0);
|
||||
_logger.fine('found settings in box: $settings');
|
||||
} catch (e) {
|
||||
_logger.warning('error reading settings from box: $e'
|
||||
'\nclearing box');
|
||||
_box.clear();
|
||||
}
|
||||
} else {
|
||||
_logger.fine('no settings found in box, creating new settings');
|
||||
}
|
||||
return settings ?? const model.AppSettings();
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class AppSettings extends _$AppSettings {
|
||||
@override
|
||||
model.AppSettings build() {
|
||||
state = loadOrCreateAppSettings();
|
||||
ref.listenSelf((_, __) {
|
||||
writeToBox();
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
// write the settings to the box
|
||||
void writeToBox() {
|
||||
_box.clear();
|
||||
_box.add(state);
|
||||
_logger.fine('wrote settings to box: $state');
|
||||
}
|
||||
|
||||
void update(model.AppSettings newSettings) {
|
||||
state = newSettings;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const model.AppSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// SleepTimerSettings provider but only rebuilds when the sleep timer settings change
|
||||
@Riverpod(keepAlive: true)
|
||||
class SleepTimerSettings extends _$SleepTimerSettings {
|
||||
@override
|
||||
model.SleepTimerSettings build() {
|
||||
final settings = ref.read(appSettingsProvider).sleepTimerSettings;
|
||||
state = settings;
|
||||
ref.listen(appSettingsProvider, (a, b) {
|
||||
if (a?.sleepTimerSettings != b.sleepTimerSettings) {
|
||||
state = b.sleepTimerSettings;
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}
|
||||
}
|
||||
42
lib/features/settings/app_settings_provider.g.dart
Normal file
42
lib/features/settings/app_settings_provider.g.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_settings_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$appSettingsHash() => r'314d7936f54550f57d308056a99230402342a6d0';
|
||||
|
||||
/// See also [AppSettings].
|
||||
@ProviderFor(AppSettings)
|
||||
final appSettingsProvider =
|
||||
NotifierProvider<AppSettings, model.AppSettings>.internal(
|
||||
AppSettings.new,
|
||||
name: r'appSettingsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$appSettingsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AppSettings = Notifier<model.AppSettings>;
|
||||
String _$sleepTimerSettingsHash() =>
|
||||
r'85bb3d3fb292b9a3a5b771d86e5fc57718519c69';
|
||||
|
||||
/// See also [SleepTimerSettings].
|
||||
@ProviderFor(SleepTimerSettings)
|
||||
final sleepTimerSettingsProvider =
|
||||
NotifierProvider<SleepTimerSettings, model.SleepTimerSettings>.internal(
|
||||
SleepTimerSettings.new,
|
||||
name: r'sleepTimerSettingsProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$sleepTimerSettingsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$SleepTimerSettings = Notifier<model.SleepTimerSettings>;
|
||||
// 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
|
||||
23
lib/features/settings/models/api_settings.dart
Normal file
23
lib/features/settings/models/api_settings.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// a freezed class to store the settings of the app
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:vaani/features/settings/models/audiobookshelf_server.dart';
|
||||
import 'package:vaani/features/settings/models/authenticated_user.dart';
|
||||
|
||||
part 'api_settings.freezed.dart';
|
||||
part 'api_settings.g.dart';
|
||||
|
||||
/// stores the settings for the active server and user
|
||||
///
|
||||
/// all settings that are needed to interact with the server are stored here
|
||||
@freezed
|
||||
class ApiSettings with _$ApiSettings {
|
||||
const factory ApiSettings({
|
||||
AudiobookShelfServer? activeServer,
|
||||
AuthenticatedUser? activeUser,
|
||||
String? activeLibraryId,
|
||||
}) = _ApiSettings;
|
||||
|
||||
factory ApiSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$ApiSettingsFromJson(json);
|
||||
}
|
||||
246
lib/features/settings/models/api_settings.freezed.dart
Normal file
246
lib/features/settings/models/api_settings.freezed.dart
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'api_settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
||||
|
||||
ApiSettings _$ApiSettingsFromJson(Map<String, dynamic> json) {
|
||||
return _ApiSettings.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$ApiSettings {
|
||||
AudiobookShelfServer? get activeServer => throw _privateConstructorUsedError;
|
||||
AuthenticatedUser? get activeUser => throw _privateConstructorUsedError;
|
||||
String? get activeLibraryId => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this ApiSettings to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of ApiSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$ApiSettingsCopyWith<ApiSettings> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $ApiSettingsCopyWith<$Res> {
|
||||
factory $ApiSettingsCopyWith(
|
||||
ApiSettings value, $Res Function(ApiSettings) then) =
|
||||
_$ApiSettingsCopyWithImpl<$Res, ApiSettings>;
|
||||
@useResult
|
||||
$Res call(
|
||||
{AudiobookShelfServer? activeServer,
|
||||
AuthenticatedUser? activeUser,
|
||||
String? activeLibraryId});
|
||||
|
||||
$AudiobookShelfServerCopyWith<$Res>? get activeServer;
|
||||
$AuthenticatedUserCopyWith<$Res>? get activeUser;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$ApiSettingsCopyWithImpl<$Res, $Val extends ApiSettings>
|
||||
implements $ApiSettingsCopyWith<$Res> {
|
||||
_$ApiSettingsCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of ApiSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? activeServer = freezed,
|
||||
Object? activeUser = freezed,
|
||||
Object? activeLibraryId = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
activeServer: freezed == activeServer
|
||||
? _value.activeServer
|
||||
: activeServer // ignore: cast_nullable_to_non_nullable
|
||||
as AudiobookShelfServer?,
|
||||
activeUser: freezed == activeUser
|
||||
? _value.activeUser
|
||||
: activeUser // ignore: cast_nullable_to_non_nullable
|
||||
as AuthenticatedUser?,
|
||||
activeLibraryId: freezed == activeLibraryId
|
||||
? _value.activeLibraryId
|
||||
: activeLibraryId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
) as $Val);
|
||||
}
|
||||
|
||||
/// Create a copy of ApiSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$AudiobookShelfServerCopyWith<$Res>? get activeServer {
|
||||
if (_value.activeServer == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $AudiobookShelfServerCopyWith<$Res>(_value.activeServer!, (value) {
|
||||
return _then(_value.copyWith(activeServer: value) as $Val);
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a copy of ApiSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$AuthenticatedUserCopyWith<$Res>? get activeUser {
|
||||
if (_value.activeUser == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $AuthenticatedUserCopyWith<$Res>(_value.activeUser!, (value) {
|
||||
return _then(_value.copyWith(activeUser: value) as $Val);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$ApiSettingsImplCopyWith<$Res>
|
||||
implements $ApiSettingsCopyWith<$Res> {
|
||||
factory _$$ApiSettingsImplCopyWith(
|
||||
_$ApiSettingsImpl value, $Res Function(_$ApiSettingsImpl) then) =
|
||||
__$$ApiSettingsImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{AudiobookShelfServer? activeServer,
|
||||
AuthenticatedUser? activeUser,
|
||||
String? activeLibraryId});
|
||||
|
||||
@override
|
||||
$AudiobookShelfServerCopyWith<$Res>? get activeServer;
|
||||
@override
|
||||
$AuthenticatedUserCopyWith<$Res>? get activeUser;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$ApiSettingsImplCopyWithImpl<$Res>
|
||||
extends _$ApiSettingsCopyWithImpl<$Res, _$ApiSettingsImpl>
|
||||
implements _$$ApiSettingsImplCopyWith<$Res> {
|
||||
__$$ApiSettingsImplCopyWithImpl(
|
||||
_$ApiSettingsImpl _value, $Res Function(_$ApiSettingsImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
/// Create a copy of ApiSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? activeServer = freezed,
|
||||
Object? activeUser = freezed,
|
||||
Object? activeLibraryId = freezed,
|
||||
}) {
|
||||
return _then(_$ApiSettingsImpl(
|
||||
activeServer: freezed == activeServer
|
||||
? _value.activeServer
|
||||
: activeServer // ignore: cast_nullable_to_non_nullable
|
||||
as AudiobookShelfServer?,
|
||||
activeUser: freezed == activeUser
|
||||
? _value.activeUser
|
||||
: activeUser // ignore: cast_nullable_to_non_nullable
|
||||
as AuthenticatedUser?,
|
||||
activeLibraryId: freezed == activeLibraryId
|
||||
? _value.activeLibraryId
|
||||
: activeLibraryId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$ApiSettingsImpl implements _ApiSettings {
|
||||
const _$ApiSettingsImpl(
|
||||
{this.activeServer, this.activeUser, this.activeLibraryId});
|
||||
|
||||
factory _$ApiSettingsImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$ApiSettingsImplFromJson(json);
|
||||
|
||||
@override
|
||||
final AudiobookShelfServer? activeServer;
|
||||
@override
|
||||
final AuthenticatedUser? activeUser;
|
||||
@override
|
||||
final String? activeLibraryId;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ApiSettings(activeServer: $activeServer, activeUser: $activeUser, activeLibraryId: $activeLibraryId)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$ApiSettingsImpl &&
|
||||
(identical(other.activeServer, activeServer) ||
|
||||
other.activeServer == activeServer) &&
|
||||
(identical(other.activeUser, activeUser) ||
|
||||
other.activeUser == activeUser) &&
|
||||
(identical(other.activeLibraryId, activeLibraryId) ||
|
||||
other.activeLibraryId == activeLibraryId));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(runtimeType, activeServer, activeUser, activeLibraryId);
|
||||
|
||||
/// Create a copy of ApiSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$ApiSettingsImplCopyWith<_$ApiSettingsImpl> get copyWith =>
|
||||
__$$ApiSettingsImplCopyWithImpl<_$ApiSettingsImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$ApiSettingsImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _ApiSettings implements ApiSettings {
|
||||
const factory _ApiSettings(
|
||||
{final AudiobookShelfServer? activeServer,
|
||||
final AuthenticatedUser? activeUser,
|
||||
final String? activeLibraryId}) = _$ApiSettingsImpl;
|
||||
|
||||
factory _ApiSettings.fromJson(Map<String, dynamic> json) =
|
||||
_$ApiSettingsImpl.fromJson;
|
||||
|
||||
@override
|
||||
AudiobookShelfServer? get activeServer;
|
||||
@override
|
||||
AuthenticatedUser? get activeUser;
|
||||
@override
|
||||
String? get activeLibraryId;
|
||||
|
||||
/// Create a copy of ApiSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$ApiSettingsImplCopyWith<_$ApiSettingsImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
27
lib/features/settings/models/api_settings.g.dart
Normal file
27
lib/features/settings/models/api_settings.g.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'api_settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$ApiSettingsImpl _$$ApiSettingsImplFromJson(Map<String, dynamic> json) =>
|
||||
_$ApiSettingsImpl(
|
||||
activeServer: json['activeServer'] == null
|
||||
? null
|
||||
: AudiobookShelfServer.fromJson(
|
||||
json['activeServer'] as Map<String, dynamic>),
|
||||
activeUser: json['activeUser'] == null
|
||||
? null
|
||||
: AuthenticatedUser.fromJson(
|
||||
json['activeUser'] as Map<String, dynamic>),
|
||||
activeLibraryId: json['activeLibraryId'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ApiSettingsImplToJson(_$ApiSettingsImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'activeServer': instance.activeServer,
|
||||
'activeUser': instance.activeUser,
|
||||
'activeLibraryId': instance.activeLibraryId,
|
||||
};
|
||||
247
lib/features/settings/models/app_settings.dart
Normal file
247
lib/features/settings/models/app_settings.dart
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
// a freezed class to store the settings of the app
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'app_settings.freezed.dart';
|
||||
part 'app_settings.g.dart';
|
||||
|
||||
/// stores the settings of the app
|
||||
///
|
||||
/// only the visual settings are stored here
|
||||
@freezed
|
||||
class AppSettings with _$AppSettings {
|
||||
const factory AppSettings({
|
||||
@Default('zh') String language,
|
||||
@Default(ThemeSettings()) ThemeSettings themeSettings,
|
||||
@Default(PlayerSettings()) PlayerSettings playerSettings,
|
||||
@Default(SleepTimerSettings()) SleepTimerSettings sleepTimerSettings,
|
||||
@Default(DownloadSettings()) DownloadSettings downloadSettings,
|
||||
@Default(NotificationSettings()) NotificationSettings notificationSettings,
|
||||
@Default(ShakeDetectionSettings())
|
||||
ShakeDetectionSettings shakeDetectionSettings,
|
||||
@Default(HomePageSettings()) HomePageSettings homePageSettings,
|
||||
}) = _AppSettings;
|
||||
|
||||
factory AppSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$AppSettingsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ThemeSettings with _$ThemeSettings {
|
||||
const factory ThemeSettings({
|
||||
@Default(ThemeMode.system) ThemeMode themeMode,
|
||||
@Default(false) bool highContrast,
|
||||
@Default(false) bool useMaterialThemeFromSystem,
|
||||
@Default('#FF311B92') String customThemeColor,
|
||||
@Default(true) bool useMaterialThemeOnItemPage,
|
||||
@Default(true) bool useCurrentPlayerThemeThroughoutApp,
|
||||
}) = _ThemeSettings;
|
||||
|
||||
factory ThemeSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$ThemeSettingsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class PlayerSettings with _$PlayerSettings {
|
||||
const factory PlayerSettings({
|
||||
@Default(MinimizedPlayerSettings())
|
||||
MinimizedPlayerSettings miniPlayerSettings,
|
||||
@Default(ExpandedPlayerSettings())
|
||||
ExpandedPlayerSettings expandedPlayerSettings,
|
||||
@Default(1) double preferredDefaultVolume,
|
||||
@Default(1) double preferredDefaultSpeed,
|
||||
@Default([1, 1.25, 1.5, 1.75, 2]) List<double> speedOptions,
|
||||
@Default(0.05) double speedIncrement,
|
||||
@Default(0.1) double minSpeed,
|
||||
@Default(4) double maxSpeed,
|
||||
@Default(Duration(seconds: 10)) Duration minimumPositionForReporting,
|
||||
@Default(Duration(seconds: 10)) Duration playbackReportInterval,
|
||||
@Default(Duration(seconds: 15)) Duration markCompleteWhenTimeLeft,
|
||||
@Default(true) bool configurePlayerForEveryBook,
|
||||
}) = _PlayerSettings;
|
||||
|
||||
factory PlayerSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlayerSettingsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ExpandedPlayerSettings with _$ExpandedPlayerSettings {
|
||||
const factory ExpandedPlayerSettings({
|
||||
@Default(false) bool showTotalProgress,
|
||||
@Default(true) bool showChapterProgress,
|
||||
}) = _ExpandedPlayerSettings;
|
||||
|
||||
factory ExpandedPlayerSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$ExpandedPlayerSettingsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class MinimizedPlayerSettings with _$MinimizedPlayerSettings {
|
||||
const factory MinimizedPlayerSettings({
|
||||
@Default(false) bool useChapterInfo,
|
||||
}) = _MinimizedPlayerSettings;
|
||||
|
||||
factory MinimizedPlayerSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$MinimizedPlayerSettingsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SleepTimerSettings with _$SleepTimerSettings {
|
||||
const factory SleepTimerSettings({
|
||||
@Default(Duration(minutes: 15)) Duration defaultDuration,
|
||||
@Default(
|
||||
[
|
||||
Duration(minutes: 5),
|
||||
Duration(minutes: 10),
|
||||
Duration(minutes: 15),
|
||||
Duration(minutes: 20),
|
||||
Duration(minutes: 30),
|
||||
],
|
||||
)
|
||||
List<Duration> presetDurations,
|
||||
@Default(Duration(minutes: 100)) Duration maxDuration,
|
||||
@Default(false) bool fadeOutAudio,
|
||||
@Default(Duration(seconds: 20)) Duration fadeOutDuration,
|
||||
|
||||
/// if true, the player will automatically rewind the audio when the sleep timer is stopped
|
||||
@Default(false) bool autoRewindWhenStopped,
|
||||
|
||||
/// the key is the duration in minutes
|
||||
@Default({
|
||||
5: Duration(seconds: 10),
|
||||
15: Duration(seconds: 30),
|
||||
45: Duration(seconds: 45),
|
||||
60: Duration(minutes: 1),
|
||||
120: Duration(minutes: 2),
|
||||
})
|
||||
Map<int, Duration> autoRewindDurations,
|
||||
|
||||
/// auto turn on timer settings
|
||||
@Default(false) bool autoTurnOnTimer,
|
||||
|
||||
/// always auto turn on timer settings or during specific times
|
||||
@Default(false) bool alwaysAutoTurnOnTimer,
|
||||
|
||||
/// auto timer settings, only used if [alwaysAutoTurnOnTimer] is false
|
||||
///
|
||||
/// duration is the time from 00:00
|
||||
@Default(Duration(hours: 22, minutes: 0)) Duration autoTurnOnTime,
|
||||
@Default(Duration(hours: 6, minutes: 0)) Duration autoTurnOffTime,
|
||||
}) = _SleepTimerSettings;
|
||||
|
||||
factory SleepTimerSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$SleepTimerSettingsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class DownloadSettings with _$DownloadSettings {
|
||||
const factory DownloadSettings({
|
||||
@Default(true) bool requiresWiFi,
|
||||
@Default(3) int retries,
|
||||
@Default(true) bool allowPause,
|
||||
@Default(3) int maxConcurrent,
|
||||
@Default(3) int maxConcurrentByHost,
|
||||
@Default(3) int maxConcurrentByGroup,
|
||||
}) = _DownloadSettings;
|
||||
|
||||
factory DownloadSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$DownloadSettingsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class NotificationSettings with _$NotificationSettings {
|
||||
const factory NotificationSettings({
|
||||
@Default(Duration(seconds: 30)) Duration fastForwardInterval,
|
||||
@Default(Duration(seconds: 10)) Duration rewindInterval,
|
||||
@Default(true) bool progressBarIsChapterProgress,
|
||||
@Default('\$bookTitle') String primaryTitle,
|
||||
@Default('\$author') String secondaryTitle,
|
||||
@Default(
|
||||
[
|
||||
NotificationMediaControl.rewind,
|
||||
NotificationMediaControl.fastForward,
|
||||
NotificationMediaControl.skipToPreviousChapter,
|
||||
NotificationMediaControl.skipToNextChapter,
|
||||
],
|
||||
)
|
||||
List<NotificationMediaControl> mediaControls,
|
||||
}) = _NotificationSettings;
|
||||
|
||||
factory NotificationSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$NotificationSettingsFromJson(json);
|
||||
}
|
||||
|
||||
enum NotificationTitleType {
|
||||
chapterTitle,
|
||||
bookTitle,
|
||||
author,
|
||||
subtitle,
|
||||
series,
|
||||
narrator,
|
||||
year,
|
||||
}
|
||||
|
||||
enum NotificationMediaControl {
|
||||
fastForward(Icons.fast_forward),
|
||||
rewind(Icons.fast_rewind),
|
||||
speedToggle(Icons.speed),
|
||||
stop(Icons.stop),
|
||||
skipToNextChapter(Icons.skip_next),
|
||||
skipToPreviousChapter(Icons.skip_previous);
|
||||
|
||||
const NotificationMediaControl(this.icon);
|
||||
|
||||
final IconData icon;
|
||||
}
|
||||
|
||||
/// Shake Detection Settings
|
||||
@freezed
|
||||
class ShakeDetectionSettings with _$ShakeDetectionSettings {
|
||||
const factory ShakeDetectionSettings({
|
||||
@Default(true) bool isEnabled,
|
||||
@Default(ShakeDirection.horizontal) ShakeDirection direction,
|
||||
@Default(5) double threshold,
|
||||
@Default(ShakeAction.resetSleepTimer) ShakeAction shakeAction,
|
||||
@Default({ShakeDetectedFeedback.vibrate})
|
||||
Set<ShakeDetectedFeedback> feedback,
|
||||
@Default(0.5) double beepVolume,
|
||||
|
||||
/// the duration to wait before the shake detection is enabled again
|
||||
@Default(Duration(seconds: 2)) Duration shakeTriggerCoolDown,
|
||||
|
||||
/// the number of shakes required to trigger the action
|
||||
@Default(2) int shakeTriggerCount,
|
||||
|
||||
/// acceleration sampling interval
|
||||
@Default(Duration(milliseconds: 100)) Duration samplingPeriod,
|
||||
}) = _ShakeDetectionSettings;
|
||||
|
||||
factory ShakeDetectionSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$ShakeDetectionSettingsFromJson(json);
|
||||
}
|
||||
|
||||
enum ShakeDirection { horizontal, vertical }
|
||||
|
||||
enum ShakeAction {
|
||||
none,
|
||||
playPause,
|
||||
resetSleepTimer,
|
||||
fastForward,
|
||||
rewind,
|
||||
}
|
||||
|
||||
enum ShakeDetectedFeedback { vibrate, beep }
|
||||
|
||||
@freezed
|
||||
class HomePageSettings with _$HomePageSettings {
|
||||
const factory HomePageSettings({
|
||||
@Default(true) bool showPlayButtonOnContinueListeningShelf,
|
||||
@Default(false) bool showPlayButtonOnContinueSeriesShelf,
|
||||
@Default(false) bool showPlayButtonOnAllRemainingShelves,
|
||||
@Default(false) bool showPlayButtonOnListenAgainShelf,
|
||||
}) = _HomePageSettings;
|
||||
|
||||
factory HomePageSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$HomePageSettingsFromJson(json);
|
||||
}
|
||||
3133
lib/features/settings/models/app_settings.freezed.dart
Normal file
3133
lib/features/settings/models/app_settings.freezed.dart
Normal file
File diff suppressed because it is too large
Load diff
386
lib/features/settings/models/app_settings.g.dart
Normal file
386
lib/features/settings/models/app_settings.g.dart
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$AppSettingsImpl _$$AppSettingsImplFromJson(Map<String, dynamic> json) =>
|
||||
_$AppSettingsImpl(
|
||||
language: json['language'] as String? ?? 'zh',
|
||||
themeSettings: json['themeSettings'] == null
|
||||
? const ThemeSettings()
|
||||
: ThemeSettings.fromJson(
|
||||
json['themeSettings'] as Map<String, dynamic>),
|
||||
playerSettings: json['playerSettings'] == null
|
||||
? const PlayerSettings()
|
||||
: PlayerSettings.fromJson(
|
||||
json['playerSettings'] as Map<String, dynamic>),
|
||||
sleepTimerSettings: json['sleepTimerSettings'] == null
|
||||
? const SleepTimerSettings()
|
||||
: SleepTimerSettings.fromJson(
|
||||
json['sleepTimerSettings'] as Map<String, dynamic>),
|
||||
downloadSettings: json['downloadSettings'] == null
|
||||
? const DownloadSettings()
|
||||
: DownloadSettings.fromJson(
|
||||
json['downloadSettings'] as Map<String, dynamic>),
|
||||
notificationSettings: json['notificationSettings'] == null
|
||||
? const NotificationSettings()
|
||||
: NotificationSettings.fromJson(
|
||||
json['notificationSettings'] as Map<String, dynamic>),
|
||||
shakeDetectionSettings: json['shakeDetectionSettings'] == null
|
||||
? const ShakeDetectionSettings()
|
||||
: ShakeDetectionSettings.fromJson(
|
||||
json['shakeDetectionSettings'] as Map<String, dynamic>),
|
||||
homePageSettings: json['homePageSettings'] == null
|
||||
? const HomePageSettings()
|
||||
: HomePageSettings.fromJson(
|
||||
json['homePageSettings'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AppSettingsImplToJson(_$AppSettingsImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'language': instance.language,
|
||||
'themeSettings': instance.themeSettings,
|
||||
'playerSettings': instance.playerSettings,
|
||||
'sleepTimerSettings': instance.sleepTimerSettings,
|
||||
'downloadSettings': instance.downloadSettings,
|
||||
'notificationSettings': instance.notificationSettings,
|
||||
'shakeDetectionSettings': instance.shakeDetectionSettings,
|
||||
'homePageSettings': instance.homePageSettings,
|
||||
};
|
||||
|
||||
_$ThemeSettingsImpl _$$ThemeSettingsImplFromJson(Map<String, dynamic> json) =>
|
||||
_$ThemeSettingsImpl(
|
||||
themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ??
|
||||
ThemeMode.system,
|
||||
highContrast: json['highContrast'] as bool? ?? false,
|
||||
useMaterialThemeFromSystem:
|
||||
json['useMaterialThemeFromSystem'] as bool? ?? false,
|
||||
customThemeColor: json['customThemeColor'] as String? ?? '#FF311B92',
|
||||
useMaterialThemeOnItemPage:
|
||||
json['useMaterialThemeOnItemPage'] as bool? ?? true,
|
||||
useCurrentPlayerThemeThroughoutApp:
|
||||
json['useCurrentPlayerThemeThroughoutApp'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ThemeSettingsImplToJson(_$ThemeSettingsImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
|
||||
'highContrast': instance.highContrast,
|
||||
'useMaterialThemeFromSystem': instance.useMaterialThemeFromSystem,
|
||||
'customThemeColor': instance.customThemeColor,
|
||||
'useMaterialThemeOnItemPage': instance.useMaterialThemeOnItemPage,
|
||||
'useCurrentPlayerThemeThroughoutApp':
|
||||
instance.useCurrentPlayerThemeThroughoutApp,
|
||||
};
|
||||
|
||||
const _$ThemeModeEnumMap = {
|
||||
ThemeMode.system: 'system',
|
||||
ThemeMode.light: 'light',
|
||||
ThemeMode.dark: 'dark',
|
||||
};
|
||||
|
||||
_$PlayerSettingsImpl _$$PlayerSettingsImplFromJson(Map<String, dynamic> json) =>
|
||||
_$PlayerSettingsImpl(
|
||||
miniPlayerSettings: json['miniPlayerSettings'] == null
|
||||
? const MinimizedPlayerSettings()
|
||||
: MinimizedPlayerSettings.fromJson(
|
||||
json['miniPlayerSettings'] as Map<String, dynamic>),
|
||||
expandedPlayerSettings: json['expandedPlayerSettings'] == null
|
||||
? const ExpandedPlayerSettings()
|
||||
: ExpandedPlayerSettings.fromJson(
|
||||
json['expandedPlayerSettings'] as Map<String, dynamic>),
|
||||
preferredDefaultVolume:
|
||||
(json['preferredDefaultVolume'] as num?)?.toDouble() ?? 1,
|
||||
preferredDefaultSpeed:
|
||||
(json['preferredDefaultSpeed'] as num?)?.toDouble() ?? 1,
|
||||
speedOptions: (json['speedOptions'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toDouble())
|
||||
.toList() ??
|
||||
const [1, 1.25, 1.5, 1.75, 2],
|
||||
speedIncrement: (json['speedIncrement'] as num?)?.toDouble() ?? 0.05,
|
||||
minSpeed: (json['minSpeed'] as num?)?.toDouble() ?? 0.1,
|
||||
maxSpeed: (json['maxSpeed'] as num?)?.toDouble() ?? 4,
|
||||
minimumPositionForReporting: json['minimumPositionForReporting'] == null
|
||||
? const Duration(seconds: 10)
|
||||
: Duration(
|
||||
microseconds:
|
||||
(json['minimumPositionForReporting'] as num).toInt()),
|
||||
playbackReportInterval: json['playbackReportInterval'] == null
|
||||
? const Duration(seconds: 10)
|
||||
: Duration(
|
||||
microseconds: (json['playbackReportInterval'] as num).toInt()),
|
||||
markCompleteWhenTimeLeft: json['markCompleteWhenTimeLeft'] == null
|
||||
? const Duration(seconds: 15)
|
||||
: Duration(
|
||||
microseconds: (json['markCompleteWhenTimeLeft'] as num).toInt()),
|
||||
configurePlayerForEveryBook:
|
||||
json['configurePlayerForEveryBook'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$PlayerSettingsImplToJson(
|
||||
_$PlayerSettingsImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'miniPlayerSettings': instance.miniPlayerSettings,
|
||||
'expandedPlayerSettings': instance.expandedPlayerSettings,
|
||||
'preferredDefaultVolume': instance.preferredDefaultVolume,
|
||||
'preferredDefaultSpeed': instance.preferredDefaultSpeed,
|
||||
'speedOptions': instance.speedOptions,
|
||||
'speedIncrement': instance.speedIncrement,
|
||||
'minSpeed': instance.minSpeed,
|
||||
'maxSpeed': instance.maxSpeed,
|
||||
'minimumPositionForReporting':
|
||||
instance.minimumPositionForReporting.inMicroseconds,
|
||||
'playbackReportInterval': instance.playbackReportInterval.inMicroseconds,
|
||||
'markCompleteWhenTimeLeft':
|
||||
instance.markCompleteWhenTimeLeft.inMicroseconds,
|
||||
'configurePlayerForEveryBook': instance.configurePlayerForEveryBook,
|
||||
};
|
||||
|
||||
_$ExpandedPlayerSettingsImpl _$$ExpandedPlayerSettingsImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$ExpandedPlayerSettingsImpl(
|
||||
showTotalProgress: json['showTotalProgress'] as bool? ?? false,
|
||||
showChapterProgress: json['showChapterProgress'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ExpandedPlayerSettingsImplToJson(
|
||||
_$ExpandedPlayerSettingsImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'showTotalProgress': instance.showTotalProgress,
|
||||
'showChapterProgress': instance.showChapterProgress,
|
||||
};
|
||||
|
||||
_$MinimizedPlayerSettingsImpl _$$MinimizedPlayerSettingsImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$MinimizedPlayerSettingsImpl(
|
||||
useChapterInfo: json['useChapterInfo'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$MinimizedPlayerSettingsImplToJson(
|
||||
_$MinimizedPlayerSettingsImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'useChapterInfo': instance.useChapterInfo,
|
||||
};
|
||||
|
||||
_$SleepTimerSettingsImpl _$$SleepTimerSettingsImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SleepTimerSettingsImpl(
|
||||
defaultDuration: json['defaultDuration'] == null
|
||||
? const Duration(minutes: 15)
|
||||
: Duration(microseconds: (json['defaultDuration'] as num).toInt()),
|
||||
presetDurations: (json['presetDurations'] as List<dynamic>?)
|
||||
?.map((e) => Duration(microseconds: (e as num).toInt()))
|
||||
.toList() ??
|
||||
const [
|
||||
Duration(minutes: 5),
|
||||
Duration(minutes: 10),
|
||||
Duration(minutes: 15),
|
||||
Duration(minutes: 20),
|
||||
Duration(minutes: 30)
|
||||
],
|
||||
maxDuration: json['maxDuration'] == null
|
||||
? const Duration(minutes: 100)
|
||||
: Duration(microseconds: (json['maxDuration'] as num).toInt()),
|
||||
fadeOutAudio: json['fadeOutAudio'] as bool? ?? false,
|
||||
fadeOutDuration: json['fadeOutDuration'] == null
|
||||
? const Duration(seconds: 20)
|
||||
: Duration(microseconds: (json['fadeOutDuration'] as num).toInt()),
|
||||
autoRewindWhenStopped: json['autoRewindWhenStopped'] as bool? ?? false,
|
||||
autoRewindDurations:
|
||||
(json['autoRewindDurations'] as Map<String, dynamic>?)?.map(
|
||||
(k, e) => MapEntry(
|
||||
int.parse(k), Duration(microseconds: (e as num).toInt())),
|
||||
) ??
|
||||
const {
|
||||
5: Duration(seconds: 10),
|
||||
15: Duration(seconds: 30),
|
||||
45: Duration(seconds: 45),
|
||||
60: Duration(minutes: 1),
|
||||
120: Duration(minutes: 2)
|
||||
},
|
||||
autoTurnOnTimer: json['autoTurnOnTimer'] as bool? ?? false,
|
||||
alwaysAutoTurnOnTimer: json['alwaysAutoTurnOnTimer'] as bool? ?? false,
|
||||
autoTurnOnTime: json['autoTurnOnTime'] == null
|
||||
? const Duration(hours: 22, minutes: 0)
|
||||
: Duration(microseconds: (json['autoTurnOnTime'] as num).toInt()),
|
||||
autoTurnOffTime: json['autoTurnOffTime'] == null
|
||||
? const Duration(hours: 6, minutes: 0)
|
||||
: Duration(microseconds: (json['autoTurnOffTime'] as num).toInt()),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SleepTimerSettingsImplToJson(
|
||||
_$SleepTimerSettingsImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'defaultDuration': instance.defaultDuration.inMicroseconds,
|
||||
'presetDurations':
|
||||
instance.presetDurations.map((e) => e.inMicroseconds).toList(),
|
||||
'maxDuration': instance.maxDuration.inMicroseconds,
|
||||
'fadeOutAudio': instance.fadeOutAudio,
|
||||
'fadeOutDuration': instance.fadeOutDuration.inMicroseconds,
|
||||
'autoRewindWhenStopped': instance.autoRewindWhenStopped,
|
||||
'autoRewindDurations': instance.autoRewindDurations
|
||||
.map((k, e) => MapEntry(k.toString(), e.inMicroseconds)),
|
||||
'autoTurnOnTimer': instance.autoTurnOnTimer,
|
||||
'alwaysAutoTurnOnTimer': instance.alwaysAutoTurnOnTimer,
|
||||
'autoTurnOnTime': instance.autoTurnOnTime.inMicroseconds,
|
||||
'autoTurnOffTime': instance.autoTurnOffTime.inMicroseconds,
|
||||
};
|
||||
|
||||
_$DownloadSettingsImpl _$$DownloadSettingsImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$DownloadSettingsImpl(
|
||||
requiresWiFi: json['requiresWiFi'] as bool? ?? true,
|
||||
retries: (json['retries'] as num?)?.toInt() ?? 3,
|
||||
allowPause: json['allowPause'] as bool? ?? true,
|
||||
maxConcurrent: (json['maxConcurrent'] as num?)?.toInt() ?? 3,
|
||||
maxConcurrentByHost: (json['maxConcurrentByHost'] as num?)?.toInt() ?? 3,
|
||||
maxConcurrentByGroup:
|
||||
(json['maxConcurrentByGroup'] as num?)?.toInt() ?? 3,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$DownloadSettingsImplToJson(
|
||||
_$DownloadSettingsImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'requiresWiFi': instance.requiresWiFi,
|
||||
'retries': instance.retries,
|
||||
'allowPause': instance.allowPause,
|
||||
'maxConcurrent': instance.maxConcurrent,
|
||||
'maxConcurrentByHost': instance.maxConcurrentByHost,
|
||||
'maxConcurrentByGroup': instance.maxConcurrentByGroup,
|
||||
};
|
||||
|
||||
_$NotificationSettingsImpl _$$NotificationSettingsImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$NotificationSettingsImpl(
|
||||
fastForwardInterval: json['fastForwardInterval'] == null
|
||||
? const Duration(seconds: 30)
|
||||
: Duration(
|
||||
microseconds: (json['fastForwardInterval'] as num).toInt()),
|
||||
rewindInterval: json['rewindInterval'] == null
|
||||
? const Duration(seconds: 10)
|
||||
: Duration(microseconds: (json['rewindInterval'] as num).toInt()),
|
||||
progressBarIsChapterProgress:
|
||||
json['progressBarIsChapterProgress'] as bool? ?? true,
|
||||
primaryTitle: json['primaryTitle'] as String? ?? '\$bookTitle',
|
||||
secondaryTitle: json['secondaryTitle'] as String? ?? '\$author',
|
||||
mediaControls: (json['mediaControls'] as List<dynamic>?)
|
||||
?.map((e) => $enumDecode(_$NotificationMediaControlEnumMap, e))
|
||||
.toList() ??
|
||||
const [
|
||||
NotificationMediaControl.rewind,
|
||||
NotificationMediaControl.fastForward,
|
||||
NotificationMediaControl.skipToPreviousChapter,
|
||||
NotificationMediaControl.skipToNextChapter
|
||||
],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$NotificationSettingsImplToJson(
|
||||
_$NotificationSettingsImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'fastForwardInterval': instance.fastForwardInterval.inMicroseconds,
|
||||
'rewindInterval': instance.rewindInterval.inMicroseconds,
|
||||
'progressBarIsChapterProgress': instance.progressBarIsChapterProgress,
|
||||
'primaryTitle': instance.primaryTitle,
|
||||
'secondaryTitle': instance.secondaryTitle,
|
||||
'mediaControls': instance.mediaControls
|
||||
.map((e) => _$NotificationMediaControlEnumMap[e]!)
|
||||
.toList(),
|
||||
};
|
||||
|
||||
const _$NotificationMediaControlEnumMap = {
|
||||
NotificationMediaControl.fastForward: 'fastForward',
|
||||
NotificationMediaControl.rewind: 'rewind',
|
||||
NotificationMediaControl.speedToggle: 'speedToggle',
|
||||
NotificationMediaControl.stop: 'stop',
|
||||
NotificationMediaControl.skipToNextChapter: 'skipToNextChapter',
|
||||
NotificationMediaControl.skipToPreviousChapter: 'skipToPreviousChapter',
|
||||
};
|
||||
|
||||
_$ShakeDetectionSettingsImpl _$$ShakeDetectionSettingsImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$ShakeDetectionSettingsImpl(
|
||||
isEnabled: json['isEnabled'] as bool? ?? true,
|
||||
direction:
|
||||
$enumDecodeNullable(_$ShakeDirectionEnumMap, json['direction']) ??
|
||||
ShakeDirection.horizontal,
|
||||
threshold: (json['threshold'] as num?)?.toDouble() ?? 5,
|
||||
shakeAction:
|
||||
$enumDecodeNullable(_$ShakeActionEnumMap, json['shakeAction']) ??
|
||||
ShakeAction.resetSleepTimer,
|
||||
feedback: (json['feedback'] as List<dynamic>?)
|
||||
?.map((e) => $enumDecode(_$ShakeDetectedFeedbackEnumMap, e))
|
||||
.toSet() ??
|
||||
const {ShakeDetectedFeedback.vibrate},
|
||||
beepVolume: (json['beepVolume'] as num?)?.toDouble() ?? 0.5,
|
||||
shakeTriggerCoolDown: json['shakeTriggerCoolDown'] == null
|
||||
? const Duration(seconds: 2)
|
||||
: Duration(
|
||||
microseconds: (json['shakeTriggerCoolDown'] as num).toInt()),
|
||||
shakeTriggerCount: (json['shakeTriggerCount'] as num?)?.toInt() ?? 2,
|
||||
samplingPeriod: json['samplingPeriod'] == null
|
||||
? const Duration(milliseconds: 100)
|
||||
: Duration(microseconds: (json['samplingPeriod'] as num).toInt()),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ShakeDetectionSettingsImplToJson(
|
||||
_$ShakeDetectionSettingsImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'isEnabled': instance.isEnabled,
|
||||
'direction': _$ShakeDirectionEnumMap[instance.direction]!,
|
||||
'threshold': instance.threshold,
|
||||
'shakeAction': _$ShakeActionEnumMap[instance.shakeAction]!,
|
||||
'feedback': instance.feedback
|
||||
.map((e) => _$ShakeDetectedFeedbackEnumMap[e]!)
|
||||
.toList(),
|
||||
'beepVolume': instance.beepVolume,
|
||||
'shakeTriggerCoolDown': instance.shakeTriggerCoolDown.inMicroseconds,
|
||||
'shakeTriggerCount': instance.shakeTriggerCount,
|
||||
'samplingPeriod': instance.samplingPeriod.inMicroseconds,
|
||||
};
|
||||
|
||||
const _$ShakeDirectionEnumMap = {
|
||||
ShakeDirection.horizontal: 'horizontal',
|
||||
ShakeDirection.vertical: 'vertical',
|
||||
};
|
||||
|
||||
const _$ShakeActionEnumMap = {
|
||||
ShakeAction.none: 'none',
|
||||
ShakeAction.playPause: 'playPause',
|
||||
ShakeAction.resetSleepTimer: 'resetSleepTimer',
|
||||
ShakeAction.fastForward: 'fastForward',
|
||||
ShakeAction.rewind: 'rewind',
|
||||
};
|
||||
|
||||
const _$ShakeDetectedFeedbackEnumMap = {
|
||||
ShakeDetectedFeedback.vibrate: 'vibrate',
|
||||
ShakeDetectedFeedback.beep: 'beep',
|
||||
};
|
||||
|
||||
_$HomePageSettingsImpl _$$HomePageSettingsImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$HomePageSettingsImpl(
|
||||
showPlayButtonOnContinueListeningShelf:
|
||||
json['showPlayButtonOnContinueListeningShelf'] as bool? ?? true,
|
||||
showPlayButtonOnContinueSeriesShelf:
|
||||
json['showPlayButtonOnContinueSeriesShelf'] as bool? ?? false,
|
||||
showPlayButtonOnAllRemainingShelves:
|
||||
json['showPlayButtonOnAllRemainingShelves'] as bool? ?? false,
|
||||
showPlayButtonOnListenAgainShelf:
|
||||
json['showPlayButtonOnListenAgainShelf'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$HomePageSettingsImplToJson(
|
||||
_$HomePageSettingsImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'showPlayButtonOnContinueListeningShelf':
|
||||
instance.showPlayButtonOnContinueListeningShelf,
|
||||
'showPlayButtonOnContinueSeriesShelf':
|
||||
instance.showPlayButtonOnContinueSeriesShelf,
|
||||
'showPlayButtonOnAllRemainingShelves':
|
||||
instance.showPlayButtonOnAllRemainingShelves,
|
||||
'showPlayButtonOnListenAgainShelf':
|
||||
instance.showPlayButtonOnListenAgainShelf,
|
||||
};
|
||||
18
lib/features/settings/models/audiobookshelf_server.dart
Normal file
18
lib/features/settings/models/audiobookshelf_server.dart
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'audiobookshelf_server.freezed.dart';
|
||||
part 'audiobookshelf_server.g.dart';
|
||||
|
||||
typedef AudiobookShelfUri = Uri;
|
||||
|
||||
/// Represents a audiobookshelf server
|
||||
@freezed
|
||||
class AudiobookShelfServer with _$AudiobookShelfServer {
|
||||
const factory AudiobookShelfServer({
|
||||
required AudiobookShelfUri serverUrl,
|
||||
// String? serverName,
|
||||
}) = _AudiobookShelfServer;
|
||||
|
||||
factory AudiobookShelfServer.fromJson(Map<String, dynamic> json) =>
|
||||
_$AudiobookShelfServerFromJson(json);
|
||||
}
|
||||
169
lib/features/settings/models/audiobookshelf_server.freezed.dart
Normal file
169
lib/features/settings/models/audiobookshelf_server.freezed.dart
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'audiobookshelf_server.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
||||
|
||||
AudiobookShelfServer _$AudiobookShelfServerFromJson(Map<String, dynamic> json) {
|
||||
return _AudiobookShelfServer.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$AudiobookShelfServer {
|
||||
Uri get serverUrl => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this AudiobookShelfServer to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of AudiobookShelfServer
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$AudiobookShelfServerCopyWith<AudiobookShelfServer> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $AudiobookShelfServerCopyWith<$Res> {
|
||||
factory $AudiobookShelfServerCopyWith(AudiobookShelfServer value,
|
||||
$Res Function(AudiobookShelfServer) then) =
|
||||
_$AudiobookShelfServerCopyWithImpl<$Res, AudiobookShelfServer>;
|
||||
@useResult
|
||||
$Res call({Uri serverUrl});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$AudiobookShelfServerCopyWithImpl<$Res,
|
||||
$Val extends AudiobookShelfServer>
|
||||
implements $AudiobookShelfServerCopyWith<$Res> {
|
||||
_$AudiobookShelfServerCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of AudiobookShelfServer
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? serverUrl = null,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
serverUrl: null == serverUrl
|
||||
? _value.serverUrl
|
||||
: serverUrl // ignore: cast_nullable_to_non_nullable
|
||||
as Uri,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$AudiobookShelfServerImplCopyWith<$Res>
|
||||
implements $AudiobookShelfServerCopyWith<$Res> {
|
||||
factory _$$AudiobookShelfServerImplCopyWith(_$AudiobookShelfServerImpl value,
|
||||
$Res Function(_$AudiobookShelfServerImpl) then) =
|
||||
__$$AudiobookShelfServerImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({Uri serverUrl});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$AudiobookShelfServerImplCopyWithImpl<$Res>
|
||||
extends _$AudiobookShelfServerCopyWithImpl<$Res, _$AudiobookShelfServerImpl>
|
||||
implements _$$AudiobookShelfServerImplCopyWith<$Res> {
|
||||
__$$AudiobookShelfServerImplCopyWithImpl(_$AudiobookShelfServerImpl _value,
|
||||
$Res Function(_$AudiobookShelfServerImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
/// Create a copy of AudiobookShelfServer
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? serverUrl = null,
|
||||
}) {
|
||||
return _then(_$AudiobookShelfServerImpl(
|
||||
serverUrl: null == serverUrl
|
||||
? _value.serverUrl
|
||||
: serverUrl // ignore: cast_nullable_to_non_nullable
|
||||
as Uri,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$AudiobookShelfServerImpl implements _AudiobookShelfServer {
|
||||
const _$AudiobookShelfServerImpl({required this.serverUrl});
|
||||
|
||||
factory _$AudiobookShelfServerImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$AudiobookShelfServerImplFromJson(json);
|
||||
|
||||
@override
|
||||
final Uri serverUrl;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AudiobookShelfServer(serverUrl: $serverUrl)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$AudiobookShelfServerImpl &&
|
||||
(identical(other.serverUrl, serverUrl) ||
|
||||
other.serverUrl == serverUrl));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, serverUrl);
|
||||
|
||||
/// Create a copy of AudiobookShelfServer
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$AudiobookShelfServerImplCopyWith<_$AudiobookShelfServerImpl>
|
||||
get copyWith =>
|
||||
__$$AudiobookShelfServerImplCopyWithImpl<_$AudiobookShelfServerImpl>(
|
||||
this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$AudiobookShelfServerImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _AudiobookShelfServer implements AudiobookShelfServer {
|
||||
const factory _AudiobookShelfServer({required final Uri serverUrl}) =
|
||||
_$AudiobookShelfServerImpl;
|
||||
|
||||
factory _AudiobookShelfServer.fromJson(Map<String, dynamic> json) =
|
||||
_$AudiobookShelfServerImpl.fromJson;
|
||||
|
||||
@override
|
||||
Uri get serverUrl;
|
||||
|
||||
/// Create a copy of AudiobookShelfServer
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$AudiobookShelfServerImplCopyWith<_$AudiobookShelfServerImpl>
|
||||
get copyWith => throw _privateConstructorUsedError;
|
||||
}
|
||||
19
lib/features/settings/models/audiobookshelf_server.g.dart
Normal file
19
lib/features/settings/models/audiobookshelf_server.g.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'audiobookshelf_server.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$AudiobookShelfServerImpl _$$AudiobookShelfServerImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$AudiobookShelfServerImpl(
|
||||
serverUrl: Uri.parse(json['serverUrl'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AudiobookShelfServerImplToJson(
|
||||
_$AudiobookShelfServerImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'serverUrl': instance.serverUrl.toString(),
|
||||
};
|
||||
19
lib/features/settings/models/authenticated_user.dart
Normal file
19
lib/features/settings/models/authenticated_user.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:vaani/features/settings/models/audiobookshelf_server.dart';
|
||||
|
||||
part 'authenticated_user.freezed.dart';
|
||||
part 'authenticated_user.g.dart';
|
||||
|
||||
/// authenticated user with server and credentials
|
||||
@freezed
|
||||
class AuthenticatedUser with _$AuthenticatedUser {
|
||||
const factory AuthenticatedUser({
|
||||
required AudiobookShelfServer server,
|
||||
required String authToken,
|
||||
required String id,
|
||||
String? username,
|
||||
}) = _AuthenticatedUser;
|
||||
|
||||
factory AuthenticatedUser.fromJson(Map<String, dynamic> json) =>
|
||||
_$AuthenticatedUserFromJson(json);
|
||||
}
|
||||
246
lib/features/settings/models/authenticated_user.freezed.dart
Normal file
246
lib/features/settings/models/authenticated_user.freezed.dart
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'authenticated_user.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
||||
|
||||
AuthenticatedUser _$AuthenticatedUserFromJson(Map<String, dynamic> json) {
|
||||
return _AuthenticatedUser.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$AuthenticatedUser {
|
||||
AudiobookShelfServer get server => throw _privateConstructorUsedError;
|
||||
String get authToken => throw _privateConstructorUsedError;
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
String? get username => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this AuthenticatedUser to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of AuthenticatedUser
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$AuthenticatedUserCopyWith<AuthenticatedUser> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $AuthenticatedUserCopyWith<$Res> {
|
||||
factory $AuthenticatedUserCopyWith(
|
||||
AuthenticatedUser value, $Res Function(AuthenticatedUser) then) =
|
||||
_$AuthenticatedUserCopyWithImpl<$Res, AuthenticatedUser>;
|
||||
@useResult
|
||||
$Res call(
|
||||
{AudiobookShelfServer server,
|
||||
String authToken,
|
||||
String id,
|
||||
String? username});
|
||||
|
||||
$AudiobookShelfServerCopyWith<$Res> get server;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$AuthenticatedUserCopyWithImpl<$Res, $Val extends AuthenticatedUser>
|
||||
implements $AuthenticatedUserCopyWith<$Res> {
|
||||
_$AuthenticatedUserCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of AuthenticatedUser
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? server = null,
|
||||
Object? authToken = null,
|
||||
Object? id = null,
|
||||
Object? username = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
server: null == server
|
||||
? _value.server
|
||||
: server // ignore: cast_nullable_to_non_nullable
|
||||
as AudiobookShelfServer,
|
||||
authToken: null == authToken
|
||||
? _value.authToken
|
||||
: authToken // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
username: freezed == username
|
||||
? _value.username
|
||||
: username // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
) as $Val);
|
||||
}
|
||||
|
||||
/// Create a copy of AuthenticatedUser
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$AudiobookShelfServerCopyWith<$Res> get server {
|
||||
return $AudiobookShelfServerCopyWith<$Res>(_value.server, (value) {
|
||||
return _then(_value.copyWith(server: value) as $Val);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$AuthenticatedUserImplCopyWith<$Res>
|
||||
implements $AuthenticatedUserCopyWith<$Res> {
|
||||
factory _$$AuthenticatedUserImplCopyWith(_$AuthenticatedUserImpl value,
|
||||
$Res Function(_$AuthenticatedUserImpl) then) =
|
||||
__$$AuthenticatedUserImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{AudiobookShelfServer server,
|
||||
String authToken,
|
||||
String id,
|
||||
String? username});
|
||||
|
||||
@override
|
||||
$AudiobookShelfServerCopyWith<$Res> get server;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$AuthenticatedUserImplCopyWithImpl<$Res>
|
||||
extends _$AuthenticatedUserCopyWithImpl<$Res, _$AuthenticatedUserImpl>
|
||||
implements _$$AuthenticatedUserImplCopyWith<$Res> {
|
||||
__$$AuthenticatedUserImplCopyWithImpl(_$AuthenticatedUserImpl _value,
|
||||
$Res Function(_$AuthenticatedUserImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
/// Create a copy of AuthenticatedUser
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? server = null,
|
||||
Object? authToken = null,
|
||||
Object? id = null,
|
||||
Object? username = freezed,
|
||||
}) {
|
||||
return _then(_$AuthenticatedUserImpl(
|
||||
server: null == server
|
||||
? _value.server
|
||||
: server // ignore: cast_nullable_to_non_nullable
|
||||
as AudiobookShelfServer,
|
||||
authToken: null == authToken
|
||||
? _value.authToken
|
||||
: authToken // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
username: freezed == username
|
||||
? _value.username
|
||||
: username // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$AuthenticatedUserImpl implements _AuthenticatedUser {
|
||||
const _$AuthenticatedUserImpl(
|
||||
{required this.server,
|
||||
required this.authToken,
|
||||
required this.id,
|
||||
this.username});
|
||||
|
||||
factory _$AuthenticatedUserImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$AuthenticatedUserImplFromJson(json);
|
||||
|
||||
@override
|
||||
final AudiobookShelfServer server;
|
||||
@override
|
||||
final String authToken;
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String? username;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthenticatedUser(server: $server, authToken: $authToken, id: $id, username: $username)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$AuthenticatedUserImpl &&
|
||||
(identical(other.server, server) || other.server == server) &&
|
||||
(identical(other.authToken, authToken) ||
|
||||
other.authToken == authToken) &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.username, username) ||
|
||||
other.username == username));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, server, authToken, id, username);
|
||||
|
||||
/// Create a copy of AuthenticatedUser
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$AuthenticatedUserImplCopyWith<_$AuthenticatedUserImpl> get copyWith =>
|
||||
__$$AuthenticatedUserImplCopyWithImpl<_$AuthenticatedUserImpl>(
|
||||
this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$AuthenticatedUserImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _AuthenticatedUser implements AuthenticatedUser {
|
||||
const factory _AuthenticatedUser(
|
||||
{required final AudiobookShelfServer server,
|
||||
required final String authToken,
|
||||
required final String id,
|
||||
final String? username}) = _$AuthenticatedUserImpl;
|
||||
|
||||
factory _AuthenticatedUser.fromJson(Map<String, dynamic> json) =
|
||||
_$AuthenticatedUserImpl.fromJson;
|
||||
|
||||
@override
|
||||
AudiobookShelfServer get server;
|
||||
@override
|
||||
String get authToken;
|
||||
@override
|
||||
String get id;
|
||||
@override
|
||||
String? get username;
|
||||
|
||||
/// Create a copy of AuthenticatedUser
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$AuthenticatedUserImplCopyWith<_$AuthenticatedUserImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
26
lib/features/settings/models/authenticated_user.g.dart
Normal file
26
lib/features/settings/models/authenticated_user.g.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'authenticated_user.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$AuthenticatedUserImpl _$$AuthenticatedUserImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$AuthenticatedUserImpl(
|
||||
server:
|
||||
AudiobookShelfServer.fromJson(json['server'] as Map<String, dynamic>),
|
||||
authToken: json['authToken'] as String,
|
||||
id: json['id'] as String,
|
||||
username: json['username'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AuthenticatedUserImplToJson(
|
||||
_$AuthenticatedUserImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'server': instance.server,
|
||||
'authToken': instance.authToken,
|
||||
'id': instance.id,
|
||||
'username': instance.username,
|
||||
};
|
||||
4
lib/features/settings/models/models.dart
Normal file
4
lib/features/settings/models/models.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export 'api_settings.dart';
|
||||
export 'app_settings.dart';
|
||||
export 'audiobookshelf_server.dart';
|
||||
export 'authenticated_user.dart';
|
||||
1
lib/features/settings/settings.dart
Normal file
1
lib/features/settings/settings.dart
Normal file
|
|
@ -0,0 +1 @@
|
|||
export 'app_settings_provider.dart';
|
||||
316
lib/features/settings/view/app_settings_page.dart
Normal file
316
lib/features/settings/view/app_settings_page.dart
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
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:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/router/router.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/models/app_settings.dart' as model;
|
||||
import 'package:vaani/features/settings/view/buttons.dart';
|
||||
import 'package:vaani/features/settings/view/simple_settings_page.dart';
|
||||
import 'package:vaani/features/settings/view/widgets/navigation_with_switch_tile.dart';
|
||||
|
||||
class AppSettingsPage extends HookConsumerWidget {
|
||||
const AppSettingsPage({
|
||||
super.key,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettings = ref.watch(appSettingsProvider);
|
||||
final sleepTimerSettings = appSettings.sleepTimerSettings;
|
||||
final locales = {'en': 'English', 'zh': '中文'};
|
||||
return SimpleSettingsPage(
|
||||
title: Text(S.of(context).appSettings),
|
||||
sections: [
|
||||
// General section
|
||||
SettingsSection(
|
||||
margin: const EdgeInsetsDirectional.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
title: Text(
|
||||
S.of(context).general,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
tiles: [
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).language),
|
||||
leading: const Icon(Icons.play_arrow),
|
||||
trailing: DropdownButton(
|
||||
value: appSettings.language,
|
||||
items: S.delegate.supportedLocales.map((locale) {
|
||||
return DropdownMenuItem(
|
||||
value: locale.languageCode,
|
||||
child: Text(locales[locale.languageCode] ?? 'unknown'),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith(
|
||||
language: value!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
description: Text(S.of(context).languageDescription),
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).playerSettings),
|
||||
leading: const Icon(Icons.play_arrow),
|
||||
description: Text(S.of(context).playerSettingsDescription),
|
||||
onPressed: (context) {
|
||||
context.pushNamed(Routes.playerSettings.name);
|
||||
},
|
||||
),
|
||||
NavigationWithSwitchTile(
|
||||
title: Text(S.of(context).autoTurnOnSleepTimer),
|
||||
description: Text(S.of(context).automaticallyDescription),
|
||||
leading: sleepTimerSettings.autoTurnOnTimer
|
||||
? const Icon(Icons.timer, fill: 1)
|
||||
: const Icon(Icons.timer_off, fill: 1),
|
||||
onPressed: (context) {
|
||||
context.pushNamed(Routes.autoSleepTimerSettings.name);
|
||||
},
|
||||
value: sleepTimerSettings.autoTurnOnTimer,
|
||||
onToggle: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.sleepTimerSettings(
|
||||
autoTurnOnTimer: value,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
NavigationWithSwitchTile(
|
||||
title: Text(S.of(context).shakeDetector),
|
||||
leading: const Icon(Icons.vibration),
|
||||
description: Text(
|
||||
S.of(context).shakeDetectorDescription,
|
||||
),
|
||||
value: appSettings.shakeDetectionSettings.isEnabled,
|
||||
onPressed: (context) {
|
||||
context.pushNamed(Routes.shakeDetectorSettings.name);
|
||||
},
|
||||
onToggle: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.shakeDetectionSettings(
|
||||
isEnabled: value,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Appearance section
|
||||
SettingsSection(
|
||||
margin: const EdgeInsetsDirectional.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
title: Text(
|
||||
S.of(context).appearance,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
tiles: [
|
||||
SettingsTile.navigation(
|
||||
leading: const Icon(Icons.color_lens),
|
||||
title: Text(S.of(context).themeSettings),
|
||||
description: Text(S.of(context).themeSettingsDescription),
|
||||
onPressed: (context) {
|
||||
context.pushNamed(Routes.themeSettings.name);
|
||||
},
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).notificationMediaPlayer),
|
||||
leading: const Icon(Icons.play_lesson),
|
||||
description:
|
||||
Text(S.of(context).notificationMediaPlayerDescription),
|
||||
onPressed: (context) {
|
||||
context.pushNamed(Routes.notificationSettings.name);
|
||||
},
|
||||
),
|
||||
SettingsTile.navigation(
|
||||
leading: const Icon(Icons.home_filled),
|
||||
title: Text(S.of(context).homePageSettings),
|
||||
description: Text(S.of(context).homePageSettingsDescription),
|
||||
onPressed: (context) {
|
||||
context.pushNamed(Routes.homePageSettings.name);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Backup and Restore section
|
||||
SettingsSection(
|
||||
margin: const EdgeInsetsDirectional.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
title: Text(
|
||||
S.of(context).backupAndRestore,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
tiles: [
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).copyToClipboard),
|
||||
leading: const Icon(Icons.copy),
|
||||
description: Text(
|
||||
S.of(context).copyToClipboardDescription,
|
||||
),
|
||||
onPressed: (context) async {
|
||||
// copy to clipboard
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: jsonEncode(appSettings.toJson()),
|
||||
),
|
||||
);
|
||||
// show toast
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context).copyToClipboardToast),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).restore),
|
||||
leading: const Icon(Icons.restore),
|
||||
description: Text(S.of(context).restoreDescription),
|
||||
onPressed: (context) {
|
||||
// show a dialog to get the backup
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return RestoreDialogue();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// a button to reset the app settings
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).resetAppSettings),
|
||||
leading: const Icon(Icons.settings_backup_restore),
|
||||
description: Text(S.of(context).resetAppSettingsDescription),
|
||||
onPressed: (context) async {
|
||||
// confirm the reset
|
||||
final res = await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(S.of(context).resetAppSettings),
|
||||
content: Text(S.of(context).resetAppSettingsDialog),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: Text(S.of(context).cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: Text(S.of(context).reset),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// if the user confirms the reset
|
||||
if (res == true) {
|
||||
ref.read(appSettingsProvider.notifier).reset();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RestoreDialogue extends HookConsumerWidget {
|
||||
const RestoreDialogue({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final formKey = useMemoized(() => GlobalKey<FormState>());
|
||||
final settings = useState<model.AppSettings?>(null);
|
||||
|
||||
final settingsInputController = useTextEditingController();
|
||||
return AlertDialog(
|
||||
title: Text(S.of(context).restoreBackup),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: TextFormField(
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: S.of(context).backup,
|
||||
hintText: S.of(context).restoreBackupHint,
|
||||
// clear button
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
settingsInputController.clear();
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return S.of(context).restoreBackupValidator;
|
||||
}
|
||||
try {
|
||||
// try to decode the backup
|
||||
settings.value = model.AppSettings.fromJson(
|
||||
jsonDecode(value),
|
||||
);
|
||||
} catch (e) {
|
||||
return S.of(context).restoreBackupInvalid;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
CancelButton(),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (formKey.currentState!.validate()) {
|
||||
if (settings.value == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context).restoreBackupInvalid),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ref.read(appSettingsProvider.notifier).update(settings.value!);
|
||||
settingsInputController.clear();
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context).restoreBackupSuccess),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context).restoreBackupInvalid),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(S.of(context).restore),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
129
lib/features/settings/view/auto_sleep_timer_settings_page.dart
Normal file
129
lib/features/settings/view/auto_sleep_timer_settings_page.dart
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/view/simple_settings_page.dart';
|
||||
import 'package:vaani/shared/extensions/time_of_day.dart';
|
||||
|
||||
class AutoSleepTimerSettingsPage extends HookConsumerWidget {
|
||||
const AutoSleepTimerSettingsPage({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettings = ref.watch(appSettingsProvider);
|
||||
final sleepTimerSettings = appSettings.sleepTimerSettings;
|
||||
|
||||
var enabled = sleepTimerSettings.autoTurnOnTimer &&
|
||||
!sleepTimerSettings.alwaysAutoTurnOnTimer;
|
||||
final selectedValueColor = enabled
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).disabledColor;
|
||||
return SimpleSettingsPage(
|
||||
title: Text(S.of(context).autoSleepTimerSettings),
|
||||
sections: [
|
||||
SettingsSection(
|
||||
margin: const EdgeInsetsDirectional.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
tiles: [
|
||||
SettingsTile.switchTile(
|
||||
// initialValue: sleepTimerSettings.autoTurnOnTimer,
|
||||
title: Text(S.of(context).autoTurnOnTimer),
|
||||
description: Text(
|
||||
S.of(context).autoTurnOnTimerDescription,
|
||||
),
|
||||
leading: sleepTimerSettings.autoTurnOnTimer
|
||||
? const Icon(Icons.timer_outlined)
|
||||
: const Icon(Icons.timer_off_outlined),
|
||||
onToggle: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.sleepTimerSettings(
|
||||
autoTurnOnTimer: value,
|
||||
),
|
||||
);
|
||||
},
|
||||
initialValue: sleepTimerSettings.autoTurnOnTimer,
|
||||
),
|
||||
// auto turn on time settings, enabled only when autoTurnOnTimer is enabled
|
||||
SettingsTile.navigation(
|
||||
enabled: enabled,
|
||||
leading: const Icon(Icons.play_circle),
|
||||
title: Text(S.of(context).autoTurnOnTimerFrom),
|
||||
description: Text(
|
||||
S.of(context).autoTurnOnTimerFromDescription,
|
||||
),
|
||||
onPressed: (context) async {
|
||||
// navigate to the time picker
|
||||
final selected = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: sleepTimerSettings.autoTurnOnTime.toTimeOfDay(),
|
||||
);
|
||||
if (selected != null) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.sleepTimerSettings(
|
||||
autoTurnOnTime: selected.toDuration(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
trailing: Text(
|
||||
sleepTimerSettings.autoTurnOnTime.toTimeOfDay().format(context),
|
||||
style: TextStyle(color: selectedValueColor),
|
||||
),
|
||||
),
|
||||
SettingsTile.navigation(
|
||||
enabled: enabled,
|
||||
leading: const Icon(Icons.pause_circle),
|
||||
title: Text(S.of(context).autoTurnOnTimerUntil),
|
||||
description: Text(
|
||||
S.of(context).autoTurnOnTimerUntilDescription,
|
||||
),
|
||||
onPressed: (context) async {
|
||||
// navigate to the time picker
|
||||
final selected = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: sleepTimerSettings.autoTurnOffTime.toTimeOfDay(),
|
||||
);
|
||||
if (selected != null) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.sleepTimerSettings(
|
||||
autoTurnOffTime: selected.toDuration(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
trailing: Text(
|
||||
sleepTimerSettings.autoTurnOffTime
|
||||
.toTimeOfDay()
|
||||
.format(context),
|
||||
style: TextStyle(color: selectedValueColor),
|
||||
),
|
||||
),
|
||||
|
||||
// switch tile for always auto turn on timer no matter what
|
||||
SettingsTile.switchTile(
|
||||
leading: const Icon(Icons.all_inclusive),
|
||||
title: Text(S.of(context).autoTurnOnTimerAlways),
|
||||
description: Text(
|
||||
S.of(context).autoTurnOnTimerAlwaysDescription,
|
||||
),
|
||||
onToggle: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.sleepTimerSettings(
|
||||
alwaysAutoTurnOnTimer: value,
|
||||
),
|
||||
);
|
||||
},
|
||||
enabled: sleepTimerSettings.autoTurnOnTimer,
|
||||
initialValue: sleepTimerSettings.alwaysAutoTurnOnTimer,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
39
lib/features/settings/view/buttons.dart
Normal file
39
lib/features/settings/view/buttons.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:vaani/generated/l10n.dart';
|
||||
|
||||
class OkButton<T> extends StatelessWidget {
|
||||
const OkButton({
|
||||
super.key,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
final void Function()? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton(
|
||||
onPressed: onPressed,
|
||||
child: Text(S.of(context).ok),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CancelButton extends StatelessWidget {
|
||||
const CancelButton({
|
||||
super.key,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
final void Function()? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton(
|
||||
onPressed: () {
|
||||
onPressed?.call();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(S.of(context).cancel),
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/features/settings/view/home_page_settings_page.dart
Normal file
99
lib/features/settings/view/home_page_settings_page.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
||||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/view/simple_settings_page.dart'
|
||||
show SimpleSettingsPage;
|
||||
|
||||
class HomePageSettingsPage extends HookConsumerWidget {
|
||||
const HomePageSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettings = ref.watch(appSettingsProvider);
|
||||
final appSettingsNotifier = ref.read(appSettingsProvider.notifier);
|
||||
|
||||
return SimpleSettingsPage(
|
||||
title: Text(S.of(context).homePageSettings),
|
||||
sections: [
|
||||
SettingsSection(
|
||||
title: Text(S.of(context).homePageSettingsQuickPlay),
|
||||
margin: const EdgeInsetsDirectional.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
tiles: [
|
||||
SettingsTile.switchTile(
|
||||
initialValue: appSettings
|
||||
.homePageSettings.showPlayButtonOnContinueListeningShelf,
|
||||
title: Text(S.of(context).homeContinueListening),
|
||||
leading: const Icon(Icons.play_arrow),
|
||||
description:
|
||||
Text(S.of(context).homeBookContinueListeningDescription),
|
||||
onToggle: (value) {
|
||||
appSettingsNotifier.update(
|
||||
appSettings.copyWith(
|
||||
homePageSettings: appSettings.homePageSettings.copyWith(
|
||||
showPlayButtonOnContinueListeningShelf: value,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(S.of(context).homeBookContinueSeries),
|
||||
leading: const Icon(Icons.play_arrow),
|
||||
description:
|
||||
Text(S.of(context).homeBookContinueSeriesDescription),
|
||||
initialValue: appSettings
|
||||
.homePageSettings.showPlayButtonOnContinueSeriesShelf,
|
||||
onToggle: (value) {
|
||||
appSettingsNotifier.update(
|
||||
appSettings.copyWith(
|
||||
homePageSettings: appSettings.homePageSettings.copyWith(
|
||||
showPlayButtonOnContinueSeriesShelf: value,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(S.of(context).homePageSettingsOtherShelves),
|
||||
leading: const Icon(Icons.all_inclusive),
|
||||
description:
|
||||
Text(S.of(context).homePageSettingsOtherShelvesDescription),
|
||||
initialValue: appSettings
|
||||
.homePageSettings.showPlayButtonOnAllRemainingShelves,
|
||||
onToggle: (value) {
|
||||
appSettingsNotifier.update(
|
||||
appSettings.copyWith(
|
||||
homePageSettings: appSettings.homePageSettings.copyWith(
|
||||
showPlayButtonOnAllRemainingShelves: value,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(S.of(context).homeBookListenAgain),
|
||||
leading: const Icon(Icons.replay),
|
||||
description: Text(S.of(context).homeBookListenAgainDescription),
|
||||
initialValue:
|
||||
appSettings.homePageSettings.showPlayButtonOnListenAgainShelf,
|
||||
onToggle: (value) {
|
||||
appSettingsNotifier.update(
|
||||
appSettings.copyWith(
|
||||
homePageSettings: appSettings.homePageSettings.copyWith(
|
||||
showPlayButtonOnListenAgainShelf: value,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
385
lib/features/settings/view/notification_settings_page.dart
Normal file
385
lib/features/settings/view/notification_settings_page.dart
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/models/app_settings.dart';
|
||||
import 'package:vaani/features/settings/view/buttons.dart';
|
||||
import 'package:vaani/features/settings/view/simple_settings_page.dart';
|
||||
import 'package:vaani/shared/extensions/enum.dart';
|
||||
|
||||
class NotificationSettingsPage extends HookConsumerWidget {
|
||||
const NotificationSettingsPage({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettings = ref.watch(appSettingsProvider);
|
||||
final notificationSettings = appSettings.notificationSettings;
|
||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||
return SimpleSettingsPage(
|
||||
title: Text(S.of(context).notificationMediaPlayer),
|
||||
sections: [
|
||||
SettingsSection(
|
||||
margin: const EdgeInsetsDirectional.only(
|
||||
start: 16.0,
|
||||
end: 16.0,
|
||||
top: 8.0,
|
||||
bottom: 8.0,
|
||||
),
|
||||
tiles: [
|
||||
// set the primary and secondary titles
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).nmpSettingsTitle),
|
||||
description: Text.rich(
|
||||
TextSpan(
|
||||
text: S.of(context).nmpSettingsTitleDescription,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: notificationSettings.primaryTitle,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
leading: const Icon(Icons.title),
|
||||
onPressed: (context) async {
|
||||
// show the notification title picker
|
||||
final selectedTitle = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return NotificationTitlePicker(
|
||||
initialValue: notificationSettings.primaryTitle,
|
||||
title: S.of(context).nmpSettingsTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (selectedTitle != null) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.notificationSettings(
|
||||
primaryTitle: selectedTitle,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).nmpSettingsSubTitle),
|
||||
description: Text.rich(
|
||||
TextSpan(
|
||||
text: S.of(context).nmpSettingsSubTitleDescription,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: notificationSettings.secondaryTitle,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
leading: const Icon(Icons.title),
|
||||
onPressed: (context) async {
|
||||
// show the notification title picker
|
||||
final selectedTitle = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return NotificationTitlePicker(
|
||||
initialValue: notificationSettings.secondaryTitle,
|
||||
title: S.of(context).nmpSettingsSubTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (selectedTitle != null) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.notificationSettings(
|
||||
secondaryTitle: selectedTitle,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// set forward and backward intervals
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).nmpSettingsForward),
|
||||
description: Row(
|
||||
children: [
|
||||
Text(
|
||||
S.of(context).timeSecond(
|
||||
notificationSettings.fastForwardInterval.inSeconds,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TimeIntervalSlider(
|
||||
defaultValue: notificationSettings.fastForwardInterval,
|
||||
onChangedEnd: (interval) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.notificationSettings(
|
||||
fastForwardInterval: interval,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: const Icon(Icons.fast_forward),
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).nmpSettingsBackward),
|
||||
description: Row(
|
||||
children: [
|
||||
Text(
|
||||
S.of(context).timeSecond(
|
||||
notificationSettings.rewindInterval.inSeconds,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TimeIntervalSlider(
|
||||
defaultValue: notificationSettings.rewindInterval,
|
||||
onChangedEnd: (interval) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.notificationSettings(
|
||||
rewindInterval: interval,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: const Icon(Icons.fast_rewind),
|
||||
),
|
||||
// set the media controls
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).nmpSettingsMediaControls),
|
||||
leading: const Icon(Icons.control_camera),
|
||||
// description: const Text('Select the media controls to display'),
|
||||
description:
|
||||
Text(S.of(context).nmpSettingsMediaControlsDescription),
|
||||
trailing: Wrap(
|
||||
spacing: 8.0,
|
||||
children: notificationSettings.mediaControls
|
||||
.map(
|
||||
(control) => Icon(
|
||||
control.icon,
|
||||
color: primaryColor,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
onPressed: (context) async {
|
||||
final selectedControls =
|
||||
await showDialog<List<NotificationMediaControl>>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return MediaControlsPicker(
|
||||
selectedControls: notificationSettings.mediaControls,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (selectedControls != null) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.notificationSettings(
|
||||
mediaControls: selectedControls,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// set the progress bar to show chapter progress
|
||||
SettingsTile.switchTile(
|
||||
title: Text(S.of(context).nmpSettingsShowChapterProgress),
|
||||
leading: const Icon(Icons.book),
|
||||
description:
|
||||
Text(S.of(context).nmpSettingsShowChapterProgressDescription),
|
||||
initialValue: notificationSettings.progressBarIsChapterProgress,
|
||||
onToggle: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.notificationSettings(
|
||||
progressBarIsChapterProgress: value,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MediaControlsPicker extends HookConsumerWidget {
|
||||
const MediaControlsPicker({
|
||||
super.key,
|
||||
required this.selectedControls,
|
||||
});
|
||||
|
||||
final List<NotificationMediaControl> selectedControls;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedMediaControls = useState(selectedControls);
|
||||
return AlertDialog(
|
||||
title: Text(S.of(context).nmpSettingsMediaControls),
|
||||
actions: [
|
||||
const CancelButton(),
|
||||
OkButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(selectedMediaControls.value);
|
||||
},
|
||||
),
|
||||
],
|
||||
// a list of chips to easily select the media controls to display
|
||||
// with icons and labels
|
||||
content: Wrap(
|
||||
spacing: 8.0,
|
||||
children: NotificationMediaControl.values
|
||||
.map(
|
||||
(control) => ChoiceChip(
|
||||
avatar: Icon(control.icon),
|
||||
label: Text(control.pascalCase),
|
||||
selected: selectedMediaControls.value.contains(control),
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
selectedMediaControls.value = [
|
||||
...selectedMediaControls.value,
|
||||
control,
|
||||
];
|
||||
} else {
|
||||
selectedMediaControls.value = [
|
||||
...selectedMediaControls.value.where((c) => c != control),
|
||||
];
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TimeIntervalSlider extends HookConsumerWidget {
|
||||
const TimeIntervalSlider({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.defaultValue,
|
||||
this.onChanged,
|
||||
this.onChangedEnd,
|
||||
this.min = const Duration(seconds: 5),
|
||||
this.max = const Duration(seconds: 120),
|
||||
this.step = const Duration(seconds: 5),
|
||||
});
|
||||
|
||||
final Widget? title;
|
||||
final Duration defaultValue;
|
||||
final ValueChanged<Duration>? onChanged;
|
||||
final ValueChanged<Duration>? onChangedEnd;
|
||||
final Duration min;
|
||||
final Duration max;
|
||||
final Duration step;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedInterval = useState(defaultValue);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
title ?? const SizedBox.shrink(),
|
||||
if (title != null) const SizedBox(height: 8.0),
|
||||
Slider(
|
||||
value: selectedInterval.value.inSeconds.toDouble(),
|
||||
min: min.inSeconds.toDouble(),
|
||||
max: max.inSeconds.toDouble(),
|
||||
divisions: ((max.inSeconds - min.inSeconds) ~/ step.inSeconds),
|
||||
label: S.of(context).timeSecond(selectedInterval.value.inSeconds),
|
||||
onChanged: (value) {
|
||||
selectedInterval.value = Duration(seconds: value.toInt());
|
||||
onChanged?.call(selectedInterval.value);
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
onChangedEnd?.call(selectedInterval.value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationTitlePicker extends HookConsumerWidget {
|
||||
const NotificationTitlePicker({
|
||||
super.key,
|
||||
required this.initialValue,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
final String initialValue;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedTitle = useState(initialValue);
|
||||
final controller = useTextEditingController(text: initialValue);
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
const CancelButton(),
|
||||
OkButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(selectedTitle.value);
|
||||
},
|
||||
),
|
||||
],
|
||||
// a list of chips to easily insert available fields into the text field
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
autofocus: true,
|
||||
controller: controller,
|
||||
onChanged: (value) {
|
||||
selectedTitle.value = value;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
helper: Text(S.of(context).nmpSettingsSelectOne),
|
||||
suffix: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
selectedTitle.value = '';
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8.0),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
children: NotificationTitleType.values
|
||||
.map(
|
||||
(type) => ActionChip(
|
||||
label: Text(type.pascalCase),
|
||||
onPressed: () {
|
||||
final text = controller.text;
|
||||
final newText = '$text\$${type.name}';
|
||||
controller.text = newText;
|
||||
selectedTitle.value = newText;
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
440
lib/features/settings/view/player_settings_page.dart
Normal file
440
lib/features/settings/view/player_settings_page.dart
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
import 'package:duration_picker/duration_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/view/buttons.dart';
|
||||
import 'package:vaani/features/settings/view/simple_settings_page.dart';
|
||||
import 'package:vaani/shared/extensions/duration_format.dart';
|
||||
|
||||
class PlayerSettingsPage extends HookConsumerWidget {
|
||||
const PlayerSettingsPage({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettings = ref.watch(appSettingsProvider);
|
||||
final playerSettings = appSettings.playerSettings;
|
||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||
|
||||
return SimpleSettingsPage(
|
||||
title: Text(S.of(context).playerSettings),
|
||||
sections: [
|
||||
SettingsSection(
|
||||
margin: const EdgeInsetsDirectional.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
tiles: [
|
||||
// preferred settings for every book
|
||||
SettingsTile.switchTile(
|
||||
title: Text(S.of(context).playerSettingsRememberForEveryBook),
|
||||
leading: const Icon(Icons.settings_applications),
|
||||
description: Text(
|
||||
S.of(context).playerSettingsRememberForEveryBookDescription,
|
||||
),
|
||||
initialValue: playerSettings.configurePlayerForEveryBook,
|
||||
onToggle: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.playerSettings(
|
||||
configurePlayerForEveryBook: value,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// preferred default speed
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).playerSettingsSpeedDefault),
|
||||
trailing: Text(
|
||||
'${playerSettings.preferredDefaultSpeed}x',
|
||||
style:
|
||||
TextStyle(color: primaryColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
leading: const Icon(Icons.speed),
|
||||
onPressed: (context) async {
|
||||
final newSpeed = await showDialog(
|
||||
context: context,
|
||||
builder: (context) => SpeedPicker(
|
||||
initialValue: playerSettings.preferredDefaultSpeed,
|
||||
),
|
||||
);
|
||||
if (newSpeed != null) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.playerSettings(
|
||||
preferredDefaultSpeed: newSpeed,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
// preferred speed options
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).playerSettingsSpeedOptions),
|
||||
description: Text(
|
||||
playerSettings.speedOptions.map((e) => '${e}x').join(', '),
|
||||
style:
|
||||
TextStyle(fontWeight: FontWeight.bold, color: primaryColor),
|
||||
),
|
||||
leading: const Icon(Icons.speed),
|
||||
onPressed: (context) async {
|
||||
final newSpeedOptions = await showDialog<List<double>?>(
|
||||
context: context,
|
||||
builder: (context) => SpeedOptionsPicker(
|
||||
initialValue: playerSettings.speedOptions,
|
||||
),
|
||||
);
|
||||
if (newSpeedOptions != null) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.playerSettings(
|
||||
speedOptions: newSpeedOptions..sort(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Playback Reporting
|
||||
SettingsSection(
|
||||
title: Text(S.of(context).playerSettingsPlaybackReporting),
|
||||
tiles: [
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).playerSettingsPlaybackReportingMinimum),
|
||||
description: Text.rich(
|
||||
TextSpan(
|
||||
text: S
|
||||
.of(context)
|
||||
.playerSettingsPlaybackReportingMinimumDescriptionHead,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: playerSettings
|
||||
.minimumPositionForReporting.smartBinaryFormat,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: S
|
||||
.of(context)
|
||||
.playerSettingsPlaybackReportingMinimumDescriptionTail),
|
||||
],
|
||||
),
|
||||
),
|
||||
leading: const Icon(Icons.timer),
|
||||
onPressed: (context) async {
|
||||
final newDuration = await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return TimeDurationSelector(
|
||||
title: Text(
|
||||
S.of(context).playerSettingsPlaybackReportingIgnore),
|
||||
baseUnit: BaseUnit.second,
|
||||
initialValue: playerSettings.minimumPositionForReporting,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (newDuration != null) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.playerSettings(
|
||||
minimumPositionForReporting: newDuration,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
// when to mark complete
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).playerSettingsCompleteTime),
|
||||
description: Text.rich(
|
||||
TextSpan(
|
||||
text: S.of(context).playerSettingsCompleteTimeDescriptionHead,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: playerSettings
|
||||
.markCompleteWhenTimeLeft.smartBinaryFormat,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: S
|
||||
.of(context)
|
||||
.playerSettingsCompleteTimeDescriptionTail),
|
||||
],
|
||||
),
|
||||
),
|
||||
leading: const Icon(Icons.cloud_done),
|
||||
onPressed: (context) async {
|
||||
final newDuration = await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return TimeDurationSelector(
|
||||
title: Text(S.of(context).playerSettingsCompleteTime),
|
||||
baseUnit: BaseUnit.second,
|
||||
initialValue: playerSettings.markCompleteWhenTimeLeft,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (newDuration != null) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.playerSettings(
|
||||
markCompleteWhenTimeLeft: newDuration,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
// playback report interval
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).playerSettingsPlaybackInterval),
|
||||
description: Text.rich(
|
||||
TextSpan(
|
||||
text: S
|
||||
.of(context)
|
||||
.playerSettingsPlaybackIntervalDescriptionHead,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: playerSettings
|
||||
.playbackReportInterval.smartBinaryFormat,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: S
|
||||
.of(context)
|
||||
.playerSettingsPlaybackIntervalDescriptionTail),
|
||||
],
|
||||
),
|
||||
),
|
||||
leading: const Icon(Icons.change_circle_outlined),
|
||||
onPressed: (context) async {
|
||||
final newDuration = await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return TimeDurationSelector(
|
||||
title: Text(S.of(context).playerSettingsPlaybackInterval),
|
||||
baseUnit: BaseUnit.second,
|
||||
initialValue: playerSettings.playbackReportInterval,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (newDuration != null) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.playerSettings(
|
||||
playbackReportInterval: newDuration,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// Display Settings
|
||||
SettingsSection(
|
||||
title: Text(S.of(context).playerSettingsDisplay),
|
||||
tiles: [
|
||||
// show total progress
|
||||
SettingsTile.switchTile(
|
||||
title: Text(S.of(context).playerSettingsDisplayTotalProgress),
|
||||
leading: const Icon(Icons.show_chart),
|
||||
description: Text(
|
||||
S.of(context).playerSettingsDisplayTotalProgressDescription,
|
||||
),
|
||||
initialValue:
|
||||
playerSettings.expandedPlayerSettings.showTotalProgress,
|
||||
onToggle: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.playerSettings
|
||||
.expandedPlayerSettings(showTotalProgress: value),
|
||||
);
|
||||
},
|
||||
),
|
||||
// show chapter progress
|
||||
SettingsTile.switchTile(
|
||||
title: Text(S.of(context).playerSettingsDisplayChapterProgress),
|
||||
leading: const Icon(Icons.show_chart),
|
||||
description: Text(
|
||||
S.of(context).playerSettingsDisplayChapterProgressDescription,
|
||||
),
|
||||
initialValue:
|
||||
playerSettings.expandedPlayerSettings.showChapterProgress,
|
||||
onToggle: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.playerSettings(
|
||||
expandedPlayerSettings: playerSettings
|
||||
.expandedPlayerSettings
|
||||
.copyWith(showChapterProgress: value),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TimeDurationSelector extends HookConsumerWidget {
|
||||
const TimeDurationSelector({
|
||||
super.key,
|
||||
this.title = const Text('Select Duration'),
|
||||
this.baseUnit = BaseUnit.second,
|
||||
this.initialValue = Duration.zero,
|
||||
});
|
||||
|
||||
final Widget title;
|
||||
final BaseUnit baseUnit;
|
||||
final Duration initialValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final duration = useState(initialValue);
|
||||
return AlertDialog(
|
||||
title: title,
|
||||
content: DurationPicker(
|
||||
duration: duration.value,
|
||||
baseUnit: baseUnit,
|
||||
onChange: (value) {
|
||||
duration.value = value;
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
const CancelButton(),
|
||||
OkButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(duration.value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpeedPicker extends HookConsumerWidget {
|
||||
const SpeedPicker({
|
||||
super.key,
|
||||
this.initialValue = 1,
|
||||
});
|
||||
|
||||
final double initialValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final speedController =
|
||||
useTextEditingController(text: initialValue.toString());
|
||||
final speed = useState<double?>(initialValue);
|
||||
return AlertDialog(
|
||||
title: Text(S.of(context).playerSettingsSpeedSelect),
|
||||
content: TextField(
|
||||
controller: speedController,
|
||||
onChanged: (value) => speed.value = double.tryParse(value),
|
||||
onSubmitted: (value) {
|
||||
Navigator.of(context).pop(speed.value);
|
||||
},
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: S.of(context).playerSettingsSpeed,
|
||||
helper: Text(S.of(context).playerSettingsSpeedSelectHelper),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
const CancelButton(),
|
||||
OkButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(speed.value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpeedOptionsPicker extends HookConsumerWidget {
|
||||
const SpeedOptionsPicker({
|
||||
super.key,
|
||||
this.initialValue = const [0.75, 1, 1.25, 1.5, 1.75, 2],
|
||||
});
|
||||
|
||||
final List<double> initialValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final speedOptionAddController = useTextEditingController();
|
||||
final speedOptions = useState<List<double>>(initialValue);
|
||||
final focusNode = useFocusNode();
|
||||
return AlertDialog(
|
||||
title: Text(S.of(context).playerSettingsSpeedOptionsSelect),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 8.0,
|
||||
children: speedOptions.value
|
||||
.map(
|
||||
(speed) => Chip(
|
||||
label: Text('${speed}x'),
|
||||
onDeleted: speed == 1
|
||||
? null
|
||||
: () {
|
||||
speedOptions.value =
|
||||
speedOptions.value.where((element) {
|
||||
// speed option 1 can't be removed
|
||||
return element != speed;
|
||||
}).toList();
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
// if (a.label == const Text('1x')) {
|
||||
// return -1;
|
||||
// } else if (b.label == const Text('1x')) {
|
||||
// return 1;
|
||||
// }
|
||||
return a.label.toString().compareTo(b.label.toString());
|
||||
}),
|
||||
),
|
||||
TextField(
|
||||
focusNode: focusNode,
|
||||
autofocus: true,
|
||||
controller: speedOptionAddController,
|
||||
onSubmitted: (value) {
|
||||
final newSpeed = double.tryParse(value);
|
||||
if (newSpeed != null && !speedOptions.value.contains(newSpeed)) {
|
||||
speedOptions.value = [...speedOptions.value, newSpeed];
|
||||
}
|
||||
speedOptionAddController.clear();
|
||||
focusNode.requestFocus();
|
||||
},
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: S.of(context).playerSettingsSpeedOptionsSelectAdd,
|
||||
helper:
|
||||
Text(S.of(context).playerSettingsSpeedOptionsSelectAddHelper),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
const CancelButton(),
|
||||
OkButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(speedOptions.value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
396
lib/features/settings/view/shake_detector_settings_page.dart
Normal file
396
lib/features/settings/view/shake_detector_settings_page.dart
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/models/app_settings.dart';
|
||||
import 'package:vaani/features/settings/view/buttons.dart';
|
||||
import 'package:vaani/features/settings/view/simple_settings_page.dart';
|
||||
import 'package:vaani/shared/extensions/enum.dart';
|
||||
|
||||
class ShakeDetectorSettingsPage extends HookConsumerWidget {
|
||||
const ShakeDetectorSettingsPage({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettings = ref.watch(appSettingsProvider);
|
||||
final shakeDetectionSettings = appSettings.shakeDetectionSettings;
|
||||
final isShakeDetectionEnabled = shakeDetectionSettings.isEnabled;
|
||||
final selectedValueColor = isShakeDetectionEnabled
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).disabledColor;
|
||||
|
||||
return SimpleSettingsPage(
|
||||
title: Text(S.of(context).shakeDetectorSettings),
|
||||
sections: [
|
||||
SettingsSection(
|
||||
margin: const EdgeInsetsDirectional.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
tiles: [
|
||||
SettingsTile.switchTile(
|
||||
leading: shakeDetectionSettings.isEnabled
|
||||
? const Icon(Icons.vibration)
|
||||
: const Icon(Icons.not_interested),
|
||||
title: Text(S.of(context).shakeDetectorEnable),
|
||||
description: Text(
|
||||
S.of(context).shakeDetectorEnableDescription,
|
||||
),
|
||||
initialValue: shakeDetectionSettings.isEnabled,
|
||||
onToggle: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.shakeDetectionSettings(
|
||||
isEnabled: value,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Shake Detection Settings
|
||||
SettingsSection(
|
||||
tiles: [
|
||||
SettingsTile(
|
||||
enabled: isShakeDetectionEnabled,
|
||||
leading: const Icon(Icons.flag_circle),
|
||||
title: Text(S.of(context).shakeActivationThreshold),
|
||||
description: Text(
|
||||
S.of(context).shakeActivationThresholdDescription,
|
||||
),
|
||||
trailing: Text(
|
||||
'${shakeDetectionSettings.threshold} m/s²',
|
||||
style: TextStyle(
|
||||
color: selectedValueColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onPressed: (context) async {
|
||||
final newThreshold = await showDialog<double>(
|
||||
context: context,
|
||||
builder: (context) => ShakeForceSelector(
|
||||
initialValue: shakeDetectionSettings.threshold,
|
||||
),
|
||||
);
|
||||
|
||||
if (newThreshold != null) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.shakeDetectionSettings(
|
||||
threshold: newThreshold,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// shake action
|
||||
SettingsTile(
|
||||
enabled: isShakeDetectionEnabled,
|
||||
leading: const Icon(Icons.directions_run),
|
||||
title: Text(S.of(context).shakeAction),
|
||||
description: Text(
|
||||
S.of(context).shakeActionDescription,
|
||||
),
|
||||
trailing: Icon(
|
||||
shakeDetectionSettings.shakeAction.icon,
|
||||
color: selectedValueColor,
|
||||
),
|
||||
onPressed: (context) async {
|
||||
final newShakeAction = await showDialog<ShakeAction>(
|
||||
context: context,
|
||||
builder: (context) => ShakeActionSelector(
|
||||
initialValue: shakeDetectionSettings.shakeAction,
|
||||
),
|
||||
);
|
||||
|
||||
if (newShakeAction != null) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.shakeDetectionSettings(
|
||||
shakeAction: newShakeAction,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// shake feedback
|
||||
SettingsTile(
|
||||
enabled: isShakeDetectionEnabled,
|
||||
leading: const Icon(Icons.feedback),
|
||||
title: Text(S.of(context).shakeFeedback),
|
||||
description: Text(
|
||||
S.of(context).shakeFeedbackDescription,
|
||||
),
|
||||
trailing: shakeDetectionSettings.feedback.isEmpty
|
||||
? Icon(
|
||||
Icons.not_interested,
|
||||
color: Theme.of(context).disabledColor,
|
||||
)
|
||||
: Wrap(
|
||||
spacing: 8.0,
|
||||
children: shakeDetectionSettings.feedback.map(
|
||||
(feedback) {
|
||||
return Icon(
|
||||
feedback.icon,
|
||||
color: selectedValueColor,
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
onPressed: (context) async {
|
||||
final newFeedback =
|
||||
await showDialog<Set<ShakeDetectedFeedback>>(
|
||||
context: context,
|
||||
builder: (context) => ShakeFeedbackSelector(
|
||||
initialValue: shakeDetectionSettings.feedback,
|
||||
),
|
||||
);
|
||||
|
||||
if (newFeedback != null) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.shakeDetectionSettings(
|
||||
feedback: newFeedback,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShakeFeedbackSelector extends HookConsumerWidget {
|
||||
const ShakeFeedbackSelector({
|
||||
super.key,
|
||||
this.initialValue = const {ShakeDetectedFeedback.vibrate},
|
||||
});
|
||||
|
||||
final Set<ShakeDetectedFeedback> initialValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final feedback = useState(initialValue);
|
||||
return AlertDialog(
|
||||
title: Text(S.of(context).shakeSelectFeedback),
|
||||
content: Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 8.0,
|
||||
children: ShakeDetectedFeedback.values
|
||||
.map(
|
||||
(feedbackType) => ChoiceChip(
|
||||
avatar: Icon(feedbackType.icon),
|
||||
label: Text(feedbackType.pascalCase),
|
||||
tooltip: feedbackType.description,
|
||||
onSelected: (val) {
|
||||
if (feedback.value.contains(feedbackType)) {
|
||||
feedback.value = feedback.value
|
||||
.where((element) => element != feedbackType)
|
||||
.toSet();
|
||||
} else {
|
||||
feedback.value = {...feedback.value, feedbackType};
|
||||
}
|
||||
},
|
||||
selected: feedback.value.contains(feedbackType),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
actions: [
|
||||
const CancelButton(),
|
||||
OkButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(feedback.value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShakeActionSelector extends HookConsumerWidget {
|
||||
const ShakeActionSelector({
|
||||
super.key,
|
||||
this.initialValue = ShakeAction.resetSleepTimer,
|
||||
});
|
||||
|
||||
final ShakeAction initialValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final shakeAction = useState(initialValue);
|
||||
return AlertDialog(
|
||||
title: Text(S.of(context).shakeSelectAction),
|
||||
content: Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 8.0,
|
||||
children: ShakeAction.values
|
||||
.map(
|
||||
// chips with radio buttons as one of the options can be selected
|
||||
(action) => ChoiceChip(
|
||||
avatar: Icon(action.icon),
|
||||
label: Text(action.pascalCase),
|
||||
onSelected: (val) {
|
||||
shakeAction.value = action;
|
||||
},
|
||||
selected: shakeAction.value == action,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
actions: [
|
||||
const CancelButton(),
|
||||
OkButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(shakeAction.value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShakeForceSelector extends HookConsumerWidget {
|
||||
const ShakeForceSelector({
|
||||
super.key,
|
||||
this.initialValue = 6,
|
||||
});
|
||||
|
||||
final double initialValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final shakeForce = useState(initialValue);
|
||||
final controller = useTextEditingController(text: initialValue.toString());
|
||||
return AlertDialog(
|
||||
title: Text(S.of(context).shakeSelectActivationThreshold),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
autofocus: true,
|
||||
controller: controller,
|
||||
onChanged: (value) {
|
||||
final newThreshold = double.tryParse(value);
|
||||
if (newThreshold != null) {
|
||||
shakeForce.value = newThreshold;
|
||||
}
|
||||
},
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
// clear button
|
||||
suffix: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
shakeForce.value = 0;
|
||||
},
|
||||
),
|
||||
helper: Text(
|
||||
S.of(context).shakeSelectActivationThresholdHelper,
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 8.0,
|
||||
children: ShakeForce.values
|
||||
.map(
|
||||
(force) => ChoiceChip(
|
||||
label: Text(force.pascalCase),
|
||||
onSelected: (val) {
|
||||
controller.text = force.threshold.toString();
|
||||
shakeForce.value = force.threshold;
|
||||
},
|
||||
selected: shakeForce.value == force.threshold,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
const CancelButton(),
|
||||
OkButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(shakeForce.value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ShakeForce {
|
||||
whisper(0.5),
|
||||
low(2.5),
|
||||
medium(5),
|
||||
high(7.5),
|
||||
storm(10),
|
||||
hurricane(15),
|
||||
earthquake(20),
|
||||
meteorShower(30),
|
||||
supernova(40),
|
||||
blackHole(50);
|
||||
|
||||
const ShakeForce(this.threshold);
|
||||
|
||||
final double threshold;
|
||||
}
|
||||
|
||||
extension ShakeActionIcon on ShakeAction {
|
||||
IconData? get icon {
|
||||
switch (this) {
|
||||
case ShakeAction.none:
|
||||
return Icons.not_interested;
|
||||
case ShakeAction.resetSleepTimer:
|
||||
return Icons.timer;
|
||||
case ShakeAction.playPause:
|
||||
return Icons.play_arrow;
|
||||
// case ShakeAction.nextChapter:
|
||||
// return Icons.skip_next;
|
||||
// case ShakeAction.previousChapter:
|
||||
// return Icons.skip_previous;
|
||||
// case ShakeAction.volumeUp:
|
||||
// return Icons.volume_up;
|
||||
// case ShakeAction.volumeDown:
|
||||
// return Icons.volume_down;
|
||||
case ShakeAction.fastForward:
|
||||
return Icons.fast_forward;
|
||||
case ShakeAction.rewind:
|
||||
return Icons.fast_rewind;
|
||||
default:
|
||||
return Icons.question_mark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on ShakeDetectedFeedback {
|
||||
IconData? get icon {
|
||||
switch (this) {
|
||||
case ShakeDetectedFeedback.vibrate:
|
||||
return Icons.vibration;
|
||||
case ShakeDetectedFeedback.beep:
|
||||
return Icons.volume_up;
|
||||
default:
|
||||
return Icons.question_mark;
|
||||
}
|
||||
}
|
||||
|
||||
String get description {
|
||||
switch (this) {
|
||||
case ShakeDetectedFeedback.vibrate:
|
||||
return 'Vibrate the device';
|
||||
case ShakeDetectedFeedback.beep:
|
||||
return 'Play a beep sound';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
57
lib/features/settings/view/simple_settings_page.dart
Normal file
57
lib/features/settings/view/simple_settings_page.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/features/player/view/mini_player_bottom_padding.dart';
|
||||
|
||||
class SimpleSettingsPage extends HookConsumerWidget {
|
||||
const SimpleSettingsPage({
|
||||
super.key,
|
||||
this.title,
|
||||
this.sections,
|
||||
});
|
||||
|
||||
final Widget? title;
|
||||
final List<AbstractSettingsSection>? sections;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
// appBar: AppBar(
|
||||
// title: title,
|
||||
// ),
|
||||
// body: body,
|
||||
// an app bar which is bigger than the default app bar but on scroll shrinks to the default app bar with the title being animated
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 200.0,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
title: title,
|
||||
// background: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
if (sections != null)
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
child: SettingsList(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
sections: sections!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// some padding at the bottom
|
||||
const SliverPadding(padding: EdgeInsets.only(bottom: 20)),
|
||||
SliverToBoxAdapter(child: MiniPlayerBottomPadding()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
203
lib/features/settings/view/theme_settings_page.dart
Normal file
203
lib/features/settings/view/theme_settings_page.dart
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/view/simple_settings_page.dart';
|
||||
|
||||
class ThemeSettingsPage extends HookConsumerWidget {
|
||||
const ThemeSettingsPage({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettings = ref.watch(appSettingsProvider);
|
||||
final themeSettings = appSettings.themeSettings;
|
||||
// final primaryColor = Theme.of(context).colorScheme.primary;
|
||||
|
||||
return SimpleSettingsPage(
|
||||
title: Text(S.of(context).themeSettings),
|
||||
sections: [
|
||||
SettingsSection(
|
||||
margin: const EdgeInsetsDirectional.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
tiles: [
|
||||
// choose system , light or dark theme
|
||||
SettingsTile(
|
||||
title: Text(S.of(context).themeMode),
|
||||
description: SegmentedButton(
|
||||
expandedInsets: const EdgeInsets.only(top: 8.0),
|
||||
showSelectedIcon: true,
|
||||
selectedIcon: const Icon(Icons.check),
|
||||
selected: {themeSettings.themeMode},
|
||||
onSelectionChanged: (newSelection) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.themeSettings(
|
||||
themeMode: newSelection.first,
|
||||
),
|
||||
);
|
||||
},
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: ThemeMode.light,
|
||||
icon: Icon(Icons.light_mode),
|
||||
label: Text(S.of(context).themeModeLight),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.system,
|
||||
icon: Icon(Icons.auto_awesome),
|
||||
label: Text(S.of(context).themeModeSystem),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.dark,
|
||||
icon: Icon(Icons.dark_mode),
|
||||
label: Text(S.of(context).themeModeDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: Icon(
|
||||
themeSettings.themeMode == ThemeMode.light
|
||||
? Icons.light_mode
|
||||
: themeSettings.themeMode == ThemeMode.dark
|
||||
? Icons.dark_mode
|
||||
: Icons.auto_awesome,
|
||||
),
|
||||
),
|
||||
|
||||
// high contrast mode
|
||||
SettingsTile.switchTile(
|
||||
leading: themeSettings.highContrast
|
||||
? const Icon(Icons.accessibility)
|
||||
: const Icon(Icons.accessibility_new_outlined),
|
||||
initialValue: themeSettings.highContrast,
|
||||
title: Text(S.of(context).themeModeHighContrast),
|
||||
description: Text(
|
||||
S.of(context).themeModeHighContrastDescription,
|
||||
),
|
||||
onToggle: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.themeSettings(
|
||||
highContrast: value,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// use material theme from system
|
||||
SettingsTile.switchTile(
|
||||
initialValue: themeSettings.useMaterialThemeFromSystem,
|
||||
title: Platform.isAndroid
|
||||
? Text(S.of(context).themeSettingsColorsAndroid)
|
||||
: Text(S.of(context).themeSettingsColors),
|
||||
description: Text(S.of(context).themeSettingsColorsDescription),
|
||||
leading: themeSettings.useMaterialThemeFromSystem
|
||||
? const Icon(Icons.auto_awesome)
|
||||
: const Icon(Icons.auto_fix_off),
|
||||
onToggle: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.themeSettings(
|
||||
useMaterialThemeFromSystem: value,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// TODO choose the primary color
|
||||
// SettingsTile.navigation(
|
||||
// title: const Text('Primary Color'),
|
||||
// description: const Text(
|
||||
// 'Choose the primary color for the app',
|
||||
// ),
|
||||
// leading: const Icon(Icons.colorize),
|
||||
// trailing: Icon(
|
||||
// Icons.circle,
|
||||
// color: themeSettings.customThemeColor.toColor(),
|
||||
// ),
|
||||
// onPressed: (context) async {
|
||||
// final selectedColor = await showDialog<Color>(
|
||||
// context: context,
|
||||
// builder: (context) {
|
||||
// return SimpleDialog(
|
||||
// title: const Text('Select Primary Color'),
|
||||
// children: [
|
||||
// for (final color in Colors.primaries)
|
||||
// SimpleDialogOption(
|
||||
// onPressed: () {
|
||||
// Navigator.pop(context, color);
|
||||
// },
|
||||
// child: Container(
|
||||
// color: color,
|
||||
// height: 48,
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
// if (selectedColor != null) {
|
||||
// ref.read(appSettingsProvider.notifier).update(
|
||||
// appSettings.copyWith.themeSettings(
|
||||
// customThemeColor: selectedColor.toHexString(),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
// ),
|
||||
|
||||
// use theme throughout the app when playing item
|
||||
SettingsTile.switchTile(
|
||||
initialValue: themeSettings.useCurrentPlayerThemeThroughoutApp,
|
||||
title: Text(S.of(context).themeSettingsColorsCurrent),
|
||||
description:
|
||||
Text(S.of(context).themeSettingsColorsCurrentDescription),
|
||||
leading: themeSettings.useCurrentPlayerThemeThroughoutApp
|
||||
? const Icon(Icons.auto_fix_high)
|
||||
: const Icon(Icons.auto_fix_off),
|
||||
onToggle: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.themeSettings(
|
||||
useCurrentPlayerThemeThroughoutApp: value,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
SettingsTile.switchTile(
|
||||
initialValue: themeSettings.useMaterialThemeOnItemPage,
|
||||
title: Text(S.of(context).themeSettingsColorsBook),
|
||||
description:
|
||||
Text(S.of(context).themeSettingsColorsBookDescription),
|
||||
leading: themeSettings.useMaterialThemeOnItemPage
|
||||
? const Icon(Icons.auto_fix_high)
|
||||
: const Icon(Icons.auto_fix_off),
|
||||
onToggle: (value) {
|
||||
ref.read(appSettingsProvider.notifier).update(
|
||||
appSettings.copyWith.themeSettings(
|
||||
useMaterialThemeOnItemPage: value,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension ColorExtension on Color {
|
||||
String toHexString() {
|
||||
return '#${value.toRadixString(16).substring(2)}';
|
||||
}
|
||||
}
|
||||
|
||||
extension StringExtension on String {
|
||||
Color toColor() {
|
||||
return Color(int.parse('0xff$substring(1)'));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
||||
|
||||
class NavigationWithSwitchTile extends AbstractSettingsTile {
|
||||
const NavigationWithSwitchTile({
|
||||
this.leading,
|
||||
// this.trailing,
|
||||
required this.value,
|
||||
required this.title,
|
||||
this.description,
|
||||
this.descriptionInlineIos = false,
|
||||
this.onPressed,
|
||||
this.enabled = true,
|
||||
this.backgroundColor,
|
||||
super.key,
|
||||
this.onToggle,
|
||||
});
|
||||
|
||||
final Widget title;
|
||||
final Widget? description;
|
||||
final Color? backgroundColor;
|
||||
final bool descriptionInlineIos;
|
||||
final bool enabled;
|
||||
final Widget? leading;
|
||||
final Function(BuildContext)? onPressed;
|
||||
final bool value;
|
||||
final Function(bool)? onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsTile.navigation(
|
||||
title: title,
|
||||
description: description,
|
||||
backgroundColor: backgroundColor,
|
||||
descriptionInlineIos: descriptionInlineIos,
|
||||
enabled: enabled,
|
||||
leading: leading,
|
||||
onPressed: onPressed,
|
||||
trailing: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
VerticalDivider(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.5),
|
||||
indent: 8.0,
|
||||
endIndent: 8.0,
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: value,
|
||||
onChanged: onToggle,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import 'dart:math';
|
|||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
import 'package:vaani/settings/models/app_settings.dart';
|
||||
import 'package:vaani/features/settings/models/app_settings.dart';
|
||||
|
||||
final _logger = Logger('ShakeDetector');
|
||||
|
||||
|
|
@ -2,17 +2,17 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:vaani/features/player/providers/session_provider.dart';
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart'
|
||||
show sleepTimerProvider;
|
||||
import 'package:vaani/settings/app_settings_provider.dart'
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart'
|
||||
show appSettingsProvider;
|
||||
import 'package:vaani/settings/models/app_settings.dart';
|
||||
import 'package:vaani/features/settings/models/app_settings.dart';
|
||||
import 'package:vibration/vibration.dart';
|
||||
|
||||
import '../core/shake_detector.dart' as core;
|
||||
import 'shake_detector.dart' as core;
|
||||
|
||||
part 'shake_detector.g.dart';
|
||||
part 'shake_detector_provider.g.dart';
|
||||
|
||||
Logger _logger = Logger('ShakeDetector');
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'shake_detector.dart';
|
||||
part of 'shake_detector_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:vaani/features/player/core/audiobook_player_session.dart';
|
||||
import 'package:vaani/features/player/core/audiobook_player.dart';
|
||||
import 'package:vaani/shared/extensions/chapter.dart';
|
||||
import 'package:vaani/shared/utils/throttler.dart';
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:vaani/features/per_book_settings/providers/book_settings_provider.dart';
|
||||
import 'package:vaani/features/player/providers/session_provider.dart';
|
||||
import 'package:vaani/features/skip_start_end/skip_start_end.dart' as core;
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
import 'package:vaani/features/skip_start_end/core/skip_start_end.dart' as core;
|
||||
|
||||
part 'skip_start_end_provider.g.dart';
|
||||
|
||||
|
|
@ -3,10 +3,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:icons_plus/icons_plus.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/providers/session_provider.dart';
|
||||
import 'package:vaani/features/player/view/player_expanded.dart';
|
||||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/settings/view/notification_settings_page.dart';
|
||||
import 'package:vaani/features/settings/view/notification_settings_page.dart';
|
||||
|
||||
class SkipChapterStartEndButton extends HookConsumerWidget {
|
||||
const SkipChapterStartEndButton({super.key});
|
||||
|
|
@ -74,7 +73,6 @@ class PlayerSkipChapterStartEnd extends HookConsumerWidget {
|
|||
bookSettings.copyWith
|
||||
.playerSettings(skipChapterStart: interval),
|
||||
);
|
||||
ref.read(audiobookPlayerProvider).setClip(start: interval);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -3,7 +3,6 @@ import 'dart:async';
|
|||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:vaani/features/player/core/audiobook_player.dart';
|
||||
|
||||
/// this timer pauses the music player after a certain duration
|
||||
///
|
||||
|
|
@ -33,7 +32,7 @@ class SleepTimer {
|
|||
}
|
||||
|
||||
/// The player to be paused
|
||||
final AudiobookPlayer player;
|
||||
final AudioPlayer player;
|
||||
|
||||
/// The timer that will pause the player
|
||||
Timer? timer;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
import 'package:vaani/features/sleep_timer/core/sleep_timer.dart' as core;
|
||||
import 'package:vaani/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/shared/extensions/time_of_day.dart';
|
||||
|
||||
part 'sleep_timer_provider.g.dart';
|
||||
|
|
@ -26,7 +26,7 @@ class SleepTimer extends _$SleepTimer {
|
|||
|
||||
var sleepTimer = core.SleepTimer(
|
||||
duration: sleepTimerSettings.defaultDuration,
|
||||
player: ref.watch(simpleAudiobookPlayerProvider),
|
||||
player: ref.watch(playerProvider).player,
|
||||
);
|
||||
ref.onDispose(sleepTimer.dispose);
|
||||
return sleepTimer;
|
||||
|
|
@ -45,7 +45,7 @@ class SleepTimer extends _$SleepTimer {
|
|||
} else {
|
||||
final timer = core.SleepTimer(
|
||||
duration: resultingDuration,
|
||||
player: ref.watch(simpleAudiobookPlayerProvider),
|
||||
player: ref.watch(playerProvider).player,
|
||||
);
|
||||
ref.onDispose(timer.dispose);
|
||||
state = timer;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ part of 'sleep_timer_provider.dart';
|
|||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$sleepTimerHash() => r'2679454a217d0630a833d730557ab4e4feac2e56';
|
||||
String _$sleepTimerHash() => r'daaaf63d599fb991e71a0da0ca1075fb46ccc6be';
|
||||
|
||||
/// See also [SleepTimer].
|
||||
@ProviderFor(SleepTimer)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import 'package:vaani/features/sleep_timer/core/sleep_timer.dart';
|
|||
import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart'
|
||||
show sleepTimerProvider;
|
||||
import 'package:vaani/globals.dart';
|
||||
import 'package:vaani/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/features/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/shared/extensions/duration_format.dart';
|
||||
|
||||
class SleepTimerButton extends HookConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ import 'package:vaani/features/player/view/mini_player_bottom_padding.dart'
|
|||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/globals.dart';
|
||||
import 'package:vaani/router/router.dart' show Routes;
|
||||
import 'package:vaani/settings/api_settings_provider.dart'
|
||||
import 'package:vaani/features/settings/api_settings_provider.dart'
|
||||
show apiSettingsProvider;
|
||||
import 'package:vaani/settings/models/models.dart' as model;
|
||||
import 'package:vaani/features/settings/models/models.dart' as model;
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart' show ObfuscateSet;
|
||||
import 'package:vaani/shared/widgets/add_new_server.dart' show AddNewServer;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import 'package:shelfsdk/audiobookshelf_api.dart' show Library;
|
|||
import 'package:vaani/api/library_provider.dart';
|
||||
import 'package:vaani/generated/l10n.dart';
|
||||
import 'package:vaani/globals.dart';
|
||||
import 'package:vaani/settings/api_settings_provider.dart'
|
||||
import 'package:vaani/features/settings/api_settings_provider.dart'
|
||||
show apiSettingsProvider;
|
||||
import 'package:vaani/shared/icons/abs_icons.dart';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue