Custom playlist support (working)

This commit is contained in:
stonega 2020-12-20 17:35:39 +08:00
parent 0a8f622f85
commit 1d8db22dde
21 changed files with 2259 additions and 407 deletions

View File

@ -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. 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 ``` 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. 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 UI
src src
──home ──home
├──home.dart [Homepage] ├──home.dart [Homepage]
├──searc_podcast.dart [Search Page] ├──searc_podcast.dart [Search Page]
└──playlist.dart [Playlist Page] └──playlist.dart [Playlist Page]
──podcasts ──podcasts
├──podcast_manage.dart [Group Page] ├──podcast_manage.dart [Group Page]
└──podcast_detail.dart [Podcast Page] └──podcast_detail.dart [Podcast Page]
──episodes ──episodes
└──episode_detail.dart [Episode Page] └──episode_detail.dart [Episode Page]
──settings ──settings
└──setting.dart [Setting Page] └──setting.dart [Setting Page]
STATE STATE
src src
──state ──state
├──audio_state.dart [Audio State] ├──audio_state.dart [Audio State]
├──download_state.dart [Episode Download] ├──download_state.dart [Episode Download]
├──podcast_group.dart [Podcast Groups] ├──podcast_group.dart [Podcast Groups]
@ -135,8 +136,9 @@ src
Service Service
src src
──service ──service
├──api_service.dart [Podcast Search] ├──api_service.dart [Podcast Search]
├──gpodder_api.dart [Gpodder intergate]
└──ompl_builde.dart [OMPL export] └──ompl_builde.dart [OMPL export]
``` ```

View File

@ -487,13 +487,10 @@ class __MenuBarState extends State<_MenuBar> {
}, },
), ),
DownloadButton(episode: widget.episodeItem), DownloadButton(episode: widget.episodeItem),
Selector<AudioPlayerNotifier, Selector<AudioPlayerNotifier, List<EpisodeBrief>>(
Tuple2<List<EpisodeBrief>, bool>>( selector: (_, audio) => audio.queue.episodes,
selector: (_, audio) =>
Tuple2(audio.queue.playlist, audio.queueUpdate),
builder: (_, data, __) { builder: (_, data, __) {
final inPlaylist = final inPlaylist = data.contains(widget.episodeItem);
data.item1.contains(widget.episodeItem);
return inPlaylist return inPlaylist
? _buttonOnMenu( ? _buttonOnMenu(
child: Icon(Icons.playlist_add_check, child: Icon(Icons.playlist_add_check,

View File

@ -413,12 +413,10 @@ class _PlaylistWidgetState extends State<PlaylistWidget> {
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: child: Selector<AudioPlayerNotifier, List<EpisodeBrief>>(
Selector<AudioPlayerNotifier, Tuple2<List<EpisodeBrief>, bool>>( selector: (_, audio) => audio.playlist.episodes,
selector: (_, audio) =>
Tuple2(audio.queue.playlist, audio.queueUpdate),
builder: (_, data, __) { builder: (_, data, __) {
var episodesToPlay = data.item1.sublist(1); var episodesToPlay = data.sublist(1);
return AnimatedList( return AnimatedList(
key: miniPlaylistKey, key: miniPlaylistKey,
shrinkWrap: true, shrinkWrap: true,
@ -436,7 +434,7 @@ class _PlaylistWidgetState extends State<PlaylistWidget> {
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
audio.episodeLoad(data.item1[index]); audio.episodeLoad(data[index]);
miniPlaylistKey.currentState.removeItem( miniPlaylistKey.currentState.removeItem(
index, index,
(context, animation) => Center()); (context, animation) => Center());
@ -506,8 +504,7 @@ class _PlaylistWidgetState extends State<PlaylistWidget> {
duration: Duration(milliseconds: 100)); duration: Duration(milliseconds: 100));
await Future.delayed( await Future.delayed(
Duration(milliseconds: 100)); Duration(milliseconds: 100));
await audio await audio.moveToTop(data[index + 1]);
.moveToTop(data.item1[index + 1]);
}, },
child: SizedBox( child: SizedBox(
height: 30.0, height: 30.0,

View File

@ -13,6 +13,7 @@ import 'package:tuple/tuple.dart';
import '../local_storage/key_value_storage.dart'; import '../local_storage/key_value_storage.dart';
import '../local_storage/sqflite_localpodcast.dart'; import '../local_storage/sqflite_localpodcast.dart';
import '../playlists/playlist_home.dart';
import '../state/audio_state.dart'; import '../state/audio_state.dart';
import '../state/download_state.dart'; import '../state/download_state.dart';
import '../state/podcast_group.dart'; import '../state/podcast_group.dart';
@ -190,7 +191,8 @@ class _HomeState extends State<Home> with SingleTickerProviderStateMixin {
description: description:
s.featureDiscoveryOMPLDes, s.featureDiscoveryOMPLDes,
child: Padding( child: Padding(
padding: const EdgeInsets.only(right: 5.0), padding: const EdgeInsets.only(
right: 5.0),
child: PopupMenu(), child: PopupMenu(),
)), )),
], ],
@ -319,7 +321,7 @@ class __PlaylistButtonState extends State<_PlaylistButton> {
bool _loadPlay; bool _loadPlay;
Future<void> _getPlaylist() async { Future<void> _getPlaylist() async {
await context.read<AudioPlayerNotifier>().loadPlaylist(); await context.read<AudioPlayerNotifier>().initPlaylist();
if (mounted) { if (mounted) {
setState(() { setState(() {
_loadPlay = true; _loadPlay = true;
@ -336,7 +338,6 @@ class __PlaylistButtonState extends State<_PlaylistButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final audio = context.watch<AudioPlayerNotifier>();
final s = context.s; final s = context.s;
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
@ -365,7 +366,7 @@ class __PlaylistButtonState extends State<_PlaylistButton> {
? SizedBox( ? SizedBox(
height: 8.0, height: 8.0,
) )
: data.item1 || data.item2.playlist.length == 0 : data.item1 || data.item2.episodes.isEmpty
? SizedBox( ? SizedBox(
height: 8.0, height: 8.0,
) )
@ -374,7 +375,9 @@ class __PlaylistButtonState extends State<_PlaylistButton> {
topLeft: Radius.circular(10.0), topLeft: Radius.circular(10.0),
topRight: Radius.circular(10.0)), topRight: Radius.circular(10.0)),
onTap: () { onTap: () {
audio.playlistLoad(); context
.read<AudioPlayerNotifier>()
.playFromLastPosition();
Navigator.pop<int>(context); Navigator.pop<int>(context);
}, },
child: Column( child: Column(
@ -388,7 +391,7 @@ class __PlaylistButtonState extends State<_PlaylistButton> {
CircleAvatar( CircleAvatar(
radius: 20, radius: 20,
backgroundImage: data backgroundImage: data
.item2.playlist.first.avatarImage), .item2.episodes.first.avatarImage),
Container( Container(
height: 40.0, height: 40.0,
width: 40.0, width: 40.0,
@ -416,7 +419,7 @@ class __PlaylistButtonState extends State<_PlaylistButton> {
// TextStyle(color: Colors.white) // TextStyle(color: Colors.white)
), ),
Text( Text(
data.item2.playlist.first.title, data.item2.episodes.first.title,
maxLines: 2, maxLines: 2,
textAlign: TextAlign.center, textAlign: TextAlign.center,
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
@ -476,9 +479,7 @@ class __PlaylistButtonState extends State<_PlaylistButton> {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => PlaylistPage( builder: (context) => PlaylistHome(),
initPage: InitPage.playlist,
),
), ),
); );
} else if (value == 2) { } else if (value == 2) {

View File

@ -85,14 +85,12 @@ class _ScrollPodcastsState extends State<ScrollPodcasts>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width; final width = MediaQuery.of(context).size.width;
final s = context.s; final s = context.s;
return Selector<GroupList, Tuple3<List<PodcastGroup>, bool, bool>>( return Selector<GroupList, Tuple2<List<PodcastGroup>, bool>>(
selector: (_, groupList) => selector: (_, groupList) => Tuple2(groupList.groups, groupList.created),
Tuple3(groupList.groups, groupList.created, groupList.isLoading),
builder: (_, data, __) { builder: (_, data, __) {
var groups = data.item1; var groups = data.item1;
var import = data.item2; var import = data.item2;
var isLoading = data.item3; return groups.isEmpty
return isLoading
? Container( ? Container(
height: (width - 20) / 3 + 140, height: (width - 20) / 3 + 140,
) )
@ -246,7 +244,7 @@ class _ScrollPodcastsState extends State<ScrollPodcasts>
), ),
) )
: DefaultTabController( : DefaultTabController(
length: groups[_groupIndex].podcastList.length, length: groups[_groupIndex].podcasts.length,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -696,7 +694,7 @@ class ShowEpisode extends StatelessWidget {
Tuple2<EpisodeBrief, List<String>>>( Tuple2<EpisodeBrief, List<String>>>(
selector: (_, audio) => Tuple2( selector: (_, audio) => Tuple2(
audio?.episode, audio?.episode,
audio.queue.playlist audio.queue.episodes
.map((e) => e.enclosureUrl) .map((e) => e.enclosureUrl)
.toList(), .toList(),
), ),

View File

@ -77,11 +77,11 @@ class _PlaylistPageState extends State<PlaylistPage> {
), ),
body: SafeArea( body: SafeArea(
child: Selector<AudioPlayerNotifier, child: Selector<AudioPlayerNotifier,
Tuple4<Playlist, bool, bool, EpisodeBrief>>( Tuple3<Playlist, bool, EpisodeBrief>>(
selector: (_, audio) => Tuple4(audio.queue, audio.playerRunning, selector: (_, audio) =>
audio.queueUpdate, audio.episode), Tuple3(audio.playlist, audio.playerRunning, audio.episode),
builder: (_, data, __) { builder: (_, data, __) {
var episodes = data.item1.playlist; var episodes = data.item1.episodes;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -227,12 +227,12 @@ class _PlaylistPageState extends State<PlaylistPage> {
CircleAvatar( CircleAvatar(
radius: 15, radius: 15,
backgroundImage: backgroundImage:
data.item4.avatarImage), data.item3.avatarImage),
Container( Container(
width: 150, width: 150,
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
data.item4.title, data.item3.title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -254,7 +254,7 @@ class _PlaylistPageState extends State<PlaylistPage> {
icon: Icon(Icons.play_circle_filled, icon: Icon(Icons.play_circle_filled,
size: 40, color: context.accentColor), size: 40, color: context.accentColor),
onPressed: () { onPressed: () {
audio.playlistLoad(); //audio.playlistLoad();
// setState(() {}); // setState(() {});
}), }),
), ),
@ -293,7 +293,7 @@ class __ReorderablePlaylistState extends State<_ReorderablePlaylist> {
return Selector<AudioPlayerNotifier, Tuple2<Playlist, bool>>( return Selector<AudioPlayerNotifier, Tuple2<Playlist, bool>>(
selector: (_, audio) => Tuple2(audio.queue, audio.playerRunning), selector: (_, audio) => Tuple2(audio.queue, audio.playerRunning),
builder: (_, data, __) { builder: (_, data, __) {
var episodes = data.item1.playlist; var episodes = data.item1.episodes;
return ReorderableListView( return ReorderableListView(
onReorder: (oldIndex, newIndex) { onReorder: (oldIndex, newIndex) {
if (newIndex > oldIndex) { if (newIndex > oldIndex) {
@ -708,19 +708,12 @@ class __HistoryListState extends State<_HistoryList> {
SizedBox( SizedBox(
child: Selector< child: Selector<
AudioPlayerNotifier, AudioPlayerNotifier,
Tuple2< List<EpisodeBrief>>(
List<EpisodeBrief>,
bool>>(
selector: (_, audio) => selector: (_, audio) =>
Tuple2( audio.queue.episodes,
audio.queue
.playlist,
audio
.queueUpdate),
builder: (_, data, __) { builder: (_, data, __) {
return data.item1 return data.contains(
.contains( episode)
episode)
? IconButton( ? IconButton(
icon: Icon( icon: Icon(
Icons Icons

View File

@ -59,6 +59,7 @@ const String markListenedAfterSkipKey = 'markListenedAfterSkipKey';
const String downloadPositionKey = 'downloadPositionKey'; const String downloadPositionKey = 'downloadPositionKey';
const String deleteAfterPlayedKey = 'removeAfterPlayedKey'; const String deleteAfterPlayedKey = 'removeAfterPlayedKey';
const String playlistsAllKey = 'playlistsAllKey'; const String playlistsAllKey = 'playlistsAllKey';
const String playerStateKey = 'playerStateKey';
class KeyValueStorage { class KeyValueStorage {
final String key; final String key;
@ -92,13 +93,14 @@ class KeyValueStorage {
var prefs = await SharedPreferences.getInstance(); var prefs = await SharedPreferences.getInstance();
if (prefs.getString(key) == null) { if (prefs.getString(key) == null) {
var episodeList = prefs.getStringList(playlistKey); var episodeList = prefs.getStringList(playlistKey);
var playlist = Playlist('Playlist', episodeList: episodeList); var playlist = Playlist('Queue', episodeList: episodeList);
await prefs.setString( await prefs.setString(
key, key,
json.encode({ json.encode({
'playlists': [playlist.toEntity().toJson()] 'playlists': [playlist.toEntity().toJson()]
})); }));
} }
print(prefs.getString(key));
return json return json
.decode(prefs.getString(key))['playlists'] .decode(prefs.getString(key))['playlists']
.cast<Map<String, Object>>() .cast<Map<String, Object>>()
@ -115,6 +117,21 @@ class KeyValueStorage {
})); }));
} }
Future<bool> savePlayerState(
String playlist, String episode, int position) async {
var prefs = await SharedPreferences.getInstance();
return prefs.setStringList(key, [playlist, episode, position.toString()]);
}
Future<List<String>> 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<bool> saveInt(int setting) async { Future<bool> saveInt(int setting) async {
var prefs = await SharedPreferences.getInstance(); var prefs = await SharedPreferences.getInstance();
return prefs.setInt(key, setting); return prefs.setInt(key, setting);
@ -212,7 +229,7 @@ class KeyValueStorage {
return prefs.setDouble(key, data); return prefs.setDouble(key, data);
} }
Future<double> getDoubel({double defaultValue = 0.0}) async { Future<double> getDouble({double defaultValue = 0.0}) async {
var prefs = await SharedPreferences.getInstance(); var prefs = await SharedPreferences.getInstance();
if (prefs.getDouble(key) == null) { if (prefs.getDouble(key) == null) {
await prefs.setDouble(key, defaultValue); await prefs.setDouble(key, defaultValue);

View File

@ -109,7 +109,7 @@ class DBHelper {
list.first['imagePath'], list.first['imagePath'],
list.first['provider'], list.first['provider'],
list.first['link'], list.first['link'],
upateCount: list.first['update_count'], updateCount: list.first['update_count'],
episodeCount: list.first['episode_count'])); episodeCount: list.first['episode_count']));
} }
} }
@ -166,7 +166,7 @@ class DBHelper {
list.first['imagePath'], list.first['imagePath'],
list.first['provider'], list.first['provider'],
list.first['link'], list.first['link'],
upateCount: list.first['update_count'], updateCount: list.first['update_count'],
episodeCount: list.first['episode_count']); episodeCount: list.first['episode_count']);
} }
return null; return null;
@ -1376,7 +1376,7 @@ class DBHelper {
await txn.rawUpdate( await txn.rawUpdate(
"UPDATE Episodes SET is_new = 0 WHERE enclosure_url = ?", [url]); "UPDATE Episodes SET is_new = 0 WHERE enclosure_url = ?", [url]);
}); });
developer.log('remove new episode $url'); developer.log('remove episode mark $url');
} }
Future<List<EpisodeBrief>> getLikedRssItem(int i, int sortBy, Future<List<EpisodeBrief>> getLikedRssItem(int i, int sortBy,

View File

@ -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<PlaylistHome> {
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<SystemUiOverlayStyle>(
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<AudioPlayerNotifier,
Tuple4<Playlist, bool, bool, EpisodeBrief>>(
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<AudioPlayerNotifier>();
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<AudioPlayerNotifier>()
.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<AudioPlayerNotifier, Tuple2<Playlist, bool>>(
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<AudioPlayerNotifier>()
.reorderPlaylist(oldIndex, newIndex);
setState(() {});
},
scrollDirection: Axis.vertical,
children: data.item2
? episodes.map<Widget>((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<Widget>((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: <Widget>[
Expanded(
child: ListTile(
contentPadding: EdgeInsets.symmetric(vertical: 8),
onTap: () async {
await context
.read<AudioPlayerNotifier>()
.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: <Widget>[
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<List<PlayHistory>> getPlayRecords(int top) async {
List<PlayHistory> playHistory;
playHistory = await dbHelper.getPlayRecords(top);
for (var record in playHistory) {
await record.getEpisode();
}
return playHistory;
}
Future<void> _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<AudioPlayerNotifier>();
return FutureBuilder<List<PlayHistory>>(
future: _getData,
builder: (context, snapshot) {
return snapshot.hasData
? NotificationListener<ScrollNotification>(
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: <Widget>[
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<EpisodeBrief>>(
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<EpisodeBrief> _getEpisode(String url) async {
var dbHelper = DBHelper();
return await dbHelper.getRssItemWithUrl(url);
}
@override
Widget build(BuildContext context) {
final s = context.s;
return Selector<AudioPlayerNotifier, List<Playlist>>(
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<AudioPlayerNotifier>()
.playlistLoad(queue);
},
child: Text('Play'))
],
)
],
),
),
);
}
if (index < data.length) {
final episodeList = data[index].episodeList;
return ListTile(
onTap: () async {
await context
.read<AudioPlayerNotifier>()
.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<EpisodeBrief>(
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<AudioPlayerNotifier>()
.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<String> _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<SystemUiOverlayStyle>(
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: <Widget>[
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<AudioPlayerNotifier>()
.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<AudioPlayerNotifier>().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: <Widget>[
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),
],
),
],
),
),
);
}
}

View File

@ -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<PlaylistDetail> {
final List<EpisodeBrief> _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<AudioPlayerNotifier>().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<int>(
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<AudioPlayerNotifier>()
.clearPlaylist(widget.playlist);
}
}),
),
),
)
],
),
body: Selector<AudioPlayerNotifier, List<Playlist>>(
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<AudioPlayerNotifier>().reorderEpisodesInPlaylist(
widget.playlist,
oldIndex: oldIndex,
newIndex: newIndex);
setState(() {});
},
scrollDirection: Axis.vertical,
children: episodes.map<Widget>((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<EpisodeBrief> onSelect;
final ValueChanged<EpisodeBrief> 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: <Widget>[
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: <Widget>[
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,
),
],
));
}
}

View File

@ -23,38 +23,47 @@ class PodcastGroupList extends StatefulWidget {
} }
class _PodcastGroupListState extends State<PodcastGroupList> { class _PodcastGroupListState extends State<PodcastGroupList> {
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var groupList = Provider.of<GroupList>(context, listen: false); return _group.podcastList.isEmpty
return widget.group.podcastList.length == 0
? Container( ? Container(
color: Theme.of(context).primaryColor, color: context.primaryColor,
) )
: Container( : Container(
color: Theme.of(context).primaryColor, color: context.primaryColor,
child: ReorderableListView( child: ReorderableListView(
onReorder: (oldIndex, newIndex) { onReorder: (oldIndex, newIndex) {
setState(() { setState(() {
if (newIndex > oldIndex) { _group.reorderGroup(oldIndex, newIndex);
newIndex -= 1;
}
final podcast = widget.group.podcasts.removeAt(oldIndex);
widget.group.podcasts.insert(newIndex, podcast);
}); });
widget.group.orderedPodcasts = widget.group.podcasts; context.read<GroupList>().addToOrderChanged(_group);
groupList.addToOrderChanged(widget.group);
}, },
children: widget.group.podcasts.map<Widget>((podcastLocal) { children: _group.podcasts.map<Widget>(
return Container( (podcastLocal) {
decoration: return Container(
BoxDecoration(color: Theme.of(context).primaryColor), decoration:
key: ObjectKey(podcastLocal.title), BoxDecoration(color: Theme.of(context).primaryColor),
child: _PodcastCard( key: ObjectKey(podcastLocal.title),
podcastLocal: podcastLocal, child: _PodcastCard(
group: widget.group, podcastLocal: podcastLocal,
), group: _group,
); ),
}).toList(), );
},
).toList(),
), ),
); );
} }
@ -310,7 +319,7 @@ class __PodcastCardState extends State<_PodcastCard>
_addGroup = false; _addGroup = false;
}); });
await groupList.changeGroup( await groupList.changeGroup(
widget.podcastLocal.id, widget.podcastLocal,
_selectedGroups, _selectedGroups,
); );
Fluttertoast.showToast( Fluttertoast.showToast(
@ -530,8 +539,8 @@ class _RenameGroupState extends State<RenameGroup> {
borderRadius: BorderRadius.all(Radius.circular(10))), borderRadius: BorderRadius.all(Radius.circular(10))),
elevation: 1, elevation: 1,
contentPadding: EdgeInsets.symmetric(horizontal: 20), contentPadding: EdgeInsets.symmetric(horizontal: 20),
titlePadding: EdgeInsets.only(top: 20, left: 20, right: 20, bottom: 20), titlePadding: EdgeInsets.all(20),
actionsPadding: EdgeInsets.all(0), actionsPadding: EdgeInsets.zero,
actions: <Widget>[ actions: <Widget>[
FlatButton( FlatButton(
splashColor: context.accentColor.withAlpha(70), splashColor: context.accentColor.withAlpha(70),

View File

@ -195,15 +195,14 @@ class _PodcastManageState extends State<PodcastManage>
), ),
body: WillPopScope( body: WillPopScope(
onWillPop: () async { onWillPop: () async {
await Provider.of<GroupList>(context, listen: false) await context.read<GroupList>().clearOrderChanged();
.clearOrderChanged();
return true; return true;
}, },
child: Consumer<GroupList>( child: Consumer<GroupList>(
builder: (_, groupList, __) { builder: (_, groupList, __) {
var _isLoading = groupList.isLoading; // var _isLoading = groupList.isLoading;
var _groups = groupList.groups; var _groups = groupList.groups;
return _isLoading return _groups.isEmpty
? Center() ? Center()
: Stack( : Stack(
children: <Widget>[ children: <Widget>[

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
@ -57,7 +58,6 @@ void _audioPlayerTaskEntrypoint() async {
/// Sleep timer mode. /// Sleep timer mode.
enum SleepTimerMode { endOfEpisode, timer, undefined } enum SleepTimerMode { endOfEpisode, timer, undefined }
enum PlayerHeight { short, mid, tall } enum PlayerHeight { short, mid, tall }
//enum ShareStatus { generate, download, complete, undefined, error }
class AudioPlayerNotifier extends ChangeNotifier { class AudioPlayerNotifier extends ChangeNotifier {
final _dbHelper = DBHelper(); final _dbHelper = DBHelper();
@ -77,15 +77,20 @@ class AudioPlayerNotifier extends ChangeNotifier {
final _volumeGainStorage = KeyValueStorage(volumeGainKey); final _volumeGainStorage = KeyValueStorage(volumeGainKey);
final _markListenedAfterSkipStorage = final _markListenedAfterSkipStorage =
KeyValueStorage(markListenedAfterSkipKey); KeyValueStorage(markListenedAfterSkipKey);
final _playlistsStorgae = KeyValueStorage(playlistsAllKey);
final _playerStateStorage = KeyValueStorage(playerStateKey);
/// Current playing episdoe. /// Playing episdoe.
EpisodeBrief _episode; EpisodeBrief _episode;
/// Current playlist. /// Playlists include queue and playlists created by user.
Playlist _queue = Playlist('Playlist'); List<Playlist> _playlists;
/// Notifier for playlist change. /// Playing playlist.
bool _queueUpdate = false; Playlist _playlist;
/// Queue is the first playlist by default.
Playlist get _queue => _playlists.first;
/// Player state. /// Player state.
AudioProcessingState _audioState = AudioProcessingState.none; AudioProcessingState _audioState = AudioProcessingState.none;
@ -170,6 +175,20 @@ class AudioPlayerNotifier extends ChangeNotifier {
bool _markListened; 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; AudioProcessingState get audioState => _audioState;
int get backgroundAudioDuration => _backgroundAudioDuration; int get backgroundAudioDuration => _backgroundAudioDuration;
int get backgroundAudioPosition => _backgroundAudioPosition; int get backgroundAudioPosition => _backgroundAudioPosition;
@ -177,11 +196,16 @@ class AudioPlayerNotifier extends ChangeNotifier {
String get remoteErrorMessage => _remoteErrorMessage; String get remoteErrorMessage => _remoteErrorMessage;
bool get playerRunning => _playerRunning; bool get playerRunning => _playerRunning;
bool get buffering => _audioState != AudioProcessingState.ready; 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; EpisodeBrief get episode => _episode;
/// Playlist provider.
int get lastPositin => _lastPostion;
List<Playlist> get playlists => _playlists;
Playlist get queue => _playlists.first;
bool get playing => _playing;
Playlist get playlist => _playlist;
/// Player control.
bool get stopOnComplete => _stopOnComplete; bool get stopOnComplete => _stopOnComplete;
bool get startSleepTimer => _startSleepTimer; bool get startSleepTimer => _startSleepTimer;
SleepTimerMode get sleepTimerMode => _sleepTimerMode; SleepTimerMode get sleepTimerMode => _sleepTimerMode;
@ -213,19 +237,10 @@ class AudioPlayerNotifier extends ChangeNotifier {
_savePlayerHeight(); _savePlayerHeight();
} }
set setVolumeGain(int volumeGain) {
_volumeGain = volumeGain;
if (_playerRunning && _boostVolume) {
setBoostVolume(boostVolume: _boostVolume, gain: _volumeGain);
}
notifyListeners();
_volumeGainStorage.saveInt(volumeGain);
}
Future<void> _initAudioData() async { Future<void> _initAudioData() async {
var index = await _playerHeightStorage.getInt(defaultValue: 0); var index = await _playerHeightStorage.getInt(defaultValue: 0);
_playerHeight = PlayerHeight.values[index]; _playerHeight = PlayerHeight.values[index];
_currentSpeed = await _speedStorage.getDoubel(defaultValue: 1.0); _currentSpeed = await _speedStorage.getDouble(defaultValue: 1.0);
_skipSilence = await _skipSilenceStorage.getBool(defaultValue: false); _skipSilence = await _skipSilenceStorage.getBool(defaultValue: false);
_boostVolume = await _boostVolumeStorage.getBool(defaultValue: false); _boostVolume = await _boostVolumeStorage.getBool(defaultValue: false);
_volumeGain = await _volumeGainStorage.getInt(defaultValue: 3000); _volumeGain = await _volumeGainStorage.getInt(defaultValue: 3000);
@ -245,45 +260,73 @@ class AudioPlayerNotifier extends ChangeNotifier {
_autoSleepTimer = i == 1; _autoSleepTimer = i == 1;
} }
set setSleepTimerMode(SleepTimerMode timer) { Future<void> initPlaylist() async {
_sleepTimerMode = timer; var playlistEntities = await _playlistsStorgae.getPlaylists();
notifyListeners(); _playlists = [
} for (var entity in playlistEntities) Playlist.fromEntity(entity)
];
@override await _playlists.first.getPlaylist();
void addListener(VoidCallback listener) {
super.addListener(listener);
_initAudioData();
AudioService.connect();
}
Future<void> loadPlaylist() async {
await _queue.getPlaylist();
await _getAutoPlay(); await _getAutoPlay();
_lastPostion = await _positionStorage.getInt();
if (_lastPostion > 0 && _queue.playlist.length > 0) { var state = await _playerStateStorage.getPlayerState();
final episode = _queue.playlist.first; if (state[0] != '') {
final duration = episode.duration * 1000; _playlist = _playlists.firstWhere((p) => p.id == state[0],
final seekValue = duration != 0 ? _lastPostion / duration : 1.0; orElse: () => _playlists.first);
final history = PlayHistory( } else {
episode.title, episode.enclosureUrl, _lastPostion ~/ 1000, seekValue); _playlist = _playlists.first;
await _dbHelper.saveHistory(history);
} }
var lastWorkStorage = KeyValueStorage(lastWorkKey); await _playlist.getPlaylist();
await lastWorkStorage.saveInt(0); 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<void> playlistLoad() async { Future<void> playFromLastPosition() async {
await _queue.getPlaylist();
_backgroundAudioDuration = 0;
_backgroundAudioPosition = 0;
_seekSliderValue = 0;
_episode = _queue.playlist.first;
_queueUpdate = !_queueUpdate;
_audioState = AudioProcessingState.none; _audioState = AudioProcessingState.none;
_playerRunning = true; _playerRunning = true;
notifyListeners(); notifyListeners();
_startAudioService(_lastPostion ?? 0, _queue.playlist.first.enclosureUrl); _startAudioService(_playlist,
position: _lastPostion ?? 0,
index: _playlist.episodes.indexOf(_episode));
}
Future<void> 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<void> episodeLoad(EpisodeBrief episode, Future<void> episodeLoad(EpisodeBrief episode,
@ -298,13 +341,10 @@ class AudioPlayerNotifier extends ChangeNotifier {
if (startPosition > 0) { if (startPosition > 0) {
await AudioService.seekTo(Duration(milliseconds: startPosition)); await AudioService.seekTo(Duration(milliseconds: startPosition));
} }
_queue.playlist.removeAt(0); _queue.addToPlayListAt(episodeNew, 0);
_queue.playlist.removeWhere((item) => item == episode); await updatePlaylist(_queue);
_queue.playlist.insert(0, episodeNew);
_queueUpdate != _queueUpdate;
_remoteErrorMessage = null; _remoteErrorMessage = null;
notifyListeners(); notifyListeners();
await _queue.savePlaylist();
if (episodeNew.isNew == 1) { if (episodeNew.isNew == 1) {
await _dbHelper.removeEpisodeNewMark(episodeNew.enclosureUrl); await _dbHelper.removeEpisodeNewMark(episodeNew.enclosureUrl);
} }
@ -317,11 +357,12 @@ class AudioPlayerNotifier extends ChangeNotifier {
_episode = episodeNew; _episode = episodeNew;
_playerRunning = true; _playerRunning = true;
notifyListeners(); notifyListeners();
_startAudioService(startPosition, episodeNew.enclosureUrl); _startAudioService(_queue, position: startPosition);
} }
} }
Future<void> _startAudioService(int position, String url) async { Future<void> _startAudioService(playlist,
{int index = 0, int position = 0}) async {
_stopOnComplete = false; _stopOnComplete = false;
_sleepTimerMode = SleepTimerMode.undefined; _sleepTimerMode = SleepTimerMode.undefined;
_switchValue = 0; _switchValue = 0;
@ -343,6 +384,7 @@ class AudioPlayerNotifier extends ChangeNotifier {
/// Start audio service. /// Start audio service.
await AudioService.start( await AudioService.start(
backgroundTaskEntrypoint: _audioPlayerTaskEntrypoint, backgroundTaskEntrypoint: _audioPlayerTaskEntrypoint,
params: {'index': index, 'isQueue': playlist.name == 'Queue'},
androidNotificationChannelName: 'Tsacdop', androidNotificationChannelName: 'Tsacdop',
androidNotificationColor: 0xFF4d91be, androidNotificationColor: 0xFF4d91be,
androidNotificationIcon: 'drawable/ic_notification', androidNotificationIcon: 'drawable/ic_notification',
@ -354,11 +396,11 @@ class AudioPlayerNotifier extends ChangeNotifier {
//Check autoplay setting, if true only add one episode, else add playlist. //Check autoplay setting, if true only add one episode, else add playlist.
await _getAutoPlay(); await _getAutoPlay();
if (_autoPlay) { if (_autoPlay) {
for (var episode in _queue.playlist) { for (var episode in playlist.episodes) {
await AudioService.addQueueItem(episode.toMediaItem()); await AudioService.addQueueItem(episode.toMediaItem());
} }
} else { } else {
await AudioService.addQueueItem(_queue.playlist.first.toMediaItem()); await AudioService.addQueueItem(playlist.episodes[index].toMediaItem());
} }
//Check auto sleep timer setting //Check auto sleep timer setting
await _getAutoSleepTimer(); await _getAutoSleepTimer();
@ -408,7 +450,7 @@ class AudioPlayerNotifier extends ChangeNotifier {
_backgroundAudioDuration = item.duration.inMilliseconds ?? 0; _backgroundAudioDuration = item.duration.inMilliseconds ?? 0;
if (position > 0 && if (position > 0 &&
_backgroundAudioDuration > 0 && _backgroundAudioDuration > 0 &&
_episode.enclosureUrl == url) { _episode.enclosureUrl == _playlist.episodeList[index]) {
await AudioService.seekTo(Duration(milliseconds: position)); await AudioService.seekTo(Duration(milliseconds: position));
position = 0; position = 0;
} }
@ -445,13 +487,17 @@ class AudioPlayerNotifier extends ChangeNotifier {
}); });
AudioService.customEventStream.distinct().listen((event) async { AudioService.customEventStream.distinct().listen((event) async {
if (event is String && if (event is String) {
_queue.playlist.isNotEmpty && if (_playlist.name == 'Queue' &&
_queue.playlist.first.title == event) { _queue.episodes.isNotEmpty &&
_queue.delFromPlaylist(_episode); _queue.episodes.first.title == event) {
_queue.delFromPlaylist(_episode);
}
_lastPostion = 0; _lastPostion = 0;
notifyListeners(); notifyListeners();
await _positionStorage.saveInt(_lastPostion); // await _positionStorage.saveInt(_lastPostion);
await _playerStateStorage.savePlayerState(
_playlist.id, _episode.enclosureUrl, _lastPostion);
var history; var history;
if (_markListened) { if (_markListened) {
history = PlayHistory(_episode.title, _episode.enclosureUrl, history = PlayHistory(_episode.title, _episode.enclosureUrl,
@ -501,7 +547,9 @@ class AudioPlayerNotifier extends ChangeNotifier {
if (_backgroundAudioPosition > 0 && if (_backgroundAudioPosition > 0 &&
_backgroundAudioPosition < _backgroundAudioDuration) { _backgroundAudioPosition < _backgroundAudioDuration) {
_lastPostion = _backgroundAudioPosition; _lastPostion = _backgroundAudioPosition;
_positionStorage.saveInt(_lastPostion); // _positionStorage.saveInt(_lastPostion);
_playerStateStorage.savePlayerState(
_playlist.id, _episode.enclosureUrl, _lastPostion);
} }
notifyListeners(); notifyListeners();
} }
@ -511,33 +559,31 @@ class AudioPlayerNotifier extends ChangeNotifier {
}); });
} }
Future<void> playNext() async { /// Playlists management.
_remoteErrorMessage = null;
await AudioService.skipToNext();
_queueUpdate = !_queueUpdate;
notifyListeners();
}
Future<void> addToPlaylist(EpisodeBrief episode) async { Future<void> addToPlaylist(EpisodeBrief episode) async {
var episodeNew = await _dbHelper.getRssItemWithUrl(episode.enclosureUrl); var episodeNew = await _dbHelper.getRssItemWithUrl(episode.enclosureUrl);
if (!_queue.playlist.contains(episodeNew)) { if (episodeNew.isNew == 1) {
if (playerRunning) { await _dbHelper.removeEpisodeNewMark(episodeNew.enclosureUrl);
}
if (!_queue.episodes.contains(episodeNew)) {
if (playerRunning && _playlist.name == 'Queue') {
await AudioService.addQueueItem(episodeNew.toMediaItem()); await AudioService.addQueueItem(episodeNew.toMediaItem());
} }
await _queue.addToPlayList(episodeNew); _queue.addToPlayList(episodeNew);
_queueUpdate = !_queueUpdate; await updatePlaylist(_queue, updateEpisodes: false);
notifyListeners();
} }
} }
Future<void> addToPlaylistAt(EpisodeBrief episode, int index) async { Future<void> addToPlaylistAt(EpisodeBrief episode, int index) async {
var episodeNew = await _dbHelper.getRssItemWithUrl(episode.enclosureUrl); 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 AudioService.addQueueItemAt(episodeNew.toMediaItem(), index);
} }
await _queue.addToPlayListAt(episodeNew, index); _queue.addToPlayListAt(episodeNew, index);
_queueUpdate = !_queueUpdate; await updatePlaylist(_queue, updateEpisodes: false);
notifyListeners();
} }
Future<void> addNewEpisode(List<String> group) async { Future<void> addNewEpisode(List<String> group) async {
@ -560,40 +606,34 @@ class AudioPlayerNotifier extends ChangeNotifier {
} }
Future<void> updateMediaItem(EpisodeBrief episode) async { Future<void> updateMediaItem(EpisodeBrief episode) async {
if (episode.enclosureUrl == episode.mediaId) { if (episode.enclosureUrl == episode.mediaId && _episode != episode) {
var index = _queue.playlist var episodeNew = await _dbHelper.getRssItemWithUrl(episode.enclosureUrl);
.indexWhere((item) => item.enclosureUrl == episode.enclosureUrl); _playlist.updateEpisode(episodeNew);
if (index > 0) {
var episodeNew =
await _dbHelper.getRssItemWithUrl(episode.enclosureUrl);
await delFromPlaylist(episode);
await addToPlaylistAt(episodeNew, index);
}
} }
} }
Future<int> delFromPlaylist(EpisodeBrief episode) async { Future<int> delFromPlaylist(EpisodeBrief episode) async {
var episodeNew = await _dbHelper.getRssItemWithUrl(episode.enclosureUrl); var episodeNew = await _dbHelper.getRssItemWithUrl(episode.enclosureUrl);
if (playerRunning) { if (playerRunning && _playlist.name == 'Queue') {
await AudioService.removeQueueItem(episodeNew.toMediaItem()); await AudioService.removeQueueItem(episodeNew.toMediaItem());
} }
var index = await _queue.delFromPlaylist(episodeNew); var index = _queue.delFromPlaylist(episodeNew);
if (index == 0) { if (index == 0) {
_lastPostion = 0; _lastPostion = 0;
await _positionStorage.saveInt(0); await _positionStorage.saveInt(0);
} }
_queueUpdate = !_queueUpdate; updatePlaylist(_queue, updateEpisodes: false);
notifyListeners();
return index; return index;
} }
Future reorderPlaylist(int oldIndex, int newIndex) async { Future reorderPlaylist(int oldIndex, int newIndex) async {
var episode = _queue.playlist[oldIndex]; var episode = _queue.episodes[oldIndex];
if (playerRunning) { if (playerRunning && _playlist.name == 'Queue') {
await AudioService.removeQueueItem(episode.toMediaItem()); await AudioService.removeQueueItem(episode.toMediaItem());
await AudioService.addQueueItemAt(episode.toMediaItem(), newIndex); await AudioService.addQueueItemAt(episode.toMediaItem(), newIndex);
} }
await _queue.addToPlayListAt(episode, newIndex); _queue.addToPlayListAt(episode, newIndex);
updatePlaylist(_queue, updateEpisodes: false);
if (newIndex == 0) { if (newIndex == 0) {
_lastPostion = 0; _lastPostion = 0;
await _positionStorage.saveInt(0); await _positionStorage.saveInt(0);
@ -602,21 +642,102 @@ class AudioPlayerNotifier extends ChangeNotifier {
Future<bool> moveToTop(EpisodeBrief episode) async { Future<bool> moveToTop(EpisodeBrief episode) async {
await delFromPlaylist(episode); await delFromPlaylist(episode);
if (playerRunning) { if (playerRunning && _playlist.name == 'Queue') {
final episodeNew = final episodeNew =
await _dbHelper.getRssItemWithUrl(episode.enclosureUrl); await _dbHelper.getRssItemWithUrl(episode.enclosureUrl);
await AudioService.addQueueItemAt(episodeNew.toMediaItem(), 1); await AudioService.addQueueItemAt(episodeNew.toMediaItem(), 1);
await _queue.addToPlayListAt(episode, 1, existed: false); _queue.addToPlayListAt(episode, 1, existed: false);
} else { } else {
await _queue.addToPlayListAt(episode, 0, existed: false); _queue.addToPlayListAt(episode, 0, existed: false);
_lastPostion = 0; _lastPostion = 0;
_positionStorage.saveInt(_lastPostion); _positionStorage.saveInt(_lastPostion);
} }
_queueUpdate = !_queueUpdate; updatePlaylist(_queue, updateEpisodes: false);
notifyListeners();
return true; 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<EpisodeBrief> 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<EpisodeBrief> 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<void> 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<void> _savePlaylists() async {
await _playlistsStorgae
.savePlaylists([for (var p in _playlists) p.toEntity()]);
}
/// Audio control.
Future<void> pauseAduio() async { Future<void> pauseAduio() async {
await AudioService.pause(); await AudioService.pause();
} }
@ -630,6 +751,12 @@ class AudioPlayerNotifier extends ChangeNotifier {
} }
} }
Future<void> playNext() async {
_remoteErrorMessage = null;
await AudioService.skipToNext();
notifyListeners();
}
Future<void> forwardAudio(int s) async { Future<void> forwardAudio(int s) async {
var pos = _backgroundAudioPosition + s * 1000; var pos = _backgroundAudioPosition + s * 1000;
await AudioService.seekTo(Duration(milliseconds: pos)); await AudioService.seekTo(Duration(milliseconds: pos));
@ -670,6 +797,7 @@ class AudioPlayerNotifier extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Set skip silence.
Future<void> setSkipSilence({@required bool skipSilence}) async { Future<void> setSkipSilence({@required bool skipSilence}) async {
await AudioService.customAction('setSkipSilence', skipSilence); await AudioService.customAction('setSkipSilence', skipSilence);
_skipSilence = skipSilence; _skipSilence = skipSilence;
@ -677,6 +805,15 @@ class AudioPlayerNotifier extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
set setVolumeGain(int volumeGain) {
_volumeGain = volumeGain;
if (_playerRunning && _boostVolume) {
setBoostVolume(boostVolume: _boostVolume, gain: _volumeGain);
}
notifyListeners();
_volumeGainStorage.saveInt(volumeGain);
}
Future<void> setBoostVolume({@required bool boostVolume, int gain}) async { Future<void> setBoostVolume({@required bool boostVolume, int gain}) async {
await AudioService.customAction( await AudioService.customAction(
'setBoostVolume', [boostVolume, _volumeGain]); 'setBoostVolume', [boostVolume, _volumeGain]);
@ -715,12 +852,17 @@ class AudioPlayerNotifier extends ChangeNotifier {
_stopOnComplete = true; _stopOnComplete = true;
_switchValue = 1; _switchValue = 1;
notifyListeners(); notifyListeners();
if (_queue.playlist.length > 1 && _autoPlay) { if (_queue.episodes.length > 1 && _autoPlay) {
AudioService.customAction('stopAtEnd'); AudioService.customAction('stopAtEnd');
} }
} }
} }
set setSleepTimerMode(SleepTimerMode timer) {
_sleepTimerMode = timer;
notifyListeners();
}
//Cancel sleep timer //Cancel sleep timer
void cancelTimer() { void cancelTimer() {
if (_sleepTimerMode == SleepTimerMode.timer) { if (_sleepTimerMode == SleepTimerMode.timer) {
@ -737,11 +879,8 @@ class AudioPlayerNotifier extends ChangeNotifier {
} }
} }
@override /// Save player state.
void dispose() async { Future<void> _savePlayerPosiont() {}
await AudioService.disconnect();
super.dispose();
}
} }
class AudioPlayerTask extends BackgroundAudioTask { class AudioPlayerTask extends BackgroundAudioTask {
@ -755,9 +894,11 @@ class AudioPlayerTask extends BackgroundAudioTask {
bool _interrupted = false; bool _interrupted = false;
bool _stopAtEnd; bool _stopAtEnd;
int _cacheMax; int _cacheMax;
int _index = 0;
bool _isQueue;
bool get hasNext => _queue.length > 0; 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<AudioPlaybackState> _playerStateSubscription; StreamSubscription<AudioPlaybackState> _playerStateSubscription;
StreamSubscription<AudioPlaybackEvent> _eventSubscription; StreamSubscription<AudioPlaybackEvent> _eventSubscription;
@ -766,6 +907,8 @@ class AudioPlayerTask extends BackgroundAudioTask {
Future<void> onStart(Map<String, dynamic> params) async { Future<void> onStart(Map<String, dynamic> params) async {
_stopAtEnd = false; _stopAtEnd = false;
_session = await AudioSession.instance; _session = await AudioSession.instance;
_index = params['index'] ?? 0;
_isQueue = params['isQueue'] ?? true;
await _session.configure(AudioSessionConfiguration.speech()); await _session.configure(AudioSessionConfiguration.speech());
_handleInterruption(_session); _handleInterruption(_session);
_playerStateSubscription = _audioPlayer.playbackStateStream _playerStateSubscription = _audioPlayer.playbackStateStream
@ -876,19 +1019,23 @@ class AudioPlayerTask extends BackgroundAudioTask {
_skipState = AudioProcessingState.skippingToNext; _skipState = AudioProcessingState.skippingToNext;
_playing = false; _playing = false;
await _audioPlayer.stop(); await _audioPlayer.stop();
if (_queue.length > 0) { AudioServiceBackground.sendCustomEvent(_queue.first.title);
AudioServiceBackground.sendCustomEvent(_queue.first.title); if (_isQueue) {
_queue.removeAt(0); if (_queue.length > 0) {
_queue.removeAt(0);
}
await AudioServiceBackground.setQueue(_queue);
} else {
_index += 1;
} }
await AudioServiceBackground.setQueue(_queue);
if (_queue.length == 0 || _stopAtEnd) { if (_queue.length == 0 || _stopAtEnd) {
_skipState = null; _skipState = null;
await Future.delayed(Duration(milliseconds: 200)); await Future.delayed(Duration(milliseconds: 200));
await onStop(); await onStop();
} else { } else {
await AudioServiceBackground.setQueue(_queue); // await AudioServiceBackground.setQueue(_queue);
await AudioServiceBackground.setMediaItem(mediaItem); await AudioServiceBackground.setMediaItem(mediaItem);
await _audioPlayer.setUrl(mediaItem.id, cacheMax: _cacheMax); await _audioPlayer.setUrl(mediaItem.id, cacheMax: _cacheMax);
var duration = await _audioPlayer.durationFuture; var duration = await _audioPlayer.durationFuture;
if (duration != null) { if (duration != null) {
@ -1017,13 +1164,15 @@ class AudioPlayerTask extends BackgroundAudioTask {
@override @override
Future<void> onRemoveQueueItem(MediaItem mediaItem) async { Future<void> onRemoveQueueItem(MediaItem mediaItem) async {
var index = _queue.indexOf(mediaItem);
if (index < _index) _index -= 1;
_queue.removeWhere((item) => item.id == mediaItem.id); _queue.removeWhere((item) => item.id == mediaItem.id);
await AudioServiceBackground.setQueue(_queue); await AudioServiceBackground.setQueue(_queue);
} }
@override @override
Future<void> onAddQueueItemAt(MediaItem mediaItem, int index) async { Future<void> onAddQueueItemAt(MediaItem mediaItem, int index) async {
if (index == 0) { if (index == 0 && _isQueue) {
await _audioPlayer.stop(); await _audioPlayer.stop();
_queue.removeAt(0); _queue.removeAt(0);
_queue.removeWhere((item) => item.id == mediaItem.id); _queue.removeWhere((item) => item.id == mediaItem.id);
@ -1038,6 +1187,7 @@ class AudioPlayerTask extends BackgroundAudioTask {
//onPlay(); //onPlay();
} else { } else {
_queue.insert(index, mediaItem); _queue.insert(index, mediaItem);
if (index < _index) _index += 1;
await AudioServiceBackground.setQueue(_queue); await AudioServiceBackground.setQueue(_queue);
} }
} }
@ -1070,9 +1220,28 @@ class AudioPlayerTask extends BackgroundAudioTask {
case 'setBoostVolume': case 'setBoostVolume':
await _setBoostVolume(argument[0], argument[1]); await _setBoostVolume(argument[0], argument[1]);
break; break;
case 'setIsQueue':
_isQueue = argument;
break;
case 'changeQueue':
await _changeQueue(argument);
} }
} }
Future _changeQueue(List<dynamic> 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 { Future _setSkipSilence(bool boo) async {
await _audioPlayer.setSkipSilence(boo); await _audioPlayer.setSkipSilence(boo);
var duration = await _audioPlayer.durationFuture ?? Duration.zero; var duration = await _audioPlayer.durationFuture ?? Duration.zero;

View File

@ -66,22 +66,32 @@ class PodcastGroup extends Equatable {
final String color; final String color;
/// Id lists of podcasts in group. /// Id lists of podcasts in group.
List<String> podcastList; final List<String> podcastList;
final List<PodcastLocal> podcasts;
PodcastGroup(this.name, PodcastGroup(this.name,
{this.color = '#000000', String id, List<String> podcastList}) {this.color = '#000000',
String id,
List<String> podcastList,
List<PodcastLocal> podcasts})
: id = id ?? Uuid().v4(), : id = id ?? Uuid().v4(),
podcastList = podcastList ?? []; podcastList = podcastList ?? [],
podcasts = podcasts ?? [];
final _dbHelper = DBHelper();
Future<void> getPodcasts() async { Future<void> getPodcasts() async {
var dbHelper = DBHelper(); podcasts.clear();
if (podcastList != []) { if (podcastList.isNotEmpty) {
try { try {
_podcasts = await dbHelper.getPodcastLocal(podcastList); var result = await _dbHelper.getPodcastLocal(podcastList);
if (podcasts.isEmpty) podcasts.addAll(result);
} catch (e) { } catch (e) {
await Future.delayed(Duration(milliseconds: 200)); await Future.delayed(Duration(milliseconds: 200));
try { try {
_podcasts = await dbHelper.getPodcastLocal(podcastList); var result = await _dbHelper.getPodcastLocal(podcastList);
if (podcasts.isEmpty) podcasts.addAll(result);
} catch (e) { } catch (e) {
developer.log(e.toString()); developer.log(e.toString());
} }
@ -89,6 +99,45 @@ class PodcastGroup extends Equatable {
} }
} }
Future<PodcastGroup> 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() { Color getColor() {
if (color != '#000000') { if (color != '#000000') {
var colorInt = int.parse('FF${color.toUpperCase()}', radix: 16); var colorInt = int.parse('FF${color.toUpperCase()}', radix: 16);
@ -98,15 +147,11 @@ class PodcastGroup extends Equatable {
} }
} }
///Podcast in group.
List<PodcastLocal> _podcasts;
List<PodcastLocal> get podcasts => _podcasts;
///Ordered podcast list. ///Ordered podcast list.
List<PodcastLocal> _orderedPodcasts; //List<PodcastLocal> _orderedPodcasts;
List<PodcastLocal> get orderedPodcasts => _orderedPodcasts; //List<PodcastLocal> get orderedPodcasts => _orderedPodcasts;
set orderedPodcasts(list) => _orderedPodcasts = list; //set orderedPodcasts(list) => _orderedPodcasts = list;
GroupEntity toEntity() { GroupEntity toEntity() {
return GroupEntity(name, id, color, podcastList); return GroupEntity(name, id, color, podcastList);
@ -117,7 +162,7 @@ class PodcastGroup extends Equatable {
entity.name, entity.name,
id: entity.id, id: entity.id,
color: entity.color, color: entity.color,
podcastList: entity.podcastList, podcastList: entity.podcastList.toSet().toList(),
); );
} }
@ -158,25 +203,12 @@ class SubscribeItem {
class GroupList extends ChangeNotifier { class GroupList extends ChangeNotifier {
/// List of all gourps. /// List of all gourps.
final List<PodcastGroup> _groups = []; List<PodcastGroup> _groups = [];
List<PodcastGroup> get groups => _groups; List<PodcastGroup> get groups => _groups;
final DBHelper _dbHelper = DBHelper(); List<PodcastGroup> _orderChanged = [];
/// Groups save in shared_prefrences.
final KeyValueStorage _groupStorage = KeyValueStorage(groupsKey);
//GroupList({List<PodcastGroup> 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<PodcastGroup> _orderChanged = [];
List<PodcastGroup> get orderChanged => _orderChanged; List<PodcastGroup> get orderChanged => _orderChanged;
//GroupList({List<PodcastGroup> groups}) : _groups = groups ?? [];
/// Subscribe worker isolate /// Subscribe worker isolate
FlutterIsolate subIsolate; FlutterIsolate subIsolate;
@ -187,11 +219,31 @@ class GroupList extends ChangeNotifier {
SubscribeItem _currentSubscribeItem = SubscribeItem('', ''); SubscribeItem _currentSubscribeItem = SubscribeItem('', '');
SubscribeItem get currentSubscribeItem => _currentSubscribeItem; SubscribeItem get currentSubscribeItem => _currentSubscribeItem;
bool _created = false;
/// Default false, true if subscribe isolate is created. /// Default false, true if subscribe isolate is created.
bool _created = false;
bool get created => _created; 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 /// Add subsribe item
SubscribeItem _subscribeItem; SubscribeItem _subscribeItem;
setSubscribeItem(SubscribeItem item, {bool syncGpodder = true}) async { setSubscribeItem(SubscribeItem item, {bool syncGpodder = true}) async {
@ -283,7 +335,8 @@ class GroupList extends ChangeNotifier {
for (var rssLink in removeList) { for (var rssLink in removeList) {
final exist = await _dbHelper.checkPodcast(rssLink); final exist = await _dbHelper.checkPodcast(rssLink);
if (exist != '') { if (exist != '') {
await _unsubscribe(exist); var podcast = await _dbHelper.getPodcastWithUrl(rssLink);
await _unsubscribe(podcast);
} }
} }
await _remoteAddStorage.clearList(); await _remoteAddStorage.clearList();
@ -322,146 +375,118 @@ class GroupList extends ChangeNotifier {
developer.log('work job cancelled'); developer.log('work job cancelled');
} }
void addToOrderChanged(PodcastGroup group) { /// Mange groups states in app.
_orderChanged.add(group);
notifyListeners();
}
void drlFromOrderChanged(String name) {
_orderChanged.removeWhere((group) => group.name == name);
notifyListeners();
}
Future<void> 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();
}
/// Load groups from storage at start. /// Load groups from storage at start.
Future<void> loadGroups() async { Future<void> loadGroups() async {
_isLoading = true;
notifyListeners();
_groupStorage.getGroups().then((loadgroups) async { _groupStorage.getGroups().then((loadgroups) async {
_groups.addAll(loadgroups.map(PodcastGroup.fromEntity)); _groups.addAll(loadgroups.map(PodcastGroup.fromEntity));
for (var group in _groups) { for (var group in _groups) {
await group.getPodcasts(); await group.getPodcasts();
} }
_isLoading = false; _groups = [...groups];
notifyListeners(); 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 /// Update podcasts of each group
Future<void> updateGroups() async { Future<void> updateGroups() async {
for (var group in _groups) { for (var group in _groups) {
await group.getPodcasts(); await group.getPodcasts();
} }
_groups = [..._groups];
notifyListeners(); notifyListeners();
} }
/// Add new group. /// Add new group.
Future<void> addGroup(PodcastGroup podcastGroup) async { Future<void> addGroup(PodcastGroup podcastGroup) async {
_isLoading = true; _groups = [..._groups, podcastGroup];
_groups.add(podcastGroup);
await _saveGroup();
_isLoading = false;
notifyListeners(); notifyListeners();
await _saveGroup();
} }
/// Remove group. /// Remove group.
Future<void> delGroup(PodcastGroup podcastGroup) async { Future<void> delGroup(PodcastGroup podcastGroup) async {
_isLoading = true; for (var podcast in podcastGroup.podcasts) {
for (var podcast in podcastGroup.podcastList) { if (!_groups.first.podcasts.contains(podcast)) {
if (!_groups.first.podcastList.contains(podcast)) { _groups.first.addToGroup(podcast);
_groups[0].podcastList.insert(0, podcast);
} }
} }
await _saveGroup(); _groups = [
_groups.remove(podcastGroup); for (var group in _groups)
await _groups[0].getPodcasts(); if (group.id != podcastGroup) group
_isLoading = false; ];
notifyListeners(); notifyListeners();
await _saveGroup();
} }
Future<void> updateGroup(PodcastGroup podcastGroup) async { Future<void> updateGroup(PodcastGroup podcastGroup) async {
var oldGroup = _groups.firstWhere((it) => it.id == podcastGroup.id); _groups = [
var index = _groups.indexOf(oldGroup); for (var group in _groups) group == podcastGroup ? podcastGroup : group
_groups.replaceRange(index, index + 1, [podcastGroup]); ];
await podcastGroup.getPodcasts();
notifyListeners(); notifyListeners();
_saveGroup(); _saveGroup();
} }
Future<void> _updateGroups() async {
_groups = [..._groups];
notifyListeners();
await _saveGroup();
}
Future<void> _saveGroup() async { Future<void> _saveGroup() async {
await _groupStorage.saveGroup(_groups.map((it) => it.toEntity()).toList()); await _groupStorage.saveGroup(_groups.map((it) => it.toEntity()).toList());
} }
/// Subscribe podcast from search result. /// Subscribe podcast from search result.
Future subscribe(PodcastLocal podcastLocal) async { Future subscribe(PodcastLocal podcast) async {
_groups[0].podcastList.insert(0, podcastLocal.id); await _dbHelper.savePodcastLocal(podcast);
await _saveGroup(); _groups.first.addToGroupAt(podcast);
await _dbHelper.savePodcastLocal(podcastLocal); _updateGroups();
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();
}
}
} }
/// Subscribe podcast from OPML. /// Subscribe podcast from OPML.
Future<bool> _subscribeNewPodcast( Future<bool> _subscribeNewPodcast(
{String id, String groupName = 'Home'}) async { {String id, String groupName = 'Home'}) async {
//List<String> groupNames = _groups.map((e) => e.name).toList(); //List<String> groupNames = _groups.map((e) => e.name).toList();
var podcasts = await _dbHelper.getPodcastLocal([id]);
for (var group in _groups) { for (var group in _groups) {
if (group.name == groupName) { if (group.name == groupName) {
if (group.podcastList.contains(id)) { if (group.podcastList.contains(id)) {
return true; return true;
} else { } else {
_isLoading = true; group.addToGroupAt(podcasts.first);
notifyListeners(); _updateGroups();
group.podcastList.insert(0, id);
await _saveGroup();
await group.getPodcasts();
_isLoading = false;
notifyListeners();
return true; return true;
} }
} }
} }
_isLoading = true; _groups = [
..._groups,
PodcastGroup(groupName, podcastList: [id], podcasts: podcasts)
];
notifyListeners(); notifyListeners();
_groups.add(PodcastGroup(groupName, podcastList: [id]));
//_groups.last.podcastList.insert(0, id);
await _saveGroup(); await _saveGroup();
await _groups.last.getPodcasts();
_isLoading = false;
notifyListeners();
return true; return true;
} }
@ -476,26 +501,19 @@ class GroupList extends ChangeNotifier {
} }
//Change podcast groups //Change podcast groups
Future<void> changeGroup(String id, List<PodcastGroup> list) async { Future<void> changeGroup(
_isLoading = true; PodcastLocal podcast, List<PodcastGroup> list) async {
notifyListeners(); for (var group in getPodcastGroup(podcast.id)) {
for (var group in getPodcastGroup(id)) {
if (list.contains(group)) { if (list.contains(group)) {
list.remove(group); list.remove(group);
} else { } else {
group.podcastList.remove(id); group.deleteFromGroup(podcast);
} }
} }
for (var s in list) { for (var s in list) {
s.podcastList.insert(0, id); s.addToGroup(podcast);
} }
await _saveGroup(); _updateGroups();
for (var group in _groups) {
await group.getPodcasts();
}
_isLoading = false;
notifyListeners();
} }
/// Unsubscribe podcast /// Unsubscribe podcast
@ -506,35 +524,32 @@ class GroupList extends ChangeNotifier {
} }
} }
Future<void> _unsubscribe(String id) async { Future<void> _unsubscribe(PodcastLocal podcast) async {
_isLoading = true;
notifyListeners();
for (var group in _groups) { for (var group in _groups) {
group.podcastList.remove(id); group.deleteFromGroup(podcast);
} }
await _saveGroup(); _updateGroups();
await _dbHelper.delPodcastLocal(id); await _dbHelper.delPodcastLocal(podcast.id);
for (var group in _groups) {
await group.getPodcasts();
}
_isLoading = false;
notifyListeners();
} }
/// Delete podcsat from device. /// Delete podcsat from device.
Future<void> removePodcast( Future<void> removePodcast(
PodcastLocal podcast, PodcastLocal podcast,
) async { ) async {
_syncRemove(podcast.rssUrl); _syncRemove(podcast.rssUrl);
await _unsubscribe(podcast.id); await _unsubscribe(podcast);
await File(podcast.imagePath)?.delete(); await File(podcast.imagePath)?.delete();
} }
Future<void> saveOrder(PodcastGroup group) async { Future<void> saveOrder(PodcastGroup group) async {
group.podcastList = group.orderedPodcasts.map((e) => e.id).toList(); // group.podcastList = group.orderedPodcasts.map((e) => e.id).toList();
await _saveGroup(); var orderedGroup;
await group.getPodcasts(); for (var g in _orderChanged) {
if (g == group) orderedGroup = g;
}
_groups = [for (var g in _groups) g == orderedGroup ? orderedGroup : g];
notifyListeners(); notifyListeners();
await _saveGroup();
} }
} }

View File

@ -48,6 +48,7 @@ class RefreshWorker extends ChangeNotifier {
_currentRefreshItem = RefreshItem('', RefreshState.none); _currentRefreshItem = RefreshItem('', RefreshState.none);
_complete = true; _complete = true;
notifyListeners(); notifyListeners();
_complete = false;
refreshIsolate?.kill(); refreshIsolate?.kill();
refreshIsolate = null; refreshIsolate = null;
_created = false; _created = false;

View File

@ -1,6 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../local_storage/key_value_storage.dart';
import '../local_storage/sqflite_localpodcast.dart'; import '../local_storage/sqflite_localpodcast.dart';
import 'episodebrief.dart'; import 'episodebrief.dart';
@ -21,7 +21,7 @@ class PlaylistEntity {
} }
} }
class Playlist { class Playlist extends Equatable {
/// Playlist name. the default playlist is named "Playlist". /// Playlist name. the default playlist is named "Playlist".
final String name; final String name;
@ -31,12 +31,19 @@ class Playlist {
/// Episode url list for playlist. /// Episode url list for playlist.
final List<String> episodeList; final List<String> episodeList;
Playlist(this.name, {String id, List<String> episodeList}) /// Eposides in playlist.
final List<EpisodeBrief> episodes;
bool get isEmpty => episodeList.isEmpty;
Playlist(this.name,
{String id, List<String> episodeList, List<EpisodeBrief> episodes})
: id = id ?? Uuid().v4(), : id = id ?? Uuid().v4(),
episodeList = episodeList ?? []; episodeList = episodeList ?? [],
episodes = episodes ?? [];
PlaylistEntity toEntity() { PlaylistEntity toEntity() {
return PlaylistEntity(name, id, episodeList); return PlaylistEntity(name, id, episodeList.toSet().toList());
} }
static Playlist fromEntity(PlaylistEntity entity) { static Playlist fromEntity(PlaylistEntity entity) {
@ -48,58 +55,70 @@ class Playlist {
} }
final DBHelper _dbHelper = DBHelper(); final DBHelper _dbHelper = DBHelper();
List<EpisodeBrief> _playlist = []; // final KeyValueStorage _playlistStorage = KeyValueStorage(playlistKey);
List<EpisodeBrief> get playlist => _playlist;
final KeyValueStorage _playlistStorage = KeyValueStorage(playlistKey);
Future<void> getPlaylist() async { Future<void> getPlaylist() async {
var urls = await _playlistStorage.getStringList(); episodes.clear();
episodeList.addAll(urls); if (episodeList.isNotEmpty) {
if (urls.length == 0) { for (var url in episodeList) {
_playlist = []; print(url);
} else {
_playlist = [];
for (var url in urls) {
var episode = await _dbHelper.getRssItemWithUrl(url); var episode = await _dbHelper.getRssItemWithUrl(url);
if (episode != null) _playlist.add(episode); if (episode != null) episodes.add(episode);
} }
} }
} }
Future<void> savePlaylist() async { // Future<void> savePlaylist() async {
var urls = <String>[]; // var urls = <String>[];
urls.addAll(_playlist.map((e) => e.enclosureUrl)); // urls.addAll(_playlist.map((e) => e.enclosureUrl));
await _playlistStorage.saveStringList(urls.toSet().toList()); // await _playlistStorage.saveStringList(urls.toSet().toList());
} // }
Future<void> addToPlayList(EpisodeBrief episodeBrief) async { void addToPlayList(EpisodeBrief episodeBrief) {
if (!_playlist.contains(episodeBrief)) { if (!episodes.contains(episodeBrief)) {
_playlist.add(episodeBrief); episodes.add(episodeBrief);
await savePlaylist(); episodeList.add(episodeBrief.enclosureUrl);
if (episodeBrief.isNew == 1) {
await _dbHelper.removeEpisodeNewMark(episodeBrief.enclosureUrl);
}
} }
} }
Future<void> addToPlayListAt(EpisodeBrief episodeBrief, int index, void addToPlayListAt(EpisodeBrief episodeBrief, int index,
{bool existed = true}) async { {bool existed = true}) {
if (existed) { if (existed) {
_playlist.removeWhere( episodes.removeWhere((episode) => episode == episodeBrief);
(episode) => episode.enclosureUrl == episodeBrief.enclosureUrl); episodeList.removeWhere((url) => url == episodeBrief.enclosureUrl);
if (episodeBrief.isNew == 1) {
await _dbHelper.removeEpisodeNewMark(episodeBrief.enclosureUrl);
}
} }
_playlist.insert(index, episodeBrief); episodes.insert(index, episodeBrief);
await savePlaylist(); episodeList.insert(index, episodeBrief.enclosureUrl);
} }
Future<int> delFromPlaylist(EpisodeBrief episodeBrief) async { void updateEpisode(EpisodeBrief episode) {
var index = _playlist.indexOf(episodeBrief); var index = episodes.indexOf(episode);
_playlist.removeWhere( if (index != -1) episodes[index] = episode;
}
int delFromPlaylist(EpisodeBrief episodeBrief) {
var index = episodes.indexOf(episodeBrief);
episodes.removeWhere(
(episode) => episode.enclosureUrl == episodeBrief.enclosureUrl); (episode) => episode.enclosureUrl == episodeBrief.enclosureUrl);
await savePlaylist(); episodeList.removeWhere((url) => url == episodeBrief.enclosureUrl);
return index; 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<Object> get props => [id, name];
} }

View File

@ -19,20 +19,19 @@ class PodcastLocal extends Equatable {
final String description; final String description;
int _upateCount; final int updateCount;
int get updateCount => _upateCount; final int episodeCount;
set updateCount(i) => _upateCount = i;
int _episodeCount; //set setUpdateCount(i) => updateCount = i;
int get episodeCount => _episodeCount;
set episodeCount(i) => _episodeCount = i; //set setEpisodeCount(i) => episodeCount = i;
PodcastLocal(this.title, this.imageUrl, this.rssUrl, this.primaryColor, PodcastLocal(this.title, this.imageUrl, this.rssUrl, this.primaryColor,
this.author, this.id, this.imagePath, this.provider, this.link, 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), : assert(rssUrl != null),
_episodeCount = episodeCount ?? 0, episodeCount = episodeCount ?? 0,
_upateCount = upateCount ?? 0; updateCount = updateCount ?? 0;
ImageProvider get avatarImage { ImageProvider get avatarImage {
return File(imagePath).existsSync() return File(imagePath).existsSync()
@ -46,6 +45,14 @@ class PodcastLocal extends Equatable {
: primaryColor.colorizeLight(); : 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 @override
List<Object> get props => [id, rssUrl]; List<Object> get props => [id, rssUrl];
} }

View File

@ -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)),
);
}

View File

@ -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<bool> onRemove;
DismissibleContainer({this.episode, this.onRemove, Key key})
: super(key: key);
@override
_DismissibleContainerState createState() => _DismissibleContainerState();
}
class _DismissibleContainerState extends State<DismissibleContainer> {
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: <Widget>[
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<AudioPlayerNotifier>()
.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<AudioPlayerNotifier>()
.addToPlaylistAt(episodeRemove, index);
widget.onRemove(false);
}),
));
},
child: SizedBox(
height: 90.0,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
Expanded(
child: ListTile(
contentPadding: EdgeInsets.symmetric(vertical: 8),
onTap: () async {
await context
.read<AudioPlayerNotifier>()
.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: <Widget>[
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,
),
],
),
),
),
);
}
}

View File

@ -549,7 +549,7 @@ class EpisodeGrid extends StatelessWidget {
Tuple3<EpisodeBrief, List<String>, bool>>( Tuple3<EpisodeBrief, List<String>, bool>>(
selector: (_, audio) => Tuple3( selector: (_, audio) => Tuple3(
audio?.episode, audio?.episode,
audio.queue.playlist.map((e) => e.enclosureUrl).toList(), audio.queue.episodes.map((e) => e.enclosureUrl).toList(),
audio.episodeState), audio.episodeState),
builder: (_, data, __) => OpenContainerWrapper( builder: (_, data, __) => OpenContainerWrapper(
avatarSize: layout == Layout.one avatarSize: layout == Layout.one

View File

@ -1,5 +1,6 @@
import 'package:connectivity/connectivity.dart'; import 'package:connectivity/connectivity.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -10,6 +11,7 @@ import '../state/audio_state.dart';
import '../state/download_state.dart'; import '../state/download_state.dart';
import '../type/episodebrief.dart'; import '../type/episodebrief.dart';
import '../type/play_histroy.dart'; import '../type/play_histroy.dart';
import '../type/playlist.dart';
import '../util/extension_helper.dart'; import '../util/extension_helper.dart';
import 'custom_widget.dart'; import 'custom_widget.dart';
import 'general_dialog.dart'; import 'general_dialog.dart';
@ -44,6 +46,7 @@ class _MultiSelectMenuBarState extends State<MultiSelectMenuBar> {
bool _marked; bool _marked;
bool _inPlaylist; bool _inPlaylist;
bool _downloaded; bool _downloaded;
bool _showPlaylists;
final _dbHelper = DBHelper(); final _dbHelper = DBHelper();
@override @override
@ -53,6 +56,7 @@ class _MultiSelectMenuBarState extends State<MultiSelectMenuBar> {
_marked = false; _marked = false;
_downloaded = false; _downloaded = false;
_inPlaylist = false; _inPlaylist = false;
_showPlaylists = false;
} }
@override @override
@ -63,6 +67,7 @@ class _MultiSelectMenuBarState extends State<MultiSelectMenuBar> {
_marked = false; _marked = false;
_downloaded = false; _downloaded = false;
_inPlaylist = false; _inPlaylist = false;
_showPlaylists = false;
}); });
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
} }
@ -179,6 +184,11 @@ class _MultiSelectMenuBarState extends State<MultiSelectMenuBar> {
} }
} }
Future<EpisodeBrief> _getEpisode(String url) async {
var dbHelper = DBHelper();
return await dbHelper.getRssItemWithUrl(url);
}
Widget _buttonOnMenu({Widget child, VoidCallback onTap}) => Material( Widget _buttonOnMenu({Widget child, VoidCallback onTap}) => Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
@ -190,6 +200,106 @@ class _MultiSelectMenuBarState extends State<MultiSelectMenuBar> {
), ),
), ),
); );
Widget _playlistList() => SizedBox(
height: 50,
child: Selector<AudioPlayerNotifier, List<Playlist>>(
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<AudioPlayerNotifier>()
.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<EpisodeBrief>(
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() { OverlayEntry _createOverlayEntry() {
RenderBox renderBox = context.findRenderObject(); RenderBox renderBox = context.findRenderObject();
var offset = renderBox.localToGlobal(Offset.zero); var offset = renderBox.localToGlobal(Offset.zero);
@ -214,7 +324,13 @@ class _MultiSelectMenuBarState extends State<MultiSelectMenuBar> {
tween: Tween<double>(begin: 0, end: 1), tween: Tween<double>(begin: 0, end: 1),
duration: Duration(milliseconds: 500), duration: Duration(milliseconds: 500),
builder: (context, value, child) => Container( 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), decoration: BoxDecoration(color: context.primaryColor),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
@ -296,6 +412,7 @@ class _MultiSelectMenuBarState extends State<MultiSelectMenuBar> {
) )
], ],
), ),
if (_showPlaylists) _playlistList(),
Row( Row(
children: [ children: [
if (!widget.hideFavorite) if (!widget.hideFavorite)
@ -421,6 +538,18 @@ class _MultiSelectMenuBarState extends State<MultiSelectMenuBar> {
} }
} }
}), }),
_buttonOnMenu(
child: Icon(
Icons.add_box_outlined,
color: Colors.grey[700],
),
onTap: () {
if (widget.selectedList.isNotEmpty) {
setState(() {
_showPlaylists = !_showPlaylists;
});
}
}),
Spacer(), Spacer(),
if (widget.selectAll == null) if (widget.selectAll == null)
SizedBox( SizedBox(
@ -446,3 +575,104 @@ class _MultiSelectMenuBarState extends State<MultiSelectMenuBar> {
); );
} }
} }
class _NewPlaylist extends StatefulWidget {
final List<EpisodeBrief> 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<SystemUiOverlayStyle>(
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: <Widget>[
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<AudioPlayerNotifier>()
.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<AudioPlayerNotifier>().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: <Widget>[
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(),
),
],
),
),
);
}
}