diff --git a/lib/episodes/episode_detail.dart b/lib/episodes/episode_detail.dart index b13aed8..7954df7 100644 --- a/lib/episodes/episode_detail.dart +++ b/lib/episodes/episode_detail.dart @@ -271,7 +271,7 @@ class _EpisodeDetailState extends State { ], ), ), - _ShowNote(episode: widget.episodeItem), + ShowNote(episode: widget.episodeItem), Selector>( selector: (_, audio) => Tuple2( @@ -580,9 +580,9 @@ class __MenuBarState extends State<_MenuBar> { } } -class _ShowNote extends StatelessWidget { +class ShowNote extends StatelessWidget { final EpisodeBrief episode; - const _ShowNote({this.episode, Key key}) : super(key: key); + const ShowNote({this.episode, Key key}) : super(key: key); int _getTimeStamp(String url) { final time = url.substring(3).trim(); diff --git a/lib/home/audioplayer.dart b/lib/home/audioplayer.dart index 1962eee..4cc3d87 100644 --- a/lib/home/audioplayer.dart +++ b/lib/home/audioplayer.dart @@ -1,19 +1,25 @@ +import 'dart:convert'; +import 'dart:developer' as developer; import 'dart:io'; import 'dart:math' as math; import 'package:audio_service/audio_service.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:line_icons/line_icons.dart'; import 'package:marquee/marquee.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import '../episodes/episode_detail.dart'; import '../local_storage/key_value_storage.dart'; import '../local_storage/sqflite_localpodcast.dart'; import '../playlists/playlist_home.dart'; import '../state/audio_state.dart'; +import '../type/chapter.dart'; import '../type/episodebrief.dart'; import '../type/play_histroy.dart'; import '../type/playlist.dart'; @@ -425,6 +431,7 @@ class _PlaylistWidgetState extends State { children: [ Expanded( child: ListView.builder( + padding: EdgeInsets.zero, itemCount: episodes.length, itemBuilder: (context, index) { final isPlaying = episodes[index] != null && @@ -886,6 +893,280 @@ class SleepModeState extends State } } +class ChaptersWidget extends StatefulWidget { + ChaptersWidget({Key key}) : super(key: key); + + @override + _ChaptersWidgetState createState() => _ChaptersWidgetState(); +} + +class _ChaptersWidgetState extends State { + bool _showChapter; + + @override + void initState() { + super.initState(); + _showChapter = false; + } + + Future> _getChapters(EpisodeBrief episode) async { + if (episode.chapterLink == '' || episode.chapterLink == null) { + return []; + } + try { + final file = + await DefaultCacheManager().getSingleFile(episode.chapterLink); + final response = file.readAsStringSync(); + var chapterInfo = ChapterInfo.fromJson(jsonDecode(response)); + return chapterInfo.chapters; + } catch (e) { + developer.log('Download cahpter error', error: e); + return []; + } + } + + Widget _chapterDetailWidget(Chapters chapters) { + return Column( + children: [ + Container( + height: 60, + width: double.infinity, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: ButtonTheme( + height: 28, + padding: EdgeInsets.symmetric(horizontal: 0), + child: OutlineButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(100.0), + side: BorderSide(color: context.accentColor)), + highlightedBorderColor: Colors.green[700], + onPressed: () { + context + .read() + .seekTo(chapters.startTime * 1000); + }, + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CustomPaint( + painter: + ListenedPainter(context.textColor, stroke: 2.0), + ), + ), + SizedBox(width: 5), + Text( + chapters.startTime.toTime, + style: TextStyle(color: Colors.black), + ), + ], + ), + ), + ), + ), + Expanded( + child: Text(chapters.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyText1)), + if (chapters.url != '') + TextButton( + style: ButtonStyle( + foregroundColor: + MaterialStateProperty.all(context.accentColor), + overlayColor: MaterialStateProperty.all( + context.primaryColor), + ), + onPressed: () => chapters.url.launchUrl, + child: Text('Link')), + SizedBox(width: 8) + ], + ), + ), + if (chapters.img != '') _ChapterImage(chapters.img) + ], + ); + } + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + alignment: Alignment.topLeft, + width: double.infinity, + decoration: BoxDecoration( + color: context.accentColor.withAlpha(70), + borderRadius: BorderRadius.circular(10), + ), + child: Selector( + selector: (_, audio) => audio.episode, + builder: (_, episode, __) => Scrollbar( + child: Column( + children: [ + Expanded( + child: _showChapter + ? FutureBuilder>( + future: _getChapters(episode), + builder: (context, snapshot) { + if (snapshot.hasData) { + final data = snapshot.data; + return ListView.builder( + itemCount: data.length, + padding: EdgeInsets.zero, + itemBuilder: (context, index) { + return _chapterDetailWidget(data[index]); + }); + } + return Center(); + }) + : ListView( + padding: EdgeInsets.zero, + children: [ + if (episode.episodeImage != '') + ClipRRect( + borderRadius: BorderRadius.circular(10.0), + child: CachedNetworkImage( + width: 100, + fit: BoxFit.fitWidth, + alignment: Alignment.center, + imageUrl: episode.episodeImage, + placeholderFadeInDuration: Duration.zero, + progressIndicatorBuilder: (context, url, + downloadProgress) => + Container( + height: 50, + width: 50, + alignment: Alignment.center, + child: SizedBox( + width: 20, + height: 2, + child: LinearProgressIndicator( + value: + downloadProgress.progress), + ), + ), + errorWidget: (context, url, error) => + Center()), + ), + ShowNote(episode: episode) + ], + ), + ), + SizedBox( + height: 60.0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Text( + context.s.settingsInfo, + overflow: TextOverflow.fade, + style: TextStyle( + color: context.accentColor, + fontWeight: FontWeight.bold, + fontSize: 16), + ), + Spacer(), + SizedBox(width: 20), + Material( + borderRadius: BorderRadius.circular(100), + color: context.primaryColor, + child: InkWell( + borderRadius: BorderRadius.circular(15.0), + onTap: () { + setState(() { + _showChapter = !_showChapter; + }); + }, + child: SizedBox( + height: 30.0, + width: 30.0, + child: !_showChapter + ? Icon(Icons.bookmark_border_outlined, + size: 18) + : Icon(Icons.chrome_reader_mode_outlined, + size: 18)), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ChapterImage extends StatefulWidget { + final String url; + _ChapterImage(this.url, {Key key}) : super(key: key); + + @override + __ChapterImageState createState() => __ChapterImageState(); +} + +class __ChapterImageState extends State<_ChapterImage> { + bool _openFullImage; + @override + void initState() { + super.initState(); + _openFullImage = false; + } + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => setState(() => _openFullImage = !_openFullImage), + child: ClipRRect( + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + CachedNetworkImage( + width: double.infinity, + height: _openFullImage ? null : 50, + fit: BoxFit.fitWidth, + alignment: Alignment.center, + imageUrl: widget.url, + placeholderFadeInDuration: Duration.zero, + progressIndicatorBuilder: (contlext, url, downloadProgress) => + Container( + height: 50, + width: double.infinity, + alignment: Alignment.center, + child: SizedBox( + width: 20, + height: 2, + child: LinearProgressIndicator( + value: downloadProgress.progress), + ), + ), + errorWidget: (context, url, error) => Center()), + if (!_openFullImage) + Container( + decoration: BoxDecoration(boxShadow: [ + BoxShadow( + color: Colors.black38, + offset: Offset(0, -5), + blurRadius: 20, + spreadRadius: 10) + ]), + ) + ], + ), + ), + ); + } +} + class ControlPanel extends StatefulWidget { ControlPanel( {this.onExpand, @@ -938,7 +1219,7 @@ class _ControlPanelState extends State @override void initState() { _setSpeed = 0; - _tabController = TabController(vsync: this, length: 2) + _tabController = TabController(vsync: this, length: 3) ..addListener(() { setState(() => _tabIndex = _tabController.index); }); @@ -1260,15 +1541,18 @@ class _ControlPanelState extends State controller: _tabController, children: [ Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20.0), - child: PlaylistWidget(), - ), + padding: const EdgeInsets.symmetric( + horizontal: 20.0), + child: PlaylistWidget()), Padding( padding: const EdgeInsets.symmetric( horizontal: 20.0), child: SleepMode(), - ) + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20.0), + child: ChaptersWidget()), ]), ))), ), @@ -1438,14 +1722,14 @@ class _ControlPanelState extends State child: InkWell( child: SizedBox( height: 50, - width: 100, + width: 115, child: Align( alignment: Alignment.bottomCenter, child: CustomPaint( - size: Size(100, 5), + size: Size(120, 5), painter: TabIndicator( index: _tabIndex, - indicatorSize: 20, + indicatorSize: 10, fraction: (height + 16 - widget.maxHeight) / (context.height - @@ -1476,17 +1760,21 @@ class _ControlPanelState extends State unselectedLabelColor: context.textColor, indicator: BoxDecoration(), tabs: [ - Container( + SizedBox( height: 20, width: 20, child: Icon(Icons.playlist_play)), - Container( + SizedBox( height: 20, width: 20, child: Transform.rotate( angle: math.pi * 0.7, child: Icon(Icons.brightness_2, size: 18))), + SizedBox( + height: 20, + width: 20, + child: Icon(Icons.library_books, size: 18)), ], ), ), diff --git a/lib/local_storage/sqflite_localpodcast.dart b/lib/local_storage/sqflite_localpodcast.dart index fa34803..c7eed4e 100644 --- a/lib/local_storage/sqflite_localpodcast.dart +++ b/lib/local_storage/sqflite_localpodcast.dart @@ -28,7 +28,7 @@ class DBHelper { var documentsDirectory = await getDatabasesPath(); var path = join(documentsDirectory, "podcasts.db"); var theDb = await openDatabase(path, - version: 5, onCreate: _onCreate, onUpgrade: _onUpgrade); + version: 6, onCreate: _onCreate, onUpgrade: _onUpgrade); return theDb; } @@ -47,54 +47,85 @@ class DBHelper { description TEXT, feed_id TEXT, feed_link TEXT, milliseconds INTEGER, duration INTEGER DEFAULT 0, explicit INTEGER DEFAULT 0, liked INTEGER DEFAULT 0, liked_date INTEGER DEFAULT 0, downloaded TEXT DEFAULT 'ND', - download_date INTEGER DEFAULT 0, media_id TEXT, is_new INTEGER DEFAULT 0)"""); + download_date INTEGER DEFAULT 0, media_id TEXT, is_new INTEGER DEFAULT 0, + chapter_link TEXT DEFAULT '', hosts TEXT DEFAULT '', episode_image TEXT DEFAULT '')"""); await db.execute( """CREATE TABLE PlayHistory(id INTEGER PRIMARY KEY, title TEXT, enclosure_url TEXT, seconds REAL, seek_value REAL, add_date INTEGER, listen_time INTEGER DEFAULT 0)"""); await db.execute( """CREATE TABLE SubscribeHistory(id TEXT PRIMARY KEY, title TEXT, rss_url TEXT UNIQUE, add_date INTEGER, remove_date INTEGER DEFAULT 0, status INTEGER DEFAULT 0)"""); + await db + .execute("""CREATE INDEX podcast_search ON PodcastLocal (id, rssUrl); + """); + await db.execute( + """CREATE INDEX episode_search ON Episodes (enclosure_url, feed_id); + """); } void _onUpgrade(Database db, int oldVersion, int newVersion) async { switch (oldVersion) { case (1): - await db.execute( - "ALTER TABLE PodcastLocal ADD skip_seconds INTEGER DEFAULT 0 "); - await db.execute( - "ALTER TABLE PodcastLocal ADD auto_download INTEGER DEFAULT 0"); - await db.execute( - "ALTER TABLE PodcastLocal ADD skip_seconds_end INTEGER DEFAULT 0 "); - await db.execute( - "ALTER TABLE PodcastLocal ADD never_update INTEGER DEFAULT 0 "); - await db - .execute("ALTER TABLE PodcastLocal ADD funding TEXT DEFAULT '[]' "); + await _v2Update(db); + await _v3Update(db); + await _v4Update(db); + await _v5Update(db); + await _v6Update(db); break; case (2): - await db.execute( - "ALTER TABLE PodcastLocal ADD auto_download INTEGER DEFAULT 0"); - await db.execute( - "ALTER TABLE PodcastLocal ADD skip_seconds_end INTEGER DEFAULT 0 "); - await db.execute( - "ALTER TABLE PodcastLocal ADD never_update INTEGER DEFAULT 0 "); - await db - .execute("ALTER TABLE PodcastLocal ADD funding TEXT DEFAULT '[]' "); + await _v3Update(db); + await _v4Update(db); + await _v5Update(db); + await _v6Update(db); break; case (3): - await db.execute( - "ALTER TABLE PodcastLocal ADD skip_seconds_end INTEGER DEFAULT 0 "); - await db.execute( - "ALTER TABLE PodcastLocal ADD never_update INTEGER DEFAULT 0 "); - await db - .execute("ALTER TABLE PodcastLocal ADD funding TEXT DEFAULT '[]' "); + await _v4Update(db); + await _v5Update(db); + await _v6Update(db); break; case (4): - await db - .execute("ALTER TABLE PodcastLocal ADD funding TEXT DEFAULT '[]' "); + await _v5Update(db); + await _v6Update(db); + break; + case (5): + await _v6Update(db); break; } } + Future _v2Update(Database db) async { + await db.execute( + "ALTER TABLE PodcastLocal ADD skip_seconds INTEGER DEFAULT 0 "); + } + + Future _v3Update(Database db) async { + await db.execute( + "ALTER TABLE PodcastLocal ADD auto_download INTEGER DEFAULT 0"); + } + + Future _v4Update(Database db) async { + await db.execute( + "ALTER TABLE PodcastLocal ADD skip_seconds_end INTEGER DEFAULT 0 "); + await db.execute( + "ALTER TABLE PodcastLocal ADD never_update INTEGER DEFAULT 0 "); + } + + Future _v5Update(Database db) async { + await db.execute("ALTER TABLE PodcastLocal ADD funding TEXT DEFAULT '[]' "); + } + + Future _v6Update(Database db) async { + await db.execute("ALTER TABLE Episodes ADD chapter_link TEXT DEFAULT '' "); + await db.execute("ALTER TABLE Episodes ADD hosts TEXT DEFAULT '' "); + await db.execute("ALTER TABLE Episodes ADD episode_image TEXT DEFAULT '' "); + await db + .execute("""CREATE INDEX podcast_search ON PodcastLocal (id, rssUrl) + """); + await db.execute( + """CREATE INDEX episode_search ON Episodes (enclosure_url, feed_id) + """); + } + Future> getPodcastLocal(List podcasts, {bool updateOnly = false}) async { var dbClient = await database; @@ -318,11 +349,10 @@ class DBHelper { }); } - Future updatePodcastImage({String id ,String filePath}) async{ + Future updatePodcastImage({String id, String filePath}) async { var dbClient = await database; return await dbClient.rawUpdate( - "UPDATE PodcastLocal SET imagePath= ? WHERE id = ?", - [filePath, id]); + "UPDATE PodcastLocal SET imagePath= ? WHERE id = ?", [filePath, id]); } Future saveFiresideData(List list) async { @@ -616,12 +646,14 @@ class DBHelper { final milliseconds = date.millisecondsSinceEpoch; final duration = feed.items[i].itunes.duration?.inSeconds ?? 0; final explicit = _getExplicit(feed.items[i].itunes.explicit); - + final chapter = feed.items[i].podcastChapters?.url ?? ''; + final image = feed.items[i].itunes.image.href ?? ''; if (url != null) { await dbClient.transaction((txn) { return txn.rawInsert( """INSERT OR REPLACE INTO Episodes(title, enclosure_url, enclosure_length, pubDate, - description, feed_id, milliseconds, duration, explicit, media_id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + description, feed_id, milliseconds, duration, explicit, media_id, chapter_link, + episode_image) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", [ title, url, @@ -632,7 +664,9 @@ class DBHelper { milliseconds, duration, explicit, - url + url, + chapter, + image ]); }); } @@ -686,12 +720,15 @@ class DBHelper { final milliseconds = date.millisecondsSinceEpoch; final duration = item.itunes.duration?.inSeconds ?? 0; final explicit = _getExplicit(item.itunes.explicit); + final chapter = item.podcastChapters?.url ?? ''; + final image = item.itunes.image.href; if (url != null) { await dbClient.transaction((txn) async { await txn.rawInsert( """INSERT OR IGNORE INTO Episodes(title, enclosure_url, enclosure_length, pubDate, - description, feed_id, milliseconds, duration, explicit, media_id, is_new) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)""", + description, feed_id, milliseconds, duration, explicit, media_id, chapter_link, + episode_image, is_new) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)""", [ title, url, @@ -703,6 +740,8 @@ class DBHelper { duration, explicit, url, + chapter, + image ]); }); } @@ -1611,13 +1650,30 @@ class DBHelper { return description; } + Future getChapter(String url) async { + var dbClient = await database; + List list = await dbClient.rawQuery( + 'SELECT chapter_link FROM Episodes WHERE enclosure_url = ?', [url]); + String chapter = list[0]['chapter_link']; + return chapter; + } + + Future getEpisodeImage(String url) async { + var dbClient = await database; + List list = await dbClient.rawQuery( + 'SELECT episode_image FROM Episodes WHERE enclosure_url = ?', [url]); + String image = list[0]['episode_image']; + return image; + } + Future getRssItemWithUrl(String url) async { var dbClient = await database; EpisodeBrief episode; List list = await dbClient.rawQuery( """SELECT E.title, E.enclosure_url, E.enclosure_length, E.milliseconds, P.imagePath, P.title as feed_title, E.duration, E.explicit, P.skip_seconds, P.skip_seconds_end, - E.is_new, P.primaryColor, E.media_id FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id + E.is_new, P.primaryColor, E.media_id, E.episode_image, E.chapter_link + FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id WHERE E.enclosure_url = ?""", [url]); if (list.isEmpty) { return null; @@ -1635,7 +1691,9 @@ class DBHelper { list.first['is_new'], mediaId: list.first['media_id'], skipSecondsStart: list.first['skip_seconds'], - skipSecondsEnd: list.first['skip_seconds_end']); + skipSecondsEnd: list.first['skip_seconds_end'], + episodeImage: list.first['episode_image'], + chapterLink: list.first['chapter_link']); return episode; } } @@ -1646,8 +1704,9 @@ class DBHelper { List list = await dbClient.rawQuery( """SELECT E.title, E.enclosure_url, E.enclosure_length, E.milliseconds, P.imagePath, P.title as feed_title, E.duration, E.explicit, P.skip_seconds, P.skip_seconds_end, - E.is_new, P.primaryColor, E.media_id FROM Episodes E INNER JOIN - PodcastLocal P ON E.feed_id = P.id WHERE E.media_id = ?""", [id]); + E.is_new, P.primaryColor, E.media_id, E.episode_image, E.chapter_link + FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id + WHERE E.media_id = ?""", [id]); if (list.isEmpty) { return null; } else { @@ -1664,7 +1723,9 @@ class DBHelper { list.first['is_new'], mediaId: list.first['media_id'], skipSecondsStart: list.first['skip_seconds'], - skipSecondsEnd: list.first['skip_seconds_end']); + skipSecondsEnd: list.first['skip_seconds_end'], + episodeImage: list.first['episode_image'], + chapterLink: list.first['chapter_link']); return episode; } } diff --git a/lib/type/chapter.dart b/lib/type/chapter.dart new file mode 100644 index 0000000..b927728 --- /dev/null +++ b/lib/type/chapter.dart @@ -0,0 +1,41 @@ +class ChapterInfo { + List chapters; + String version; + + ChapterInfo({this.chapters, this.version}); + + ChapterInfo.fromJson(Map json) { + if (json['chapters'] != null) { + chapters = []; + json['chapters'].forEach((v) { + chapters.add(Chapters.fromJson(v)); + }); + } + version = json['version']; + } +} + +class Chapters { + String img; + int startTime; + String title; + String url; + + Chapters({this.img, this.startTime, this.title, this.url}); + + Chapters.fromJson(Map json) { + img = json['img']; + startTime = json['startTime']; + title = json['title']; + url = json['url']; + } + + Map toJson() { + final data = {}; + data['img'] = img; + data['startTime'] = startTime; + data['title'] = title; + data['url'] = url; + return data; + } +} \ No newline at end of file diff --git a/lib/type/episodebrief.dart b/lib/type/episodebrief.dart index ef6754a..054e50e 100644 --- a/lib/type/episodebrief.dart +++ b/lib/type/episodebrief.dart @@ -24,6 +24,8 @@ class EpisodeBrief extends Equatable { final int skipSecondsStart; final int skipSecondsEnd; final int downloadDate; + final String episodeImage; + final String chapterLink; EpisodeBrief( this.title, this.enclosureUrl, @@ -41,7 +43,9 @@ class EpisodeBrief extends Equatable { this.skipSecondsStart, this.skipSecondsEnd, this.description = '', - this.downloadDate = 0}) + this.downloadDate = 0, + this.chapterLink = '', + this.episodeImage = ''}) : assert(enclosureUrl != null); MediaItem toMediaItem() { diff --git a/lib/widgets/custom_widget.dart b/lib/widgets/custom_widget.dart index f450675..b5506e3 100644 --- a/lib/widgets/custom_widget.dart +++ b/lib/widgets/custom_widget.dart @@ -1242,7 +1242,13 @@ class TabIndicator extends CustomPainter { canvas.drawLine(leftStart, leftEnd, index == 0 || fraction == 0 ? _accentPaint : _paint); canvas.drawLine(rightStart, rightEnd, - index == 1 || fraction == 0 ? _accentPaint : _paint); + index == 2 || fraction == 0 ? _accentPaint : _paint); + if (fraction == 1) { + canvas.drawLine( + Offset(size.width/2 - indicatorSize / 2, size.height), + Offset(size.width/2 + indicatorSize / 2, size.height), + index == 1 || fraction == 0 ? _accentPaint : _paint); + } } @override @@ -1466,7 +1472,8 @@ class CircleProgressIndicator extends CustomPainter { void paint(Canvas canvas, Size size) { var center = Offset(size.width / 2, size.height / 2); canvas.drawArc( - Rect.fromCenter(center: center, height: size.height*2, width: size.width*2), + Rect.fromCenter( + center: center, height: size.height * 2, width: size.width * 2), -math.pi / 2, math.pi * 2 * (progress / 100), true, diff --git a/pubspec.yaml b/pubspec.yaml index 593360d..9ad2fe5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: flutter_isolate: ^1.0.0+14 flutter_linkify: ^4.0.2 flutter_file_dialog: ^1.0.0 + flutter_cache_manager: ^1.4.0 flare_flutter: ^2.0.6 fl_chart: ^0.12.2 marquee: ^1.6.1