diff --git a/lib/const.dart b/lib/const.dart index 5f46f07..9f020da 100644 --- a/lib/const.dart +++ b/lib/const.dart @@ -3,3 +3,15 @@ const String defaultThemeName = 'Arc-Dark'; const String searchModeVideoShortcut = 'v '; const String searchModeChannelShortcut = 'c '; const String searchModePlaylistShortcut = 'p '; + +const List configPathLookup = [ + '/yatta/config.yaml', + '/yatta/config.yml', + '/yatta/config.json', +]; + +const List feedPathLookup = [ + '/yatta/feed.yaml', + '/yatta/feed.yml', + '/yatta/feed.json', +]; diff --git a/lib/helper/command_parser.dart b/lib/helper/command_parser.dart index 5a482ed..6cb43a5 100644 --- a/lib/helper/command_parser.dart +++ b/lib/helper/command_parser.dart @@ -46,7 +46,10 @@ List _defaultData(final Object fromObject, final String command) { icon: icon); } -Future playFromUrl(final String url) async { +Future playFromUrl( + final String url, { + final PlayMode mode = PlayMode.play, +}) async { final prefs = await SharedPreferences.getInstance(); await prefs.setStringList('history', [ @@ -54,7 +57,12 @@ Future playFromUrl(final String url) async { // TODO ]); - final commands = prefs.getStringList('video_play_commands'); + final config = await UserConfig.load(); + + final commands = switch (mode) { + PlayMode.play => config.videoPlayCommand, + PlayMode.listen => config.videoListenCommand, + }; if (commands == null) return; diff --git a/lib/helper/feed.dart b/lib/helper/feed.dart new file mode 100644 index 0000000..89da3f6 --- /dev/null +++ b/lib/helper/feed.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:xdg_directories/xdg_directories.dart' as xdg; +import 'package:yaml_edit/yaml_edit.dart'; + +import '../const.dart'; + +Future loadFeedList() async => + await feedPathLookup + .map((final path) => File('${xdg.configHome.path}$path')) + .firstWhereOrNull((final feedLookup) => feedLookup.existsSync()) + ?.readAsString() ?? + ''; + +Future updateFeedList( + final String feedList, final List newFeedList) async { + final yamlEditor = YamlEditor(feedList)..update([], newFeedList); + final rawFile = yamlEditor.toString(); + + final file = feedPathLookup + .map((final path) => File('${xdg.configHome.path}$path')) + .firstWhereOrNull((final feedLookup) => feedLookup.existsSync()); + if (file == null) { + await File('${xdg.configHome.path}${feedPathLookup[0]}') + .writeAsString(rawFile); + return; + } + + await file.writeAsString(rawFile); +} diff --git a/lib/main.dart b/lib/main.dart index 0e70547..73ce3cc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'model/config.dart'; import 'model/setting_options.dart'; import 'model/state.dart'; import 'model/theme.dart'; +import 'page/feed.dart'; import 'page/history.dart'; import 'page/home.dart'; import 'page/playlist.dart'; @@ -147,6 +148,7 @@ class App extends StatelessWidget { routes: { '/': (final _) => const HomePage(), '/playlist': (final _) => const PlaylistPage(), + '/feed': (final _) => const FeedPage(), '/history': (final _) => const HistoryPage(), '/settings': (final _) => const SettingsPage(), }, diff --git a/lib/model/config.dart b/lib/model/config.dart index 24a97e9..1f63c4e 100644 --- a/lib/model/config.dart +++ b/lib/model/config.dart @@ -4,14 +4,9 @@ import 'package:xdg_directories/xdg_directories.dart'; import 'package:yaml/yaml.dart'; import 'package:yaml_edit/yaml_edit.dart'; +import '../const.dart'; import 'setting_options.dart'; -const List configPathLookup = [ - '/yatta/config.yaml', - '/yatta/config.yml', - '/yatta/config.json', -]; - abstract class YamlConfig { YamlConfig({ final String? filePath, diff --git a/lib/page/feed.dart b/lib/page/feed.dart new file mode 100644 index 0000000..a009c26 --- /dev/null +++ b/lib/page/feed.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:autoscroll/autoscroll.dart'; +import 'package:collection/collection.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:http/http.dart' as http; +import 'package:rss_dart/dart_rss.dart'; +import 'package:yaml/yaml.dart' as yaml; + +import '../../intent.dart'; +import '../helper/feed.dart'; +import '../widget/keyboard_navigation.dart'; +import '../widget/list_items/list_item.dart'; + +class FeedPage extends StatefulWidget { + const FeedPage({super.key}); + + @override + State createState() => _FeedPageState(); +} + +class _FeedPageState extends State { + final FocusNode searchBarFocus = FocusNode(); + List? filteredList; + List? feedList; + late final Map> _actionMap = { + SearchBarFocusIntent: CallbackAction( + onInvoke: (final _) => _requestSearchBarFocus(), + ), + NavigationPopIntent: CallbackAction( + onInvoke: (final _) => _navigationPop(context), + ) + }; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((final _) async { + await fetchFeed(); + }); + } + + Future fetchFeed() async { + final rawFeed = await loadFeedList() + .then((final rawFile) => switch (yaml.loadYaml(rawFile)) { + final List urls => urls + .map((final url) => http.get(Uri.parse(url.toString()))) + .toList(), + _ => >[] + }) + .then((final urls) async => Future.wait(urls)); + + setState(() { + feedList = rawFeed + .map((final e) => AtomFeed.parse(e.body)) + .expand((final e) => e.items) + .sorted((final a, final b) => DateTime.parse(a.published ?? '') + .compareTo(DateTime.parse(b.published ?? ''))) + .toList(); + + filteredList = feedList; + }); + } + + void _requestSearchBarFocus() => searchBarFocus.requestFocus(); + + void _navigationPop(final BuildContext context) { + if (!Navigator.of(context).canPop()) return; + Navigator.of(context).pop(); + } + + void _filterList(final String keyword) { + if (filteredList == null) return; + setState(() { + filteredList = feedList + ?.where((final e) => [ + e.media?.title?.value ?? '', + e.authors.first.name ?? '', + e.media?.group?.description?.value ?? '' + ].join(' ').toLowerCase().contains(keyword.toLowerCase())) + .toList(); + }); + } + + @override + Widget build(final BuildContext context) { + return Actions( + actions: _actionMap, + child: NavigationView( + appBar: NavigationAppBar( + title: SizedBox( + height: 36, + child: TextBox( + focusNode: searchBarFocus, + placeholder: 'Search from feed', + onChanged: _filterList, + ), + ), + ), + content: KeyboardNavigation( + child: AutoscrollListView.builder( + itemCount: filteredList?.length ?? 0, + itemBuilder: (final context, final index) { + final youtubeVideo = + filteredList![filteredList!.length - index - 1]; + // print(youtubeVideo + // .items.first.media?.group?.); + + final listItem = ListItemVideo( + title: youtubeVideo.media?.title?.value ?? '', + channelTitle: youtubeVideo.authors.first.name ?? '', + description: + youtubeVideo.media?.group?.description?.value ?? '', + thumbnailUrl: + youtubeVideo.media?.group?.thumbnail.firstOrNull?.url ?? '', + publishedAt: youtubeVideo.published ?? '', + ); + + return ListItem( + autofocus: index == 0, + url: youtubeVideo.links.firstOrNull?.href ?? '', + fromHistory: true, + child: listItem, + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/page/history.dart b/lib/page/history.dart index f198fb2..12111f9 100644 --- a/lib/page/history.dart +++ b/lib/page/history.dart @@ -115,7 +115,7 @@ class _HistoryPageState extends State { return ListItem( autofocus: index == 0, - youtubeVideo: youtubeVideo, + url: youtubeVideo.url, fromHistory: true, child: listItem, ); diff --git a/lib/page/home.dart b/lib/page/home.dart index 0b3ab45..4a9dc05 100644 --- a/lib/page/home.dart +++ b/lib/page/home.dart @@ -46,7 +46,9 @@ class WelcomeMessage extends StatelessWidget { title: 'Feed', subtitle: 'Subsribe to channels via RSS Feed', icon: FluentIcons.content_feed, - onPressed: () {}, + onPressed: () async { + await Navigator.of(context).pushNamed('/feed'); + }, ), _SelectionMenu( key: const Key('history'), @@ -221,7 +223,7 @@ class _HomeVideoThumbnail extends StatelessWidget { @override Widget build(final BuildContext context) { return ListItem( - youtubeVideo: youtubeVideo, + url: youtubeVideo.url, fromHistory: true, child: SizedBox( width: 220, diff --git a/lib/page/playlist.dart b/lib/page/playlist.dart index ec9e4d6..44cb131 100644 --- a/lib/page/playlist.dart +++ b/lib/page/playlist.dart @@ -97,7 +97,7 @@ class _PlaylistPageState extends State { return ListItem( autofocus: index == 0, fromHistory: true, - youtubeVideo: youtubeVideo, + url: youtubeVideo.url, child: listItem); }, ); diff --git a/lib/page/settings.dart b/lib/page/settings.dart index d8012de..03e3f18 100644 --- a/lib/page/settings.dart +++ b/lib/page/settings.dart @@ -2,7 +2,9 @@ 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:yaml/yaml.dart' as yaml; +import '../helper/feed.dart'; import '../intent.dart'; import '../main.dart'; import '../model/config.dart'; @@ -18,7 +20,10 @@ typedef _ButtonValue = void; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); - static final Future _loadUserConfig = UserConfig.load(); + static final Future> _futureList = Future.wait([ + UserConfig.load(), + loadFeedList(), + ]); static void _navigationPop(final BuildContext context) { if (Navigator.of(context).canPop()) { @@ -37,13 +42,32 @@ class SettingsPage extends StatelessWidget { child: NavigationView( appBar: const NavigationAppBar(title: Text('Settings')), content: FutureBuilder( - future: _loadUserConfig, + future: _futureList, builder: (final context, final snapshot) { - final userConfig = snapshot.data; - if (userConfig == null) { + final data = snapshot.data; + if (data == null || data.isEmpty) { return const Center(child: ProgressBar()); } + final userConfig = switch (data.first) { + final UserConfig userConfig => userConfig, + _ => null, + }; + final feedList = switch (data.last) { + final String feedList => feedList, + _ => null, + }; + + if (userConfig == null || feedList == null) { + return const SizedBox(); + } + + final parsedFeedList = switch (yaml.loadYaml(feedList)) { + final List urls => + urls.map((final url) => url.toString()).toList(), + _ => [''] + }; + return AutoscrollListView( children: [ const Padding( @@ -174,6 +198,27 @@ class SettingsPage extends StatelessWidget { ), const SizedBox(height: 16), const Divider(), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16), + child: Row( + children: [ + Icon(FluentIcons.content_feed), + SizedBox(width: 16), + Text('Feed Settings'), + ], + ), + ), + _SettingItem( + key: UniqueKey(), + label: 'Feed List:', + value: parsedFeedList, + multiline: true, + onChanged: (final newValue) async { + await updateFeedList(feedList, newValue); + }, + ), + const SizedBox(height: 16), + const Divider(), const Padding( padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16), child: Row( diff --git a/lib/widget/list_items/channel.dart b/lib/widget/list_items/channel.dart index 03b82e1..9100748 100644 --- a/lib/widget/list_items/channel.dart +++ b/lib/widget/list_items/channel.dart @@ -1,38 +1,48 @@ part of 'list_item.dart'; class ListItemChannel extends StatelessWidget { - final String channelTitle; - final String? thumbnailUrl; - const ListItemChannel({ required this.channelTitle, final Key? key, this.thumbnailUrl, }) : super(key: key); + final String channelTitle; + final String? thumbnailUrl; + + Container _errorThumbnail(final BuildContext context) { + return Container( + width: 100, + height: 100, + color: FluentTheme.of(context).menuColor, + child: const Icon(FluentIcons.alert_solid), + ); + } + @override Widget build(final BuildContext context) { + final thumbnailUrl = this.thumbnailUrl; + return Row( children: [ - if (thumbnailUrl!.isNotEmpty) - SizedBox( - width: 180, - height: 100, - child: Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(50), - child: Image.network( - thumbnailUrl!, - width: 100, - height: 100, - errorBuilder: (final _, final __, final ___) => Container( + thumbnailUrl != null && thumbnailUrl.isNotEmpty + ? SizedBox( + width: 180, + height: 100, + child: Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(50), + child: Image.network( + thumbnailUrl, width: 100, height: 100, - color: FluentTheme.of(context).inactiveColor), + errorBuilder: (final context, final _, final __) => + _errorThumbnail(context), + ), + ), ), - ), - ), - ), + ) + : _errorThumbnail(context), const SizedBox(width: 8), Expanded( child: Column( diff --git a/lib/widget/list_items/list_item.dart b/lib/widget/list_items/list_item.dart index fbcdfc4..1ab33bf 100644 --- a/lib/widget/list_items/list_item.dart +++ b/lib/widget/list_items/list_item.dart @@ -21,14 +21,14 @@ typedef ListItemCallback = void Function(YoutubeVideo); class ListItem extends StatefulWidget { const ListItem({ required this.child, - required this.youtubeVideo, + required this.url, this.fromHistory = false, this.autofocus = false, final Key? key, }) : super(key: key); final Widget child; - final YoutubeVideo youtubeVideo; + final String url; final bool autofocus; final bool fromHistory; @@ -42,7 +42,6 @@ class _ListItemState extends State { final _focusNode = FocusNode(); bool _focused = false; bool _hovered = false; - // bool _showOptions = false; late final Map> _actionMap; @override @@ -89,10 +88,7 @@ class _ListItemState extends State { }, ); - await playFromYoutubeVideo( - widget.youtubeVideo, - fromHistory: widget.fromHistory, - ); + await playFromUrl(widget.url); } Future _playAudio(final BuildContext context) async { @@ -111,9 +107,9 @@ class _ListItemState extends State { }, ); - await playFromYoutubeVideo( - widget.youtubeVideo, - fromHistory: widget.fromHistory, + await playFromUrl( + widget.url, + // fromHistory: widget.fromHistory, mode: PlayMode.listen, ); } @@ -134,10 +130,8 @@ class _ListItemState extends State { }, ); - await Process.start('mpv', [ - '--ytdl-format=bestvideo[height<=1080]+bestaudio', - widget.youtubeVideo.url - ]); + await Process.start( + 'mpv', ['--ytdl-format=bestvideo[height<=1080]+bestaudio', widget.url]); } Future _openMenuFlyout() async { @@ -209,7 +203,7 @@ class _ListItemState extends State { ), onPressed: () { Clipboard.setData(ClipboardData( - text: widget.youtubeVideo.url, + text: widget.url, )).whenComplete(() => displayInfoBar( context, builder: (final context, final close) { @@ -235,7 +229,7 @@ class _ListItemState extends State { TextSpan(text: 'pen in browser'), ])), onPressed: () async { - if (!await launchUrl(Uri.parse(widget.youtubeVideo.url))) { + if (!await launchUrl(Uri.parse(widget.url))) { throw Exception('Could not launch feedback url'); } }, diff --git a/lib/widget/list_items/playlist.dart b/lib/widget/list_items/playlist.dart index 69020bf..7851bc2 100644 --- a/lib/widget/list_items/playlist.dart +++ b/lib/widget/list_items/playlist.dart @@ -14,25 +14,36 @@ class ListItemPlaylist extends StatelessWidget { this.thumbnailUrl, }) : super(key: key); + Container _errorThumbnail(final BuildContext context) { + return Container( + width: 180, + height: 100, + color: FluentTheme.of(context).menuColor, + child: const Icon(FluentIcons.alert_solid), + ); + } + @override Widget build(final BuildContext context) { + final thumbnailUrl = this.thumbnailUrl; + return Row( children: [ Stack( alignment: Alignment.centerRight, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.network( - thumbnailUrl!, - width: 180, - height: 100, - errorBuilder: (final _, final __, final ___) => Container( - width: 180, - height: 100, - color: FluentTheme.of(context).inactiveColor), - ), - ), + thumbnailUrl != null && thumbnailUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + thumbnailUrl, + width: 180, + height: 100, + errorBuilder: (final context, final _, final __) => + _errorThumbnail(context), + ), + ) + : _errorThumbnail(context), Container( width: 60, height: 100, diff --git a/lib/widget/list_items/video.dart b/lib/widget/list_items/video.dart index c06689e..8c52281 100644 --- a/lib/widget/list_items/video.dart +++ b/lib/widget/list_items/video.dart @@ -6,25 +6,27 @@ class ListItemVideo extends StatelessWidget { final String? description; final String? thumbnailUrl; final String? publishedAt; - final String duration; + final String? duration; const ListItemVideo({ required this.title, required this.channelTitle, required this.description, - required this.duration, required this.publishedAt, - final Key? key, this.thumbnailUrl, + this.duration, + final Key? key, }) : super(key: key); @override Widget build(final BuildContext context) { final timeNow = DateTime.now(); + final description = this.description; + final publishedAt = this.publishedAt; + return Row( children: [ - if (thumbnailUrl!.isNotEmpty) - _VideoThumbnail(thumbnailUrl: thumbnailUrl, duration: duration), + _VideoThumbnail(thumbnailUrl: thumbnailUrl, duration: duration), const SizedBox(width: 8), Expanded( child: Column( @@ -45,16 +47,17 @@ class ListItemVideo extends StatelessWidget { style: const TextStyle(fontWeight: FontWeight.w300), ), ), - Text( - ' • ${timeSince(DateTime.parse(publishedAt!), timeNow)}', - style: const TextStyle(fontWeight: FontWeight.w300), - ), + if (publishedAt != null) + Text( + ' • ${timeSince(DateTime.parse(publishedAt), timeNow)}', + style: const TextStyle(fontWeight: FontWeight.w300), + ), ], ), const SizedBox(height: 8), - if ((description ?? '').isNotEmpty) + if (description != null && description.isNotEmpty) Text( - description!, + description, style: const TextStyle(fontWeight: FontWeight.w300), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -69,47 +72,61 @@ class ListItemVideo extends StatelessWidget { class _VideoThumbnail extends StatelessWidget { const _VideoThumbnail({ - required this.thumbnailUrl, - required this.duration, + this.thumbnailUrl, + this.duration, }); final String? thumbnailUrl; - final String duration; + final String? duration; + + Container _errorThumbnail(final BuildContext context) { + return Container( + width: 180, + height: 100, + color: FluentTheme.of(context).menuColor, + child: const Icon(FluentIcons.alert_solid), + ); + } @override Widget build(final BuildContext context) { + final thumbnailUrl = this.thumbnailUrl; + final duration = this.duration; + return Stack( alignment: AlignmentDirectional.bottomEnd, children: [ ClipRRect( borderRadius: BorderRadius.circular(4), - child: Image.network( - thumbnailUrl!, - width: 180, - height: 100, - errorBuilder: (final _, final __, final ___) => Container( - width: 180, - height: 100, - color: FluentTheme.of(context).inactiveColor), - ), + child: thumbnailUrl != null && thumbnailUrl.isNotEmpty + ? Image.network( + thumbnailUrl, + fit: BoxFit.cover, + width: 180, + height: 100, + errorBuilder: (final context, final _, final __) => + _errorThumbnail(context), + ) + : _errorThumbnail(context), ), - Padding( - padding: const EdgeInsets.all(2), - child: Container( - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(4), - ), - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - child: Text( - duration, - style: const TextStyle(fontSize: 12), + if (duration != null) + Padding( + padding: const EdgeInsets.all(2), + child: Container( + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(4), + ), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + child: Text( + duration, + style: const TextStyle(fontSize: 12), + ), ), ), - ), ], ); } diff --git a/lib/widget/search_result.dart b/lib/widget/search_result.dart index cabf1ad..d89022d 100644 --- a/lib/widget/search_result.dart +++ b/lib/widget/search_result.dart @@ -86,9 +86,7 @@ class _SearchResultState extends State { }; return ListItem( - autofocus: index == 0, - youtubeVideo: youtubeVideo, - child: listItem); + autofocus: index == 0, url: youtubeVideo.url, child: listItem); }, ), ); diff --git a/pubspec.lock b/pubspec.lock index 208efe2..fef2fad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -444,6 +444,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" platform: dependency: transitive description: @@ -516,6 +524,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.10" + rss_dart: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: d74949d30ef531d39d32d305cdddbb86586407bc + url: "https://github.com/brainwo/rss.dart.git" + source: git + version: "1.0.6" rxdart: dependency: transitive description: @@ -873,6 +890,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5329ed3..30b8ed8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ publish_to: "none" version: 0.0.2 environment: - sdk: ">=3.0.0 <4.0.0" + sdk: ">=3.2.0 <4.0.0" flutter: 3.19.6 dependencies: @@ -16,6 +16,8 @@ dependencies: flutter: sdk: flutter flutter_riverpod: ^2.3.6 + rss_dart: + git: https://github.com/brainwo/rss.dart.git shared_preferences: ^2.1.2 url_launcher: ^6.1.11 yaml_edit: ^2.2.0