Podcastindex search support.

This commit is contained in:
stonegate 2020-09-30 01:25:44 +08:00
parent 8b57619960
commit a70103c3eb
19 changed files with 910 additions and 301 deletions

BIN
assets/listennotes_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

BIN
assets/podcastindex.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -857,7 +857,7 @@ class SleepModeState extends State<SleepMode>
child: Text(
context.s.sleepTimer,
style: TextStyle(
color: Theme.of(context).accentColor,
color: context.accentColor,
fontWeight: FontWeight.bold,
fontSize: 16),
),

View File

@ -235,8 +235,7 @@ class _HomeState extends State<Home> with SingleTickerProviderStateMixin {
Text(s.featureDiscoveryOMPLDes),
FlatButton(
color: Colors.cyan[600],
padding:
const EdgeInsets.all(0),
padding: EdgeInsets.zero,
child: Text(s.understood,
style: Theme.of(context)
.textTheme
@ -882,20 +881,21 @@ class _RecentUpdateState extends State<_RecentUpdate>
);
}),
)
: Material(
color: Colors.transparent,
child: IconButton(
tooltip: s.addNewEpisodeTooltip,
icon: SizedBox(
height: 15,
width: 20,
child: CustomPaint(
painter: AddToPlaylistPainter(
context.textColor,
context.textColor,
))),
onPressed: () {}),
);
: Center();
// Material(
// color: Colors.transparent,
// child: IconButton(
// tooltip: s.addNewEpisodeTooltip,
// icon: SizedBox(
// height: 15,
// width: 20,
// child: CustomPaint(
// painter: AddToPlaylistPainter(
// context.textColor,
// context.textColor,
// ))),
// onPressed: () {}),
// );
});
}
@ -951,9 +951,7 @@ class _RecentUpdateState extends State<_RecentUpdate>
color: Colors.white,
backgroundColor: context.accentColor,
semanticsLabel: s.refreshStarted,
onRefresh: () async {
await _updateRssItem();
},
onRefresh: _updateRssItem,
child: CustomScrollView(
key: PageStorageKey<String>('update'),
physics:

View File

@ -112,6 +112,41 @@ class DiscoveryPageState extends State<DiscoveryPage> {
],
));
Widget _historyList() => FutureBuilder<List<String>>(
future: _getSearchHistory(),
initialData: [],
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data.isNotEmpty) {
final history = snapshot.data;
return SizedBox(
child: Wrap(
direction: Axis.horizontal,
children: history
.map<Widget>((e) => Padding(
padding: const EdgeInsets.fromLTRB(8, 2, 0, 0),
child: FlatButton.icon(
color:
Colors.accents[history.indexOf(e)].withAlpha(70),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100.0),
),
onPressed: () => widget.onTap(e),
label: Text(e),
icon: Icon(
Icons.search,
size: 20,
),
),
))
.toList(),
),
);
}
return SizedBox(
height: 0,
);
});
Future<List<OnlinePodcast>> _getTopPodcasts({int page}) async {
final searchEngine = ListenNotesSearch();
var searchResult = await searchEngine.fetchBestPodcast(
@ -124,176 +159,163 @@ class DiscoveryPageState extends State<DiscoveryPage> {
return _podcastList;
}
Future<bool> _getHideDiscovery() async {
final storage = KeyValueStorage(hidePodcastDiscoveryKey);
return await storage.getBool(defaultValue: false);
}
@override
Widget build(BuildContext context) {
final searchState = context.watch<SearchState>();
return PodcastSlideup(
child: _selectedGenre == null
? SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FutureBuilder<List<String>>(
future: _getSearchHistory(),
initialData: [],
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data.isNotEmpty) {
final history = snapshot.data;
return SizedBox(
child: Wrap(
direction: Axis.horizontal,
children: history
.map<Widget>((e) => Padding(
padding: const EdgeInsets.fromLTRB(
8, 2, 0, 0),
child: FlatButton.icon(
color: Colors
.accents[history.indexOf(e)]
.withAlpha(70),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(100.0),
),
onPressed: () => widget.onTap(e),
label: Text(e),
icon: Icon(
Icons.search,
size: 20,
),
),
))
.toList(),
),
);
}
return SizedBox(
height: 0,
);
}),
Padding(
padding: EdgeInsets.fromLTRB(20, 10, 10, 4),
child: Text('Popular',
style: context.textTheme.headline6
.copyWith(color: context.accentColor)),
),
SizedBox(
height: 200,
child: FutureBuilder<List<OnlinePodcast>>(
future: _searchTopPodcast,
builder: (context, snapshot) {
return ScrollConfiguration(
behavior: NoGrowBehavior(),
child: ListView(
scrollDirection: Axis.horizontal,
children: snapshot.hasData
? snapshot.data.map<Widget>((podcast) {
return Container(
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(10),
color: context.primaryColor),
width: 120,
margin: EdgeInsets.fromLTRB(
10, 10, 0, 10),
child: Material(
color: Colors.transparent,
borderRadius:
BorderRadius.circular(10),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () {
searchState.selectedPodcast =
podcast;
widget.onTap('');
},
child: Padding(
padding:
const EdgeInsets.all(4.0),
child: Column(
children: [
Expanded(
flex: 2,
child: Center(
child: PodcastAvatar(
podcast)),
),
Expanded(
flex: 1,
child: Text(
podcast.title,
textAlign:
TextAlign.center,
maxLines: 2,
overflow:
TextOverflow.fade,
style: TextStyle(
fontWeight:
FontWeight
.bold),
),
),
Expanded(
flex: 1,
child: Center(
child: SizedBox(
height: 32,
child:
SubscribeButton(
podcast)),
),
),
],
),
),
),
),
);
}).toList()
: [
_loadTopPodcasts(),
_loadTopPodcasts(),
_loadTopPodcasts(),
_loadTopPodcasts(),
]),
);
}),
),
Padding(
padding: EdgeInsets.fromLTRB(20, 10, 10, 4),
child: Text('Categories',
style: context.textTheme.headline6
.copyWith(color: context.accentColor)),
),
ListView(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
children: genres
.map<Widget>((e) => ListTile(
contentPadding: EdgeInsets.fromLTRB(20, 0, 20, 0),
onTap: () {
widget.onTap('');
setState(() => _selectedGenre = e);
},
title: Text(e.name),
))
.toList(),
),
SizedBox(
height: 40,
child: Center(
child: Image(
image: context.brightness == Brightness.light
? AssetImage('assets/listennotes.png')
: AssetImage('assets/listennotes_light.png'),
height: 15,
),
),
)
],
),
return FutureBuilder<bool>(
future: _getHideDiscovery(),
initialData: true,
builder: (context, snapshot) => snapshot.data
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [_historyList(), Spacer()],
)
: _TopPodcastList(genre: _selectedGenre),
: PodcastSlideup(
searchEngine: SearchEngine.listenNotes,
child: _selectedGenre == null
? SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_historyList(),
Padding(
padding: EdgeInsets.fromLTRB(20, 10, 10, 4),
child: Text('Popular',
style: context.textTheme.headline6
.copyWith(color: context.accentColor)),
),
SizedBox(
height: 200,
child: FutureBuilder<List<OnlinePodcast>>(
future: _searchTopPodcast,
builder: (context, snapshot) {
return ScrollConfiguration(
behavior: NoGrowBehavior(),
child: ListView(
scrollDirection: Axis.horizontal,
children: snapshot.hasData
? snapshot.data
.map<Widget>((podcast) {
return Container(
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(
10),
color:
context.primaryColor),
width: 120,
margin: EdgeInsets.fromLTRB(
10, 10, 0, 10),
child: Material(
color: Colors.transparent,
borderRadius:
BorderRadius.circular(
10),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () {
searchState
.selectedPodcast =
podcast;
widget.onTap('');
},
child: Padding(
padding:
EdgeInsets.all(4.0),
child: Column(
children: [
Expanded(
flex: 2,
child: Center(
child: PodcastAvatar(
podcast)),
),
Expanded(
flex: 1,
child: Text(
podcast.title,
textAlign:
TextAlign
.center,
maxLines: 2,
overflow:
TextOverflow
.fade,
style: TextStyle(
fontWeight:
FontWeight
.bold),
),
),
Expanded(
flex: 1,
child: Center(
child: SizedBox(
height: 32,
child: SubscribeButton(
podcast)),
),
),
],
),
),
),
),
);
}).toList()
: [
_loadTopPodcasts(),
_loadTopPodcasts(),
_loadTopPodcasts(),
_loadTopPodcasts(),
]),
);
}),
),
Padding(
padding: EdgeInsets.fromLTRB(20, 10, 10, 4),
child: Text('Categories',
style: context.textTheme.headline6
.copyWith(color: context.accentColor)),
),
ListView(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
children: genres
.map<Widget>((e) => ListTile(
contentPadding:
EdgeInsets.fromLTRB(20, 0, 20, 0),
onTap: () {
widget.onTap('');
setState(() => _selectedGenre = e);
},
title: Text(e.name),
))
.toList(),
),
SizedBox(
height: 40,
child: Center(
child: Image(
image: context.brightness == Brightness.light
? AssetImage('assets/listennotes.png')
: AssetImage(
'assets/listennotes_light.png'),
height: 15,
),
),
)
],
),
)
: _TopPodcastList(genre: _selectedGenre),
),
);
}
}

View File

@ -8,6 +8,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:provider/provider.dart';
import 'package:tsacdop/type/search_api/index_episode.dart';
import 'package:webfeed/webfeed.dart';
import '../local_storage/key_value_storage.dart';
@ -16,6 +17,7 @@ import '../state/podcast_group.dart';
import '../state/search_state.dart';
import '../type/search_api/searchepisodes.dart';
import '../type/search_api/searchpodcast.dart';
import '../util/custom_widget.dart';
import '../util/extension_helper.dart';
import 'pocast_discovery.dart';
@ -27,12 +29,12 @@ class MyHomePageDelegate extends SearchDelegate<int> {
: super(
searchFieldLabel: searchFieldLabel,
);
static Future getRss(String url) async {
var _searchEngine;
static Future _getRss(String url) async {
try {
var options = BaseOptions(
connectTimeout: 10000,
receiveTimeout: 10000,
final options = BaseOptions(
connectTimeout: 30000,
receiveTimeout: 90000,
);
var response = await Dio(options).get(url);
return RssFeed.parse(response.data);
@ -41,7 +43,17 @@ class MyHomePageDelegate extends SearchDelegate<int> {
}
}
Future<SearchEngine> _getSearchEngine() async {
final storage = KeyValueStorage(searchEngineKey);
final index = await storage.getInt(defaultValue: 1);
if (_searchEngine == null) {
_searchEngine = SearchEngine.values[index];
}
return _searchEngine;
}
RegExp rssExp = RegExp(r'^(https?):\/\/(.*)');
Widget invalidRss(BuildContext context) => Container(
height: 50,
alignment: Alignment.center,
@ -100,9 +112,7 @@ class MyHomePageDelegate extends SearchDelegate<int> {
@override
List<Widget> buildActions(BuildContext context) {
return <Widget>[
if (query.isEmpty)
Center()
else
if (query.isNotEmpty)
IconButton(
tooltip: context.s.clear,
icon: const Icon(Icons.clear),
@ -111,6 +121,71 @@ class MyHomePageDelegate extends SearchDelegate<int> {
showResults(context);
},
),
FutureBuilder<SearchEngine>(
future: _getSearchEngine(),
initialData: SearchEngine.podcastIndex,
builder: (context, snapshot) => PopupMenuButton<SearchEngine>(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
elevation: 1,
icon: snapshot.data == SearchEngine.podcastIndex
? SizedBox(
height: 30,
width: 30,
child: CircleAvatar(
backgroundImage: AssetImage('assets/podcastindex_logo.png'),
backgroundColor: Colors.redAccent[700].withAlpha(70),
maxRadius: 25,
),
)
: SizedBox(
height: 30,
width: 30,
child: CircleAvatar(
backgroundImage: AssetImage('assets/listennotes_logo.png'),
backgroundColor: Colors.red.withAlpha(70),
maxRadius: 25,
),
),
onSelected: (value) {
_searchEngine = value;
showSuggestions(context);
if (query != '') {
showResults(context);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: SearchEngine.listenNotes,
child: Container(
padding: EdgeInsets.only(left: 10),
child: Row(
children: <Widget>[
Text('ListenNotes'),
Spacer(),
if (_searchEngine == SearchEngine.listenNotes)
DotIndicator()
],
),
),
),
PopupMenuItem(
value: SearchEngine.podcastIndex,
child: Container(
padding: EdgeInsets.only(left: 10),
child: Row(
children: <Widget>[
Text('PodcastIndex'),
Spacer(),
if (_searchEngine == SearchEngine.podcastIndex)
DotIndicator()
],
),
),
),
],
),
),
];
}
@ -125,7 +200,7 @@ class MyHomePageDelegate extends SearchDelegate<int> {
});
} else if (rssExp.stringMatch(query) != null) {
return FutureBuilder(
future: getRss(rssExp.stringMatch(query)),
future: _getRss(rssExp.stringMatch(query)),
builder: (context, snapshot) {
if (snapshot.hasError) {
return invalidRss(context);
@ -144,9 +219,16 @@ class MyHomePageDelegate extends SearchDelegate<int> {
},
);
} else {
return SearchList(
query: query,
);
switch (_searchEngine) {
case SearchEngine.listenNotes:
return _ListenNotesSearch(query: query);
break;
case SearchEngine.podcastIndex:
return _PodcastIndexSearch(query: query);
default:
return Center();
break;
}
}
}
}
@ -323,15 +405,15 @@ class _RssResultState extends State<RssResult> {
}
}
class SearchList extends StatefulWidget {
class _ListenNotesSearch extends StatefulWidget {
final String query;
SearchList({this.query, Key key}) : super(key: key);
_ListenNotesSearch({this.query, Key key}) : super(key: key);
@override
_SearchListState createState() => _SearchListState();
__ListenNotesSearchState createState() => __ListenNotesSearchState();
}
class _SearchListState extends State<SearchList> {
class __ListenNotesSearchState extends State<_ListenNotesSearch> {
int _nextOffset = 0;
final List<OnlinePodcast> _podcastList = [];
int _offset;
@ -341,7 +423,7 @@ class _SearchListState extends State<SearchList> {
@override
void initState() {
super.initState();
_searchFuture = _getList(widget.query, _nextOffset);
_searchFuture = _getListenNotesList(widget.query, _nextOffset);
}
Future<void> _saveHistory(String query) async {
@ -356,7 +438,7 @@ class _SearchListState extends State<SearchList> {
}
}
Future<List<OnlinePodcast>> _getList(
Future<List<OnlinePodcast>> _getListenNotesList(
String searchText, int nextOffset) async {
if (nextOffset == 0) _saveHistory(searchText);
final searchEngine = ListenNotesSearch();
@ -371,6 +453,7 @@ class _SearchListState extends State<SearchList> {
@override
Widget build(BuildContext context) {
return PodcastSlideup(
searchEngine: SearchEngine.listenNotes,
child: FutureBuilder<List>(
future: _searchFuture,
builder: (context, snapshot) {
@ -403,8 +486,7 @@ class _SearchListState extends State<SearchList> {
highlightedBorderColor: context.accentColor,
splashColor: context.accentColor.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.all(Radius.circular(100))),
borderRadius: BorderRadius.circular(100)),
child: _loading
? SizedBox(
height: 20,
@ -419,8 +501,8 @@ class _SearchListState extends State<SearchList> {
() {
_loading = true;
_nextOffset = _offset;
_searchFuture =
_getList(widget.query, _nextOffset);
_searchFuture = _getListenNotesList(
widget.query, _nextOffset);
},
),
),
@ -436,6 +518,122 @@ class _SearchListState extends State<SearchList> {
}
}
class _PodcastIndexSearch extends StatefulWidget {
final String query;
_PodcastIndexSearch({this.query, Key key}) : super(key: key);
@override
__PodcastIndexSearchState createState() => __PodcastIndexSearchState();
}
class __PodcastIndexSearchState extends State<_PodcastIndexSearch> {
int _limit;
bool _loading;
Future _searchFuture;
List _podcastList = [];
Future<void> _saveHistory(String query) async {
final storage = KeyValueStorage(searchHistoryKey);
final history = await storage.getStringList();
if (!history.contains(query)) {
if (history.length >= 6) {
history.removeLast();
}
history.insert(0, query);
await storage.saveStringList(history);
}
}
@override
void initState() {
super.initState();
_loading = false;
_limit = 10;
_searchFuture = _getPodcatsIndexList(widget.query, limit: _limit);
}
Future<List<OnlinePodcast>> _getPodcatsIndexList(String searchText,
{int limit}) async {
if (_limit == 20) _saveHistory(searchText);
final searchEngine = PodcastsIndexSearch();
var searchResult =
await searchEngine.searchPodcasts(searchText: searchText, limit: limit);
var list = searchResult.feeds.cast();
_podcastList = <OnlinePodcast>[
for (var podcast in list) podcast.toOnlinePodcast
];
_loading = false;
return _podcastList;
}
@override
Widget build(BuildContext context) {
return PodcastSlideup(
searchEngine: SearchEngine.podcastIndex,
child: FutureBuilder<List>(
future: _searchFuture,
builder: (context, snapshot) {
if (!snapshot.hasData && widget.query != null) {
return Container(
padding: EdgeInsets.only(top: 200),
alignment: Alignment.topCenter,
child: CircularProgressIndicator(),
);
}
var content = snapshot.data;
return CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return SearchResult(onlinePodcast: content[index]);
},
childCount: content.length,
),
),
SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(top: 10.0, bottom: 20.0),
child: OutlineButton(
highlightedBorderColor: context.accentColor,
splashColor: context.accentColor.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100)),
child: _loading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
))
: Text(context.s.loadMore),
onPressed: () => _loading
? null
: setState(
() {
_loading = true;
_limit += 10;
_searchFuture = _getPodcatsIndexList(
widget.query,
limit: _limit);
},
),
),
)
],
),
)
],
);
}),
);
}
}
class SearchResult extends StatelessWidget {
final OnlinePodcast onlinePodcast;
SearchResult({this.onlinePodcast, Key key}) : super(key: key);
@ -451,7 +649,6 @@ class SearchResult extends StatelessWidget {
contentPadding: EdgeInsets.fromLTRB(20, 10, 20, 10),
onTap: () {
searchState.selectedPodcast = onlinePodcast;
// onSelect(onlinePodcast);
},
leading: ClipRRect(
borderRadius: BorderRadius.circular(25.0),
@ -483,7 +680,11 @@ class SearchResult extends StatelessWidget {
),
),
title: Text(onlinePodcast.title),
subtitle: Text(onlinePodcast.publisher ?? ''),
subtitle: Text(
onlinePodcast.publisher ?? '',
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
trailing: SubscribeButton(onlinePodcast)),
],
);
@ -493,12 +694,12 @@ class SearchResult extends StatelessWidget {
/// Search podcast detail widget
class SearchResultDetail extends StatefulWidget {
SearchResultDetail(this.onlinePodcast,
{this.maxHeight, this.episodeList, this.isSubscribed, Key key})
{this.maxHeight, this.isSubscribed, this.searchEngine, Key key})
: super(key: key);
final OnlinePodcast onlinePodcast;
final double maxHeight;
final List<OnlineEpisode> episodeList;
final bool isSubscribed;
final SearchEngine searchEngine;
@override
_SearchResultDetailState createState() => _SearchResultDetailState();
}
@ -539,8 +740,11 @@ class _SearchResultDetailState extends State<SearchResultDetail>
@override
void initState() {
super.initState();
_searchFuture = _getEpisodes(
id: widget.onlinePodcast.id, nextEpisodeDate: _nextEpisdoeDate);
_searchFuture = widget.searchEngine == SearchEngine.listenNotes
? _getListenNotesEpisodes(
id: widget.onlinePodcast.id, nextEpisodeDate: _nextEpisdoeDate)
: _getIndexEpisodes(id: widget.onlinePodcast.rss);
_minHeight = widget.maxHeight / 2;
_initSize = _minHeight;
_slideDirection = SlideDirection.up;
@ -560,7 +764,7 @@ class _SearchResultDetailState extends State<SearchResultDetail>
super.dispose();
}
Future<List<OnlineEpisode>> _getEpisodes(
Future<List<OnlineEpisode>> _getListenNotesEpisodes(
{String id, int nextEpisodeDate}) async {
var searchEngine = ListenNotesSearch();
var searchResult = await searchEngine.fetchEpisode(
@ -571,6 +775,18 @@ class _SearchResultDetailState extends State<SearchResultDetail>
return _episodeList;
}
Future<List<OnlineEpisode>> _getIndexEpisodes(
{String id, int nextEpisodeDate}) async {
var searchEngine = PodcastsIndexSearch();
var searchResult = await searchEngine.fetchEpisode(rssUrl: id);
var episodes = searchResult.items.cast();
for (var episode in episodes) {
_episodeList.add(episode.toOnlineWEpisode);
}
_loading = false;
return _episodeList;
}
void _start(DragStartDetails event) {
setState(() {
_startdy = event.localPosition.dy;
@ -646,7 +862,7 @@ class _SearchResultDetailState extends State<SearchResultDetail>
onVerticalDragEnd: (event) => _end(),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
color: context.primaryColor,
boxShadow: [
BoxShadow(
offset: Offset(0, -0.5),
@ -685,8 +901,12 @@ class _SearchResultDetailState extends State<SearchResultDetail>
style: context.textTheme.headline5),
),
Text(
'${widget.onlinePodcast.interval.toInterval(context)} | '
'${widget.onlinePodcast.latestPubDate.toDate(context)}',
widget.onlinePodcast.interval
.toInterval(context) !=
''
? '${widget.onlinePodcast.interval.toInterval(context)} | '
'${widget.onlinePodcast.latestPubDate.toDate(context)}'
: '${widget.onlinePodcast.latestPubDate.toDate(context)}',
maxLines: 1,
overflow: TextOverflow.fade,
style: TextStyle(color: context.accentColor),
@ -746,16 +966,18 @@ class _SearchResultDetailState extends State<SearchResultDetail>
children: [
Text(s.episode(2)),
SizedBox(width: 2),
Container(
padding: const EdgeInsets.only(
left: 5, right: 5, top: 2, bottom: 2),
decoration: BoxDecoration(
color: context.accentColor,
borderRadius:
BorderRadius.circular(100)),
child: Text(
widget.onlinePodcast.count.toString(),
style: TextStyle(color: Colors.white)))
if (widget.onlinePodcast.count > 0)
Container(
padding: const EdgeInsets.only(
left: 5, right: 5, top: 2, bottom: 2),
decoration: BoxDecoration(
color: context.accentColor,
borderRadius:
BorderRadius.circular(100)),
child: Text(
widget.onlinePodcast.count.toString(),
style:
TextStyle(color: Colors.white)))
],
)
]),
@ -804,41 +1026,51 @@ class _SearchResultDetailState extends State<SearchResultDetail>
alignment: Alignment.center,
child: SizedBox(
child: OutlineButton(
highlightedBorderColor:
context.accentColor,
splashColor:
context.accentColor.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(100))),
child: _loading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
))
: Text(context.s.loadMore),
onPressed: () => _loading
? null
: setState(
() {
_loading = true;
_searchFuture = _getEpisodes(
id: widget.onlinePodcast.id,
nextEpisodeDate:
_nextEpisdoeDate);
},
),
),
highlightedBorderColor:
context.accentColor,
splashColor: context.accentColor
.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(100))),
child: _loading
? SizedBox(
height: 20,
width: 20,
child:
CircularProgressIndicator(
strokeWidth: 2,
))
: Text(context.s.loadMore),
onPressed: () {
if (widget.searchEngine ==
SearchEngine.listenNotes) {
_loading
? null
: setState(
() {
_loading = true;
_searchFuture =
_getListenNotesEpisodes(
id: widget
.onlinePodcast
.id,
nextEpisodeDate:
_nextEpisdoeDate);
},
);
}
}),
),
);
}
return ListTile(
title: Text(content[index].title),
subtitle: Text(
'${content[index].length.toTime} | '
'${content[index].pubDate.toDate(context)}',
content[index].length == 0
? '${content[index].pubDate.toDate(context)}'
: '${content[index].length.toTime} | '
'${content[index].pubDate.toDate(context)}',
style:
TextStyle(color: context.accentColor)),
);
@ -878,40 +1110,48 @@ class SubscribeButton extends StatelessWidget {
return Consumer<SearchState>(builder: (_, searchState, __) {
final subscribed = searchState.isSubscribed(onlinePodcast);
return !subscribed
? OutlineButton(
highlightedBorderColor: context.accentColor,
borderSide: BorderSide(color: context.accentColor),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100.0),
side: BorderSide(color: context.accentColor)),
splashColor: context.accentColor.withOpacity(0.5),
child: Text(s.subscribe,
style: TextStyle(color: context.accentColor)),
onPressed: () {
Fluttertoast.showToast(
msg: s.podcastSubscribed,
gravity: ToastGravity.BOTTOM,
);
subscribePodcast(onlinePodcast);
searchState.addPodcast(onlinePodcast);
})
: OutlineButton(
color: context.accentColor.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100.0),
side: BorderSide(color: Colors.grey[500])),
highlightedBorderColor: Colors.grey[500],
disabledTextColor: Colors.grey[500],
child: Text(s.subscribe),
disabledBorderColor: Colors.grey[500],
onPressed: () {});
? ButtonTheme(
height: 32,
child: OutlineButton(
highlightedBorderColor: context.accentColor,
borderSide: BorderSide(color: context.accentColor),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100.0),
side: BorderSide(color: context.accentColor)),
splashColor: context.accentColor.withOpacity(0.5),
child: Text(s.subscribe,
style: TextStyle(color: context.accentColor)),
onPressed: () {
Fluttertoast.showToast(
msg: s.podcastSubscribed,
gravity: ToastGravity.BOTTOM,
);
subscribePodcast(onlinePodcast);
searchState.addPodcast(onlinePodcast);
}),
)
: ButtonTheme(
height: 32,
child: OutlineButton(
color: context.accentColor.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100.0),
side: BorderSide(color: Colors.grey[500])),
highlightedBorderColor: Colors.grey[500],
disabledTextColor: Colors.grey[500],
child: Text(s.subscribe),
disabledBorderColor: Colors.grey[500],
onPressed: () {}),
);
});
}
}
class PodcastSlideup extends StatelessWidget {
const PodcastSlideup({this.child, Key key}) : super(key: key);
const PodcastSlideup({this.child, this.searchEngine, Key key})
: super(key: key);
final Widget child;
final SearchEngine searchEngine;
@override
Widget build(BuildContext context) {

View File

@ -53,6 +53,8 @@ const String gpodderSyncStatusKey = 'gpodderSyncStatusKey';
const String gpodderSyncDateTimeKey = 'gpodderSyncDateTimeKey';
const String gpodderRemoteAddKey = 'gpodderRemoteAddKey';
const String gpodderRemoteRemoveKey = 'gpodderRemoteRemoveKey';
const String hidePodcastDiscoveryKey = 'hidePodcastDiscoveryKey';
const String searchEngineKey = 'searchEngineKey';
class KeyValueStorage {
final String key;

View File

@ -1,21 +1,30 @@
import 'dart:convert';
import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import '../.env.dart';
import '../home/about.dart';
import '../type/search_api/index_episode.dart';
import '../type/search_api/index_podcast.dart';
import '../type/search_api/itunes_podcast.dart';
import '../type/search_api/search_top_podcast.dart';
import '../type/search_api/searchepisodes.dart';
import '../type/search_api/searchpodcast.dart';
enum SearchEngine { listenNotes, podcastIndex }
class ListenNotesSearch {
final apiKey = environment['apiKey'];
final _apiKey = environment['apiKey'];
Future<SearchPodcast<dynamic>> searchPodcasts(
{String searchText, int nextOffset}) async {
var url = "https://listen-api.listennotes.com/api/v2/search?q="
"${Uri.encodeComponent(searchText)}${"&sort_by_date=0&type=podcast&offset=$nextOffset"}";
var response = await Dio().get(url,
options: Options(headers: {
'X-ListenAPI-Key': "$apiKey",
'X-ListenAPI-Key': "$_apiKey",
'Accept': "application/json"
}));
Map searchResultMap = jsonDecode(response.toString());
@ -29,7 +38,7 @@ class ListenNotesSearch {
"https://listen-api.listennotes.com/api/v2/podcasts/$id?next_episode_pub_date=$nextEpisodeDate";
var response = await Dio().get(url,
options: Options(headers: {
'X-ListenAPI-Key': "$apiKey",
'X-ListenAPI-Key': "$_apiKey",
'Accept': "application/json"
}));
Map searchResultMap = jsonDecode(response.toString());
@ -43,7 +52,7 @@ class ListenNotesSearch {
"https://listen-api.listennotes.com/api/v2/best_podcasts?genre_id=$genre&page=$page&region=$region";
var response = await Dio().get(url,
options: Options(headers: {
'X-ListenAPI-Key': "$apiKey",
'X-ListenAPI-Key': "$_apiKey",
'Accept': "application/json"
}));
Map searchResultMap = jsonDecode(response.toString());
@ -53,14 +62,64 @@ class ListenNotesSearch {
}
class ItunesSearch {
Future<SearchPodcast<dynamic>> searchPodcasts(
Future<ItunesSearchResult<dynamic>> searchPodcasts(
{String searchText, int limit}) async {
var url = "https://itunes.apple.com/search/search?q="
"${Uri.encodeComponent(searchText)}${"&media=podcast&entity=podcast&limit=$limit"}";
var response = await Dio()
final url = "https://itunes.apple.com/search?term="
"${Uri.encodeComponent(searchText)}&media=podcast&entity=podcast&limit=$limit";
final response = await Dio()
.get(url, options: Options(headers: {'Accept': "application/json"}));
print(response.toString());
Map searchResultMap = jsonDecode(response.toString());
var searchResult = SearchPodcast.fromJson(searchResultMap);
final searchResult = ItunesSearchResult.fromJson(searchResultMap);
return searchResult;
}
}
class PodcastsIndexSearch {
final _dio = Dio(BaseOptions(connectTimeout: 30000, receiveTimeout: 90000));
final _baseUrl = 'https://api.podcastindex.org';
Map<String, String> _initSearch() {
final unixTime =
(DateTime.now().millisecondsSinceEpoch / 1000).round().toString();
final apiKey = environment['podcastIndexApiKey'];
final apiSecret = environment['podcastIndexApiSecret'];
final firstChunk = utf8.encode(apiKey);
final secondChunk = utf8.encode(apiSecret);
final thirdChunk = utf8.encode(unixTime);
var output = AccumulatorSink<Digest>();
var input = sha1.startChunkedConversion(output);
input.add(firstChunk);
input.add(secondChunk);
input.add(thirdChunk);
input.close();
var digest = output.events.single;
var headers = <String, String>{
"X-Auth-Date": unixTime,
"X-Auth-Key": apiKey,
"Authorization": digest.toString(),
"User-Agent": "Tsacdop/$version"
};
return headers;
}
Future<PodcastIndexSearchResult<dynamic>> searchPodcasts(
{String searchText, int limit}) async {
final url = "$_baseUrl/api/1.0/search/byterm"
"?q=${Uri.encodeComponent(searchText)}&max=$limit";
final headers = _initSearch();
final response = await _dio.get(url, options: Options(headers: headers));
Map searchResultMap = jsonDecode(response.toString());
final searchResult = PodcastIndexSearchResult.fromJson(searchResultMap);
return searchResult;
}
Future<IndexEpisodeResult<dynamic>> fetchEpisode({String rssUrl}) async {
final url = "$_baseUrl/api/1.0/episodes/byfeedurl?url=$rssUrl";
final headers = _initSearch();
final response = await _dio.get(url, options: Options(headers: headers));
Map searchResultMap = jsonDecode(response.toString());
final searchResult = IndexEpisodeResult.fromJson(searchResultMap);
return searchResult;
}
}

View File

@ -188,7 +188,7 @@ class _PlayedHistoryState extends State<PlayedHistory>
FutureBuilder<List<PlayHistory>>(
future: _getPlayHistory(_top),
builder: (context, snapshot) {
var width = MediaQuery.of(context).size.width;
var width = context.width;
return snapshot.hasData
? NotificationListener<ScrollNotification>(
onNotification: (scrollInfo) {

View File

@ -8,6 +8,7 @@ import '../util/custom_dropdown.dart';
import '../util/custom_widget.dart';
import '../util/episodegrid.dart';
import '../util/extension_helper.dart';
import '../service/search_api.dart';
import 'popup_menu.dart';
class LayoutSetting extends StatefulWidget {
@ -18,12 +19,22 @@ class LayoutSetting extends StatefulWidget {
}
class _LayoutSettingState extends State<LayoutSetting> {
final _hideDiscoveyStorage = KeyValueStorage(hidePodcastDiscoveryKey);
Future<Layout> _getLayout(String key) async {
var keyValueStorage = KeyValueStorage(key);
var layout = await keyValueStorage.getInt();
return Layout.values[layout];
}
Future<bool> _getHideDiscovery() async {
return await _hideDiscoveyStorage.getBool(defaultValue: false);
}
Future<void> _saveHideDiscovery(bool boo) async {
await _hideDiscoveyStorage.saveBool(boo);
if (mounted) setState(() {});
}
Future<bool> _hideListened() async {
var hideListenedStorage = KeyValueStorage(hideListenedKey);
var hideListened = await hideListenedStorage.getBool(defaultValue: false);
@ -36,6 +47,18 @@ class _LayoutSettingState extends State<LayoutSetting> {
if (mounted) setState(() {});
}
Future<SearchEngine> _getSearchEngine() async {
final storage = KeyValueStorage(searchEngineKey);
final index = await storage.getInt(defaultValue: 1);
return SearchEngine.values[index];
}
Future<void> _saveSearchEngine(SearchEngine engine) async {
final storage = KeyValueStorage(searchEngineKey);
await storage.saveInt(engine.index);
if (mounted) setState(() {});
}
String _getHeightString(PlayerHeight mode) {
final s = context.s;
switch (mode) {
@ -188,9 +211,7 @@ class _LayoutSettingState extends State<LayoutSetting> {
padding: const EdgeInsets.symmetric(horizontal: 70),
alignment: Alignment.centerLeft,
child: Text(s.settingsPopupMenu,
style: Theme.of(context)
.textTheme
.bodyText1
style: context.textTheme.bodyText1
.copyWith(color: context.accentColor)),
),
ListTile(
@ -217,8 +238,7 @@ class _LayoutSettingState extends State<LayoutSetting> {
.copyWith(color: Theme.of(context).accentColor)),
),
ListTile(
contentPadding: EdgeInsets.only(
left: 70.0, right: 20, bottom: 10, top: 10),
contentPadding: EdgeInsets.fromLTRB(70, 10, 10, 10),
title: Text(s.settingsPlayerHeight),
subtitle: Text(s.settingsPlayerHeightDes),
trailing: Selector<AudioPlayerNotifier, PlayerHeight>(
@ -242,6 +262,56 @@ class _LayoutSettingState extends State<LayoutSetting> {
Padding(
padding: EdgeInsets.all(10.0),
),
Container(
height: 30.0,
padding: EdgeInsets.symmetric(horizontal: 70),
alignment: Alignment.centerLeft,
child: Text('Podcast search',
style: context.textTheme.bodyText1
.copyWith(color: context.accentColor)),
),
FutureBuilder<bool>(
future: _getHideDiscovery(),
initialData: false,
builder: (context, snapshot) => ListTile(
contentPadding: EdgeInsets.fromLTRB(70, 10, 10, 10),
onTap: () => _saveHideDiscovery(!snapshot.data),
title: Text('Hide podcast discovery'),
subtitle: Text('Hide podcast discovery in search page'),
trailing: Transform.scale(
scale: 0.9,
child: Switch(
value: snapshot.data, onChanged: _saveHideDiscovery),
),
),
),
FutureBuilder(
future: _getSearchEngine(),
initialData: SearchEngine.listenNotes,
builder: (context, snapshot) => ListTile(
contentPadding: EdgeInsets.fromLTRB(70, 10, 10, 10),
title: Text('Default search engine'),
subtitle: Text('Choose default search engine'),
trailing: MyDropdownButton(
hint: Text(''),
underline: Center(),
elevation: 1,
value: snapshot.data,
items: [
DropdownMenuItem<SearchEngine>(
value: SearchEngine.listenNotes,
child: Text('ListenNotes')),
DropdownMenuItem<SearchEngine>(
value: SearchEngine.podcastIndex,
child: Text('PodcastIndex')),
],
onChanged: (value) => _saveSearchEngine(value)),
),
),
Divider(height: 1),
Padding(
padding: EdgeInsets.all(10.0),
),
Container(
height: 30.0,
padding: EdgeInsets.symmetric(horizontal: 70),

View File

@ -8,6 +8,7 @@ class SearchState extends ChangeNotifier {
bool get update => _update;
OnlinePodcast _selectedPodcast;
OnlinePodcast get selectedPodcast => _selectedPodcast;
set selectedPodcast(OnlinePodcast podcast) {
_selectedPodcast = podcast;
notifyListeners();

View File

@ -0,0 +1,51 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:tsacdop/type/search_api/searchepisodes.dart';
part 'index_episode.g.dart';
@JsonSerializable()
class IndexEpisodeResult<P> {
@_ConvertP()
final List<P> items;
final String status;
final int count;
IndexEpisodeResult({this.items, this.status, this.count});
factory IndexEpisodeResult.fromJson(Map<String, dynamic> json) =>
_$IndexEpisodeResultFromJson<P>(json);
Map<String, dynamic> toJson() => _$IndexEpisodeResultToJson(this);
}
class _ConvertP<P> implements JsonConverter<P, Object> {
const _ConvertP();
@override
P fromJson(Object json) {
return IndexEpisode.fromJson(json) as P;
}
@override
Object toJson(P object) {
return object;
}
}
@JsonSerializable()
class IndexEpisode {
final String title;
final String description;
final int datePublished;
final String enclosureUrl;
final int enclosureLength;
IndexEpisode(
{this.title,
this.description,
this.datePublished,
this.enclosureLength,
this.enclosureUrl});
factory IndexEpisode.fromJson(Map<String, dynamic> json) =>
_$IndexEpisodeFromJson(json);
Map<String, dynamic> toJson() => _$IndexEpisodeToJson(this);
OnlineEpisode get toOnlineWEpisode =>
OnlineEpisode(title: title, pubDate: datePublished * 1000, length: 0);
}

View File

@ -0,0 +1,43 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'index_episode.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
IndexEpisodeResult<P> _$IndexEpisodeResultFromJson<P>(
Map<String, dynamic> json) {
return IndexEpisodeResult<P>(
items: (json['items'] as List)?.map(_ConvertP<P>().fromJson)?.toList(),
status: json['status'] as String,
count: json['count'] as int,
);
}
Map<String, dynamic> _$IndexEpisodeResultToJson<P>(
IndexEpisodeResult<P> instance) =>
<String, dynamic>{
'items': instance.items?.map(_ConvertP<P>().toJson)?.toList(),
'status': instance.status,
'count': instance.count,
};
IndexEpisode _$IndexEpisodeFromJson(Map<String, dynamic> json) {
return IndexEpisode(
title: json['title'] as String,
description: json['description'] as String,
datePublished: json['datePublished'] as int,
enclosureLength: json['enclosureLength'] as int,
enclosureUrl: json['enclosureUrl'] as String,
);
}
Map<String, dynamic> _$IndexEpisodeToJson(IndexEpisode instance) =>
<String, dynamic>{
'title': instance.title,
'description': instance.description,
'datePublished': instance.datePublished,
'enclosureUrl': instance.enclosureUrl,
'enclosureLength': instance.enclosureLength,
};

View File

@ -0,0 +1,68 @@
import 'package:json_annotation/json_annotation.dart';
import 'searchpodcast.dart';
part 'index_podcast.g.dart';
@JsonSerializable()
class PodcastIndexSearchResult<P> {
@_ConvertP()
final List<P> feeds;
final String status;
final int count;
PodcastIndexSearchResult({this.feeds, this.status, this.count});
factory PodcastIndexSearchResult.fromJson(Map<String, dynamic> json) =>
_$PodcastIndexSearchResultFromJson<P>(json);
Map<String, dynamic> toJson() => _$PodcastIndexSearchResultToJson(this);
}
class _ConvertP<P> implements JsonConverter<P, Object> {
const _ConvertP();
@override
P fromJson(Object json) {
return IndexPodcast.fromJson(json) as P;
}
@override
Object toJson(P object) {
return object;
}
}
@JsonSerializable()
class IndexPodcast {
final int id;
final String title;
final String url;
final String link;
final String description;
final String author;
final String image;
final int lastUpdateTime;
final int itunesId;
IndexPodcast(
{this.id,
this.title,
this.url,
this.link,
this.description,
this.author,
this.image,
this.lastUpdateTime,
this.itunesId});
factory IndexPodcast.fromJson(Map<String, dynamic> json) =>
_$IndexPodcastFromJson(json);
Map<String, dynamic> toJson() => _$IndexPodcastToJson(this);
OnlinePodcast get toOnlinePodcast => OnlinePodcast(
earliestPubDate: 0,
title: title,
count: 0,
description: description,
image: image,
latestPubDate: lastUpdateTime * 1000,
rss: url,
publisher: author,
id: itunesId.toString());
}

View File

@ -0,0 +1,51 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'index_podcast.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PodcastIndexSearchResult<P> _$PodcastIndexSearchResultFromJson<P>(
Map<String, dynamic> json) {
return PodcastIndexSearchResult<P>(
feeds: (json['feeds'] as List)?.map(_ConvertP<P>().fromJson)?.toList(),
status: json['status'] as String,
count: json['count'] as int,
);
}
Map<String, dynamic> _$PodcastIndexSearchResultToJson<P>(
PodcastIndexSearchResult<P> instance) =>
<String, dynamic>{
'feeds': instance.feeds?.map(_ConvertP<P>().toJson)?.toList(),
'status': instance.status,
'count': instance.count,
};
IndexPodcast _$IndexPodcastFromJson(Map<String, dynamic> json) {
return IndexPodcast(
id: json['id'] as int,
title: json['title'] as String,
url: json['url'] as String,
link: json['link'] as String,
description: json['description'] as String,
author: json['author'] as String,
image: json['image'] as String,
lastUpdateTime: json['lastUpdateTime'] as int,
itunesId: json['itunesId'] as int,
);
}
Map<String, dynamic> _$IndexPodcastToJson(IndexPodcast instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'url': instance.url,
'link': instance.link,
'description': instance.description,
'author': instance.author,
'image': instance.image,
'lastUpdateTime': instance.lastUpdateTime,
'itunesId': instance.itunesId,
};

View File

@ -51,7 +51,7 @@ class ItunesPodcast {
_$ItunesPodcastFromJson(json);
Map<String, dynamic> toJson() => _$ItunesPodcastToJson(this);
int get latestPubDate => DateFormat('YYYY-MM-DDTHH:MM:SSZ', 'en_US')
int get latestPubDate => DateFormat('yyyy-MM-DDTHH:MM:SSZ', 'en_US')
.parse(releaseDate)
.millisecondsSinceEpoch;
OnlinePodcast get toOnlinePodcast => OnlinePodcast(

View File

@ -28,6 +28,7 @@ ItunesPodcast _$ItunesPodcastFromJson(Map<String, dynamic> json) {
feedUrl: json['feedUrl'] as String,
artworkUrl600: json['artworkUrl600'] as String,
releaseDate: json['releaseDate'] as String,
collectionId: json['collectionId'] as int,
);
}
@ -38,4 +39,5 @@ Map<String, dynamic> _$ItunesPodcastToJson(ItunesPodcast instance) =>
'feedUrl': instance.feedUrl,
'artworkUrl600': instance.artworkUrl600,
'releaseDate': instance.releaseDate,
'collectionId': instance.collectionId,
};

View File

@ -19,6 +19,8 @@ dependencies:
cookie_jar: ^1.0.1
cupertino_icons: ^1.0.0
connectivity: ^0.4.9
convert: ^2.1.1
crypto: ^2.1.5
device_info: ^0.4.2+7
dio: ^3.0.10
dio_cookie_manager: ^1.0.0