Suport chapers in player panel.

This commit is contained in:
stonega 2021-02-06 23:39:00 +08:00
parent 9ae9a27206
commit db5d038e62
7 changed files with 459 additions and 57 deletions

View File

@ -271,7 +271,7 @@ class _EpisodeDetailState extends State<EpisodeDetail> {
],
),
),
_ShowNote(episode: widget.episodeItem),
ShowNote(episode: widget.episodeItem),
Selector<AudioPlayerNotifier,
Tuple2<bool, PlayerHeight>>(
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();

View File

@ -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<PlaylistWidget> {
children: <Widget>[
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<SleepMode>
}
}
class ChaptersWidget extends StatefulWidget {
ChaptersWidget({Key key}) : super(key: key);
@override
_ChaptersWidgetState createState() => _ChaptersWidgetState();
}
class _ChaptersWidgetState extends State<ChaptersWidget> {
bool _showChapter;
@override
void initState() {
super.initState();
_showChapter = false;
}
Future<List<Chapters>> _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<AudioPlayerNotifier>()
.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<Color>(context.accentColor),
overlayColor: MaterialStateProperty.all<Color>(
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<AudioPlayerNotifier, EpisodeBrief>(
selector: (_, audio) => audio.episode,
builder: (_, episode, __) => Scrollbar(
child: Column(
children: [
Expanded(
child: _showChapter
? FutureBuilder<List<Chapters>>(
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: <Widget>[
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: <Widget>[
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<ControlPanel>
@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<ControlPanel>
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<ControlPanel>
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<ControlPanel>
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)),
],
),
),

View File

@ -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<void> _v2Update(Database db) async {
await db.execute(
"ALTER TABLE PodcastLocal ADD skip_seconds INTEGER DEFAULT 0 ");
}
Future<void> _v3Update(Database db) async {
await db.execute(
"ALTER TABLE PodcastLocal ADD auto_download INTEGER DEFAULT 0");
}
Future<void> _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<void> _v5Update(Database db) async {
await db.execute("ALTER TABLE PodcastLocal ADD funding TEXT DEFAULT '[]' ");
}
Future<void> _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<List<PodcastLocal>> getPodcastLocal(List<String> podcasts,
{bool updateOnly = false}) async {
var dbClient = await database;
@ -318,11 +349,10 @@ class DBHelper {
});
}
Future<void> updatePodcastImage({String id ,String filePath}) async{
Future<void> 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<int> saveFiresideData(List<String> 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<String> getChapter(String url) async {
var dbClient = await database;
List<Map> list = await dbClient.rawQuery(
'SELECT chapter_link FROM Episodes WHERE enclosure_url = ?', [url]);
String chapter = list[0]['chapter_link'];
return chapter;
}
Future<String> getEpisodeImage(String url) async {
var dbClient = await database;
List<Map> list = await dbClient.rawQuery(
'SELECT episode_image FROM Episodes WHERE enclosure_url = ?', [url]);
String image = list[0]['episode_image'];
return image;
}
Future<EpisodeBrief> getRssItemWithUrl(String url) async {
var dbClient = await database;
EpisodeBrief episode;
List<Map> 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<Map> 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;
}
}

41
lib/type/chapter.dart Normal file
View File

@ -0,0 +1,41 @@
class ChapterInfo {
List<Chapters> chapters;
String version;
ChapterInfo({this.chapters, this.version});
ChapterInfo.fromJson(Map<String, dynamic> json) {
if (json['chapters'] != null) {
chapters = <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<String, dynamic> json) {
img = json['img'];
startTime = json['startTime'];
title = json['title'];
url = json['url'];
}
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
data['img'] = img;
data['startTime'] = startTime;
data['title'] = title;
data['url'] = url;
return data;
}
}

View File

@ -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() {

View File

@ -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,

View File

@ -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