Skip to content

Commit

Permalink
feat: add YouTube RSS feed subscription (#38)
Browse files Browse the repository at this point in the history
- Add rss_dart dependency
- Add Feed page
- Move configPathLookup to const.dart
- Video duration is now optional on ListItemVideo
- Error thumbnails now show an icon
- Add setting entry for feed list
- Add helper functions to read and write to feed.yaml
- !Feature regression: video history disabled due to support for videos from Feed, will be added back in #29
  • Loading branch information
brainwo authored Apr 29, 2024
1 parent fd0d423 commit 16249e4
Show file tree
Hide file tree
Showing 17 changed files with 389 additions and 105 deletions.
12 changes: 12 additions & 0 deletions lib/const.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,15 @@ const String defaultThemeName = 'Arc-Dark';
const String searchModeVideoShortcut = 'v ';
const String searchModeChannelShortcut = 'c ';
const String searchModePlaylistShortcut = 'p ';

const List<String> configPathLookup = [
'/yatta/config.yaml',
'/yatta/config.yml',
'/yatta/config.json',
];

const List<String> feedPathLookup = [
'/yatta/feed.yaml',
'/yatta/feed.yml',
'/yatta/feed.json',
];
12 changes: 10 additions & 2 deletions lib/helper/command_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,23 @@ List<String> _defaultData(final Object fromObject, final String command) {
icon: icon);
}

Future<void> playFromUrl(final String url) async {
Future<void> playFromUrl(
final String url, {
final PlayMode mode = PlayMode.play,
}) async {
final prefs = await SharedPreferences.getInstance();

await prefs.setStringList('history', [
...?prefs.getStringList('history'),
// 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;

Expand Down
31 changes: 31 additions & 0 deletions lib/helper/feed.dart
Original file line number Diff line number Diff line change
@@ -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<String> loadFeedList() async =>
await feedPathLookup
.map((final path) => File('${xdg.configHome.path}$path'))
.firstWhereOrNull((final feedLookup) => feedLookup.existsSync())
?.readAsString() ??
'';

Future<void> updateFeedList(
final String feedList, final List<String> 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);
}
2 changes: 2 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
},
Expand Down
7 changes: 1 addition & 6 deletions lib/model/config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> configPathLookup = [
'/yatta/config.yaml',
'/yatta/config.yml',
'/yatta/config.json',
];

abstract class YamlConfig {
YamlConfig({
final String? filePath,
Expand Down
132 changes: 132 additions & 0 deletions lib/page/feed.dart
Original file line number Diff line number Diff line change
@@ -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<FeedPage> createState() => _FeedPageState();
}

class _FeedPageState extends State<FeedPage> {
final FocusNode searchBarFocus = FocusNode();
List<AtomItem>? filteredList;
List<AtomItem>? feedList;
late final Map<Type, Action<Intent>> _actionMap = {
SearchBarFocusIntent: CallbackAction<Intent>(
onInvoke: (final _) => _requestSearchBarFocus(),
),
NavigationPopIntent: CallbackAction<Intent>(
onInvoke: (final _) => _navigationPop(context),
)
};

@override
void initState() {
super.initState();

WidgetsBinding.instance.addPostFrameCallback((final _) async {
await fetchFeed();
});
}

Future<void> fetchFeed() async {
final rawFeed = await loadFeedList()
.then((final rawFile) => switch (yaml.loadYaml(rawFile)) {
final List<dynamic> urls => urls
.map((final url) => http.get(Uri.parse(url.toString())))
.toList(),
_ => <Future<http.Response>>[]
})
.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,
);
},
),
),
),
);
}
}
2 changes: 1 addition & 1 deletion lib/page/history.dart
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ class _HistoryPageState extends State<HistoryPage> {

return ListItem(
autofocus: index == 0,
youtubeVideo: youtubeVideo,
url: youtubeVideo.url,
fromHistory: true,
child: listItem,
);
Expand Down
6 changes: 4 additions & 2 deletions lib/page/home.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion lib/page/playlist.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ class _PlaylistPageState extends State<PlaylistPage> {
return ListItem(
autofocus: index == 0,
fromHistory: true,
youtubeVideo: youtubeVideo,
url: youtubeVideo.url,
child: listItem);
},
);
Expand Down
53 changes: 49 additions & 4 deletions lib/page/settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,7 +20,10 @@ typedef _ButtonValue = void;
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});

static final Future<UserConfig> _loadUserConfig = UserConfig.load();
static final Future<List<dynamic>> _futureList = Future.wait([
UserConfig.load(),
loadFeedList(),
]);

static void _navigationPop(final BuildContext context) {
if (Navigator.of(context).canPop()) {
Expand All @@ -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<dynamic> urls =>
urls.map((final url) => url.toString()).toList(),
_ => ['']
};

return AutoscrollListView(
children: [
const Padding(
Expand Down Expand Up @@ -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(
Expand Down
Loading

0 comments on commit 16249e4

Please sign in to comment.