diff --git a/README.md b/README.md index d583db6..d66ba69 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/android/app/build.gradle b/android/app/build.gradle index b5d5f69..8546f01 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -47,7 +47,7 @@ android { applicationId "com.stonegate.tsacdop" minSdkVersion 19 targetSdkVersion 28 - versionCode 11 + versionCode 12 versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/lib/home/about.dart b/lib/home/about.dart index dd8b77a..fad96d9 100644 --- a/lib/home/about.dart +++ b/lib/home/about.dart @@ -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 { diff --git a/lib/home/audioplayer.dart b/lib/home/audioplayer.dart index a311e3d..4e3832f 100644 --- a/lib/home/audioplayer.dart +++ b/lib/home/audioplayer.dart @@ -19,6 +19,7 @@ import '../util/customslider.dart'; import '../episodes/episodedetail.dart'; import 'playlist.dart'; import 'audiopanel.dart'; +import 'share.dart'; final List _customShadow = [ BoxShadow(blurRadius: 26, offset: Offset(-6, -6), color: Colors.white), @@ -54,7 +55,7 @@ class _PlayerWidgetState extends State { 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 { Widget _expandedPanel(BuildContext context) { return DefaultTabController( initialIndex: 1, - length: 3, + length: 4, child: Stack( children: [ TabBarView( @@ -313,6 +314,7 @@ class _PlayerWidgetState extends State { SleepMode(), ControlPanel(), _playlist(context), + ShareClip(), ], ), Positioned( @@ -364,6 +366,16 @@ class _PlayerWidgetState extends State { 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)), + ), ]), ), ), diff --git a/lib/home/preview.dart b/lib/home/preview.dart new file mode 100644 index 0000000..bb378d0 --- /dev/null +++ b/lib/home/preview.dart @@ -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 { + 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( + 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, + ), + ), + ), + ); + } +} diff --git a/lib/home/share.dart b/lib/home/share.dart new file mode 100644 index 0000000..6eb1d1d --- /dev/null +++ b/lib/home/share.dart @@ -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 _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 _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 { + int _durationSelected; + int _startPosition; + bool _startConfirm; + List _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(context, listen: false); + return Container( + height: 300, + width: double.infinity, + color: context.primaryColor, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 20.0), + height: 60.0, + // color: context.primaryColorDark, + alignment: Alignment.centerLeft, + child: Row( + children: [ + 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>( + 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(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( + 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((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, + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/local_storage/sqflite_localpodcast.dart b/lib/local_storage/sqflite_localpodcast.dart index 3460307..235a484 100644 --- a/lib/local_storage/sqflite_localpodcast.dart +++ b/lib/local_storage/sqflite_localpodcast.dart @@ -1039,4 +1039,14 @@ class DBHelper { return episode; } } + + Future getImageUrl(String url) async{ + var dbClient = await database; + List 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"]; + } } diff --git a/lib/settings/licenses.dart b/lib/settings/licenses.dart index f32ba3e..ee7c827 100644 --- a/lib/settings/licenses.dart +++ b/lib/settings/licenses.dart @@ -68,4 +68,5 @@ List 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') ]; diff --git a/lib/state/audiostate.dart b/lib/state/audiostate.dart index 3a3fa54..82dc36a 100644 --- a/lib/state/audiostate.dart +++ b/lib/state/audiostate.dart @@ -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(); diff --git a/lib/util/customslider.dart b/lib/util/customslider.dart index a212371..5d8bc00 100644 --- a/lib/util/customslider.dart +++ b/lib/util/customslider.dart @@ -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 radiusTween = Tween( 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, diff --git a/pubspec.yaml b/pubspec.yaml index 4b37217..9b1d4c6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/tool/env.dart b/tool/env.dart index 85b94e2..e5fdb2f 100644 --- a/tool/env.dart +++ b/tool/env.dart @@ -4,6 +4,7 @@ import 'dart:io'; Future main() async { final config = { 'apiKey': Platform.environment['API_KEY'], + 'shareKey': Platform.environment['SHARE_KEY'] }; final filename = 'lib/.env.dart';