Add share clip feature

This commit is contained in:
stonegate 2020-05-19 01:03:45 +08:00
parent 2f9af80c9c
commit 6ebe9a4a3c
12 changed files with 641 additions and 13 deletions

View File

@ -22,12 +22,13 @@ The podcasts search engine is powered by [ListenNotes](https://listennotes.com).
## Features
* Subscriptoin group management
* Playlist support
* Sleep timer
* Sleep timer / Speed setting
* OMPL file export and import
* Auto syncing in background
* Listen and subscribe history record
* Dark mode / Accent color personalization
* Download for offline playing
* Dark mode / Accent color
* Download for offline playing
* Share clip on twitter
More to come...
@ -46,12 +47,14 @@ Tsacdop is using ListenNotes api 1.0 pro to search podcast, which is not free. S
If you want to build the app, you need to create a new file named .env.dart in lib folder. Add below code in .env.dart.
```
final environment = {"apiKey":"APIKEY"};
final environment = {"apiKey":"APIKEY", "shareKey":"SHAREKEY"};
```
You can get own api key on [RapidApi](https://rapidapi.com/listennotes/api/listennotes), basic plan is free to all, and replace "APIKEY" with it.
If no api key added, the search function in the app won't work. But you can still add podcasts by serach rss link or import ompl file.
Share_key is used for generate clip.
## Getting Started
This project is a starting point for a Flutter application.

View File

@ -47,7 +47,7 @@ android {
applicationId "com.stonegate.tsacdop"
minSdkVersion 19
targetSdkVersion 28
versionCode 11
versionCode 12
versionName flutterVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

View File

@ -4,7 +4,7 @@ import 'package:tsacdop/util/custompaint.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:line_icons/line_icons.dart';
const String version = '0.2.3';
const String version = '0.2.4';
class AboutApp extends StatelessWidget {
_launchUrl(String url) async {

View File

@ -19,6 +19,7 @@ import '../util/customslider.dart';
import '../episodes/episodedetail.dart';
import 'playlist.dart';
import 'audiopanel.dart';
import 'share.dart';
final List<BoxShadow> _customShadow = [
BoxShadow(blurRadius: 26, offset: Offset(-6, -6), color: Colors.white),
@ -54,7 +55,7 @@ class _PlayerWidgetState extends State<PlayerWidget> {
return Container(
alignment: Alignment.topLeft,
height: 300,
width: MediaQuery.of(context).size.width,
width: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
),
@ -305,7 +306,7 @@ class _PlayerWidgetState extends State<PlayerWidget> {
Widget _expandedPanel(BuildContext context) {
return DefaultTabController(
initialIndex: 1,
length: 3,
length: 4,
child: Stack(
children: <Widget>[
TabBarView(
@ -313,6 +314,7 @@ class _PlayerWidgetState extends State<PlayerWidget> {
SleepMode(),
ControlPanel(),
_playlist(context),
ShareClip(),
],
),
Positioned(
@ -364,6 +366,16 @@ class _PlayerWidgetState extends State<PlayerWidget> {
color: Theme.of(context).accentColor,
width: 2.0)),
),
Container(
height: 8.0,
width: 8.0,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.transparent,
border: Border.all(
color: Theme.of(context).accentColor,
width: 2.0)),
),
]),
),
),

69
lib/home/preview.dart Normal file
View File

@ -0,0 +1,69 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player/video_player.dart';
import '../util/context_extension.dart';
class ClipPreview extends StatefulWidget {
final String filePath;
ClipPreview({this.filePath, Key key}) : super(key: key);
@override
_ClipPreviewState createState() => _ClipPreviewState();
}
class _ClipPreviewState extends State<ClipPreview> {
VideoPlayerController _controller;
@override
void initState() {
super.initState();
_controller = VideoPlayerController.file(File(widget.filePath))
..initialize().then((_) {
setState(() {});
});
}
@override
void dispose() {
super.dispose();
_controller.dispose();
}
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Colors.black,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Scaffold(
appBar: AppBar(backgroundColor: Colors.black),
backgroundColor: Colors.black,
body: Center(
child: _controller.value.initialized
? AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
)
: Container(),
),
floatingActionButton: FloatingActionButton(
backgroundColor: context.accentColor,
onPressed: () {
setState(() {
_controller.value.isPlaying
? _controller.pause()
: _controller.play();
});
},
child: Icon(
_controller.value.isPlaying ? Icons.pause : Icons.play_arrow,
),
),
),
);
}
}

469
lib/home/share.dart Normal file
View File

@ -0,0 +1,469 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:line_icons/line_icons.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:tsacdop/home/preview.dart';
import 'package:wc_flutter_share/wc_flutter_share.dart';
import 'package:tuple/tuple.dart';
import '../state/audiostate.dart';
import '../util/context_extension.dart';
import '../util/customslider.dart';
import '../util/pageroute.dart';
final List<BoxShadow> _customShadow = [
BoxShadow(blurRadius: 26, offset: Offset(-6, -6), color: Colors.white),
BoxShadow(
blurRadius: 8,
offset: Offset(2, 2),
color: Colors.grey[600].withOpacity(0.4))
];
final List<BoxShadow> _customShadowNight = [
BoxShadow(
blurRadius: 6,
offset: Offset(-1, -1),
color: Colors.grey[100].withOpacity(0.3)),
BoxShadow(blurRadius: 8, offset: Offset(2, 2), color: Colors.black)
];
String _stringForSeconds(int seconds) {
if (seconds == null) return null;
return '${(seconds ~/ 60)}:${(seconds.truncate() % 60).toString().padLeft(2, '0')}';
}
class ShareClip extends StatefulWidget {
ShareClip({Key key}) : super(key: key);
@override
_ShareClipState createState() => _ShareClipState();
}
class _ShareClipState extends State<ShareClip> {
int _durationSelected;
int _startPosition;
bool _startConfirm;
List<int> _durationToSelect = [30, 60, 90, 120];
Widget _animatedWidget;
Widget _toastWidget;
@override
void initState() {
super.initState();
_durationSelected = 60;
_startPosition = 0;
_startConfirm = false;
_animatedWidget = Center();
_toastWidget = Center();
}
_formatSeconds(int s) {
switch (s) {
case 30:
return "30sec";
break;
case 60:
return "1min";
break;
case 90:
return "90sec";
break;
case 120:
return "2min";
break;
default:
return '';
break;
}
}
_setShareButton(ShareStatus status, String filePath) {
switch (status) {
case ShareStatus.generate:
_animatedWidget = Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
)),
Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
),
Text('Clipping'),
],
);
_toastWidget = Text('May take one minute',
style: TextStyle(color: const Color(0xff67727d)));
break;
case ShareStatus.download:
_animatedWidget = Text('Loading');
break;
case ShareStatus.complete:
_animatedWidget = Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.share),
Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
),
Text('Share'),
],
);
_toastWidget = Row(
children: [
Text('Preview'),
IconButton(
icon: Icon(LineIcons.play_solid),
onPressed: () {
if (filePath != '')
Navigator.push(
context,
SlideLeftRoute(
page: ClipPreview(
filePath: filePath,
)),
);
}),
],
);
break;
case ShareStatus.undefined:
_animatedWidget = Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LineIcons.cut_solid),
Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
),
Text('Clip')
],
);
_toastWidget = Center();
break;
case ShareStatus.error:
_animatedWidget = Text('Retry');
_toastWidget = Text('Something wrong happened');
break;
}
}
@override
Widget build(BuildContext context) {
var audio = Provider.of<AudioPlayerNotifier>(context, listen: false);
return Container(
height: 300,
width: double.infinity,
color: context.primaryColor,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
padding: EdgeInsets.symmetric(horizontal: 20.0),
height: 60.0,
// color: context.primaryColorDark,
alignment: Alignment.centerLeft,
child: Row(
children: <Widget>[
Container(
padding: EdgeInsets.symmetric(horizontal: 20.0),
height: 20.0,
// color: context.primaryColorDark,
alignment: Alignment.centerLeft,
child: Text(
'Share Clip',
style: TextStyle(
color: Theme.of(context).accentColor,
fontWeight: FontWeight.bold,
fontSize: 16),
),
),
Spacer(),
Selector<AudioPlayerNotifier, Tuple2<ShareStatus, String>>(
selector: (_, audio) =>
Tuple2(audio.shareStatus, audio.shareFile ?? ''),
builder: (_, data, __) {
_setShareButton(data.item1, data.item2);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
transitionBuilder: (child, animation) =>
ScaleTransition(scale: animation, child: child),
duration: Duration(milliseconds: 500),
child: _toastWidget),
Container(
margin: EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.center,
height: 40,
width: 120,
decoration: BoxDecoration(
color: context.primaryColor,
borderRadius:
BorderRadius.all(Radius.circular(20)),
border: Border.all(
color: Theme.of(context).brightness ==
Brightness.dark
? Colors.black12
: Colors.white10,
width: 1),
boxShadow: Theme.of(context).brightness ==
Brightness.dark
? _customShadowNight
: _customShadow),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.all(Radius.circular(15)),
onTap: () async {
if (data.item1 == ShareStatus.undefined ||
data.item1 ==
ShareStatus.error) if (_startConfirm)
audio.shareClip(
_startPosition, _durationSelected);
else
Fluttertoast.showToast(
msg: 'Please confirm start position',
gravity: ToastGravity.BOTTOM,
);
else if (data.item1 == ShareStatus.complete) {
File file = File(data.item2);
final Uint8List bytes =
await file.readAsBytes();
await WcFlutterShare.share(
sharePopupTitle: 'share Clip',
fileName: data.item2.split('/').last,
mimeType: 'video/mp4',
bytesOfFile: bytes.buffer.asUint8List());
audio.setShareStatue = ShareStatus.undefined;
}
},
child: SizedBox(
height: 40,
width: 100,
child: Center(
child: AnimatedSwitcher(
transitionBuilder: (child, animation) =>
ScaleTransition(
scale: animation, child: child),
duration: Duration(milliseconds: 700),
child: _animatedWidget)),
),
),
),
),
],
);
},
),
],
),
),
Consumer<AudioPlayerNotifier>(builder: (_, data, __) {
return Container(
padding: EdgeInsets.only(top: 5, left: 10, right: 10),
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor:
Theme.of(context).brightness == Brightness.dark
? Colors.black38
: Colors.grey[400],
inactiveTrackColor: Theme.of(context).primaryColorDark,
trackHeight: 20.0,
trackShape: MyRectangularTrackShape(),
thumbColor: Theme.of(context).accentColor,
thumbShape: MyRoundSliderThumpShape(
enabledThumbRadius: 10.0,
disabledThumbRadius: 10.0,
thumbCenterColor: context.accentColor),
overlayColor: Theme.of(context).accentColor.withAlpha(32),
overlayShape: RoundSliderOverlayShape(overlayRadius: 4.0),
),
child: Slider(
value: data.seekSliderValue,
onChanged: (double val) {
audio.sliderSeek(val);
}),
),
);
}),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
height: 150,
width: 200,
child: Padding(
padding: const EdgeInsets.only(left: 10, right: 10),
child: Center(
child: Selector<AudioPlayerNotifier, int>(
selector: (_, audio) => audio.backgroundAudioPosition,
builder: (_, position, __) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(10.0),
child: Text.rich(
TextSpan(
text: 'Start at \n',
style:
TextStyle(color: context.accentColor),
children: [
TextSpan(
text: !_startConfirm
? _stringForSeconds(
position ~/ 1000)
: _stringForSeconds(
_startPosition),
style: context.textTheme.headline5
.copyWith(
color: _startConfirm
? context.accentColor
: null)),
]),
textAlign: TextAlign.center,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
padding: EdgeInsets.symmetric(
horizontal: 10.0),
onPressed: () => audio.forwardAudio(-30),
iconSize: 25.0,
icon: Icon(Icons.replay_30),
color: Colors.grey[500]),
InkWell(
onTap: () => setState(() {
if (!_startConfirm)
_startPosition = position ~/ 1000;
_startConfirm = !_startConfirm;
}),
child: Container(
margin: EdgeInsets.all(10.0),
decoration: BoxDecoration(
boxShadow: !_startConfirm
? (context.brightness ==
Brightness.dark)
? _customShadowNight
: _customShadow
: null,
color: _startConfirm
? Theme.of(context).accentColor
: Theme.of(context).primaryColor,
shape: BoxShape.circle),
alignment: Alignment.center,
width: 40,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
LineIcons.thumbtack_solid,
color: _startConfirm
? Colors.white
: null,
)),
),
),
IconButton(
padding: EdgeInsets.symmetric(
horizontal: 10.0),
onPressed: () => audio.forwardAudio(10),
iconSize: 25.0,
icon: Icon(Icons.forward_10),
color: Colors.grey[500]),
],
),
],
);
},
),
),
),
),
Container(
height: 100,
width: 2,
color: context.primaryColorDark,
),
SizedBox(
height: 150,
width: 200,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Center(
child: Wrap(
direction: Axis.horizontal,
children: _durationToSelect
.map<Widget>((e) => InkWell(
onTap: () =>
setState(() => _durationSelected = e),
child: Container(
margin: EdgeInsets.all(10.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.all(
Radius.circular(15)),
boxShadow: e != _durationSelected
? (context.brightness ==
Brightness.dark)
? _customShadowNight
: _customShadow
: null,
color: (e == _durationSelected)
? Theme.of(context).accentColor
: Theme.of(context).primaryColor,
),
alignment: Alignment.center,
width: 70,
height: 30,
child: Text(_formatSeconds(e),
style: TextStyle(
fontWeight: FontWeight.bold,
color: (e == _durationSelected)
? Colors.white
: null)),
),
))
.toList(),
),
)),
),
],
),
),
Container(
height: 20,
padding: EdgeInsets.symmetric(horizontal: 20),
child: InkWell(
borderRadius: BorderRadius.all(Radius.circular(15.0)),
onTap: () {},
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('experimental'),
Icon(
LineIcons.info_circle_solid,
size: 20.0,
color: context.accentColor,
),
],
),
),
),
],
),
);
}
}

View File

@ -1039,4 +1039,14 @@ class DBHelper {
return episode;
}
}
Future<String> getImageUrl(String url) async{
var dbClient = await database;
List<Map> list = await dbClient.rawQuery(
"""SELECT P.imageUrl FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id
WHERE E.enclosure_url = ?""", [url]);
if(list.length ==0)
return null;
return list.first["imageUrl"];
}
}

View File

@ -68,4 +68,5 @@ List<Libries> plugins = [
Libries('Rxdart', apacheLicense, 'https://pub.dev/packages/rxdart'),
Libries('flutter_isolate', mit, 'https://pub.dev/packages/flutter_isolate'),
Libries('auto_animated', mit, 'https://pub.dev/packages/auto_animated'),
Libries('wc_flutter_share', apacheLicense, 'https://pub.dev/packages/wc_flutter_share')
];

View File

@ -1,12 +1,19 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:path/path.dart';
import 'package:flutter/foundation.dart';
import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart';
import 'package:rxdart/rxdart.dart';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import '../type/episodebrief.dart';
import '../local_storage/key_value_storage.dart';
import '../local_storage/sqflite_localpodcast.dart';
import '../.env.dart';
MediaControl playControl = MediaControl(
androidIcon: 'drawable/ic_stat_play_circle_filled',
@ -110,6 +117,7 @@ class Playlist {
}
enum SleepTimerMode { endOfEpisode, timer, undefined }
enum ShareStatus { generate, download, complete, undefined, error }
class AudioPlayerNotifier extends ChangeNotifier {
DBHelper dbHelper = DBHelper();
@ -135,6 +143,8 @@ class AudioPlayerNotifier extends ChangeNotifier {
bool _startSleepTimer = false;
double _switchValue = 0;
SleepTimerMode _sleepTimerMode = SleepTimerMode.undefined;
ShareStatus _shareStatus = ShareStatus.undefined;
String _shareFile = '';
//set autoplay episode in playlist
bool _autoPlay = true;
//TODO Set auto add episodes to playlist
@ -158,6 +168,8 @@ class AudioPlayerNotifier extends ChangeNotifier {
bool get stopOnComplete => _stopOnComplete;
bool get startSleepTimer => _startSleepTimer;
SleepTimerMode get sleepTimerMode => _sleepTimerMode;
ShareStatus get shareStatus => _shareStatus;
String get shareFile => _shareFile;
bool get autoPlay => _autoPlay;
int get timeLeft => _timeLeft;
double get switchValue => _switchValue;
@ -174,6 +186,11 @@ class AudioPlayerNotifier extends ChangeNotifier {
_setAutoPlay();
}
set setShareStatue(ShareStatus status) {
_shareStatus = status;
notifyListeners();
}
Future _getAutoPlay() async {
int i = await autoPlayStorage.getInt();
_autoPlay = i == 0 ? true : false;
@ -556,6 +573,48 @@ class AudioPlayerNotifier extends ChangeNotifier {
}
}
shareClip(int start, int duration) async {
_shareStatus = ShareStatus.generate;
notifyListeners();
int length = math.min(duration, (_backgroundAudioDuration ~/ 1000 - start));
final BaseOptions options = BaseOptions(
connectTimeout: 60000,
receiveTimeout: 120000,
);
String imageUrl = await dbHelper.getImageUrl(_episode.enclosureUrl);
String url = "https://podcastapi.stonegate.me/clip?" +
"audio_link=${_episode.enclosureUrl}&image_link=$imageUrl&title=${_episode.feedTitle}" +
"&text=${_episode.title}&start=$start&length=$length";
String shareKey = environment['shareKey'];
try {
Response response = await Dio(options).get(url,
options: Options(headers: {
'X-Share-Key': "$shareKey",
}));
String shareLink = response.data;
print(shareLink);
String fileName = _episode.title + start.toString() + '.mp4';
_shareStatus = ShareStatus.download;
notifyListeners();
Directory dir = await getTemporaryDirectory();
String shareDir = join(dir.path, 'share', fileName);
try {
await Dio().download(shareLink, shareDir);
_shareFile = shareDir;
_shareStatus = ShareStatus.complete;
notifyListeners();
} on DioError catch (e) {
print(e);
_shareStatus = ShareStatus.error;
notifyListeners();
}
} catch (e) {
print(e);
_shareStatus = ShareStatus.error;
notifyListeners();
}
}
@override
void dispose() async {
await AudioService.stop();

View File

@ -46,16 +46,18 @@ class MyRoundSliderThumpShape extends SliderComponentShape {
@required SliderThemeData sliderTheme,
TextDirection textDirection,
double value,
double textScaleFactor,
Size sizeWithOverflow,
}) {
final Canvas canvas = context.canvas;
final Tween<double> radiusTween = Tween<double>(
begin: _disabledThumbRadius,
end: enabledThumbRadius,
);
// final ColorTween colorTween = ColorTween(
// begin: sliderTheme.disabledThumbColor,
// end: sliderTheme.thumbColor,
// );
final ColorTween colorTween = ColorTween(
begin: sliderTheme.disabledThumbColor,
end: sliderTheme.thumbColor,
);
canvas.drawCircle(
center,

View File

@ -1,7 +1,7 @@
name: tsacdop
description: An easy-use podacasts player.
version: 0.2.3
version: 0.2.4
environment:
sdk: ">=2.6.0 <3.0.0"
@ -47,6 +47,8 @@ dev_dependencies:
flare_flutter: ^2.0.3
rxdart: ^0.24.0
auto_animated: ^2.1.0
wc_flutter_share: ^0.2.1
video_player: ^0.10.11
just_audio:
git:
url: https://github.com/stonega/just_audio.git

View File

@ -4,6 +4,7 @@ import 'dart:io';
Future<void> main() async {
final config = {
'apiKey': Platform.environment['API_KEY'],
'shareKey': Platform.environment['SHARE_KEY']
};
final filename = 'lib/.env.dart';