feat: extensive settings for media controls through notification (#28)

* feat: add notification settings customisation options

* feat: add notification settings page and update routing
This commit is contained in:
Dr.Blank 2024-09-25 03:13:42 -04:00 committed by GitHub
parent 721b0a87fc
commit 3cf0a0b124
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1391 additions and 376 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -8,6 +8,9 @@ import 'package:just_audio/just_audio.dart';
import 'package:just_audio_background/just_audio_background.dart';
import 'package:logging/logging.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/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');
@ -81,6 +84,7 @@ class AudiobookPlayer extends AudioPlayer {
List<Uri>? downloadedUris,
Uri? artworkUri,
}) async {
final appSettings = loadOrCreateAppSettings();
// if the book is null, stop the player
if (book == null) {
_book = null;
@ -128,8 +132,10 @@ class AudiobookPlayer extends AudioPlayer {
// Specify a unique ID for each media item:
id: book.libraryItemId + track.index.toString(),
// Metadata to display in the notification:
album: book.metadata.title,
title: book.metadata.title ?? track.title,
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',
@ -198,7 +204,7 @@ class AudiobookPlayer extends AudioPlayer {
@override
Stream<Duration> get positionStream {
// return the positioninbook stream
// return the positionInBook stream
return super.positionStream.map((position) {
if (_book == null) {
return Duration.zero;
@ -267,3 +273,42 @@ Uri _getUri(
return uri ??
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.stringValue == 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;
}
}
}

View file

@ -0,0 +1,62 @@
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();
// 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: true,
androidStopForegroundOnPause: true,
androidNotificationChannelDescription: 'Audio playback in the background',
androidNotificationIcon: 'drawable/ic_stat_notification_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,
},
);
},
);
}

View file

@ -1,15 +1,11 @@
import 'package:audio_session/audio_session.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:just_audio_background/just_audio_background.dart'
show JustAudioBackground;
import 'package:just_audio_media_kit/just_audio_media_kit.dart'
show JustAudioMediaKit;
import 'package:logging/logging.dart';
import 'package:vaani/api/server_provider.dart';
import 'package:vaani/db/storage.dart';
import 'package:vaani/features/downloads/providers/download_manager.dart';
import 'package:vaani/features/playback_reporting/providers/playback_reporter_provider.dart';
import 'package:vaani/features/player/core/init.dart';
import 'package:vaani/features/player/providers/audiobook_player.dart';
import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart';
import 'package:vaani/router/router.dart';
@ -31,23 +27,12 @@ void main() async {
);
});
// for playing audio on windows, linux
JustAudioMediaKit.ensureInitialized();
// initialize the storage
await initStorage();
// for configuring how this app will interact with other audio apps
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.speech());
// initialize audio player
await configurePlayer();
// for playing audio in the background
await JustAudioBackground.init(
androidNotificationChannelId: 'com.vaani.bg_demo.channel.audio',
androidNotificationChannelName: 'Audio playback',
androidNotificationOngoing: true,
androidNotificationIcon: 'mipmap/launcher_icon',
);
// run the app
runApp(

View file

@ -28,9 +28,14 @@ class Routes {
name: 'settings',
);
static const autoSleepTimerSettings = _SimpleRoute(
pathName: 'autosleeptimer',
pathName: 'autoSleepTimer',
name: 'autoSleepTimerSettings',
// parentRoute: settings,
parentRoute: settings,
);
static const notificationSettings = _SimpleRoute(
pathName: 'notifications',
name: 'notificationSettings',
parentRoute: settings,
);
// search and explore

View file

@ -12,6 +12,7 @@ import 'package:vaani/features/you/view/you_page.dart';
import 'package:vaani/pages/home_page.dart';
import 'package:vaani/settings/view/app_settings_page.dart';
import 'package:vaani/settings/view/auto_sleep_timer_settings_page.dart';
import 'package:vaani/settings/view/notification_settings_page.dart';
import 'scaffold_with_nav_bar.dart';
import 'transitions/slide.dart';
@ -172,16 +173,23 @@ class MyAppRouter {
name: Routes.settings.name,
// builder: (context, state) => const AppSettingsPage(),
pageBuilder: defaultPageBuilder(const AppSettingsPage()),
),
routes: [
GoRoute(
path: Routes.autoSleepTimerSettings.localPath,
path: Routes.autoSleepTimerSettings.pathName,
name: Routes.autoSleepTimerSettings.name,
// builder: (context, state) =>
// const AutoSleepTimerSettingsPage(),
pageBuilder: defaultPageBuilder(
const AutoSleepTimerSettingsPage(),
),
),
GoRoute(
path: Routes.notificationSettings.pathName,
name: Routes.notificationSettings.name,
pageBuilder: defaultPageBuilder(
const NotificationSettingsPage(),
),
),
],
),
GoRoute(
path: Routes.userManagement.localPath,
name: Routes.userManagement.name,

View file

@ -11,25 +11,29 @@ final _box = AvailableHiveBoxes.userPrefsBox;
final _logger = Logger('AppSettingsProvider');
model.AppSettings readFromBoxOrCreate() {
model.AppSettings loadOrCreateAppSettings() {
// see if the settings are already in the box
model.AppSettings? settings;
if (_box.isNotEmpty) {
final foundSettings = _box.getAt(0);
_logger.fine('found settings in box: $foundSettings');
return foundSettings;
} else {
// create a new settings object
const settings = model.AppSettings();
_logger.fine('created new settings: $settings');
return settings;
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 = readFromBoxOrCreate();
state = loadOrCreateAppSettings();
ref.listenSelf((_, __) {
writeToBox();
});

View file

@ -6,7 +6,7 @@ part of 'app_settings_provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$appSettingsHash() => r'e0e132b782b97f11d9791d4f1e45bf4ee67dd99b';
String _$appSettingsHash() => r'f51d55f117692d4fb9f4b4febf02906c0953d334';
/// See also [AppSettings].
@ProviderFor(AppSettings)

View file

@ -1,5 +1,6 @@
// 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';
@ -14,6 +15,7 @@ class AppSettings with _$AppSettings {
@Default(ThemeSettings()) ThemeSettings themeSettings,
@Default(PlayerSettings()) PlayerSettings playerSettings,
@Default(DownloadSettings()) DownloadSettings downloadSettings,
@Default(NotificationSettings()) NotificationSettings notificationSettings,
}) = _AppSettings;
factory AppSettings.fromJson(Map<String, dynamic> json) =>
@ -133,3 +135,53 @@ class DownloadSettings with _$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('chapterTitle'),
bookTitle('bookTitle'),
author('author'),
subtitle('subtitle'),
series('series'),
narrator('narrator'),
year('year');
const NotificationTitleType(this.stringValue);
final String stringValue;
}
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;
}

View file

@ -23,6 +23,8 @@ mixin _$AppSettings {
ThemeSettings get themeSettings => throw _privateConstructorUsedError;
PlayerSettings get playerSettings => throw _privateConstructorUsedError;
DownloadSettings get downloadSettings => throw _privateConstructorUsedError;
NotificationSettings get notificationSettings =>
throw _privateConstructorUsedError;
/// Serializes this AppSettings to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -43,11 +45,13 @@ abstract class $AppSettingsCopyWith<$Res> {
$Res call(
{ThemeSettings themeSettings,
PlayerSettings playerSettings,
DownloadSettings downloadSettings});
DownloadSettings downloadSettings,
NotificationSettings notificationSettings});
$ThemeSettingsCopyWith<$Res> get themeSettings;
$PlayerSettingsCopyWith<$Res> get playerSettings;
$DownloadSettingsCopyWith<$Res> get downloadSettings;
$NotificationSettingsCopyWith<$Res> get notificationSettings;
}
/// @nodoc
@ -68,6 +72,7 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings>
Object? themeSettings = null,
Object? playerSettings = null,
Object? downloadSettings = null,
Object? notificationSettings = null,
}) {
return _then(_value.copyWith(
themeSettings: null == themeSettings
@ -82,6 +87,10 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings>
? _value.downloadSettings
: downloadSettings // ignore: cast_nullable_to_non_nullable
as DownloadSettings,
notificationSettings: null == notificationSettings
? _value.notificationSettings
: notificationSettings // ignore: cast_nullable_to_non_nullable
as NotificationSettings,
) as $Val);
}
@ -114,6 +123,17 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings>
return _then(_value.copyWith(downloadSettings: value) as $Val);
});
}
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$NotificationSettingsCopyWith<$Res> get notificationSettings {
return $NotificationSettingsCopyWith<$Res>(_value.notificationSettings,
(value) {
return _then(_value.copyWith(notificationSettings: value) as $Val);
});
}
}
/// @nodoc
@ -127,7 +147,8 @@ abstract class _$$AppSettingsImplCopyWith<$Res>
$Res call(
{ThemeSettings themeSettings,
PlayerSettings playerSettings,
DownloadSettings downloadSettings});
DownloadSettings downloadSettings,
NotificationSettings notificationSettings});
@override
$ThemeSettingsCopyWith<$Res> get themeSettings;
@ -135,6 +156,8 @@ abstract class _$$AppSettingsImplCopyWith<$Res>
$PlayerSettingsCopyWith<$Res> get playerSettings;
@override
$DownloadSettingsCopyWith<$Res> get downloadSettings;
@override
$NotificationSettingsCopyWith<$Res> get notificationSettings;
}
/// @nodoc
@ -153,6 +176,7 @@ class __$$AppSettingsImplCopyWithImpl<$Res>
Object? themeSettings = null,
Object? playerSettings = null,
Object? downloadSettings = null,
Object? notificationSettings = null,
}) {
return _then(_$AppSettingsImpl(
themeSettings: null == themeSettings
@ -167,6 +191,10 @@ class __$$AppSettingsImplCopyWithImpl<$Res>
? _value.downloadSettings
: downloadSettings // ignore: cast_nullable_to_non_nullable
as DownloadSettings,
notificationSettings: null == notificationSettings
? _value.notificationSettings
: notificationSettings // ignore: cast_nullable_to_non_nullable
as NotificationSettings,
));
}
}
@ -177,7 +205,8 @@ class _$AppSettingsImpl implements _AppSettings {
const _$AppSettingsImpl(
{this.themeSettings = const ThemeSettings(),
this.playerSettings = const PlayerSettings(),
this.downloadSettings = const DownloadSettings()});
this.downloadSettings = const DownloadSettings(),
this.notificationSettings = const NotificationSettings()});
factory _$AppSettingsImpl.fromJson(Map<String, dynamic> json) =>
_$$AppSettingsImplFromJson(json);
@ -191,10 +220,13 @@ class _$AppSettingsImpl implements _AppSettings {
@override
@JsonKey()
final DownloadSettings downloadSettings;
@override
@JsonKey()
final NotificationSettings notificationSettings;
@override
String toString() {
return 'AppSettings(themeSettings: $themeSettings, playerSettings: $playerSettings, downloadSettings: $downloadSettings)';
return 'AppSettings(themeSettings: $themeSettings, playerSettings: $playerSettings, downloadSettings: $downloadSettings, notificationSettings: $notificationSettings)';
}
@override
@ -207,13 +239,15 @@ class _$AppSettingsImpl implements _AppSettings {
(identical(other.playerSettings, playerSettings) ||
other.playerSettings == playerSettings) &&
(identical(other.downloadSettings, downloadSettings) ||
other.downloadSettings == downloadSettings));
other.downloadSettings == downloadSettings) &&
(identical(other.notificationSettings, notificationSettings) ||
other.notificationSettings == notificationSettings));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode =>
Object.hash(runtimeType, themeSettings, playerSettings, downloadSettings);
int get hashCode => Object.hash(runtimeType, themeSettings, playerSettings,
downloadSettings, notificationSettings);
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@ -235,7 +269,8 @@ abstract class _AppSettings implements AppSettings {
const factory _AppSettings(
{final ThemeSettings themeSettings,
final PlayerSettings playerSettings,
final DownloadSettings downloadSettings}) = _$AppSettingsImpl;
final DownloadSettings downloadSettings,
final NotificationSettings notificationSettings}) = _$AppSettingsImpl;
factory _AppSettings.fromJson(Map<String, dynamic> json) =
_$AppSettingsImpl.fromJson;
@ -246,6 +281,8 @@ abstract class _AppSettings implements AppSettings {
PlayerSettings get playerSettings;
@override
DownloadSettings get downloadSettings;
@override
NotificationSettings get notificationSettings;
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@ -1935,3 +1972,293 @@ abstract class _DownloadSettings implements DownloadSettings {
_$$DownloadSettingsImplCopyWith<_$DownloadSettingsImpl> get copyWith =>
throw _privateConstructorUsedError;
}
NotificationSettings _$NotificationSettingsFromJson(Map<String, dynamic> json) {
return _NotificationSettings.fromJson(json);
}
/// @nodoc
mixin _$NotificationSettings {
Duration get fastForwardInterval => throw _privateConstructorUsedError;
Duration get rewindInterval => throw _privateConstructorUsedError;
bool get progressBarIsChapterProgress => throw _privateConstructorUsedError;
String get primaryTitle => throw _privateConstructorUsedError;
String get secondaryTitle => throw _privateConstructorUsedError;
List<NotificationMediaControl> get mediaControls =>
throw _privateConstructorUsedError;
/// Serializes this NotificationSettings to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of NotificationSettings
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$NotificationSettingsCopyWith<NotificationSettings> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $NotificationSettingsCopyWith<$Res> {
factory $NotificationSettingsCopyWith(NotificationSettings value,
$Res Function(NotificationSettings) then) =
_$NotificationSettingsCopyWithImpl<$Res, NotificationSettings>;
@useResult
$Res call(
{Duration fastForwardInterval,
Duration rewindInterval,
bool progressBarIsChapterProgress,
String primaryTitle,
String secondaryTitle,
List<NotificationMediaControl> mediaControls});
}
/// @nodoc
class _$NotificationSettingsCopyWithImpl<$Res,
$Val extends NotificationSettings>
implements $NotificationSettingsCopyWith<$Res> {
_$NotificationSettingsCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of NotificationSettings
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? fastForwardInterval = null,
Object? rewindInterval = null,
Object? progressBarIsChapterProgress = null,
Object? primaryTitle = null,
Object? secondaryTitle = null,
Object? mediaControls = null,
}) {
return _then(_value.copyWith(
fastForwardInterval: null == fastForwardInterval
? _value.fastForwardInterval
: fastForwardInterval // ignore: cast_nullable_to_non_nullable
as Duration,
rewindInterval: null == rewindInterval
? _value.rewindInterval
: rewindInterval // ignore: cast_nullable_to_non_nullable
as Duration,
progressBarIsChapterProgress: null == progressBarIsChapterProgress
? _value.progressBarIsChapterProgress
: progressBarIsChapterProgress // ignore: cast_nullable_to_non_nullable
as bool,
primaryTitle: null == primaryTitle
? _value.primaryTitle
: primaryTitle // ignore: cast_nullable_to_non_nullable
as String,
secondaryTitle: null == secondaryTitle
? _value.secondaryTitle
: secondaryTitle // ignore: cast_nullable_to_non_nullable
as String,
mediaControls: null == mediaControls
? _value.mediaControls
: mediaControls // ignore: cast_nullable_to_non_nullable
as List<NotificationMediaControl>,
) as $Val);
}
}
/// @nodoc
abstract class _$$NotificationSettingsImplCopyWith<$Res>
implements $NotificationSettingsCopyWith<$Res> {
factory _$$NotificationSettingsImplCopyWith(_$NotificationSettingsImpl value,
$Res Function(_$NotificationSettingsImpl) then) =
__$$NotificationSettingsImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{Duration fastForwardInterval,
Duration rewindInterval,
bool progressBarIsChapterProgress,
String primaryTitle,
String secondaryTitle,
List<NotificationMediaControl> mediaControls});
}
/// @nodoc
class __$$NotificationSettingsImplCopyWithImpl<$Res>
extends _$NotificationSettingsCopyWithImpl<$Res, _$NotificationSettingsImpl>
implements _$$NotificationSettingsImplCopyWith<$Res> {
__$$NotificationSettingsImplCopyWithImpl(_$NotificationSettingsImpl _value,
$Res Function(_$NotificationSettingsImpl) _then)
: super(_value, _then);
/// Create a copy of NotificationSettings
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? fastForwardInterval = null,
Object? rewindInterval = null,
Object? progressBarIsChapterProgress = null,
Object? primaryTitle = null,
Object? secondaryTitle = null,
Object? mediaControls = null,
}) {
return _then(_$NotificationSettingsImpl(
fastForwardInterval: null == fastForwardInterval
? _value.fastForwardInterval
: fastForwardInterval // ignore: cast_nullable_to_non_nullable
as Duration,
rewindInterval: null == rewindInterval
? _value.rewindInterval
: rewindInterval // ignore: cast_nullable_to_non_nullable
as Duration,
progressBarIsChapterProgress: null == progressBarIsChapterProgress
? _value.progressBarIsChapterProgress
: progressBarIsChapterProgress // ignore: cast_nullable_to_non_nullable
as bool,
primaryTitle: null == primaryTitle
? _value.primaryTitle
: primaryTitle // ignore: cast_nullable_to_non_nullable
as String,
secondaryTitle: null == secondaryTitle
? _value.secondaryTitle
: secondaryTitle // ignore: cast_nullable_to_non_nullable
as String,
mediaControls: null == mediaControls
? _value._mediaControls
: mediaControls // ignore: cast_nullable_to_non_nullable
as List<NotificationMediaControl>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$NotificationSettingsImpl implements _NotificationSettings {
const _$NotificationSettingsImpl(
{this.fastForwardInterval = const Duration(seconds: 30),
this.rewindInterval = const Duration(seconds: 10),
this.progressBarIsChapterProgress = true,
this.primaryTitle = '\$bookTitle',
this.secondaryTitle = '\$author',
final List<NotificationMediaControl> mediaControls = const [
NotificationMediaControl.rewind,
NotificationMediaControl.fastForward,
NotificationMediaControl.skipToPreviousChapter,
NotificationMediaControl.skipToNextChapter
]})
: _mediaControls = mediaControls;
factory _$NotificationSettingsImpl.fromJson(Map<String, dynamic> json) =>
_$$NotificationSettingsImplFromJson(json);
@override
@JsonKey()
final Duration fastForwardInterval;
@override
@JsonKey()
final Duration rewindInterval;
@override
@JsonKey()
final bool progressBarIsChapterProgress;
@override
@JsonKey()
final String primaryTitle;
@override
@JsonKey()
final String secondaryTitle;
final List<NotificationMediaControl> _mediaControls;
@override
@JsonKey()
List<NotificationMediaControl> get mediaControls {
if (_mediaControls is EqualUnmodifiableListView) return _mediaControls;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_mediaControls);
}
@override
String toString() {
return 'NotificationSettings(fastForwardInterval: $fastForwardInterval, rewindInterval: $rewindInterval, progressBarIsChapterProgress: $progressBarIsChapterProgress, primaryTitle: $primaryTitle, secondaryTitle: $secondaryTitle, mediaControls: $mediaControls)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$NotificationSettingsImpl &&
(identical(other.fastForwardInterval, fastForwardInterval) ||
other.fastForwardInterval == fastForwardInterval) &&
(identical(other.rewindInterval, rewindInterval) ||
other.rewindInterval == rewindInterval) &&
(identical(other.progressBarIsChapterProgress,
progressBarIsChapterProgress) ||
other.progressBarIsChapterProgress ==
progressBarIsChapterProgress) &&
(identical(other.primaryTitle, primaryTitle) ||
other.primaryTitle == primaryTitle) &&
(identical(other.secondaryTitle, secondaryTitle) ||
other.secondaryTitle == secondaryTitle) &&
const DeepCollectionEquality()
.equals(other._mediaControls, _mediaControls));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
fastForwardInterval,
rewindInterval,
progressBarIsChapterProgress,
primaryTitle,
secondaryTitle,
const DeepCollectionEquality().hash(_mediaControls));
/// Create a copy of NotificationSettings
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$NotificationSettingsImplCopyWith<_$NotificationSettingsImpl>
get copyWith =>
__$$NotificationSettingsImplCopyWithImpl<_$NotificationSettingsImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$NotificationSettingsImplToJson(
this,
);
}
}
abstract class _NotificationSettings implements NotificationSettings {
const factory _NotificationSettings(
{final Duration fastForwardInterval,
final Duration rewindInterval,
final bool progressBarIsChapterProgress,
final String primaryTitle,
final String secondaryTitle,
final List<NotificationMediaControl> mediaControls}) =
_$NotificationSettingsImpl;
factory _NotificationSettings.fromJson(Map<String, dynamic> json) =
_$NotificationSettingsImpl.fromJson;
@override
Duration get fastForwardInterval;
@override
Duration get rewindInterval;
@override
bool get progressBarIsChapterProgress;
@override
String get primaryTitle;
@override
String get secondaryTitle;
@override
List<NotificationMediaControl> get mediaControls;
/// Create a copy of NotificationSettings
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$NotificationSettingsImplCopyWith<_$NotificationSettingsImpl>
get copyWith => throw _privateConstructorUsedError;
}

View file

@ -20,6 +20,10 @@ _$AppSettingsImpl _$$AppSettingsImplFromJson(Map<String, dynamic> json) =>
? const DownloadSettings()
: DownloadSettings.fromJson(
json['downloadSettings'] as Map<String, dynamic>),
notificationSettings: json['notificationSettings'] == null
? const NotificationSettings()
: NotificationSettings.fromJson(
json['notificationSettings'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$AppSettingsImplToJson(_$AppSettingsImpl instance) =>
@ -27,6 +31,7 @@ Map<String, dynamic> _$$AppSettingsImplToJson(_$AppSettingsImpl instance) =>
'themeSettings': instance.themeSettings,
'playerSettings': instance.playerSettings,
'downloadSettings': instance.downloadSettings,
'notificationSettings': instance.notificationSettings,
};
_$ThemeSettingsImpl _$$ThemeSettingsImplFromJson(Map<String, dynamic> json) =>
@ -203,3 +208,50 @@ Map<String, dynamic> _$$DownloadSettingsImplToJson(
'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',
};

View file

@ -11,6 +11,7 @@ import 'package:vaani/api/server_provider.dart';
import 'package:vaani/router/router.dart';
import 'package:vaani/settings/app_settings_provider.dart';
import 'package:vaani/settings/models/app_settings.dart' as model;
import 'package:vaani/settings/view/simple_settings_page.dart';
class AppSettingsPage extends HookConsumerWidget {
const AppSettingsPage({
@ -26,12 +27,32 @@ class AppSettingsPage extends HookConsumerWidget {
final serverURIController = useTextEditingController();
final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings;
return Scaffold(
appBar: AppBar(
return SimpleSettingsPage(
title: const Text('App Settings'),
),
body: SettingsList(
sections: [
// General section
SettingsSection(
margin: const EdgeInsetsDirectional.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
title: Text(
'General',
style: Theme.of(context).textTheme.titleLarge,
),
tiles: [
SettingsTile(
title: const Text('Notification Media Player'),
leading: const Icon(Icons.play_lesson),
description: const Text(
'Customize the media player in notifications',
),
onPressed: (context) {
context.pushNamed(Routes.notificationSettings.name);
},
),
],
),
// Appearance section
SettingsSection(
margin: const EdgeInsetsDirectional.symmetric(
@ -272,7 +293,6 @@ class AppSettingsPage extends HookConsumerWidget {
],
),
],
),
);
}
}

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:vaani/settings/app_settings_provider.dart';
import 'package:vaani/settings/view/simple_settings_page.dart';
import 'package:vaani/shared/extensions/time_of_day.dart';
class AutoSleepTimerSettingsPage extends HookConsumerWidget {
@ -14,11 +15,8 @@ class AutoSleepTimerSettingsPage extends HookConsumerWidget {
final appSettings = ref.watch(appSettingsProvider);
final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings;
return Scaffold(
appBar: AppBar(
return SimpleSettingsPage(
title: const Text('Auto Sleep Timer Settings'),
),
body: SettingsList(
sections: [
SettingsSection(
margin: const EdgeInsetsDirectional.symmetric(
@ -55,22 +53,18 @@ class AutoSleepTimerSettingsPage extends HookConsumerWidget {
// navigate to the time picker
final selected = await showTimePicker(
context: context,
initialTime:
sleepTimerSettings.autoTurnOnTime.toTimeOfDay(),
initialTime: sleepTimerSettings.autoTurnOnTime.toTimeOfDay(),
);
if (selected != null) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.playerSettings
.sleepTimerSettings(
appSettings.copyWith.playerSettings.sleepTimerSettings(
autoTurnOnTime: selected.toDuration(),
),
);
}
},
value: Text(
sleepTimerSettings.autoTurnOnTime
.toTimeOfDay()
.format(context),
sleepTimerSettings.autoTurnOnTime.toTimeOfDay().format(context),
),
),
SettingsTile.navigation(
@ -83,13 +77,11 @@ class AutoSleepTimerSettingsPage extends HookConsumerWidget {
// navigate to the time picker
final selected = await showTimePicker(
context: context,
initialTime:
sleepTimerSettings.autoTurnOffTime.toTimeOfDay(),
initialTime: sleepTimerSettings.autoTurnOffTime.toTimeOfDay(),
);
if (selected != null) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.playerSettings
.sleepTimerSettings(
appSettings.copyWith.playerSettings.sleepTimerSettings(
autoTurnOffTime: selected.toDuration(),
),
);
@ -104,7 +96,6 @@ class AutoSleepTimerSettingsPage extends HookConsumerWidget {
],
),
],
),
);
}
}

View file

@ -0,0 +1,404 @@
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/settings/app_settings_provider.dart';
import 'package:vaani/settings/models/app_settings.dart';
import 'package:vaani/settings/view/simple_settings_page.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;
return SimpleSettingsPage(
title: const Text('Notification Settings'),
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: const Text('Primary Title'),
description: Text.rich(
TextSpan(
text: 'The title of the notification\n',
children: [
TextSpan(
text: notificationSettings.primaryTitle,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
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: 'Primary Title',
);
},
);
if (selectedTitle != null) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.notificationSettings(
primaryTitle: selectedTitle,
),
);
}
},
),
SettingsTile(
title: const Text('Secondary Title'),
description: Text.rich(
TextSpan(
text: 'The subtitle of the notification\n',
children: [
TextSpan(
text: notificationSettings.secondaryTitle,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
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: 'Secondary Title',
);
},
);
if (selectedTitle != null) {
ref.read(appSettingsProvider.notifier).update(
appSettings.copyWith.notificationSettings(
secondaryTitle: selectedTitle,
),
);
}
},
),
// set forward and backward intervals
SettingsTile(
title: const Text('Forward Interval'),
description: Row(
children: [
Text(
'${notificationSettings.fastForwardInterval.inSeconds} seconds',
),
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: const Text('Backward Interval'),
description: Row(
children: [
Text(
'${notificationSettings.rewindInterval.inSeconds} seconds',
),
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: const Text('Media Controls'),
leading: const Icon(Icons.control_camera),
// description: const Text('Select the media controls to display'),
description: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Select the media controls to display'),
Wrap(
spacing: 8.0,
children: notificationSettings.mediaControls
.map(
(control) => Icon(control.icon),
)
.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: const Text('Show Chapter Progress'),
leading: const Icon(Icons.book),
description:
const Text('instead of the overall progress of the book'),
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: const Text('Media Controls'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(selectedMediaControls.value);
},
child: const Text('OK'),
),
],
// 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(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(control.icon),
const SizedBox(width: 4.0),
Text(control.name),
],
),
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: '${selectedInterval.value.inSeconds} seconds',
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: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(selectedTitle.value);
},
child: const Text('OK'),
),
],
// 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: const Text('Select a field below to insert it'),
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.stringValue),
onPressed: () {
final text = controller.text;
final newText = '$text\$${type.stringValue}';
controller.text = newText;
selectedTitle.value = newText;
},
),
)
.toList(),
),
],
),
);
}
}
Future<String?> showNotificationTitlePicker(
BuildContext context, {
required String initialValue,
required String title,
}) async {
return showDialog<String>(
context: context,
builder: (context) {
return NotificationTitlePicker(initialValue: initialValue, title: title);
},
);
}

View file

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
import 'package:hooks_riverpod/hooks_riverpod.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!,
),
),
],
),
),
],
),
);
}
}

View file

@ -71,7 +71,7 @@ packages:
source: hosted
version: "2.11.0"
audio_service:
dependency: transitive
dependency: "direct main"
description:
name: audio_service
sha256: "9dd5ba7e77567b290c35908b1950d61485b4dfdd3a0ac398e98cfeec04651b75"
@ -704,10 +704,11 @@ packages:
just_audio_background:
dependency: "direct main"
description:
name: just_audio_background
sha256: "7547b076d5445431c780b0915707f18baa6d9588077d5d8f811e8a77b4e0bec5"
url: "https://pub.dev"
source: hosted
path: just_audio_background
ref: media-notification-config
resolved-ref: "79ac48a7d322d5b8db8847b35ed0c8555fa249bc"
url: "https://github.com/Dr-Blank/just_audio"
source: git
version: "0.0.1-beta.13"
just_audio_media_kit:
dependency: "direct main"

View file

@ -32,6 +32,7 @@ isar_version: &isar_version ^4.0.0-dev.13 # define the version to be used
dependencies:
animated_list_plus: ^0.5.2
animated_theme_switcher: ^2.0.10
audio_service: ^0.18.15
audio_session: ^0.1.19
audio_video_progress_bar: ^2.0.2
auto_scroll_text: ^0.0.7
@ -59,7 +60,12 @@ dependencies:
isar_flutter_libs: ^4.0.0-dev.13
json_annotation: ^4.9.0
just_audio: ^0.9.37
just_audio_background: ^0.0.1-beta.11
just_audio_background:
# TODO Remove git dep when https://github.com/ryanheise/just_audio/issues/912 is closed
git:
url: https://github.com/Dr-Blank/just_audio
ref: media-notification-config
path: just_audio_background
just_audio_media_kit: ^2.0.4
list_wheel_scroll_view_nls: ^0.0.3
logging: ^1.2.0