Skip to content

Commit

Permalink
feat: extensive settings for media controls through notification (#28)
Browse files Browse the repository at this point in the history
* feat: add notification settings customisation options

* feat: add notification settings page and update routing
  • Loading branch information
Dr-Blank authored Sep 25, 2024
1 parent 721b0a8 commit 3cf0a0b
Show file tree
Hide file tree
Showing 21 changed files with 1,386 additions and 371 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 48 additions & 3 deletions lib/features/player/core/audiobook_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
}
62 changes: 62 additions & 0 deletions lib/features/player/core/init.dart
Original file line number Diff line number Diff line change
@@ -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,
},
);
},
);
}
21 changes: 3 additions & 18 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(
Expand Down
9 changes: 7 additions & 2 deletions lib/router/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 17 additions & 9 deletions lib/router/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -172,15 +173,22 @@ class MyAppRouter {
name: Routes.settings.name,
// builder: (context, state) => const AppSettingsPage(),
pageBuilder: defaultPageBuilder(const AppSettingsPage()),
),
GoRoute(
path: Routes.autoSleepTimerSettings.localPath,
name: Routes.autoSleepTimerSettings.name,
// builder: (context, state) =>
// const AutoSleepTimerSettingsPage(),
pageBuilder: defaultPageBuilder(
const AutoSleepTimerSettingsPage(),
),
routes: [
GoRoute(
path: Routes.autoSleepTimerSettings.pathName,
name: Routes.autoSleepTimerSettings.name,
pageBuilder: defaultPageBuilder(
const AutoSleepTimerSettingsPage(),
),
),
GoRoute(
path: Routes.notificationSettings.pathName,
name: Routes.notificationSettings.name,
pageBuilder: defaultPageBuilder(
const NotificationSettingsPage(),
),
),
],
),
GoRoute(
path: Routes.userManagement.localPath,
Expand Down
22 changes: 13 additions & 9 deletions lib/settings/app_settings_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
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 {
// create a new settings object
const settings = model.AppSettings();
_logger.fine('created new settings: $settings');
return settings;
_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();
});
Expand Down
2 changes: 1 addition & 1 deletion lib/settings/app_settings_provider.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions lib/settings/models/app_settings.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) =>
Expand Down Expand Up @@ -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;
}
Loading

0 comments on commit 3cf0a0b

Please sign in to comment.