diff --git a/lib/helper/command_parser.dart b/lib/helper/command_parser.dart index 278ecdf..5a482ed 100644 --- a/lib/helper/command_parser.dart +++ b/lib/helper/command_parser.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:youtube_api/youtube_api.dart'; +import '../model/config.dart'; + List _defaultData(final Object fromObject, final String command) { late final String url; late final String title; @@ -89,9 +91,11 @@ Future playFromYoutubeVideo( await prefs.setStringList('history', historyQueue.toList()); } + final config = await UserConfig.load(); + final commands = switch (mode) { - PlayMode.play => prefs.getStringList('video_play_commands'), - PlayMode.listen => prefs.getStringList('video_listen_commands'), + PlayMode.play => config.videoPlayCommand, + PlayMode.listen => config.videoListenCommand, }; if (commands == null) return; diff --git a/lib/main.dart b/lib/main.dart index d342bb6..0e70547 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,13 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:youtube_api/youtube_api.dart'; import 'const.dart'; import 'helper/command_parser.dart'; import 'intent.dart'; import 'locale/en_us.dart'; +import 'model/config.dart'; import 'model/setting_options.dart'; import 'model/state.dart'; import 'model/theme.dart'; @@ -39,10 +39,11 @@ class _ThemedAppState extends ConsumerState { void initState() { WidgetsBinding.instance.addPostFrameCallback((final _) async { final intialBrightnessMode = ref.read(brightnessModeProvider); - final prefs = await SharedPreferences.getInstance() - .then((final value) => value.getString('theme_brightness')); - if (prefs != null && prefs != intialBrightnessMode.name) { - ref.read(brightnessModeProvider.notifier).switchModeFromString(prefs); + final prefs = await UserConfig.load(); + final userBrightness = prefs.theme.brightness; + + if (userBrightness != intialBrightnessMode) { + ref.read(brightnessModeProvider.notifier).switchMode(userBrightness); } }); @@ -227,9 +228,10 @@ class _HomePageState extends State { return; } - final prefs = await SharedPreferences.getInstance(); - final apiKey = prefs.getString('youtube_api_key'); - final maxResults = prefs.getInt('youtube_result_per_search'); + final prefs = await UserConfig.load(); + + final apiKey = prefs.youtube?.apiKey; + final maxResults = prefs.youtube?.resultPerSearch; if (apiKey == null) { // throw error diff --git a/lib/model/config.dart b/lib/model/config.dart new file mode 100644 index 0000000..24a97e9 --- /dev/null +++ b/lib/model/config.dart @@ -0,0 +1,377 @@ +import 'dart:io'; +import 'package:collection/collection.dart'; +import 'package:xdg_directories/xdg_directories.dart'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'setting_options.dart'; + +const List configPathLookup = [ + '/yatta/config.yaml', + '/yatta/config.yml', + '/yatta/config.json', +]; + +abstract class YamlConfig { + YamlConfig({ + final String? filePath, + final String? rawFile, + }) : _filePath = filePath ?? '${configHome.path}${configPathLookup[0]}', + _rawFile = rawFile ?? ''; + + final String _filePath; + String _rawFile; + + Future _update( + final Iterable path, + final Object? value, + ) async { + final yamlEditor = YamlEditor(_rawFile)..update(path, value); + _rawFile = yamlEditor.toString(); + await File(_filePath).writeAsString(_rawFile); + } +} + +/// Yatta configuration file +/// Configuration schema for [Yatta](https://github.com/yatta/yatta), on-demand video organizer application" +class UserConfig extends YamlConfig { + UserConfig({ + required this.minimizedOnLaunch, + required this.onPlay, + required this.autofocusNavigation, + required this.theme, + required this.history, + this.videoPlayCommand, + this.videoListenCommand, + this.youtube, + super.filePath, + super.rawFile, + }); + + /// Launch application in minimized mode + bool minimizedOnLaunch; + + /// Application behavior when play button is pressed + OnPlayOptions onPlay; + + /// Autofocus on first item on a screen for easier keyboard navigation + bool autofocusNavigation; + + /// Command executed on "Play" button + List? videoPlayCommand; + + /// Command executed on "Listen" button + List? videoListenCommand; + + /// YouTube related configuration + _ConfigYoutube? youtube; + + /// Application theme related configuration + _ConfigTheme theme; + + /// History and Saved Playlist related configuration + _ConfigHistory history; + + factory UserConfig._defaultConfig() { + return UserConfig( + minimizedOnLaunch: false, + onPlay: OnPlayOptions.nothing, + autofocusNavigation: true, + theme: _ConfigTheme(), + history: _ConfigHistory(), + ); + } + + factory UserConfig._fromYaml({ + required final String filePath, + required final String rawFile, + required final dynamic json, + }) { + if (!(json is Map)) { + return UserConfig._defaultConfig(); + } + + final minimizedOnLaunch = switch (json['minimizedOnLaunch']) { + final bool value => value, + _ => null + }; + final onPlay = switch (json['onPlay']) { + 'nothing' => OnPlayOptions.nothing, + 'minimize' => OnPlayOptions.minimize, + 'tray' => OnPlayOptions.tray, + 'exit' => OnPlayOptions.exit, + _ => null + }; + final autofocusNavigation = switch (json['autofocusNavigation']) { + final bool value => value, + _ => null + }; + final videoPlayCommand = switch (json['videoPlayCommand']) { + final List value => + value.map((final command) => command.toString()).toList(), + _ => null + }; + final videoListenCommand = switch (json['videoListenCommand']) { + final List value => + value.map((final command) => command.toString()).toList(), + _ => null + }; + final youtube = switch (json['youtube']) { + final Map? value => value, + _ => null + }; + final theme = switch (json['theme']) { + final Map? value => value, + _ => null + }; + final history = switch (json['history']) { + final Map? value => value, + _ => null + }; + + return UserConfig( + filePath: filePath, + rawFile: rawFile, + minimizedOnLaunch: minimizedOnLaunch ?? false, + onPlay: onPlay ?? OnPlayOptions.nothing, + autofocusNavigation: autofocusNavigation ?? true, + videoPlayCommand: videoPlayCommand, + videoListenCommand: videoListenCommand, + youtube: _ConfigYoutube.fromMap( + filePath: filePath, + rawFile: rawFile, + youtube: youtube, + ), + theme: _ConfigTheme.fromMap( + filePath: filePath, + rawFile: rawFile, + theme: theme, + ), + history: _ConfigHistory.fromMap( + filePath: filePath, + rawFile: rawFile, + history: history, + ), + ); + } + + static Future load() async { + final configLookupResult = configPathLookup.map((final path) { + final filePath = '${configHome.path}$path'; + return (filePath, File(filePath)); + }).firstWhereOrNull((final configLookup) => configLookup.$2.existsSync()); + + if (configLookupResult == null) { + return UserConfig._defaultConfig(); + } + + final (path, file) = configLookupResult; + return file.readAsString().then((final rawFile) => UserConfig._fromYaml( + filePath: path, + json: loadYaml(rawFile), + rawFile: rawFile, + )); + } + + Future updateMinimizedOnLaunch(final bool newValue) async => + _update(['minimizedOnLaunch'], newValue) + .whenComplete(() => this.minimizedOnLaunch = newValue); + + Future updateOnPlay(final OnPlayOptions newValue) async => + _update(['onPlay'], newValue.name) + .whenComplete(() => this.onPlay = newValue); + + Future updateAutofocusNavigation(final bool newValue) async => + _update(['autofocusNavigation'], newValue) + .whenComplete(() => this.autofocusNavigation = newValue); + + Future updateVideoPlayCommand(final List? newValue) async => + _update(['videoPlayCommand'], newValue) + .whenComplete(() => this.videoPlayCommand = newValue); + + Future updateVideoListenCommand(final List? newValue) async => + _update(['videoListenCommand'], newValue) + .whenComplete(() => this.videoListenCommand = newValue); +} + +/// YouTube related configuration. +class _ConfigYoutube extends YamlConfig { + _ConfigYoutube({ + required this.apiKey, + this.enablePublishDate = true, + this.enableWatchCount = false, + this.resultPerSearch = 10, + this.infiniteScrollSearch = false, + this.regionId, + super.filePath, + super.rawFile, + }); + + /// YouTube Data API v3 API key + String apiKey; + + /// Show Publish Date on search result (might cost more quota) + bool enablePublishDate; + + /// Show Watch Count on search result (might cost more quota) + bool enableWatchCount; + + /// Number of results fetched on each API call + int resultPerSearch; + + /// Enable infinite scroll on YouTube search (will cost more quota when + /// enabled) + bool infiniteScrollSearch; + + /// Region of the search results + String? regionId; + + factory _ConfigYoutube.fromMap({ + required final String filePath, + required final String rawFile, + final Map? youtube, + }) => + youtube == null + ? _ConfigYoutube(apiKey: '') + : _ConfigYoutube( + filePath: filePath, + rawFile: rawFile, + apiKey: switch (youtube['apiKey']) { + final String apiKey => apiKey, + _ => '', + }, + enablePublishDate: switch (youtube['enablePublishDate']) { + final bool enablePublishDate => enablePublishDate, + _ => true, + }, + enableWatchCount: switch (youtube['enableWatchCount']) { + final bool enableWatchCount => enableWatchCount, + _ => false + }, + resultPerSearch: switch (youtube['resultPerSearch']) { + final int resultPerSearch => resultPerSearch, + _ => 10, + }, + infiniteScrollSearch: switch (youtube['infiniteScrollSearch']) { + final bool infiniteScrollSearch => infiniteScrollSearch, + _ => false, + }, + regionId: switch (youtube['regionId']) { + final String regionId => regionId, + _ => null, + }, + ); + + Future updateApiKey(final String newValue) async => + _update(['youtube', 'apiKey'], newValue) + .whenComplete(() => this.apiKey = newValue); + + Future updateEnablePublishDate(final bool newValue) async => + _update(['youtube', 'enablePublishDate'], newValue) + .whenComplete(() => this.enablePublishDate = newValue); + + Future updateEnableWatchCount(final bool newValue) async => + _update(['youtube', 'enableWatchCount'], newValue) + .whenComplete(() => this.enableWatchCount = newValue); + + Future updateResultPerSearch(final int newValue) async => + _update(['youtube', 'resultPerSearch'], newValue) + .whenComplete(() => this.resultPerSearch = newValue); + + Future updateInfiniteScrollSearch(final bool newValue) async => + _update(['youtube', 'infiniteScrollSearch'], newValue) + .whenComplete(() => this.infiniteScrollSearch = newValue); + + Future updateRegionId(final String newValue) async => + _update(['youtube', 'regionId'], newValue) + .whenComplete(() => this.regionId = newValue); +} + +/// Application theme related configuration. +class _ConfigTheme extends YamlConfig { + _ConfigTheme({ + this.brightness = BrightnessOptions.system, + this.visualDensity = VisualDensityOptions.standard, + super.filePath, + super.rawFile, + }); + + BrightnessOptions brightness; + VisualDensityOptions visualDensity; + + factory _ConfigTheme.fromMap({ + required final String filePath, + required final String rawFile, + final Map? theme, + }) => + theme == null + ? _ConfigTheme() + : _ConfigTheme( + filePath: filePath, + rawFile: rawFile, + brightness: switch (theme['brightness']) { + 'light' => BrightnessOptions.light, + 'dark' => BrightnessOptions.dark, + 'system' => BrightnessOptions.system, + _ => BrightnessOptions.system, + }, + visualDensity: switch (theme['visualDensity']) { + 'compact' => VisualDensityOptions.compact, + 'standard' => VisualDensityOptions.standard, + 'comfort' => VisualDensityOptions.comfort, + 'adaptive' => VisualDensityOptions.adaptive, + _ => VisualDensityOptions.adaptive, + }); + + Future updateBrightness(final BrightnessOptions newValue) async => + _update(['theme', 'brightness'], newValue.name) + .whenComplete(() => this.brightness = newValue); + + Future updateVisualDensity(final VisualDensityOptions newValue) async => + _update(['theme', 'visualDensity'], newValue.name) + .whenComplete(() => this.visualDensity = newValue); +} + +/// History and Saved Playlist related configuration. +class _ConfigHistory extends YamlConfig { + _ConfigHistory({ + this.pause = false, + this.size = 2000, + super.filePath, + super.rawFile, + }); + + /// Pause history, not receiving any more new playback history. + bool pause; + + /// Number of history to keep + int size; + + factory _ConfigHistory.fromMap({ + required final String filePath, + required final String rawFile, + final Map? history, + }) => + history == null + ? _ConfigHistory() + : _ConfigHistory( + filePath: filePath, + rawFile: rawFile, + pause: switch (history['pause']) { + final bool pause => pause, + _ => false, + }, + size: switch (history['size']) { + final int size => size, + _ => 2000, + }, + ); + + Future updatePause(final bool newValue) async => + _update(['history', 'pause'], newValue) + .whenComplete(() => this.pause = newValue); + + Future updateSize(final int newValue) async => + _update(['history', 'size'], newValue) + .whenComplete(() => this.size = newValue); +} diff --git a/lib/page/settings.dart b/lib/page/settings.dart index fc9291d..d8012de 100644 --- a/lib/page/settings.dart +++ b/lib/page/settings.dart @@ -2,10 +2,10 @@ import 'package:autoscroll/autoscroll.dart'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/material.dart' show ToggleButtons; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import '../intent.dart'; import '../main.dart'; +import '../model/config.dart'; import '../model/setting_options.dart'; typedef _TextBoxValue = TextEditingController; @@ -18,8 +18,7 @@ typedef _ButtonValue = void; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); - static final Future _loadSharedPreferences = - SharedPreferences.getInstance(); + static final Future _loadUserConfig = UserConfig.load(); static void _navigationPop(final BuildContext context) { if (Navigator.of(context).canPop()) { @@ -38,45 +37,13 @@ class SettingsPage extends StatelessWidget { child: NavigationView( appBar: const NavigationAppBar(title: Text('Settings')), content: FutureBuilder( - future: _loadSharedPreferences, + future: _loadUserConfig, builder: (final context, final snapshot) { - if (!snapshot.hasData) { + final userConfig = snapshot.data; + if (userConfig == null) { return const Center(child: ProgressBar()); } - const minimizedOnLaunchKey = 'minimized_on_launch'; - final minimizedOnLaunchValue = - snapshot.data?.getBool(minimizedOnLaunchKey); - const autoFocusNavigationKey = 'autofocus_navigation'; - final autoFocusNavigationValue = - snapshot.data?.getBool(autoFocusNavigationKey); - const videoPlayCommandsKey = 'video_play_commands'; - final videoPlayCommandsValue = - snapshot.data?.getStringList(videoPlayCommandsKey); - const videoListenCommandsKey = 'video_listen_commands'; - final videoListenCommandsValue = - snapshot.data?.getStringList(videoListenCommandsKey); - const youtubeAPIKeyKey = 'youtube_api_key'; - final youtubeAPIKeyValue = - snapshot.data?.getString(youtubeAPIKeyKey); - const youtubeResultPerSearchKey = 'youtube_result_per_search'; - final youtubeResultPerSearchValue = - snapshot.data?.getInt(youtubeResultPerSearchKey); - const enableHistoryKey = 'enable_history'; - final enableHistoryValue = - snapshot.data?.getBool(enableHistoryKey); - const historyToKeepKey = 'history_to_keep'; - final historyToKeepValue = - snapshot.data?.getInt(historyToKeepKey); - const themeBrightnessKey = 'theme_brightness'; - final _themeBrightnessData = - snapshot.data?.getString(themeBrightnessKey); - final themeBrightnessValue = _themeBrightnessData != null - ? BrightnessOptions.values - .where((final e) => e.name == _themeBrightnessData) - .firstOrNull - : null; - return AutoscrollListView( children: [ const Padding( @@ -92,10 +59,9 @@ class SettingsPage extends StatelessWidget { _SettingItem( key: UniqueKey(), label: 'Minimized on launch:', - value: minimizedOnLaunchValue ?? false, - onChanged: (final bool newValue) async { - await snapshot.data - ?.setBool(minimizedOnLaunchKey, newValue); + value: userConfig.minimizedOnLaunch, + onChanged: (final newValue) async { + await userConfig.updateMinimizedOnLaunch(newValue); }, autofocus: true, ), @@ -103,41 +69,38 @@ class SettingsPage extends StatelessWidget { _SettingItem( key: UniqueKey(), label: 'On play:', - value: OnPlayOptions.nothing, - onChanged: (final _) { - // TODO: implement on play + value: userConfig.onPlay, + onChanged: (final newValue) async { + await userConfig.updateOnPlay(newValue); }, ), const SizedBox(height: 8), _SettingItem( key: UniqueKey(), label: 'Autofocus navigation:', - value: autoFocusNavigationValue ?? true, - onChanged: (final bool newValue) async { - await snapshot.data - ?.setBool(autoFocusNavigationKey, newValue); + value: userConfig.autofocusNavigation, + onChanged: (final newValue) async { + await userConfig.updateAutofocusNavigation(newValue); }, ), const SizedBox(height: 8), _SettingItem( key: UniqueKey(), label: 'Video play commands:', - value: videoPlayCommandsValue ?? [''], + value: userConfig.videoPlayCommand ?? [''], multiline: true, - onChanged: (final List newValue) async { - await snapshot.data - ?.setStringList(videoPlayCommandsKey, newValue); + onChanged: (final newValue) async { + await userConfig.updateVideoPlayCommand(newValue); }, ), const SizedBox(height: 16), _SettingItem( key: UniqueKey(), label: 'Video listen commands:', - value: videoListenCommandsValue ?? [''], + value: userConfig.videoListenCommand ?? [''], multiline: true, - onChanged: (final List newValue) async { - await snapshot.data - ?.setStringList(videoListenCommandsKey, newValue); + onChanged: (final newValue) async { + await userConfig.updateVideoListenCommand(newValue); }, ), const SizedBox(height: 16), @@ -155,56 +118,58 @@ class SettingsPage extends StatelessWidget { _SettingItem( key: UniqueKey(), label: 'API key:', - value: youtubeAPIKeyValue ?? '', + value: userConfig.youtube?.apiKey ?? '', sensitive: true, - onChanged: (final String newValue) async { - await snapshot.data - ?.setString(youtubeAPIKeyKey, newValue); + onChanged: (final newValue) async { + await userConfig.youtube?.updateApiKey(newValue); }, ), const SizedBox(height: 8), _SettingItem( - key: UniqueKey(), - label: 'Enable publish date:', - value: true, - onChanged: (final _) { - // TODO: implement publish date - }), + key: UniqueKey(), + label: 'Enable publish date:', + value: userConfig.youtube?.enablePublishDate ?? true, + onChanged: (final newValue) async { + await userConfig.youtube + ?.updateEnablePublishDate(newValue); + }, + ), const SizedBox(height: 8), _SettingItem( key: UniqueKey(), label: 'Enable watch count:', - value: false, - onChanged: (final _) { - // TODO: implement enable watch count + value: userConfig.youtube?.enableWatchCount ?? false, + onChanged: (final newValue) async { + await userConfig.youtube + ?.updateEnableWatchCount(newValue); }, ), const SizedBox(height: 8), _SettingItem( key: UniqueKey(), label: 'Result per search:', - value: youtubeResultPerSearchValue ?? 10, - onChanged: (final int newValue) async { - await snapshot.data - ?.setInt(youtubeResultPerSearchKey, newValue); + value: userConfig.youtube?.resultPerSearch ?? 10, + onChanged: (final newValue) async { + await userConfig.youtube?.updateResultPerSearch(newValue); }, ), const SizedBox(height: 8), _SettingItem( key: UniqueKey(), label: 'Infinite scroll search:', - value: false, - onChanged: (final _) { - // TODO: implement Infinite scroll search + value: userConfig.youtube?.infiniteScrollSearch ?? false, + onChanged: (final newValue) async { + await userConfig.youtube + ?.updateInfiniteScrollSearch(newValue); }, ), const SizedBox(height: 8), _SettingItem( key: UniqueKey(), label: 'Region id:', - value: '', - onChanged: (final _) { - // TODO: implment region id + value: userConfig.youtube?.regionId ?? '', + onChanged: (final newValue) async { + await userConfig.youtube?.updateRegionId(newValue); }, ), const SizedBox(height: 16), @@ -224,10 +189,13 @@ class SettingsPage extends StatelessWidget { return _SettingItem( key: UniqueKey(), label: 'Brightness:', - value: themeBrightnessValue ?? BrightnessOptions.dark, - onChanged: (final BrightnessOptions newValue) async { - await snapshot.data - ?.setString(themeBrightnessKey, newValue.name); + value: BrightnessOptions.values + .where((final e) => + e.name == userConfig.theme.brightness.name) + .firstOrNull ?? + BrightnessOptions.dark, + onChanged: (final newValue) async { + await userConfig.theme.updateBrightness(newValue); ref .read(brightnessModeProvider.notifier) .switchMode(newValue); @@ -238,9 +206,9 @@ class SettingsPage extends StatelessWidget { _SettingItem( key: UniqueKey(), label: 'Visual Density:', - value: VisualDensityOptions.adaptive, - onChanged: (final _) { - // TODO: implement visual density options + value: userConfig.theme.visualDensity, + onChanged: (final newValue) async { + await userConfig.theme.updateVisualDensity(newValue); }, ), const SizedBox(height: 16), @@ -258,18 +226,18 @@ class SettingsPage extends StatelessWidget { _SettingItem( key: UniqueKey(), label: 'Pause history:', - value: !(enableHistoryValue ?? true), + value: userConfig.history.pause, onChanged: (final newValue) async { - await snapshot.data?.setBool(enableHistoryKey, newValue); + await userConfig.history.updatePause(newValue); }, ), const SizedBox(height: 8), _SettingItem( key: UniqueKey(), label: 'History to keep:', - value: historyToKeepValue ?? 200, + value: userConfig.history.size, onChanged: (final newValue) async { - await snapshot.data?.setInt(historyToKeepKey, newValue); + await userConfig.history.updateSize(newValue); }, ), const SizedBox(height: 8), @@ -278,7 +246,7 @@ class SettingsPage extends StatelessWidget { label: 'Remove history:', value: _ButtonValue, onChanged: (final _) async { - await snapshot.data?.setStringList('history', []); + // await snapshot.data?.setStringList('history', []); }), const SizedBox(height: 16), ], diff --git a/pubspec.lock b/pubspec.lock index 564e0cd..208efe2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -320,10 +320,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "4.8.1" + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -881,6 +881,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + yaml_edit: + dependency: "direct main" + description: + name: yaml_edit + sha256: c566f4f804215d84a7a2c377667f546c6033d5b34b4f9e60dfb09d17c4e97826 + url: "https://pub.dev" + source: hosted + version: "2.2.0" youtube_api: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f3e0018..5329ed3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: flutter_riverpod: ^2.3.6 shared_preferences: ^2.1.2 url_launcher: ^6.1.11 + yaml_edit: ^2.2.0 youtube_api: path: package/youtube_api/