diff --git a/README.md b/README.md index 2ead781..cc2360a 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ If you want to build the app, you need to create a new file named `.env.dart` in You can get your own API key on [ListenNotes](https://www.listennotes.com/api/), remember that you need to get pro plan API, because basic plan dosen't provide rss link for serach result. If no api key is added, the search function in the app won't work. But you can still add podcasts by using an RSS link or importing an OMPL file. ``` dart -final environment = {"apiKey":"APIKEY"}; +final environment = {"apiKey":"APIKEY", "podcastIndexApiKey": "PODCASTINDEXAPIKEY", + "podcastIndexApiSecret": "PODCASTINDEXAPISECRET"}; ``` 4. Run the app with Android Studio or Visual Studio. Or the command line. @@ -112,21 +113,21 @@ If you have an issue or found a bug, please raise a GitHub issue. Pull requests ``` UI src -├──home +└──home ├──home.dart [Homepage] ├──searc_podcast.dart [Search Page] └──playlist.dart [Playlist Page] -├──podcasts +└──podcasts ├──podcast_manage.dart [Group Page] └──podcast_detail.dart [Podcast Page] -├──episodes +└──episodes └──episode_detail.dart [Episode Page] -├──settings +└──settings └──setting.dart [Setting Page] STATE src -├──state +└──state ├──audio_state.dart [Audio State] ├──download_state.dart [Episode Download] ├──podcast_group.dart [Podcast Groups] @@ -135,8 +136,9 @@ src Service src -├──service +└──service ├──api_service.dart [Podcast Search] + ├──gpodder_api.dart [Gpodder intergate] └──ompl_builde.dart [OMPL export] ``` diff --git a/lib/episodes/episode_detail.dart b/lib/episodes/episode_detail.dart index 5080f18..a79f483 100644 --- a/lib/episodes/episode_detail.dart +++ b/lib/episodes/episode_detail.dart @@ -487,13 +487,10 @@ class __MenuBarState extends State<_MenuBar> { }, ), DownloadButton(episode: widget.episodeItem), - Selector, bool>>( - selector: (_, audio) => - Tuple2(audio.queue.playlist, audio.queueUpdate), + Selector>( + selector: (_, audio) => audio.queue.episodes, builder: (_, data, __) { - final inPlaylist = - data.item1.contains(widget.episodeItem); + final inPlaylist = data.contains(widget.episodeItem); return inPlaylist ? _buttonOnMenu( child: Icon(Icons.playlist_add_check, diff --git a/lib/home/audioplayer.dart b/lib/home/audioplayer.dart index c84de7d..dea8be9 100644 --- a/lib/home/audioplayer.dart +++ b/lib/home/audioplayer.dart @@ -413,12 +413,10 @@ class _PlaylistWidgetState extends State { child: Column( children: [ Expanded( - child: - Selector, bool>>( - selector: (_, audio) => - Tuple2(audio.queue.playlist, audio.queueUpdate), + child: Selector>( + selector: (_, audio) => audio.playlist.episodes, builder: (_, data, __) { - var episodesToPlay = data.item1.sublist(1); + var episodesToPlay = data.sublist(1); return AnimatedList( key: miniPlaylistKey, shrinkWrap: true, @@ -436,7 +434,7 @@ class _PlaylistWidgetState extends State { color: Colors.transparent, child: InkWell( onTap: () { - audio.episodeLoad(data.item1[index]); + audio.episodeLoad(data[index]); miniPlaylistKey.currentState.removeItem( index, (context, animation) => Center()); @@ -506,8 +504,7 @@ class _PlaylistWidgetState extends State { duration: Duration(milliseconds: 100)); await Future.delayed( Duration(milliseconds: 100)); - await audio - .moveToTop(data.item1[index + 1]); + await audio.moveToTop(data[index + 1]); }, child: SizedBox( height: 30.0, diff --git a/lib/home/home.dart b/lib/home/home.dart index 782fdd5..e7c0f64 100644 --- a/lib/home/home.dart +++ b/lib/home/home.dart @@ -13,6 +13,7 @@ import 'package:tuple/tuple.dart'; import '../local_storage/key_value_storage.dart'; import '../local_storage/sqflite_localpodcast.dart'; +import '../playlists/playlist_home.dart'; import '../state/audio_state.dart'; import '../state/download_state.dart'; import '../state/podcast_group.dart'; @@ -190,7 +191,8 @@ class _HomeState extends State with SingleTickerProviderStateMixin { description: s.featureDiscoveryOMPLDes, child: Padding( - padding: const EdgeInsets.only(right: 5.0), + padding: const EdgeInsets.only( + right: 5.0), child: PopupMenu(), )), ], @@ -319,7 +321,7 @@ class __PlaylistButtonState extends State<_PlaylistButton> { bool _loadPlay; Future _getPlaylist() async { - await context.read().loadPlaylist(); + await context.read().initPlaylist(); if (mounted) { setState(() { _loadPlay = true; @@ -336,7 +338,6 @@ class __PlaylistButtonState extends State<_PlaylistButton> { @override Widget build(BuildContext context) { - final audio = context.watch(); final s = context.s; return Material( color: Colors.transparent, @@ -365,7 +366,7 @@ class __PlaylistButtonState extends State<_PlaylistButton> { ? SizedBox( height: 8.0, ) - : data.item1 || data.item2.playlist.length == 0 + : data.item1 || data.item2.episodes.isEmpty ? SizedBox( height: 8.0, ) @@ -374,7 +375,9 @@ class __PlaylistButtonState extends State<_PlaylistButton> { topLeft: Radius.circular(10.0), topRight: Radius.circular(10.0)), onTap: () { - audio.playlistLoad(); + context + .read() + .playFromLastPosition(); Navigator.pop(context); }, child: Column( @@ -388,7 +391,7 @@ class __PlaylistButtonState extends State<_PlaylistButton> { CircleAvatar( radius: 20, backgroundImage: data - .item2.playlist.first.avatarImage), + .item2.episodes.first.avatarImage), Container( height: 40.0, width: 40.0, @@ -416,7 +419,7 @@ class __PlaylistButtonState extends State<_PlaylistButton> { // TextStyle(color: Colors.white) ), Text( - data.item2.playlist.first.title, + data.item2.episodes.first.title, maxLines: 2, textAlign: TextAlign.center, overflow: TextOverflow.fade, @@ -476,9 +479,7 @@ class __PlaylistButtonState extends State<_PlaylistButton> { Navigator.push( context, MaterialPageRoute( - builder: (context) => PlaylistPage( - initPage: InitPage.playlist, - ), + builder: (context) => PlaylistHome(), ), ); } else if (value == 2) { diff --git a/lib/home/home_groups.dart b/lib/home/home_groups.dart index 27d66ef..bd1e55e 100644 --- a/lib/home/home_groups.dart +++ b/lib/home/home_groups.dart @@ -85,14 +85,12 @@ class _ScrollPodcastsState extends State Widget build(BuildContext context) { final width = MediaQuery.of(context).size.width; final s = context.s; - return Selector, bool, bool>>( - selector: (_, groupList) => - Tuple3(groupList.groups, groupList.created, groupList.isLoading), + return Selector, bool>>( + selector: (_, groupList) => Tuple2(groupList.groups, groupList.created), builder: (_, data, __) { var groups = data.item1; var import = data.item2; - var isLoading = data.item3; - return isLoading + return groups.isEmpty ? Container( height: (width - 20) / 3 + 140, ) @@ -246,7 +244,7 @@ class _ScrollPodcastsState extends State ), ) : DefaultTabController( - length: groups[_groupIndex].podcastList.length, + length: groups[_groupIndex].podcasts.length, child: Column( mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -696,7 +694,7 @@ class ShowEpisode extends StatelessWidget { Tuple2>>( selector: (_, audio) => Tuple2( audio?.episode, - audio.queue.playlist + audio.queue.episodes .map((e) => e.enclosureUrl) .toList(), ), diff --git a/lib/home/playlist.dart b/lib/home/playlist.dart index cd46b61..e40ec3f 100644 --- a/lib/home/playlist.dart +++ b/lib/home/playlist.dart @@ -77,11 +77,11 @@ class _PlaylistPageState extends State { ), body: SafeArea( child: Selector>( - selector: (_, audio) => Tuple4(audio.queue, audio.playerRunning, - audio.queueUpdate, audio.episode), + Tuple3>( + selector: (_, audio) => + Tuple3(audio.playlist, audio.playerRunning, audio.episode), builder: (_, data, __) { - var episodes = data.item1.playlist; + var episodes = data.item1.episodes; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -227,12 +227,12 @@ class _PlaylistPageState extends State { CircleAvatar( radius: 15, backgroundImage: - data.item4.avatarImage), + data.item3.avatarImage), Container( width: 150, alignment: Alignment.center, child: Text( - data.item4.title, + data.item3.title, maxLines: 1, overflow: TextOverflow.fade, textAlign: TextAlign.center, @@ -254,7 +254,7 @@ class _PlaylistPageState extends State { icon: Icon(Icons.play_circle_filled, size: 40, color: context.accentColor), onPressed: () { - audio.playlistLoad(); + //audio.playlistLoad(); // setState(() {}); }), ), @@ -293,7 +293,7 @@ class __ReorderablePlaylistState extends State<_ReorderablePlaylist> { return Selector>( selector: (_, audio) => Tuple2(audio.queue, audio.playerRunning), builder: (_, data, __) { - var episodes = data.item1.playlist; + var episodes = data.item1.episodes; return ReorderableListView( onReorder: (oldIndex, newIndex) { if (newIndex > oldIndex) { @@ -708,19 +708,12 @@ class __HistoryListState extends State<_HistoryList> { SizedBox( child: Selector< AudioPlayerNotifier, - Tuple2< - List, - bool>>( + List>( selector: (_, audio) => - Tuple2( - audio.queue - .playlist, - audio - .queueUpdate), + audio.queue.episodes, builder: (_, data, __) { - return data.item1 - .contains( - episode) + return data.contains( + episode) ? IconButton( icon: Icon( Icons diff --git a/lib/local_storage/key_value_storage.dart b/lib/local_storage/key_value_storage.dart index d87b0f4..951f81a 100644 --- a/lib/local_storage/key_value_storage.dart +++ b/lib/local_storage/key_value_storage.dart @@ -59,6 +59,7 @@ const String markListenedAfterSkipKey = 'markListenedAfterSkipKey'; const String downloadPositionKey = 'downloadPositionKey'; const String deleteAfterPlayedKey = 'removeAfterPlayedKey'; const String playlistsAllKey = 'playlistsAllKey'; +const String playerStateKey = 'playerStateKey'; class KeyValueStorage { final String key; @@ -92,13 +93,14 @@ class KeyValueStorage { var prefs = await SharedPreferences.getInstance(); if (prefs.getString(key) == null) { var episodeList = prefs.getStringList(playlistKey); - var playlist = Playlist('Playlist', episodeList: episodeList); + var playlist = Playlist('Queue', episodeList: episodeList); await prefs.setString( key, json.encode({ 'playlists': [playlist.toEntity().toJson()] })); } + print(prefs.getString(key)); return json .decode(prefs.getString(key))['playlists'] .cast>() @@ -115,6 +117,21 @@ class KeyValueStorage { })); } + Future savePlayerState( + String playlist, String episode, int position) async { + var prefs = await SharedPreferences.getInstance(); + return prefs.setStringList(key, [playlist, episode, position.toString()]); + } + + Future> getPlayerState() async { + var prefs = await SharedPreferences.getInstance(); + if (prefs.getStringList(key) == null) { + final position = prefs.getInt(audioPositionKey) ?? 0; + await savePlayerState('', '', position); + } + return prefs.getStringList(key); + } + Future saveInt(int setting) async { var prefs = await SharedPreferences.getInstance(); return prefs.setInt(key, setting); @@ -212,7 +229,7 @@ class KeyValueStorage { return prefs.setDouble(key, data); } - Future getDoubel({double defaultValue = 0.0}) async { + Future getDouble({double defaultValue = 0.0}) async { var prefs = await SharedPreferences.getInstance(); if (prefs.getDouble(key) == null) { await prefs.setDouble(key, defaultValue); diff --git a/lib/local_storage/sqflite_localpodcast.dart b/lib/local_storage/sqflite_localpodcast.dart index dee3ba5..adce819 100644 --- a/lib/local_storage/sqflite_localpodcast.dart +++ b/lib/local_storage/sqflite_localpodcast.dart @@ -109,7 +109,7 @@ class DBHelper { list.first['imagePath'], list.first['provider'], list.first['link'], - upateCount: list.first['update_count'], + updateCount: list.first['update_count'], episodeCount: list.first['episode_count'])); } } @@ -166,7 +166,7 @@ class DBHelper { list.first['imagePath'], list.first['provider'], list.first['link'], - upateCount: list.first['update_count'], + updateCount: list.first['update_count'], episodeCount: list.first['episode_count']); } return null; @@ -1376,7 +1376,7 @@ class DBHelper { await txn.rawUpdate( "UPDATE Episodes SET is_new = 0 WHERE enclosure_url = ?", [url]); }); - developer.log('remove new episode $url'); + developer.log('remove episode mark $url'); } Future> getLikedRssItem(int i, int sortBy, diff --git a/lib/playlists/playlist_home.dart b/lib/playlists/playlist_home.dart new file mode 100644 index 0000000..6677dc1 --- /dev/null +++ b/lib/playlists/playlist_home.dart @@ -0,0 +1,948 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:line_icons/line_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +import '../local_storage/sqflite_localpodcast.dart'; +import '../state/audio_state.dart'; +import '../type/episodebrief.dart'; +import '../type/play_histroy.dart'; +import '../type/playlist.dart'; +import '../util/extension_helper.dart'; +import '../widgets/custom_widget.dart'; +import '../widgets/dismissible_container.dart'; +import 'playlist_page.dart'; + +class PlaylistHome extends StatefulWidget { + PlaylistHome({Key key}) : super(key: key); + + @override + _PlaylistHomeState createState() => _PlaylistHomeState(); +} + +class _PlaylistHomeState extends State { + Widget _body; + String _selected; + + @override + void initState() { + super.initState(); + _selected = 'PlayNext'; + _body = _Queue(); + } + + Widget _tabWidget( + {Widget icon, + String label, + Function onTap, + bool isSelected, + Color color}) { + return OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: BorderSide(color: context.scaffoldBackgroundColor), + primary: color, + backgroundColor: + isSelected ? context.primaryColorDark : Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(100)))), + icon: icon, + label: isSelected ? Text(label) : Center(), + onPressed: onTap); + } + + @override + Widget build(BuildContext context) { + return AnnotatedRegion( + value: SystemUiOverlayStyle( + systemNavigationBarIconBrightness: + Theme.of(context).accentColorBrightness, + statusBarIconBrightness: Theme.of(context).accentColorBrightness, + systemNavigationBarColor: Theme.of(context).primaryColor, + ), + child: Scaffold( + appBar: AppBar( + leading: CustomBackButton(), + backgroundColor: context.scaffoldBackgroundColor, + ), + body: Column( + children: [ + SizedBox( + height: 100, + child: Selector>( + selector: (_, audio) => Tuple4(audio.playlist, + audio.playerRunning, audio.playing, audio.episode), + builder: (_, data, __) { + final running = data.item2; + final playing = data.item3; + final audio = context.read(); + return Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: Icon(Icons.fast_rewind), + onPressed: () { + if (running) { + audio.rewind(); + } + }), + SizedBox(width: 30), + IconButton( + padding: EdgeInsets.zero, + icon: Icon( + playing + ? LineIcons.pause_solid + : LineIcons.play_solid, + size: 40), + onPressed: () { + if (running) { + playing + ? audio.pauseAduio() + : audio.resumeAudio(); + } else { + context + .read() + .playFromLastPosition(); + } + }), + SizedBox(width: 30), + IconButton( + icon: Icon(Icons.fast_forward), + onPressed: () { + if (running) { + audio.fastForward(); + } + }) + ], + ), + data.item4 != null + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20.0), + child: Text(data.item4.title, maxLines: 1), + ) + : Center(), + ], + )), + data.item3 != null + ? ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 80, + height: 80, + child: + Image(image: data.item4.avatarImage)), + ) + : Container( + decoration: BoxDecoration( + color: context.accentColor.withAlpha(70), + borderRadius: BorderRadius.circular(10)), + width: 80, + height: 80), + SizedBox( + width: 20, + ), + ], + ); + }, + ), + ), + SizedBox( + height: 50, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _tabWidget( + icon: Icon(Icons.queue_music_rounded), + label: 'Play Next', + color: Colors.blue, + isSelected: _selected == 'PlayNext', + onTap: () => setState(() { + _body = _Queue(); + _selected = 'PlayNext'; + })), + _tabWidget( + icon: Icon(Icons.history), + label: 'History', + color: Colors.green, + isSelected: _selected == 'Histtory', + onTap: () => setState(() { + _body = _History(); + _selected = 'Histtory'; + })), + _tabWidget( + icon: Icon(Icons.playlist_play), + label: 'Playlists', + color: Colors.purple, + isSelected: _selected == 'Playlists', + onTap: () => setState(() { + _body = _Playlists(); + _selected = 'Playlists'; + })), + ], + ), + ), + Divider(height: 1), + Expanded( + child: AnimatedSwitcher( + duration: Duration(milliseconds: 300), child: _body)) + ], + )), + ); + } +} + +class _Queue extends StatefulWidget { + const _Queue({Key key}) : super(key: key); + + @override + __QueueState createState() => __QueueState(); +} + +class __QueueState extends State<_Queue> { + @override + Widget build(BuildContext context) { + final s = context.s; + return Selector>( + selector: (_, audio) => Tuple2(audio.playlist, audio.playerRunning), + builder: (_, data, __) { + var episodes = data.item1.episodes.toSet().toList(); + var queue = data.item1; + return queue.name == 'Queue' + ? ReorderableListView( + onReorder: (oldIndex, newIndex) { + if (newIndex > oldIndex) { + newIndex -= 1; + } + context + .read() + .reorderPlaylist(oldIndex, newIndex); + setState(() {}); + }, + scrollDirection: Axis.vertical, + children: data.item2 + ? episodes.map((episode) { + if (episode.enclosureUrl != + episodes.first.enclosureUrl) { + return DismissibleContainer( + episode: episode, + onRemove: (value) => setState(() {}), + key: ValueKey(episode.enclosureUrl), + ); + } else { + return Container( + key: ValueKey('sd'), + ); + } + }).toList() + : episodes + .map((episode) => DismissibleContainer( + episode: episode, + onRemove: (value) => setState(() {}), + key: ValueKey(episode.enclosureUrl), + )) + .toList()) + : ListView.builder( + itemCount: queue.episodeList.length, + itemBuilder: (context, index) { + final episode = queue.episodes[index]; + final c = episode.backgroudColor(context); + return SizedBox( + height: 90.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Expanded( + child: ListTile( + contentPadding: EdgeInsets.symmetric(vertical: 8), + onTap: () async { + await context + .read() + .episodeLoad(episode); + }, + title: Container( + padding: EdgeInsets.fromLTRB(0, 5.0, 20.0, 5.0), + child: Text( + episode.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + leading: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.unfold_more, color: c), + CircleAvatar( + backgroundColor: c.withOpacity(0.5), + backgroundImage: episode.avatarImage), + ], + ), + subtitle: Container( + padding: EdgeInsets.only(top: 5, bottom: 5), + height: 35, + child: Row( + children: [ + if (episode.explicit == 1) + Container( + decoration: BoxDecoration( + color: Colors.red[800], + shape: BoxShape.circle), + height: 25.0, + width: 25.0, + margin: EdgeInsets.only(right: 10.0), + alignment: Alignment.center, + child: Text('E', + style: TextStyle( + color: Colors.white))), + if (episode.duration != 0) + episodeTag( + episode.duration == 0 + ? '' + : s.minsCount( + episode.duration ~/ 60), + Colors.cyan[300]), + if (episode.enclosureLength != null) + episodeTag( + episode.enclosureLength == 0 + ? '' + : '${(episode.enclosureLength) ~/ 1000000}MB', + Colors.lightBlue[300]), + ], + ), + ), + //trailing: Icon(Icons.menu), + ), + ), + Divider( + height: 2, + ), + ], + ), + ); + }); + }); + } +} + +class _History extends StatefulWidget { + const _History({Key key}) : super(key: key); + + @override + __HistoryState createState() => __HistoryState(); +} + +class __HistoryState extends State<_History> { + var dbHelper = DBHelper(); + bool _loadMore = false; + Future _getData; + + Future> getPlayRecords(int top) async { + List playHistory; + playHistory = await dbHelper.getPlayRecords(top); + for (var record in playHistory) { + await record.getEpisode(); + } + return playHistory; + } + + Future _loadMoreData() async { + if (mounted) { + setState(() { + _loadMore = true; + }); + } + await Future.delayed(Duration(milliseconds: 500)); + _top = _top + 20; + if (mounted) { + setState(() { + _getData = getPlayRecords(_top); + _loadMore = false; + }); + } + } + + int _top; + @override + void initState() { + super.initState(); + _top = 20; + _getData = getPlayRecords(_top); + } + + @override + Widget build(BuildContext context) { + final s = context.s; + final audio = context.watch(); + return FutureBuilder>( + future: _getData, + builder: (context, snapshot) { + return snapshot.hasData + ? NotificationListener( + onNotification: (scrollInfo) { + if (scrollInfo.metrics.pixels == + scrollInfo.metrics.maxScrollExtent && + snapshot.data.length == _top) { + if (!_loadMore) { + _loadMoreData(); + } + } + return true; + }, + child: ListView.builder( + scrollDirection: Axis.vertical, + itemCount: snapshot.data.length + 1, + itemBuilder: (context, index) { + if (index == snapshot.data.length) { + return SizedBox( + height: 2, + child: _loadMore + ? LinearProgressIndicator() + : Center()); + } else { + final seekValue = snapshot.data[index].seekValue; + final seconds = snapshot.data[index].seconds; + final date = snapshot + .data[index].playdate.millisecondsSinceEpoch; + final episode = snapshot.data[index].episode; + final c = episode?.backgroudColor(context); + return episode == null + ? Center() + : SizedBox( + height: 90.0, + child: Column( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + Expanded( + child: Center( + child: ListTile( + contentPadding: EdgeInsets.fromLTRB( + 24, 8, 20, 8), + onTap: () => audio.episodeLoad( + episode, + startPosition: seekValue < 0.9 + ? (seconds * 1000).toInt() + : 0), + leading: CircleAvatar( + backgroundColor: + c?.withOpacity(0.5), + backgroundImage: + episode.avatarImage), + title: Padding( + padding: EdgeInsets.symmetric( + vertical: 5.0), + child: Text( + snapshot.data[index].title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + subtitle: SizedBox( + height: 40, + child: Row( + mainAxisAlignment: + MainAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + if (seekValue < 0.9) + Padding( + padding: + EdgeInsets.symmetric( + vertical: 5.0), + child: Material( + color: + Colors.transparent, + child: InkWell( + onTap: () { + audio.episodeLoad( + episode, + startPosition: + (seconds * + 1000) + .toInt()); + }, + borderRadius: + BorderRadius + .circular(20), + child: Stack( + children: [ + ShaderMask( + shaderCallback: + (bounds) { + return LinearGradient( + begin: Alignment + .centerLeft, + colors: < + Color>[ + Colors + .cyan[600] + .withOpacity(0.8), + Colors + .white70 + ], + stops: [ + seekValue, + seekValue + ], + tileMode: + TileMode + .mirror, + ).createShader( + bounds); + }, + child: + Container( + height: 25, + alignment: + Alignment + .center, + padding: EdgeInsets.symmetric( + horizontal: + 20), + decoration: + BoxDecoration( + borderRadius: + BorderRadius.circular( + 20.0), + color: context + .accentColor, + ), + child: Text( + seconds + .toTime, + style: TextStyle( + color: + Colors.white), + ), + ), + ), + ]), + ), + ), + ), + SizedBox( + child: Selector< + AudioPlayerNotifier, + List>( + selector: (_, audio) => + audio.queue.episodes, + builder: (_, data, __) { + return data.contains( + episode) + ? IconButton( + icon: Icon( + Icons + .playlist_add_check, + color: context + .accentColor), + onPressed: + () async { + audio.delFromPlaylist( + episode); + Fluttertoast + .showToast( + msg: s + .toastRemovePlaylist, + gravity: + ToastGravity + .BOTTOM, + ); + }) + : IconButton( + icon: Icon( + Icons + .playlist_add, + color: Colors + .grey[ + 700]), + onPressed: + () async { + audio.addToPlaylist( + episode); + Fluttertoast + .showToast( + msg: s + .toastAddPlaylist, + gravity: + ToastGravity + .BOTTOM, + ); + }); + }, + ), + ), + Spacer(), + Text( + date.toDate(context), + style: TextStyle( + fontSize: 15, + ), + ), + ], + ), + ), + ), + ), + ), + Divider(height: 1) + ], + ), + ); + } + }), + ) + : Center( + child: SizedBox( + height: 25, + width: 25, + child: CircularProgressIndicator()), + ); + }); + } +} + +class _Playlists extends StatefulWidget { + const _Playlists({Key key}) : super(key: key); + + @override + __PlaylistsState createState() => __PlaylistsState(); +} + +class __PlaylistsState extends State<_Playlists> { + Future _getEpisode(String url) async { + var dbHelper = DBHelper(); + return await dbHelper.getRssItemWithUrl(url); + } + + @override + Widget build(BuildContext context) { + final s = context.s; + return Selector>( + selector: (_, audio) => audio.playlists, + builder: (_, data, __) { + return ScrollConfiguration( + behavior: NoGrowBehavior(), + child: ListView.builder( + itemCount: data.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + final queue = data.first; + return InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => + PlaylistDetail(data[index])), + ); + }, + child: Padding( + padding: const EdgeInsets.all(15.0), + child: Row( + children: [ + Container( + height: 80, + width: 80, + color: context.primaryColorDark, + child: GridView.builder( + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + childAspectRatio: 1, + crossAxisCount: 2, + mainAxisSpacing: 0.0, + crossAxisSpacing: 0.0, + ), + itemCount: math.min(queue.episodes.length, 4), + itemBuilder: (_, index) { + if (index < queue.episodeList.length) { + return Image( + image: + queue.episodes[index].avatarImage, + ); + } + return Center(); + }), + ), + SizedBox(width: 15), + Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Queue', + style: context.textTheme.headline6, + ), + Text('${queue.episodes.length} episodes'), + OutlinedButton( + onPressed: () { + context + .read() + .playlistLoad(queue); + }, + child: Text('Play')) + ], + ) + ], + ), + ), + ); + } + if (index < data.length) { + final episodeList = data[index].episodeList; + return ListTile( + onTap: () async { + await context + .read() + .updatePlaylist(data[index], updateEpisodes: true); + Navigator.push( + context, + MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => + PlaylistDetail(data[index])), + ); + }, + leading: Container( + height: 50, + width: 50, + color: context.primaryColorDark, + child: episodeList.isEmpty + ? Center() + : FutureBuilder( + future: _getEpisode(episodeList.first), + builder: (_, snapshot) { + if (snapshot.data != null) { + return SizedBox( + height: 50, + width: 50, + child: Image( + image: snapshot.data.avatarImage)); + } + return Center(); + }), + ), + title: Text(data[index].name), + subtitle: Text(episodeList.isNotEmpty + ? s.episode(data[index].episodeList.length) + : '0 episode'), + trailing: IconButton( + splashRadius: 20, + icon: Icon(Icons.play_arrow), + onPressed: () { + context + .read() + .playlistLoad(data[index]); + }, + ), + ); + } + return ListTile( + onTap: () { + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context) + .modalBarrierDismissLabel, + barrierColor: Colors.black54, + transitionDuration: const Duration(milliseconds: 200), + pageBuilder: + (context, animaiton, secondaryAnimation) => + _NewPlaylist()); + }, + leading: Container( + height: 50, + width: 50, + color: context.primaryColorDark, + child: Center(child: Icon(Icons.add)), + ), + title: Text('Create new playlist'), + ); + }), + ); + }); + } +} + +enum NewPlaylistOption { blank, randon10, latest10 } + +class _NewPlaylist extends StatefulWidget { + _NewPlaylist({Key key}) : super(key: key); + + @override + __NewPlaylistState createState() => __NewPlaylistState(); +} + +class __NewPlaylistState extends State<_NewPlaylist> { + String _playlistName = ''; + NewPlaylistOption _option; + int _error; + + @override + void initState() { + super.initState(); + _option = NewPlaylistOption.blank; + } + + Widget _createOption(NewPlaylistOption option) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + setState(() => _option = option); + }, + child: AnimatedContainer( + duration: Duration(milliseconds: 300), + padding: EdgeInsets.symmetric(horizontal: 8.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: _option == option + ? context.accentColor + : context.primaryColorDark), + height: 32, + child: Center( + child: Text(_optionLabel(option).first, + style: TextStyle( + color: _option == option + ? Colors.white + : context.textColor))), + ), + ), + ); + } + + List _optionLabel(NewPlaylistOption option) { + switch (option) { + case NewPlaylistOption.blank: + return ['Empty', 'Add episodes later']; + break; + case NewPlaylistOption.randon10: + return ['Randon 10', 'Add 10 random episodes to playlists']; + break; + case NewPlaylistOption.latest10: + return ['Latest 10', 'Add 10 latest updated episodes to playlist']; + break; + default: + return ['', '']; + break; + } + } + + @override + Widget build(BuildContext context) { + final s = context.s; + return AnnotatedRegion( + value: SystemUiOverlayStyle( + statusBarIconBrightness: Brightness.light, + systemNavigationBarColor: + Theme.of(context).brightness == Brightness.light + ? Color.fromRGBO(113, 113, 113, 1) + : Color.fromRGBO(5, 5, 5, 1), + ), + child: AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + elevation: 1, + contentPadding: EdgeInsets.symmetric(horizontal: 20), + titlePadding: EdgeInsets.all(20), + actionsPadding: EdgeInsets.zero, + actions: [ + FlatButton( + splashColor: context.accentColor.withAlpha(70), + onPressed: () => Navigator.of(context).pop(), + child: Text( + s.cancel, + style: TextStyle(color: Colors.grey[600]), + ), + ), + FlatButton( + splashColor: context.accentColor.withAlpha(70), + onPressed: () async { + if (context + .read() + .playlistExisted(_playlistName)) { + setState(() => _error = 1); + } else { + var playlist; + switch (_option) { + case NewPlaylistOption.blank: + playlist = Playlist( + _playlistName, + ); + break; + case NewPlaylistOption.latest10: + case NewPlaylistOption.randon10: + break; + } + context.read().addPlaylist(playlist); + Navigator.of(context).pop(); + } + }, + child: + Text(s.confirm, style: TextStyle(color: context.accentColor)), + ) + ], + title: + SizedBox(width: context.width - 160, child: Text('New playlist')), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: InputDecoration( + contentPadding: EdgeInsets.symmetric(horizontal: 10), + hintText: 'New playlist', + hintStyle: TextStyle(fontSize: 18), + filled: true, + focusedBorder: UnderlineInputBorder( + borderSide: + BorderSide(color: context.accentColor, width: 2.0), + ), + enabledBorder: UnderlineInputBorder( + borderSide: + BorderSide(color: context.accentColor, width: 2.0), + ), + ), + cursorRadius: Radius.circular(2), + autofocus: true, + maxLines: 1, + onChanged: (value) { + _playlistName = value; + }, + ), + Container( + alignment: Alignment.centerLeft, + child: (_error == 1) + ? Text( + 'Playlist existed', + style: TextStyle(color: Colors.red[400]), + ) + : Center(), + ), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + _createOption(NewPlaylistOption.blank), + _createOption(NewPlaylistOption.randon10), + _createOption(NewPlaylistOption.latest10), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/playlists/playlist_page.dart b/lib/playlists/playlist_page.dart new file mode 100644 index 0000000..818044d --- /dev/null +++ b/lib/playlists/playlist_page.dart @@ -0,0 +1,255 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/audio_state.dart'; +import '../type/episodebrief.dart'; +import '../type/playlist.dart'; +import '../util/extension_helper.dart'; + +class PlaylistDetail extends StatefulWidget { + final Playlist playlist; + PlaylistDetail(this.playlist, {Key key}) : super(key: key); + + @override + _PlaylistDetailState createState() => _PlaylistDetailState(); +} + +class _PlaylistDetailState extends State { + final List _selectedEpisodes = []; + + @override + Widget build(BuildContext context) { + final s = context.s; + return Scaffold( + appBar: AppBar( + title: Text(_selectedEpisodes.isEmpty + ? widget.playlist.name + : '${_selectedEpisodes.length} selected'), + actions: [ + IconButton( + icon: Icon(Icons.delete), + onPressed: () { + context.read().removeEpisodeFromPlaylist( + widget.playlist, + episodes: _selectedEpisodes); + setState(_selectedEpisodes.clear); + }), + SizedBox( + height: 40, + width: 40, + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(100), + clipBehavior: Clip.hardEdge, + child: SizedBox( + height: 40, + width: 40, + child: PopupMenuButton( + icon: Icon(Icons.more_vert), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 1, + tooltip: s.menu, + itemBuilder: (context) => [ + PopupMenuItem(value: 1, child: Text('Clear all')), + ], + onSelected: (value) { + if (value == 1) { + context + .read() + .clearPlaylist(widget.playlist); + } + }), + ), + ), + ) + ], + ), + body: Selector>( + selector: (_, audio) => audio.playlists, + builder: (_, data, __) { + final playlist = data.firstWhere((e) => e == widget.playlist); + final episodes = playlist.episodes; + return ReorderableListView( + onReorder: (oldIndex, newIndex) { + context.read().reorderEpisodesInPlaylist( + widget.playlist, + oldIndex: oldIndex, + newIndex: newIndex); + setState(() {}); + }, + scrollDirection: Axis.vertical, + children: episodes.map((episode) { + return _PlaylistItem(episode, + key: ValueKey(episode.enclosureUrl), onSelect: (episode) { + _selectedEpisodes.add(episode); + setState(() {}); + }, onRemove: (episode) { + _selectedEpisodes.remove(episode); + setState(() {}); + }); + }).toList()); + }), + ); + } +} + +class _PlaylistItem extends StatefulWidget { + final EpisodeBrief episode; + final ValueChanged onSelect; + final ValueChanged onRemove; + _PlaylistItem(this.episode, + {@required this.onSelect, @required this.onRemove, Key key}) + : super(key: key); + + @override + __PlaylistItemState createState() => __PlaylistItemState(); +} + +class __PlaylistItemState extends State<_PlaylistItem> + with SingleTickerProviderStateMixin { + AnimationController _controller; + Animation _animation; + double _fraction; + + @override + void initState() { + super.initState(); + _fraction = 0; + _controller = AnimationController( + vsync: this, duration: const Duration(milliseconds: 500)); + _animation = Tween(begin: 0.0, end: 1.0).animate(_controller) + ..addListener(() { + if (mounted) { + setState(() => _fraction = _animation.value); + } + }); + _controller.addStatusListener((status) { + if (status == AnimationStatus.completed) { + _controller.stop(); + } else if (status == AnimationStatus.dismissed) { + _controller.stop(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Widget _episodeTag(String text, Color color) { + if (text == '') { + return Center(); + } + return Container( + decoration: BoxDecoration( + color: color, borderRadius: BorderRadius.circular(15.0)), + height: 25.0, + margin: EdgeInsets.only(right: 10.0), + padding: EdgeInsets.symmetric(horizontal: 8.0), + alignment: Alignment.center, + child: Text(text, style: TextStyle(fontSize: 14.0, color: Colors.black)), + ); + } + + @override + Widget build(BuildContext context) { + final s = context.s; + final episode = widget.episode; + final c = episode.backgroudColor(context); + return SizedBox( + height: 90.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Expanded( + child: ListTile( + contentPadding: EdgeInsets.symmetric(vertical: 8), + onTap: () { + if (_fraction == 0) { + _controller.forward(); + widget.onSelect(episode); + } else { + _controller.reverse(); + widget.onRemove(episode); + } + }, + title: Container( + padding: EdgeInsets.fromLTRB(0, 5.0, 20.0, 5.0), + child: Text( + episode.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + leading: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.unfold_more, color: c), + Transform( + alignment: FractionalOffset.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateY(math.pi * _fraction), + child: _fraction < 0.5 + ? CircleAvatar( + backgroundColor: c.withOpacity(0.5), + backgroundImage: episode.avatarImage) + : CircleAvatar( + backgroundColor: + context.accentColor.withAlpha(70), + child: Transform( + alignment: FractionalOffset.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateY(math.pi), + child: Icon(Icons.done)), + ), + ), + ], + ), + subtitle: Container( + padding: EdgeInsets.only(top: 5, bottom: 5), + height: 35, + child: Row( + children: [ + if (episode.explicit == 1) + Container( + decoration: BoxDecoration( + color: Colors.red[800], shape: BoxShape.circle), + height: 25.0, + width: 25.0, + margin: EdgeInsets.only(right: 10.0), + alignment: Alignment.center, + child: Text('E', + style: TextStyle(color: Colors.white))), + if (episode.duration != 0) + _episodeTag( + episode.duration == 0 + ? '' + : s.minsCount(episode.duration ~/ 60), + Colors.cyan[300]), + if (episode.enclosureLength != null) + _episodeTag( + episode.enclosureLength == 0 + ? '' + : '${(episode.enclosureLength) ~/ 1000000}MB', + Colors.lightBlue[300]), + ], + ), + ), + ), + ), + Divider( + height: 2, + ), + ], + )); + } +} diff --git a/lib/podcasts/podcast_group.dart b/lib/podcasts/podcast_group.dart index 1cd721f..d4d5392 100644 --- a/lib/podcasts/podcast_group.dart +++ b/lib/podcasts/podcast_group.dart @@ -23,38 +23,47 @@ class PodcastGroupList extends StatefulWidget { } class _PodcastGroupListState extends State { + PodcastGroup _group; + @override + void initState() { + super.initState(); + _group = widget.group; + } + + @override + void didUpdateWidget(PodcastGroupList oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.group != widget.group) setState(() => _group = widget.group); + } + @override Widget build(BuildContext context) { - var groupList = Provider.of(context, listen: false); - return widget.group.podcastList.length == 0 + return _group.podcastList.isEmpty ? Container( - color: Theme.of(context).primaryColor, + color: context.primaryColor, ) : Container( - color: Theme.of(context).primaryColor, + color: context.primaryColor, child: ReorderableListView( onReorder: (oldIndex, newIndex) { setState(() { - if (newIndex > oldIndex) { - newIndex -= 1; - } - final podcast = widget.group.podcasts.removeAt(oldIndex); - widget.group.podcasts.insert(newIndex, podcast); + _group.reorderGroup(oldIndex, newIndex); }); - widget.group.orderedPodcasts = widget.group.podcasts; - groupList.addToOrderChanged(widget.group); + context.read().addToOrderChanged(_group); }, - children: widget.group.podcasts.map((podcastLocal) { - return Container( - decoration: - BoxDecoration(color: Theme.of(context).primaryColor), - key: ObjectKey(podcastLocal.title), - child: _PodcastCard( - podcastLocal: podcastLocal, - group: widget.group, - ), - ); - }).toList(), + children: _group.podcasts.map( + (podcastLocal) { + return Container( + decoration: + BoxDecoration(color: Theme.of(context).primaryColor), + key: ObjectKey(podcastLocal.title), + child: _PodcastCard( + podcastLocal: podcastLocal, + group: _group, + ), + ); + }, + ).toList(), ), ); } @@ -310,7 +319,7 @@ class __PodcastCardState extends State<_PodcastCard> _addGroup = false; }); await groupList.changeGroup( - widget.podcastLocal.id, + widget.podcastLocal, _selectedGroups, ); Fluttertoast.showToast( @@ -530,8 +539,8 @@ class _RenameGroupState extends State { borderRadius: BorderRadius.all(Radius.circular(10))), elevation: 1, contentPadding: EdgeInsets.symmetric(horizontal: 20), - titlePadding: EdgeInsets.only(top: 20, left: 20, right: 20, bottom: 20), - actionsPadding: EdgeInsets.all(0), + titlePadding: EdgeInsets.all(20), + actionsPadding: EdgeInsets.zero, actions: [ FlatButton( splashColor: context.accentColor.withAlpha(70), diff --git a/lib/podcasts/podcast_manage.dart b/lib/podcasts/podcast_manage.dart index 18a48a3..f8b44c4 100644 --- a/lib/podcasts/podcast_manage.dart +++ b/lib/podcasts/podcast_manage.dart @@ -195,15 +195,14 @@ class _PodcastManageState extends State ), body: WillPopScope( onWillPop: () async { - await Provider.of(context, listen: false) - .clearOrderChanged(); + await context.read().clearOrderChanged(); return true; }, child: Consumer( builder: (_, groupList, __) { - var _isLoading = groupList.isLoading; + // var _isLoading = groupList.isLoading; var _groups = groupList.groups; - return _isLoading + return _groups.isEmpty ? Center() : Stack( children: [ diff --git a/lib/state/audio_state.dart b/lib/state/audio_state.dart index 783e0bb..2eee5b5 100644 --- a/lib/state/audio_state.dart +++ b/lib/state/audio_state.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math' as math; import 'package:audio_service/audio_service.dart'; @@ -57,7 +58,6 @@ void _audioPlayerTaskEntrypoint() async { /// Sleep timer mode. enum SleepTimerMode { endOfEpisode, timer, undefined } enum PlayerHeight { short, mid, tall } -//enum ShareStatus { generate, download, complete, undefined, error } class AudioPlayerNotifier extends ChangeNotifier { final _dbHelper = DBHelper(); @@ -77,15 +77,20 @@ class AudioPlayerNotifier extends ChangeNotifier { final _volumeGainStorage = KeyValueStorage(volumeGainKey); final _markListenedAfterSkipStorage = KeyValueStorage(markListenedAfterSkipKey); + final _playlistsStorgae = KeyValueStorage(playlistsAllKey); + final _playerStateStorage = KeyValueStorage(playerStateKey); - /// Current playing episdoe. + /// Playing episdoe. EpisodeBrief _episode; - /// Current playlist. - Playlist _queue = Playlist('Playlist'); + /// Playlists include queue and playlists created by user. + List _playlists; - /// Notifier for playlist change. - bool _queueUpdate = false; + /// Playing playlist. + Playlist _playlist; + + /// Queue is the first playlist by default. + Playlist get _queue => _playlists.first; /// Player state. AudioProcessingState _audioState = AudioProcessingState.none; @@ -170,6 +175,20 @@ class AudioPlayerNotifier extends ChangeNotifier { bool _markListened; + @override + void addListener(VoidCallback listener) { + super.addListener(listener); + _initAudioData(); + AudioService.connect(); + } + + @override + void dispose() async { + await AudioService.disconnect(); + super.dispose(); + } + + /// Audio playing state. AudioProcessingState get audioState => _audioState; int get backgroundAudioDuration => _backgroundAudioDuration; int get backgroundAudioPosition => _backgroundAudioPosition; @@ -177,11 +196,16 @@ class AudioPlayerNotifier extends ChangeNotifier { String get remoteErrorMessage => _remoteErrorMessage; bool get playerRunning => _playerRunning; bool get buffering => _audioState != AudioProcessingState.ready; - int get lastPositin => _lastPostion; - Playlist get queue => _queue; - bool get playing => _playing; - bool get queueUpdate => _queueUpdate; EpisodeBrief get episode => _episode; + + /// Playlist provider. + int get lastPositin => _lastPostion; + List get playlists => _playlists; + Playlist get queue => _playlists.first; + bool get playing => _playing; + Playlist get playlist => _playlist; + + /// Player control. bool get stopOnComplete => _stopOnComplete; bool get startSleepTimer => _startSleepTimer; SleepTimerMode get sleepTimerMode => _sleepTimerMode; @@ -213,19 +237,10 @@ class AudioPlayerNotifier extends ChangeNotifier { _savePlayerHeight(); } - set setVolumeGain(int volumeGain) { - _volumeGain = volumeGain; - if (_playerRunning && _boostVolume) { - setBoostVolume(boostVolume: _boostVolume, gain: _volumeGain); - } - notifyListeners(); - _volumeGainStorage.saveInt(volumeGain); - } - Future _initAudioData() async { var index = await _playerHeightStorage.getInt(defaultValue: 0); _playerHeight = PlayerHeight.values[index]; - _currentSpeed = await _speedStorage.getDoubel(defaultValue: 1.0); + _currentSpeed = await _speedStorage.getDouble(defaultValue: 1.0); _skipSilence = await _skipSilenceStorage.getBool(defaultValue: false); _boostVolume = await _boostVolumeStorage.getBool(defaultValue: false); _volumeGain = await _volumeGainStorage.getInt(defaultValue: 3000); @@ -245,45 +260,73 @@ class AudioPlayerNotifier extends ChangeNotifier { _autoSleepTimer = i == 1; } - set setSleepTimerMode(SleepTimerMode timer) { - _sleepTimerMode = timer; - notifyListeners(); - } - - @override - void addListener(VoidCallback listener) { - super.addListener(listener); - _initAudioData(); - AudioService.connect(); - } - - Future loadPlaylist() async { - await _queue.getPlaylist(); + Future initPlaylist() async { + var playlistEntities = await _playlistsStorgae.getPlaylists(); + _playlists = [ + for (var entity in playlistEntities) Playlist.fromEntity(entity) + ]; + await _playlists.first.getPlaylist(); await _getAutoPlay(); - _lastPostion = await _positionStorage.getInt(); - if (_lastPostion > 0 && _queue.playlist.length > 0) { - final episode = _queue.playlist.first; - final duration = episode.duration * 1000; - final seekValue = duration != 0 ? _lastPostion / duration : 1.0; - final history = PlayHistory( - episode.title, episode.enclosureUrl, _lastPostion ~/ 1000, seekValue); - await _dbHelper.saveHistory(history); + + var state = await _playerStateStorage.getPlayerState(); + if (state[0] != '') { + _playlist = _playlists.firstWhere((p) => p.id == state[0], + orElse: () => _playlists.first); + } else { + _playlist = _playlists.first; } - var lastWorkStorage = KeyValueStorage(lastWorkKey); - await lastWorkStorage.saveInt(0); + await _playlist.getPlaylist(); + if (state[1] != '') { + _episode = await _dbHelper.getRssItemWithUrl(state[1]); + } else { + _episode = _queue.episodes.isNotEmpty ? _queue.episodes.first : null; + } + _lastPostion = int.parse(state[2] ?? '0'); + + /// Save plays history if app is closed accidentally. + /// if (_lastPostion > 0 && _queue.episodes.isNotEmpty) { + /// final episode = _queue.episodes.first; + /// final duration = episode.duration * 1000; + /// final seekValue = duration != 0 ? _lastPostion / duration : 1.0; + /// final history = PlayHistory( + /// episode.title, episode.enclosureUrl, _lastPostion ~/ 1000, seekValue); + /// await _dbHelper.saveHistory(history); + /// } + await KeyValueStorage(lastWorkKey).saveInt(0); } - Future playlistLoad() async { - await _queue.getPlaylist(); - _backgroundAudioDuration = 0; - _backgroundAudioPosition = 0; - _seekSliderValue = 0; - _episode = _queue.playlist.first; - _queueUpdate = !_queueUpdate; + Future playFromLastPosition() async { _audioState = AudioProcessingState.none; _playerRunning = true; notifyListeners(); - _startAudioService(_lastPostion ?? 0, _queue.playlist.first.enclosureUrl); + _startAudioService(_playlist, + position: _lastPostion ?? 0, + index: _playlist.episodes.indexOf(_episode)); + } + + Future playlistLoad(Playlist playlist) async { + var p = playlist; + if(playlist.name != 'Queue') { + await updatePlaylist(p, updateEpisodes: true); + } + _playlist = p; + notifyListeners(); + if (playlist.episodeList.isNotEmpty) { + if (playerRunning) { + AudioService.customAction('setIsQueue', playlist.name == 'Queue'); + AudioService.customAction('changeQueue', + [for (var e in p.episodes) jsonEncode(e.toMediaItem().toJson())]); + } else { + _backgroundAudioDuration = 0; + _backgroundAudioPosition = 0; + _seekSliderValue = 0; + _episode = playlist.episodes.first; + _audioState = AudioProcessingState.none; + _playerRunning = true; + notifyListeners(); + _startAudioService(_playlist, position: _lastPostion ?? 0, index: 0); + } + } } Future episodeLoad(EpisodeBrief episode, @@ -298,13 +341,10 @@ class AudioPlayerNotifier extends ChangeNotifier { if (startPosition > 0) { await AudioService.seekTo(Duration(milliseconds: startPosition)); } - _queue.playlist.removeAt(0); - _queue.playlist.removeWhere((item) => item == episode); - _queue.playlist.insert(0, episodeNew); - _queueUpdate != _queueUpdate; + _queue.addToPlayListAt(episodeNew, 0); + await updatePlaylist(_queue); _remoteErrorMessage = null; notifyListeners(); - await _queue.savePlaylist(); if (episodeNew.isNew == 1) { await _dbHelper.removeEpisodeNewMark(episodeNew.enclosureUrl); } @@ -317,11 +357,12 @@ class AudioPlayerNotifier extends ChangeNotifier { _episode = episodeNew; _playerRunning = true; notifyListeners(); - _startAudioService(startPosition, episodeNew.enclosureUrl); + _startAudioService(_queue, position: startPosition); } } - Future _startAudioService(int position, String url) async { + Future _startAudioService(playlist, + {int index = 0, int position = 0}) async { _stopOnComplete = false; _sleepTimerMode = SleepTimerMode.undefined; _switchValue = 0; @@ -343,6 +384,7 @@ class AudioPlayerNotifier extends ChangeNotifier { /// Start audio service. await AudioService.start( backgroundTaskEntrypoint: _audioPlayerTaskEntrypoint, + params: {'index': index, 'isQueue': playlist.name == 'Queue'}, androidNotificationChannelName: 'Tsacdop', androidNotificationColor: 0xFF4d91be, androidNotificationIcon: 'drawable/ic_notification', @@ -354,11 +396,11 @@ class AudioPlayerNotifier extends ChangeNotifier { //Check autoplay setting, if true only add one episode, else add playlist. await _getAutoPlay(); if (_autoPlay) { - for (var episode in _queue.playlist) { + for (var episode in playlist.episodes) { await AudioService.addQueueItem(episode.toMediaItem()); } } else { - await AudioService.addQueueItem(_queue.playlist.first.toMediaItem()); + await AudioService.addQueueItem(playlist.episodes[index].toMediaItem()); } //Check auto sleep timer setting await _getAutoSleepTimer(); @@ -408,7 +450,7 @@ class AudioPlayerNotifier extends ChangeNotifier { _backgroundAudioDuration = item.duration.inMilliseconds ?? 0; if (position > 0 && _backgroundAudioDuration > 0 && - _episode.enclosureUrl == url) { + _episode.enclosureUrl == _playlist.episodeList[index]) { await AudioService.seekTo(Duration(milliseconds: position)); position = 0; } @@ -445,13 +487,17 @@ class AudioPlayerNotifier extends ChangeNotifier { }); AudioService.customEventStream.distinct().listen((event) async { - if (event is String && - _queue.playlist.isNotEmpty && - _queue.playlist.first.title == event) { - _queue.delFromPlaylist(_episode); + if (event is String) { + if (_playlist.name == 'Queue' && + _queue.episodes.isNotEmpty && + _queue.episodes.first.title == event) { + _queue.delFromPlaylist(_episode); + } _lastPostion = 0; notifyListeners(); - await _positionStorage.saveInt(_lastPostion); + // await _positionStorage.saveInt(_lastPostion); + await _playerStateStorage.savePlayerState( + _playlist.id, _episode.enclosureUrl, _lastPostion); var history; if (_markListened) { history = PlayHistory(_episode.title, _episode.enclosureUrl, @@ -501,7 +547,9 @@ class AudioPlayerNotifier extends ChangeNotifier { if (_backgroundAudioPosition > 0 && _backgroundAudioPosition < _backgroundAudioDuration) { _lastPostion = _backgroundAudioPosition; - _positionStorage.saveInt(_lastPostion); + // _positionStorage.saveInt(_lastPostion); + _playerStateStorage.savePlayerState( + _playlist.id, _episode.enclosureUrl, _lastPostion); } notifyListeners(); } @@ -511,33 +559,31 @@ class AudioPlayerNotifier extends ChangeNotifier { }); } - Future playNext() async { - _remoteErrorMessage = null; - await AudioService.skipToNext(); - _queueUpdate = !_queueUpdate; - notifyListeners(); - } - + /// Playlists management. Future addToPlaylist(EpisodeBrief episode) async { var episodeNew = await _dbHelper.getRssItemWithUrl(episode.enclosureUrl); - if (!_queue.playlist.contains(episodeNew)) { - if (playerRunning) { + if (episodeNew.isNew == 1) { + await _dbHelper.removeEpisodeNewMark(episodeNew.enclosureUrl); + } + if (!_queue.episodes.contains(episodeNew)) { + if (playerRunning && _playlist.name == 'Queue') { await AudioService.addQueueItem(episodeNew.toMediaItem()); } - await _queue.addToPlayList(episodeNew); - _queueUpdate = !_queueUpdate; - notifyListeners(); + _queue.addToPlayList(episodeNew); + await updatePlaylist(_queue, updateEpisodes: false); } } Future addToPlaylistAt(EpisodeBrief episode, int index) async { var episodeNew = await _dbHelper.getRssItemWithUrl(episode.enclosureUrl); - if (playerRunning) { + if (episodeNew.isNew == 1) { + await _dbHelper.removeEpisodeNewMark(episodeNew.enclosureUrl); + } + if (playerRunning && _playlist.name == 'Queue') { await AudioService.addQueueItemAt(episodeNew.toMediaItem(), index); } - await _queue.addToPlayListAt(episodeNew, index); - _queueUpdate = !_queueUpdate; - notifyListeners(); + _queue.addToPlayListAt(episodeNew, index); + await updatePlaylist(_queue, updateEpisodes: false); } Future addNewEpisode(List group) async { @@ -560,40 +606,34 @@ class AudioPlayerNotifier extends ChangeNotifier { } Future updateMediaItem(EpisodeBrief episode) async { - if (episode.enclosureUrl == episode.mediaId) { - var index = _queue.playlist - .indexWhere((item) => item.enclosureUrl == episode.enclosureUrl); - if (index > 0) { - var episodeNew = - await _dbHelper.getRssItemWithUrl(episode.enclosureUrl); - await delFromPlaylist(episode); - await addToPlaylistAt(episodeNew, index); - } + if (episode.enclosureUrl == episode.mediaId && _episode != episode) { + var episodeNew = await _dbHelper.getRssItemWithUrl(episode.enclosureUrl); + _playlist.updateEpisode(episodeNew); } } Future delFromPlaylist(EpisodeBrief episode) async { var episodeNew = await _dbHelper.getRssItemWithUrl(episode.enclosureUrl); - if (playerRunning) { + if (playerRunning && _playlist.name == 'Queue') { await AudioService.removeQueueItem(episodeNew.toMediaItem()); } - var index = await _queue.delFromPlaylist(episodeNew); + var index = _queue.delFromPlaylist(episodeNew); if (index == 0) { _lastPostion = 0; await _positionStorage.saveInt(0); } - _queueUpdate = !_queueUpdate; - notifyListeners(); + updatePlaylist(_queue, updateEpisodes: false); return index; } Future reorderPlaylist(int oldIndex, int newIndex) async { - var episode = _queue.playlist[oldIndex]; - if (playerRunning) { + var episode = _queue.episodes[oldIndex]; + if (playerRunning && _playlist.name == 'Queue') { await AudioService.removeQueueItem(episode.toMediaItem()); await AudioService.addQueueItemAt(episode.toMediaItem(), newIndex); } - await _queue.addToPlayListAt(episode, newIndex); + _queue.addToPlayListAt(episode, newIndex); + updatePlaylist(_queue, updateEpisodes: false); if (newIndex == 0) { _lastPostion = 0; await _positionStorage.saveInt(0); @@ -602,21 +642,102 @@ class AudioPlayerNotifier extends ChangeNotifier { Future moveToTop(EpisodeBrief episode) async { await delFromPlaylist(episode); - if (playerRunning) { + if (playerRunning && _playlist.name == 'Queue') { final episodeNew = await _dbHelper.getRssItemWithUrl(episode.enclosureUrl); await AudioService.addQueueItemAt(episodeNew.toMediaItem(), 1); - await _queue.addToPlayListAt(episode, 1, existed: false); + _queue.addToPlayListAt(episode, 1, existed: false); } else { - await _queue.addToPlayListAt(episode, 0, existed: false); + _queue.addToPlayListAt(episode, 0, existed: false); _lastPostion = 0; _positionStorage.saveInt(_lastPostion); } - _queueUpdate = !_queueUpdate; - notifyListeners(); + updatePlaylist(_queue, updateEpisodes: false); return true; } + void addPlaylist(Playlist playlist) { + _playlists = [..._playlists, playlist]; + notifyListeners(); + _savePlaylists(); + } + + void deletePlaylist(Playlist playlist) { + _playlists = [ + for (var p in _playlists) + if (p != playlist) p + ]; + notifyListeners(); + _savePlaylists(); + } + + void addEpisodesToPlaylist(Playlist playlist, {List episodes}) { + for (var e in episodes) { + playlist.addToPlayList(e); + if (playerRunning && playlist == _playlist) { + AudioService.addQueueItem(e.toMediaItem()); + } + } + updatePlaylist(playlist, updateEpisodes: false); + } + + void removeEpisodeFromPlaylist(Playlist playlist, + {List episodes}) { + for (var e in episodes) { + playlist.delFromPlaylist(e); + if (playerRunning && playlist == _playlist) { + AudioService.removeQueueItem(e.toMediaItem()); + } + } + updatePlaylist(playlist, updateEpisodes: false); + } + + void reorderEpisodesInPlaylist(Playlist playlist, + {int oldIndex, int newIndex}) async { + playlist.reorderPlaylist(oldIndex, newIndex); + + if (playerRunning && playlist == _playlist) { + if (newIndex > oldIndex) { + newIndex -= 1; + } + await AudioService.removeQueueItem(episode.toMediaItem()); + await AudioService.addQueueItemAt(episode.toMediaItem(), newIndex); + } + updatePlaylist(playlist, updateEpisodes: false); + } + + void clearPlaylist(Playlist playlist) { + playlist.clear(); + updatePlaylist(playlist, updateEpisodes: false); + } + + Future updatePlaylist(Playlist playlist, + {bool updateEpisodes = true}) async { + if (updateEpisodes) await playlist.getPlaylist(); + _playlists = [for (var p in _playlists) p.id == playlist.id ? playlist : p]; + notifyListeners(); + _savePlaylists(); + } + + bool playlistExisted(String name) { + for (var p in _playlists) { + if (p.name == name) return true; + } + return false; + } + + void _updateAllPlaylists() { + _playlists = [..._playlists]; + notifyListeners(); + _savePlaylists(); + } + + Future _savePlaylists() async { + await _playlistsStorgae + .savePlaylists([for (var p in _playlists) p.toEntity()]); + } + + /// Audio control. Future pauseAduio() async { await AudioService.pause(); } @@ -630,6 +751,12 @@ class AudioPlayerNotifier extends ChangeNotifier { } } + Future playNext() async { + _remoteErrorMessage = null; + await AudioService.skipToNext(); + notifyListeners(); + } + Future forwardAudio(int s) async { var pos = _backgroundAudioPosition + s * 1000; await AudioService.seekTo(Duration(milliseconds: pos)); @@ -670,6 +797,7 @@ class AudioPlayerNotifier extends ChangeNotifier { notifyListeners(); } + /// Set skip silence. Future setSkipSilence({@required bool skipSilence}) async { await AudioService.customAction('setSkipSilence', skipSilence); _skipSilence = skipSilence; @@ -677,6 +805,15 @@ class AudioPlayerNotifier extends ChangeNotifier { notifyListeners(); } + set setVolumeGain(int volumeGain) { + _volumeGain = volumeGain; + if (_playerRunning && _boostVolume) { + setBoostVolume(boostVolume: _boostVolume, gain: _volumeGain); + } + notifyListeners(); + _volumeGainStorage.saveInt(volumeGain); + } + Future setBoostVolume({@required bool boostVolume, int gain}) async { await AudioService.customAction( 'setBoostVolume', [boostVolume, _volumeGain]); @@ -715,12 +852,17 @@ class AudioPlayerNotifier extends ChangeNotifier { _stopOnComplete = true; _switchValue = 1; notifyListeners(); - if (_queue.playlist.length > 1 && _autoPlay) { + if (_queue.episodes.length > 1 && _autoPlay) { AudioService.customAction('stopAtEnd'); } } } + set setSleepTimerMode(SleepTimerMode timer) { + _sleepTimerMode = timer; + notifyListeners(); + } + //Cancel sleep timer void cancelTimer() { if (_sleepTimerMode == SleepTimerMode.timer) { @@ -737,11 +879,8 @@ class AudioPlayerNotifier extends ChangeNotifier { } } - @override - void dispose() async { - await AudioService.disconnect(); - super.dispose(); - } + /// Save player state. + Future _savePlayerPosiont() {} } class AudioPlayerTask extends BackgroundAudioTask { @@ -755,9 +894,11 @@ class AudioPlayerTask extends BackgroundAudioTask { bool _interrupted = false; bool _stopAtEnd; int _cacheMax; + int _index = 0; + bool _isQueue; bool get hasNext => _queue.length > 0; - MediaItem get mediaItem => _queue.length > 0 ? _queue.first : null; + MediaItem get mediaItem => _queue.length > 0 ? _queue[_index] : null; StreamSubscription _playerStateSubscription; StreamSubscription _eventSubscription; @@ -766,6 +907,8 @@ class AudioPlayerTask extends BackgroundAudioTask { Future onStart(Map params) async { _stopAtEnd = false; _session = await AudioSession.instance; + _index = params['index'] ?? 0; + _isQueue = params['isQueue'] ?? true; await _session.configure(AudioSessionConfiguration.speech()); _handleInterruption(_session); _playerStateSubscription = _audioPlayer.playbackStateStream @@ -876,19 +1019,23 @@ class AudioPlayerTask extends BackgroundAudioTask { _skipState = AudioProcessingState.skippingToNext; _playing = false; await _audioPlayer.stop(); - if (_queue.length > 0) { - AudioServiceBackground.sendCustomEvent(_queue.first.title); - _queue.removeAt(0); + AudioServiceBackground.sendCustomEvent(_queue.first.title); + if (_isQueue) { + if (_queue.length > 0) { + _queue.removeAt(0); + } + await AudioServiceBackground.setQueue(_queue); + } else { + _index += 1; } - await AudioServiceBackground.setQueue(_queue); + if (_queue.length == 0 || _stopAtEnd) { _skipState = null; await Future.delayed(Duration(milliseconds: 200)); await onStop(); } else { - await AudioServiceBackground.setQueue(_queue); + // await AudioServiceBackground.setQueue(_queue); await AudioServiceBackground.setMediaItem(mediaItem); - await _audioPlayer.setUrl(mediaItem.id, cacheMax: _cacheMax); var duration = await _audioPlayer.durationFuture; if (duration != null) { @@ -1017,13 +1164,15 @@ class AudioPlayerTask extends BackgroundAudioTask { @override Future onRemoveQueueItem(MediaItem mediaItem) async { + var index = _queue.indexOf(mediaItem); + if (index < _index) _index -= 1; _queue.removeWhere((item) => item.id == mediaItem.id); await AudioServiceBackground.setQueue(_queue); } @override Future onAddQueueItemAt(MediaItem mediaItem, int index) async { - if (index == 0) { + if (index == 0 && _isQueue) { await _audioPlayer.stop(); _queue.removeAt(0); _queue.removeWhere((item) => item.id == mediaItem.id); @@ -1038,6 +1187,7 @@ class AudioPlayerTask extends BackgroundAudioTask { //onPlay(); } else { _queue.insert(index, mediaItem); + if (index < _index) _index += 1; await AudioServiceBackground.setQueue(_queue); } } @@ -1070,9 +1220,28 @@ class AudioPlayerTask extends BackgroundAudioTask { case 'setBoostVolume': await _setBoostVolume(argument[0], argument[1]); break; + case 'setIsQueue': + _isQueue = argument; + break; + case 'changeQueue': + await _changeQueue(argument); } } + Future _changeQueue(List items) async { + var queue = [for (var i in items) MediaItem.fromJson(json.decode(i))]; + await _audioPlayer.stop(); + _queue.clear(); + _queue.addAll(queue); + _index = 0; + await AudioServiceBackground.setQueue(_queue); + await AudioServiceBackground.setMediaItem(mediaItem); + await _audioPlayer.setUrl(mediaItem.id, cacheMax: _cacheMax); + var duration = await _audioPlayer.durationFuture ?? Duration.zero; + AudioServiceBackground.setMediaItem(mediaItem.copyWith(duration: duration)); + _playFromStart(); + } + Future _setSkipSilence(bool boo) async { await _audioPlayer.setSkipSilence(boo); var duration = await _audioPlayer.durationFuture ?? Duration.zero; diff --git a/lib/state/podcast_group.dart b/lib/state/podcast_group.dart index 4f22454..83dce7f 100644 --- a/lib/state/podcast_group.dart +++ b/lib/state/podcast_group.dart @@ -66,22 +66,32 @@ class PodcastGroup extends Equatable { final String color; /// Id lists of podcasts in group. - List podcastList; + final List podcastList; + + final List podcasts; PodcastGroup(this.name, - {this.color = '#000000', String id, List podcastList}) + {this.color = '#000000', + String id, + List podcastList, + List podcasts}) : id = id ?? Uuid().v4(), - podcastList = podcastList ?? []; + podcastList = podcastList ?? [], + podcasts = podcasts ?? []; + + final _dbHelper = DBHelper(); Future getPodcasts() async { - var dbHelper = DBHelper(); - if (podcastList != []) { + podcasts.clear(); + if (podcastList.isNotEmpty) { try { - _podcasts = await dbHelper.getPodcastLocal(podcastList); + var result = await _dbHelper.getPodcastLocal(podcastList); + if (podcasts.isEmpty) podcasts.addAll(result); } catch (e) { await Future.delayed(Duration(milliseconds: 200)); try { - _podcasts = await dbHelper.getPodcastLocal(podcastList); + var result = await _dbHelper.getPodcastLocal(podcastList); + if (podcasts.isEmpty) podcasts.addAll(result); } catch (e) { developer.log(e.toString()); } @@ -89,6 +99,45 @@ class PodcastGroup extends Equatable { } } + Future updatePodcast(PodcastLocal podcast) async { + var count = await _dbHelper.getPodcastCounts(podcast.id); + var list = [ + for (var p in podcasts) + p == podcast ? podcast.copyWith(updateCount: count) : p + ]; + return PodcastGroup(name, + id: id, color: color, podcastList: podcastList, podcasts: list); + } + + void reorderGroup(int oldIndex, int newIndex) { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final podcast = podcasts.removeAt(oldIndex); + podcasts.insert(newIndex, podcast); + podcastList.removeAt(oldIndex); + podcastList.insert(newIndex, podcast.id); + } + + void addToGroup(PodcastLocal podcast) { + if (!podcasts.contains(podcast)) { + podcasts.add(podcast); + podcastList.add(podcast.id); + } + } + + void addToGroupAt(PodcastLocal podcast, {int index = 0}) { + if (!podcasts.contains(podcast)) { + podcasts.insert(index, podcast); + podcastList.insert(index, podcast.id); + } + } + + void deleteFromGroup(PodcastLocal podcast) { + podcasts.remove(podcast); + podcastList.remove(podcast.id); + } + Color getColor() { if (color != '#000000') { var colorInt = int.parse('FF${color.toUpperCase()}', radix: 16); @@ -98,15 +147,11 @@ class PodcastGroup extends Equatable { } } - ///Podcast in group. - List _podcasts; - List get podcasts => _podcasts; - ///Ordered podcast list. - List _orderedPodcasts; - List get orderedPodcasts => _orderedPodcasts; + //List _orderedPodcasts; + //List get orderedPodcasts => _orderedPodcasts; - set orderedPodcasts(list) => _orderedPodcasts = list; + //set orderedPodcasts(list) => _orderedPodcasts = list; GroupEntity toEntity() { return GroupEntity(name, id, color, podcastList); @@ -117,7 +162,7 @@ class PodcastGroup extends Equatable { entity.name, id: entity.id, color: entity.color, - podcastList: entity.podcastList, + podcastList: entity.podcastList.toSet().toList(), ); } @@ -158,25 +203,12 @@ class SubscribeItem { class GroupList extends ChangeNotifier { /// List of all gourps. - final List _groups = []; - + List _groups = []; List get groups => _groups; - final DBHelper _dbHelper = DBHelper(); - - /// Groups save in shared_prefrences. - final KeyValueStorage _groupStorage = KeyValueStorage(groupsKey); - - //GroupList({List groups}) : _groups = groups ?? []; - - /// Default false, true during loading groups from storage. - bool _isLoading = false; - - bool get isLoading => _isLoading; - - /// Svae ordered gourps info before saved. - final List _orderChanged = []; + List _orderChanged = []; List get orderChanged => _orderChanged; + //GroupList({List groups}) : _groups = groups ?? []; /// Subscribe worker isolate FlutterIsolate subIsolate; @@ -187,11 +219,31 @@ class GroupList extends ChangeNotifier { SubscribeItem _currentSubscribeItem = SubscribeItem('', ''); SubscribeItem get currentSubscribeItem => _currentSubscribeItem; - bool _created = false; - /// Default false, true if subscribe isolate is created. + bool _created = false; bool get created => _created; + final DBHelper _dbHelper = DBHelper(); + + /// Groups save in shared_prefrences. + final KeyValueStorage _groupStorage = KeyValueStorage(groupsKey); + + @override + void addListener(VoidCallback listener) { + if (_groups.isEmpty) { + loadGroups().then((value) => super.addListener(listener)); + gpodderSyncNow(); + } + } + + @override + void dispose() { + subIsolate?.kill(); + subIsolate = null; + super.dispose(); + } + + /// Subscribe podcast via isolate. /// Add subsribe item SubscribeItem _subscribeItem; setSubscribeItem(SubscribeItem item, {bool syncGpodder = true}) async { @@ -283,7 +335,8 @@ class GroupList extends ChangeNotifier { for (var rssLink in removeList) { final exist = await _dbHelper.checkPodcast(rssLink); if (exist != '') { - await _unsubscribe(exist); + var podcast = await _dbHelper.getPodcastWithUrl(rssLink); + await _unsubscribe(podcast); } } await _remoteAddStorage.clearList(); @@ -322,146 +375,118 @@ class GroupList extends ChangeNotifier { developer.log('work job cancelled'); } - void addToOrderChanged(PodcastGroup group) { - _orderChanged.add(group); - notifyListeners(); - } - - void drlFromOrderChanged(String name) { - _orderChanged.removeWhere((group) => group.name == name); - notifyListeners(); - } - - Future clearOrderChanged() async { - if (_orderChanged.length > 0) { - for (var group in _orderChanged) { - await group.getPodcasts(); - } - _orderChanged.clear(); - // notifyListeners(); - } - } - - @override - void addListener(VoidCallback listener) { - loadGroups().then((value) => super.addListener(listener)); - gpodderSyncNow(); - } - - @override - void dispose() { - subIsolate?.kill(); - subIsolate = null; - super.dispose(); - } - + /// Mange groups states in app. /// Load groups from storage at start. Future loadGroups() async { - _isLoading = true; - notifyListeners(); _groupStorage.getGroups().then((loadgroups) async { _groups.addAll(loadgroups.map(PodcastGroup.fromEntity)); for (var group in _groups) { await group.getPodcasts(); } - _isLoading = false; + _groups = [...groups]; notifyListeners(); }); } + void addToOrderChanged(PodcastGroup group) { + if (_orderChanged.contains(group)) { + _orderChanged = [for (var g in _orderChanged) g == group ? group : g]; + } else { + _orderChanged = [..._orderChanged, group]; + } + notifyListeners(); + } + + void drlFromOrderChanged(String name) { + _orderChanged = [ + for (var group in _orderChanged) + if (group.name != name) group + ]; + notifyListeners(); + } + + void clearOrderChanged() { + _orderChanged.clear(); + } + /// Update podcasts of each group Future updateGroups() async { for (var group in _groups) { await group.getPodcasts(); } + _groups = [..._groups]; notifyListeners(); } /// Add new group. Future addGroup(PodcastGroup podcastGroup) async { - _isLoading = true; - _groups.add(podcastGroup); - await _saveGroup(); - _isLoading = false; + _groups = [..._groups, podcastGroup]; notifyListeners(); + await _saveGroup(); } /// Remove group. Future delGroup(PodcastGroup podcastGroup) async { - _isLoading = true; - for (var podcast in podcastGroup.podcastList) { - if (!_groups.first.podcastList.contains(podcast)) { - _groups[0].podcastList.insert(0, podcast); + for (var podcast in podcastGroup.podcasts) { + if (!_groups.first.podcasts.contains(podcast)) { + _groups.first.addToGroup(podcast); } } - await _saveGroup(); - _groups.remove(podcastGroup); - await _groups[0].getPodcasts(); - _isLoading = false; + _groups = [ + for (var group in _groups) + if (group.id != podcastGroup) group + ]; notifyListeners(); + await _saveGroup(); } Future updateGroup(PodcastGroup podcastGroup) async { - var oldGroup = _groups.firstWhere((it) => it.id == podcastGroup.id); - var index = _groups.indexOf(oldGroup); - _groups.replaceRange(index, index + 1, [podcastGroup]); - await podcastGroup.getPodcasts(); + _groups = [ + for (var group in _groups) group == podcastGroup ? podcastGroup : group + ]; notifyListeners(); _saveGroup(); } + Future _updateGroups() async { + _groups = [..._groups]; + notifyListeners(); + await _saveGroup(); + } + Future _saveGroup() async { await _groupStorage.saveGroup(_groups.map((it) => it.toEntity()).toList()); } /// Subscribe podcast from search result. - Future subscribe(PodcastLocal podcastLocal) async { - _groups[0].podcastList.insert(0, podcastLocal.id); - await _saveGroup(); - await _dbHelper.savePodcastLocal(podcastLocal); - await _groups[0].getPodcasts(); - notifyListeners(); - } - - Future updatePodcast(String id) async { - var counts = await _dbHelper.getPodcastCounts(id); - for (var group in _groups) { - if (group.podcastList.contains(id)) { - group.podcasts.firstWhere((podcast) => podcast.id == id) - ..episodeCount = counts; - notifyListeners(); - } - } + Future subscribe(PodcastLocal podcast) async { + await _dbHelper.savePodcastLocal(podcast); + _groups.first.addToGroupAt(podcast); + _updateGroups(); } /// Subscribe podcast from OPML. Future _subscribeNewPodcast( {String id, String groupName = 'Home'}) async { //List groupNames = _groups.map((e) => e.name).toList(); + var podcasts = await _dbHelper.getPodcastLocal([id]); for (var group in _groups) { if (group.name == groupName) { if (group.podcastList.contains(id)) { return true; } else { - _isLoading = true; - notifyListeners(); - group.podcastList.insert(0, id); - await _saveGroup(); - await group.getPodcasts(); - _isLoading = false; - notifyListeners(); + group.addToGroupAt(podcasts.first); + _updateGroups(); return true; } } } - _isLoading = true; + _groups = [ + ..._groups, + PodcastGroup(groupName, podcastList: [id], podcasts: podcasts) + ]; notifyListeners(); - _groups.add(PodcastGroup(groupName, podcastList: [id])); - //_groups.last.podcastList.insert(0, id); await _saveGroup(); - await _groups.last.getPodcasts(); - _isLoading = false; - notifyListeners(); return true; } @@ -476,26 +501,19 @@ class GroupList extends ChangeNotifier { } //Change podcast groups - Future changeGroup(String id, List list) async { - _isLoading = true; - notifyListeners(); - - for (var group in getPodcastGroup(id)) { + Future changeGroup( + PodcastLocal podcast, List list) async { + for (var group in getPodcastGroup(podcast.id)) { if (list.contains(group)) { list.remove(group); } else { - group.podcastList.remove(id); + group.deleteFromGroup(podcast); } } for (var s in list) { - s.podcastList.insert(0, id); + s.addToGroup(podcast); } - await _saveGroup(); - for (var group in _groups) { - await group.getPodcasts(); - } - _isLoading = false; - notifyListeners(); + _updateGroups(); } /// Unsubscribe podcast @@ -506,35 +524,32 @@ class GroupList extends ChangeNotifier { } } - Future _unsubscribe(String id) async { - _isLoading = true; - notifyListeners(); + Future _unsubscribe(PodcastLocal podcast) async { for (var group in _groups) { - group.podcastList.remove(id); + group.deleteFromGroup(podcast); } - await _saveGroup(); - await _dbHelper.delPodcastLocal(id); - for (var group in _groups) { - await group.getPodcasts(); - } - _isLoading = false; - notifyListeners(); + _updateGroups(); + await _dbHelper.delPodcastLocal(podcast.id); } - + /// Delete podcsat from device. Future removePodcast( PodcastLocal podcast, ) async { _syncRemove(podcast.rssUrl); - await _unsubscribe(podcast.id); + await _unsubscribe(podcast); await File(podcast.imagePath)?.delete(); } Future saveOrder(PodcastGroup group) async { - group.podcastList = group.orderedPodcasts.map((e) => e.id).toList(); - await _saveGroup(); - await group.getPodcasts(); + // group.podcastList = group.orderedPodcasts.map((e) => e.id).toList(); + var orderedGroup; + for (var g in _orderChanged) { + if (g == group) orderedGroup = g; + } + _groups = [for (var g in _groups) g == orderedGroup ? orderedGroup : g]; notifyListeners(); + await _saveGroup(); } } diff --git a/lib/state/refresh_podcast.dart b/lib/state/refresh_podcast.dart index 1b8d388..bf407f5 100644 --- a/lib/state/refresh_podcast.dart +++ b/lib/state/refresh_podcast.dart @@ -48,6 +48,7 @@ class RefreshWorker extends ChangeNotifier { _currentRefreshItem = RefreshItem('', RefreshState.none); _complete = true; notifyListeners(); + _complete = false; refreshIsolate?.kill(); refreshIsolate = null; _created = false; diff --git a/lib/type/playlist.dart b/lib/type/playlist.dart index 61c514a..1a2e644 100644 --- a/lib/type/playlist.dart +++ b/lib/type/playlist.dart @@ -1,6 +1,6 @@ +import 'package:equatable/equatable.dart'; import 'package:uuid/uuid.dart'; -import '../local_storage/key_value_storage.dart'; import '../local_storage/sqflite_localpodcast.dart'; import 'episodebrief.dart'; @@ -21,7 +21,7 @@ class PlaylistEntity { } } -class Playlist { +class Playlist extends Equatable { /// Playlist name. the default playlist is named "Playlist". final String name; @@ -31,12 +31,19 @@ class Playlist { /// Episode url list for playlist. final List episodeList; - Playlist(this.name, {String id, List episodeList}) + /// Eposides in playlist. + final List episodes; + + bool get isEmpty => episodeList.isEmpty; + + Playlist(this.name, + {String id, List episodeList, List episodes}) : id = id ?? Uuid().v4(), - episodeList = episodeList ?? []; + episodeList = episodeList ?? [], + episodes = episodes ?? []; PlaylistEntity toEntity() { - return PlaylistEntity(name, id, episodeList); + return PlaylistEntity(name, id, episodeList.toSet().toList()); } static Playlist fromEntity(PlaylistEntity entity) { @@ -48,58 +55,70 @@ class Playlist { } final DBHelper _dbHelper = DBHelper(); - List _playlist = []; - List get playlist => _playlist; - final KeyValueStorage _playlistStorage = KeyValueStorage(playlistKey); +// final KeyValueStorage _playlistStorage = KeyValueStorage(playlistKey); Future getPlaylist() async { - var urls = await _playlistStorage.getStringList(); - episodeList.addAll(urls); - if (urls.length == 0) { - _playlist = []; - } else { - _playlist = []; - for (var url in urls) { + episodes.clear(); + if (episodeList.isNotEmpty) { + for (var url in episodeList) { + print(url); var episode = await _dbHelper.getRssItemWithUrl(url); - if (episode != null) _playlist.add(episode); + if (episode != null) episodes.add(episode); } } } - Future savePlaylist() async { - var urls = []; - urls.addAll(_playlist.map((e) => e.enclosureUrl)); - await _playlistStorage.saveStringList(urls.toSet().toList()); - } +// Future savePlaylist() async { +// var urls = []; +// urls.addAll(_playlist.map((e) => e.enclosureUrl)); +// await _playlistStorage.saveStringList(urls.toSet().toList()); +// } - Future addToPlayList(EpisodeBrief episodeBrief) async { - if (!_playlist.contains(episodeBrief)) { - _playlist.add(episodeBrief); - await savePlaylist(); - if (episodeBrief.isNew == 1) { - await _dbHelper.removeEpisodeNewMark(episodeBrief.enclosureUrl); - } + void addToPlayList(EpisodeBrief episodeBrief) { + if (!episodes.contains(episodeBrief)) { + episodes.add(episodeBrief); + episodeList.add(episodeBrief.enclosureUrl); } } - Future addToPlayListAt(EpisodeBrief episodeBrief, int index, - {bool existed = true}) async { + void addToPlayListAt(EpisodeBrief episodeBrief, int index, + {bool existed = true}) { if (existed) { - _playlist.removeWhere( - (episode) => episode.enclosureUrl == episodeBrief.enclosureUrl); - if (episodeBrief.isNew == 1) { - await _dbHelper.removeEpisodeNewMark(episodeBrief.enclosureUrl); - } + episodes.removeWhere((episode) => episode == episodeBrief); + episodeList.removeWhere((url) => url == episodeBrief.enclosureUrl); } - _playlist.insert(index, episodeBrief); - await savePlaylist(); + episodes.insert(index, episodeBrief); + episodeList.insert(index, episodeBrief.enclosureUrl); } - Future delFromPlaylist(EpisodeBrief episodeBrief) async { - var index = _playlist.indexOf(episodeBrief); - _playlist.removeWhere( + void updateEpisode(EpisodeBrief episode) { + var index = episodes.indexOf(episode); + if (index != -1) episodes[index] = episode; + } + + int delFromPlaylist(EpisodeBrief episodeBrief) { + var index = episodes.indexOf(episodeBrief); + episodes.removeWhere( (episode) => episode.enclosureUrl == episodeBrief.enclosureUrl); - await savePlaylist(); + episodeList.removeWhere((url) => url == episodeBrief.enclosureUrl); return index; } + + void reorderPlaylist(int oldIndex, int newIndex) { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final episode = episodes.removeAt(oldIndex); + episodes.insert(newIndex, episode); + episodeList.removeAt(oldIndex); + episodeList.insert(newIndex, episode.enclosureUrl); + } + + void clear() { + episodeList.clear(); + episodes.clear(); + } + + @override + List get props => [id, name]; } diff --git a/lib/type/podcastlocal.dart b/lib/type/podcastlocal.dart index 5e56720..1da04f5 100644 --- a/lib/type/podcastlocal.dart +++ b/lib/type/podcastlocal.dart @@ -19,20 +19,19 @@ class PodcastLocal extends Equatable { final String description; - int _upateCount; - int get updateCount => _upateCount; - set updateCount(i) => _upateCount = i; + final int updateCount; + final int episodeCount; - int _episodeCount; - int get episodeCount => _episodeCount; - set episodeCount(i) => _episodeCount = i; + //set setUpdateCount(i) => updateCount = i; + + //set setEpisodeCount(i) => episodeCount = i; PodcastLocal(this.title, this.imageUrl, this.rssUrl, this.primaryColor, this.author, this.id, this.imagePath, this.provider, this.link, - {this.description = '', int upateCount, int episodeCount}) + {this.description = '', int updateCount, int episodeCount}) : assert(rssUrl != null), - _episodeCount = episodeCount ?? 0, - _upateCount = upateCount ?? 0; + episodeCount = episodeCount ?? 0, + updateCount = updateCount ?? 0; ImageProvider get avatarImage { return File(imagePath).existsSync() @@ -46,6 +45,14 @@ class PodcastLocal extends Equatable { : primaryColor.colorizeLight(); } + PodcastLocal copyWith({int updateCount, int episodeCount}) { + return PodcastLocal(title, imageUrl, rssUrl, primaryColor, author, id, + imagePath, provider, link, + description: description, + updateCount: updateCount ?? 0, + episodeCount: episodeCount ?? 0); + } + @override List get props => [id, rssUrl]; } diff --git a/lib/widgets/custom_widget.dart b/lib/widgets/custom_widget.dart index b682f73..ab9e146 100644 --- a/lib/widgets/custom_widget.dart +++ b/lib/widgets/custom_widget.dart @@ -1220,3 +1220,19 @@ class CustomBackButton extends StatelessWidget { ); } } + +// Episode tag widget. +Widget episodeTag(String text, Color color) { + if (text == '') { + return Center(); + } + return Container( + decoration: + BoxDecoration(color: color, borderRadius: BorderRadius.circular(15.0)), + height: 25.0, + margin: EdgeInsets.only(right: 10.0), + padding: EdgeInsets.symmetric(horizontal: 8.0), + alignment: Alignment.center, + child: Text(text, style: TextStyle(fontSize: 14.0, color: Colors.black)), + ); +} diff --git a/lib/widgets/dismissible_container.dart b/lib/widgets/dismissible_container.dart new file mode 100644 index 0000000..129b9db --- /dev/null +++ b/lib/widgets/dismissible_container.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:line_icons/line_icons.dart'; +import 'package:provider/provider.dart'; + +import '../state/audio_state.dart'; +import '../type/episodebrief.dart'; +import '../util/extension_helper.dart'; +import 'custom_widget.dart'; + +class DismissibleContainer extends StatefulWidget { + final EpisodeBrief episode; + final ValueChanged onRemove; + DismissibleContainer({this.episode, this.onRemove, Key key}) + : super(key: key); + + @override + _DismissibleContainerState createState() => _DismissibleContainerState(); +} + +class _DismissibleContainerState extends State { + bool _delete; + + @override + void initState() { + _delete = false; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final s = context.s; + final c = widget.episode.backgroudColor(context); + return AnimatedContainer( + duration: Duration(milliseconds: 300), + curve: Curves.easeInSine, + alignment: Alignment.center, + height: _delete ? 0 : 90.0, + child: _delete + ? Container( + color: Colors.transparent, + ) + : Dismissible( + key: ValueKey('${widget.episode.enclosureUrl}dis'), + background: Container( + padding: EdgeInsets.symmetric(horizontal: 20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, color: Colors.red), + padding: EdgeInsets.all(5), + alignment: Alignment.center, + child: Icon( + LineIcons.trash_alt_solid, + color: Colors.white, + size: 15, + ), + ), + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, color: Colors.red), + padding: EdgeInsets.all(5), + alignment: Alignment.center, + child: Icon( + LineIcons.trash_alt_solid, + color: Colors.white, + size: 15, + ), + ), + ], + ), + height: 30, + color: context.accentColor, + ), + onDismissed: (direction) async { + setState(() { + _delete = true; + }); + var index = await context + .read() + .delFromPlaylist(widget.episode); + widget.onRemove(true); + final episodeRemove = widget.episode; + Scaffold.of(context).removeCurrentSnackBar(); + Scaffold.of(context).showSnackBar(SnackBar( + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.grey[800], + content: Text(s.toastRemovePlaylist, + style: TextStyle(color: Colors.white)), + action: SnackBarAction( + textColor: context.accentColor, + label: s.undo, + onPressed: () async { + await context + .read() + .addToPlaylistAt(episodeRemove, index); + widget.onRemove(false); + }), + )); + }, + child: SizedBox( + height: 90.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Expanded( + child: ListTile( + contentPadding: EdgeInsets.symmetric(vertical: 8), + onTap: () async { + await context + .read() + .episodeLoad(widget.episode); + widget.onRemove(true); + }, + title: Container( + padding: EdgeInsets.fromLTRB(0, 5.0, 20.0, 5.0), + child: Text( + widget.episode.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + leading: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.unfold_more, color: c), + CircleAvatar( + backgroundColor: c.withOpacity(0.5), + backgroundImage: widget.episode.avatarImage), + ], + ), + subtitle: Container( + padding: EdgeInsets.only(top: 5, bottom: 5), + height: 35, + child: Row( + children: [ + if (widget.episode.explicit == 1) + Container( + decoration: BoxDecoration( + color: Colors.red[800], + shape: BoxShape.circle), + height: 25.0, + width: 25.0, + margin: EdgeInsets.only(right: 10.0), + alignment: Alignment.center, + child: Text('E', + style: TextStyle(color: Colors.white))), + if (widget.episode.duration != 0) + episodeTag( + widget.episode.duration == 0 + ? '' + : s.minsCount( + widget.episode.duration ~/ 60), + Colors.cyan[300]), + if (widget.episode.enclosureLength != null) + episodeTag( + widget.episode.enclosureLength == 0 + ? '' + : '${(widget.episode.enclosureLength) ~/ 1000000}MB', + Colors.lightBlue[300]), + ], + ), + ), + //trailing: Icon(Icons.menu), + ), + ), + Divider( + height: 2, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/episodegrid.dart b/lib/widgets/episodegrid.dart index abe94da..7c091e2 100644 --- a/lib/widgets/episodegrid.dart +++ b/lib/widgets/episodegrid.dart @@ -549,7 +549,7 @@ class EpisodeGrid extends StatelessWidget { Tuple3, bool>>( selector: (_, audio) => Tuple3( audio?.episode, - audio.queue.playlist.map((e) => e.enclosureUrl).toList(), + audio.queue.episodes.map((e) => e.enclosureUrl).toList(), audio.episodeState), builder: (_, data, __) => OpenContainerWrapper( avatarSize: layout == Layout.one diff --git a/lib/widgets/muiliselect_bar.dart b/lib/widgets/muiliselect_bar.dart index fa1fcf0..2c769d9 100644 --- a/lib/widgets/muiliselect_bar.dart +++ b/lib/widgets/muiliselect_bar.dart @@ -1,5 +1,6 @@ import 'package:connectivity/connectivity.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; @@ -10,6 +11,7 @@ import '../state/audio_state.dart'; import '../state/download_state.dart'; import '../type/episodebrief.dart'; import '../type/play_histroy.dart'; +import '../type/playlist.dart'; import '../util/extension_helper.dart'; import 'custom_widget.dart'; import 'general_dialog.dart'; @@ -44,6 +46,7 @@ class _MultiSelectMenuBarState extends State { bool _marked; bool _inPlaylist; bool _downloaded; + bool _showPlaylists; final _dbHelper = DBHelper(); @override @@ -53,6 +56,7 @@ class _MultiSelectMenuBarState extends State { _marked = false; _downloaded = false; _inPlaylist = false; + _showPlaylists = false; } @override @@ -63,6 +67,7 @@ class _MultiSelectMenuBarState extends State { _marked = false; _downloaded = false; _inPlaylist = false; + _showPlaylists = false; }); super.didUpdateWidget(oldWidget); } @@ -179,6 +184,11 @@ class _MultiSelectMenuBarState extends State { } } + Future _getEpisode(String url) async { + var dbHelper = DBHelper(); + return await dbHelper.getRssItemWithUrl(url); + } + Widget _buttonOnMenu({Widget child, VoidCallback onTap}) => Material( color: Colors.transparent, child: InkWell( @@ -190,6 +200,106 @@ class _MultiSelectMenuBarState extends State { ), ), ); + + Widget _playlistList() => SizedBox( + height: 50, + child: Selector>( + selector: (_, audio) => audio.playlists, + builder: (_, data, child) { + return Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + for (var p in data) + if (p.name == 'Queue') + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: InkWell( + onTap: () { + setState(() => _showPlaylists = false); + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context) + .modalBarrierDismissLabel, + barrierColor: Colors.black54, + transitionDuration: + const Duration(milliseconds: 200), + pageBuilder: + (context, animaiton, secondaryAnimation) => + _NewPlaylist(widget.selectedList)); + }, + child: Container( + height: 30, + child: Row( + children: [ + Container( + height: 30, + width: 30, + color: context.primaryColorDark, + child: Center(child: Icon(Icons.add)), + ), + SizedBox(width: 10), + Text('New') + ], + ), + ), + ), + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: InkWell( + onTap: () { + context + .read() + .addEpisodesToPlaylist(p, + episodes: widget.selectedList); + setState(() { + _showPlaylists = false; + }); + }, + child: Container( + height: 30, + child: Row( + children: [ + Container( + height: 30, + width: 30, + color: context.primaryColorDark, + child: p.episodeList.isEmpty + ? Center() + : FutureBuilder( + future: + _getEpisode(p.episodeList.first), + builder: (_, snapshot) { + if (snapshot.data != null) { + return SizedBox( + height: 30, + width: 30, + child: Image( + image: snapshot + .data.avatarImage)); + } + return Center(); + }), + ), + SizedBox(width: 10), + Text(p.name), + ], + ), + ), + ), + ), + ], + ), + ), + ); + }, + )); OverlayEntry _createOverlayEntry() { RenderBox renderBox = context.findRenderObject(); var offset = renderBox.localToGlobal(Offset.zero); @@ -214,7 +324,13 @@ class _MultiSelectMenuBarState extends State { tween: Tween(begin: 0, end: 1), duration: Duration(milliseconds: 500), builder: (context, value, child) => Container( - height: widget.selectAll == null ? 40 : 90.0 * value, + height: widget.selectAll == null + ? _showPlaylists + ? 90 + : 40 + : _showPlaylists + ? 140 + : 90.0 * value, decoration: BoxDecoration(color: context.primaryColor), child: SingleChildScrollView( child: Column( @@ -296,6 +412,7 @@ class _MultiSelectMenuBarState extends State { ) ], ), + if (_showPlaylists) _playlistList(), Row( children: [ if (!widget.hideFavorite) @@ -421,6 +538,18 @@ class _MultiSelectMenuBarState extends State { } } }), + _buttonOnMenu( + child: Icon( + Icons.add_box_outlined, + color: Colors.grey[700], + ), + onTap: () { + if (widget.selectedList.isNotEmpty) { + setState(() { + _showPlaylists = !_showPlaylists; + }); + } + }), Spacer(), if (widget.selectAll == null) SizedBox( @@ -446,3 +575,104 @@ class _MultiSelectMenuBarState extends State { ); } } + +class _NewPlaylist extends StatefulWidget { + final List episodes; + _NewPlaylist(this.episodes, {Key key}) : super(key: key); + + @override + __NewPlaylistState createState() => __NewPlaylistState(); +} + +class __NewPlaylistState extends State<_NewPlaylist> { + String _playlistName; + int _error; + + @override + Widget build(BuildContext context) { + final s = context.s; + return AnnotatedRegion( + value: SystemUiOverlayStyle( + statusBarIconBrightness: Brightness.light, + systemNavigationBarColor: + Theme.of(context).brightness == Brightness.light + ? Color.fromRGBO(113, 113, 113, 1) + : Color.fromRGBO(5, 5, 5, 1), + ), + child: AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + elevation: 1, + contentPadding: EdgeInsets.symmetric(horizontal: 20), + titlePadding: EdgeInsets.all(20), + actionsPadding: EdgeInsets.zero, + actions: [ + FlatButton( + splashColor: context.accentColor.withAlpha(70), + onPressed: () => Navigator.of(context).pop(), + child: Text( + s.cancel, + style: TextStyle(color: Colors.grey[600]), + ), + ), + FlatButton( + splashColor: context.accentColor.withAlpha(70), + onPressed: () async { + if (context + .read() + .playlistExisted(_playlistName)) { + setState(() => _error = 1); + } else { + final episodesList = + widget.episodes.map((e) => e.enclosureUrl).toList(); + final playlist = Playlist(_playlistName, + episodeList: episodesList, episodes: widget.episodes); + context.read().addPlaylist(playlist); + Navigator.of(context).pop(); + } + }, + child: + Text(s.confirm, style: TextStyle(color: context.accentColor)), + ) + ], + title: + SizedBox(width: context.width - 160, child: Text('New playlist')), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: InputDecoration( + contentPadding: EdgeInsets.symmetric(horizontal: 10), + hintText: 'New playlist', + hintStyle: TextStyle(fontSize: 18), + filled: true, + focusedBorder: UnderlineInputBorder( + borderSide: + BorderSide(color: context.accentColor, width: 2.0), + ), + enabledBorder: UnderlineInputBorder( + borderSide: + BorderSide(color: context.accentColor, width: 2.0), + ), + ), + cursorRadius: Radius.circular(2), + autofocus: true, + maxLines: 1, + onChanged: (value) { + _playlistName = value; + }, + ), + Container( + alignment: Alignment.centerLeft, + child: (_error == 1) + ? Text( + 'Playlist existed', + style: TextStyle(color: Colors.red[400]), + ) + : Center(), + ), + ], + ), + ), + ); + } +}