From e6b79370939274cf1c537540d98ada732857c1d3 Mon Sep 17 00:00:00 2001 From: ArtisanLRO Date: Mon, 3 May 2021 23:28:34 +1000 Subject: [PATCH] Shadowing mode, share intent, card creator --- android/app/src/main/AndroidManifest.xml | 31 + .../lrorpilla/jidoujisho/MainActivity.java | 79 +- chewie/lib/src/chewie_player.dart | 4 + chewie/lib/src/material_controls.dart | 45 +- lib/anki.dart | 58 +- lib/main.dart | 1086 ++++++++++++++++- lib/player.dart | 24 +- lib/preferences.dart | 10 + lib/util.dart | 27 + pubspec.lock | 28 + pubspec.yaml | 5 +- 11 files changed, 1338 insertions(+), 59 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 29b9d40b6..960778f6b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -42,6 +42,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/io/flutter/plugins/com/lrorpilla/jidoujisho/MainActivity.java b/android/app/src/main/java/io/flutter/plugins/com/lrorpilla/jidoujisho/MainActivity.java index 065fc89b8..41052b64e 100644 --- a/android/app/src/main/java/io/flutter/plugins/com/lrorpilla/jidoujisho/MainActivity.java +++ b/android/app/src/main/java/io/flutter/plugins/com/lrorpilla/jidoujisho/MainActivity.java @@ -59,6 +59,64 @@ protected void onCreate(Bundle savedInstanceState) { mAnkiDroid = new AnkiDroidHelper(context); } + private void addCreatorNote(String deck, String image, String audio, String sentence, String word, String meaning, String reading) { + final AddContentApi api = new AddContentApi(context); + + long deckId; + if (deckExists(deck)) { + deckId = mAnkiDroid.findDeckIdByName(deck); + } else { + deckId = api.addNewDeck(deck); + } + + long modelId; + if (modelExists("jidoujisho (Creator)")) { + modelId = mAnkiDroid.findModelIdByName("jidoujisho (Creator)", 6); + } else { + modelId = api.addNewCustomModel("jidoujisho (Creator)", + new String[] {"Image", "Audio", "Sentence", "Word", "Meaning", "Reading"}, + new String[] {"jidoujisho (Creator) Default"}, + new String[] {"{{Image}}
{{Word}}"}, + new String[] {"{{Image}}
{{Word}}" + + "

{{Reading}}

{{Word}}


{{Meaning}}

"}, + "p {\n" + + " margin: 0px\n" + + "}\n" + + "\n" + + "h2 {\n" + + " margin: 0px\n" + + "}\n" + + "\n" + + "small {\n" + + " margin: 0px\n" + + "}\n" + + "\n" + + ".card {\n" + + " font-family: arial;\n" + + " font-size: 20px;\n" + + " white-space: pre-line;\n" + + " text-align: center;\n" + + " color: black;\n" + + " background-color: white;\n" + + "}\n" + + "\n" + + "#sentence {\n" + + " font-size: 30px\n" + + "}", + null, + null + ); + } + + Set tags = new HashSet<>(Arrays.asList("jidoujisho")); + + api.addNote(modelId, deckId, new String[] {image, audio, sentence, word, meaning, reading}, tags); + + System.out.println("Added note via flutter_ankidroid_api"); + System.out.println("Model: " + modelId); + System.out.println("Deck: " + deckId); + } + private void addNote(String deck, String image, String audio, String sentence, String word, String meaning, String reading) { final AddContentApi api = new AddContentApi(context); @@ -156,22 +214,27 @@ private Long getModelId() { @Override public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), ANKIDROID_CHANNEL) .setMethodCallHandler( (call, result) -> { + final String deck = call.argument("deck"); + final String image = call.argument("image"); + final String audio = call.argument("audio"); + final String sentence = call.argument("sentence"); + final String answer = call.argument("answer"); + final String meaning = call.argument("meaning"); + final String reading = call.argument("reading"); + switch (call.method) { case "addNote": - final String deck = call.argument("deck"); - final String image = call.argument("image"); - final String audio = call.argument("audio"); - final String sentence = call.argument("sentence"); - final String answer = call.argument("answer"); - final String meaning = call.argument("meaning"); - final String reading = call.argument("reading"); - addNote(deck, image, audio, sentence, answer, meaning, reading); break; + case "addCreatorNote": + + addCreatorNote(deck, image, audio, sentence, answer, meaning, reading); + break; case "getDecks": final AddContentApi api = new AddContentApi(context); result.success(api.getDeckList()); diff --git a/chewie/lib/src/chewie_player.dart b/chewie/lib/src/chewie_player.dart index 1f6dc13d2..9355793e0 100755 --- a/chewie/lib/src/chewie_player.dart +++ b/chewie/lib/src/chewie_player.dart @@ -222,6 +222,8 @@ class ChewieController extends ChangeNotifier { @required this.playExternalSubtitles, @required this.retimeSubtitles, @required this.exportSingleCallback, + @required this.toggleShadowingMode, + @required this.shadowingSubtitle, this.aspectRatio, this.autoInitialize = false, this.autoPlay = false, @@ -266,6 +268,8 @@ class ChewieController extends ChangeNotifier { final VoidCallback playExternalSubtitles; final VoidCallback retimeSubtitles; final VoidCallback exportSingleCallback; + final VoidCallback toggleShadowingMode; + final ValueNotifier shadowingSubtitle; final YouTubeMux streamData; /// Initialize the Video on Startup. This will prep the video for playback. diff --git a/chewie/lib/src/material_controls.dart b/chewie/lib/src/material_controls.dart index 34fdfa10c..42b05d5e9 100755 --- a/chewie/lib/src/material_controls.dart +++ b/chewie/lib/src/material_controls.dart @@ -549,19 +549,42 @@ class _MaterialControlsState extends State ? _latestValue.duration : Duration.zero; - return GestureDetector( - child: Padding( - padding: const EdgeInsets.only(right: 24.0), - child: Text( - duration != Duration.zero - ? '${formatDuration(position)} / ${formatDuration(duration)}' - : '', - style: const TextStyle( - fontSize: 14.0, + if (chewieController.shadowingSubtitle.value != null) { + return GestureDetector( + onTap: () { + chewieController.toggleShadowingMode(); + }, + child: Padding( + padding: const EdgeInsets.only(right: 24.0), + child: Text( + duration != Duration.zero + ? '${formatDuration(position)} / ${formatDuration(chewieController.shadowingSubtitle.value.endTime)}' + : '', + style: const TextStyle( + fontSize: 14.0, + color: Colors.red, + ), ), ), - ), - ); + ); + } else { + return GestureDetector( + onTap: () { + chewieController.toggleShadowingMode(); + }, + child: Padding( + padding: const EdgeInsets.only(right: 24.0), + child: Text( + duration != Duration.zero + ? '${formatDuration(position)} / ${formatDuration(duration)}' + : '', + style: const TextStyle( + fontSize: 14.0, + ), + ), + ), + ); + } } void _cancelAndRestartTimer() { diff --git a/lib/anki.dart b/lib/anki.dart index 74c57fc22..caef9b99b 100644 --- a/lib/anki.dart +++ b/lib/anki.dart @@ -164,7 +164,7 @@ Future exportToAnki( int audioAllowance, int subtitleDelay, ) async { - String lastDeck = gSharedPrefs.getString("lastDeck") ?? "Default"; + String lastDeck = getLastDeck(); List decks; try { @@ -247,9 +247,9 @@ void showAnkiDialog( decoration: InputDecoration( prefixIcon: Icon(icon), suffixIcon: IconButton( - iconSize: 12, + iconSize: 18, onPressed: () => controller.clear(), - icon: Icon(Icons.clear), + icon: Icon(Icons.clear, color: Colors.white), ), labelText: labelText, hintText: hintText, @@ -521,6 +521,33 @@ Future addNote( } } +Future addCreatorNote( + String deck, + String image, + String audio, + String sentence, + String answer, + String meaning, + String reading, +) async { + const platform = const MethodChannel('com.lrorpilla.api/ankidroid'); + + try { + await platform.invokeMethod('addCreatorNote', { + 'deck': deck, + 'image': image, + 'audio': audio, + 'sentence': sentence, + 'answer': answer, + 'meaning': meaning, + 'reading': reading, + }); + } on PlatformException catch (e) { + print("Failed to add note via AnkiDroid API"); + print(e); + } +} + Future> getDecks() async { const platform = const MethodChannel('com.lrorpilla.api/ankidroid'); Map deckMap = await platform.invokeMethod('getDecks'); @@ -557,7 +584,7 @@ class _DeckDropDownState extends State { ); }).toList(), onChanged: (selectedDeck) async { - gSharedPrefs.setString("lastDeck", selectedDeck); + setLastDeck(selectedDeck); setState(() { _selectedDeck.value = selectedDeck; @@ -606,3 +633,26 @@ void exportAnkiCard(String deck, String sentence, String answer, String reading, requestAnkiDroidPermissions(); addNote(deck, addImage, addAudio, sentence, answer, meaning, reading); } + +void exportCreatorAnkiCard(String deck, String sentence, String answer, + String reading, String meaning, File imageFile) { + DateTime now = DateTime.now(); + String newFileName = + "jidoujisho-" + intl.DateFormat('yyyyMMddTkkmmss').format(now); + + String newImagePath = path.join( + getAnkiDroidDirectory().path, + "collection.media/$newFileName.jpg", + ); + + String addImage = ""; + String addAudio = ""; + + if (imageFile.existsSync()) { + imageFile.copySync(newImagePath); + addImage = ""; + } + + requestAnkiDroidPermissions(); + addCreatorNote(deck, addImage, addAudio, sentence, answer, meaning, reading); +} diff --git a/lib/main.dart b/lib/main.dart index bdebf8797..1a9e3b363 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,16 +1,21 @@ +import 'dart:async'; import 'dart:io'; import 'package:async/async.dart'; import 'package:audio_service/audio_service.dart'; +import 'package:external_app_launcher/external_app_launcher.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:fuzzy/fuzzy.dart'; - +import 'package:image_picker/image_picker.dart'; +import 'package:http/http.dart' as http; import 'package:lazy_load_scrollview/lazy_load_scrollview.dart'; import 'package:mecab_dart/mecab_dart.dart'; import 'package:package_info/package_info.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:transparent_image/transparent_image.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -26,6 +31,7 @@ import 'package:jidoujisho/preferences.dart'; import 'package:jidoujisho/util.dart'; typedef void ChannelCallback(String id, String name, bool isReversed); +typedef void CreatorCallback(DictionaryEntry entry, File file); typedef void SearchCallback(String term); void main() async { @@ -142,11 +148,23 @@ class Home extends StatefulWidget { } class _HomeState extends State { + StreamSubscription _intentDataStreamSubscription; + List _sharedFiles; + String _sharedText; + TextEditingController _searchQueryController = TextEditingController(); bool _isSearching = false; bool _isChannelView = false; + bool _isCreatorView = false; bool _isOldest = false; + DictionaryEntry _creatorDictionaryEntry = DictionaryEntry( + word: "", + reading: "", + meaning: "", + ); + File _creatorFile; + String _searchQuery = ""; int _selectedIndex = 0; String _selectedChannelName = ""; @@ -154,6 +172,84 @@ class _HomeState extends State { ValueNotifier>([]); YoutubeExplode yt = YoutubeExplode(); + @override + void initState() { + super.initState(); + + _intentDataStreamSubscription = ReceiveSharingIntent.getMediaStream() + .listen((List value) { + if (value == null) { + return; + } + + setCreatorView(DictionaryEntry(word: "", meaning: "", reading: ""), + File(value.first.path)); + }, onError: (err) { + print("getIntentDataStream error: $err"); + }); + + // For sharing images coming from outside the app while the app is closed + ReceiveSharingIntent.getInitialMedia().then((List value) { + if (value == null) { + return; + } + + setCreatorView(DictionaryEntry(word: "", meaning: "", reading: ""), + File(value.first.path)); + }); + + // For sharing or opening urls/text coming from outside the app while the app is in the memory + _intentDataStreamSubscription = + ReceiveSharingIntent.getTextStream().listen((String value) { + if (value == null) { + return; + } + if (value.startsWith("https://")) { + playYouTubeVideoLink(value); + } else { + setCreatorView( + DictionaryEntry(word: value, meaning: "", reading: ""), null); + } + }, onError: (err) { + print("getLinkStream error: $err"); + }); + + // For sharing or opening urls/text coming from outside the app while the app is closed + ReceiveSharingIntent.getInitialText().then((String value) { + if (value == null) { + return; + } + + if (value.startsWith("https://")) { + playYouTubeVideoLink(value); + } else { + setCreatorView( + DictionaryEntry(word: value, meaning: "", reading: ""), null); + } + }); + } + + void playYouTubeVideoLink(String link) { + if (YoutubePlayer.convertUrlToId(link) != null) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => Player( + url: link, + ), + ), + ).then((returnValue) { + setState(() { + unlockLandscape(); + }); + + setLastPlayedPath(link); + setLastPlayedPosition(0); + gIsResumable.value = getResumeAvailable(); + }); + } + } + void setStateFromResult() { setState(() {}); } @@ -171,9 +267,10 @@ class _HomeState extends State { }); } else { _selectedIndex = index; - if (_isSearching || _isChannelView) { + if (_isSearching || _isChannelView || _isCreatorView) { _isSearching = false; _isChannelView = false; + _isCreatorView = false; _searchQuery = ""; } } @@ -185,6 +282,12 @@ class _HomeState extends State { return buildBody(); } else if (_isChannelView) { return buildChannels(); + } else if (_isCreatorView) { + return Creator( + "", + _creatorDictionaryEntry, + _creatorFile, + ); } switch (getNavigationBarItems()[index].label) { @@ -195,7 +298,7 @@ class _HomeState extends State { case "History": return History(); case "Clipboard": - return ClipboardMenu(); + return ClipboardMenu(setCreatorView); default: return Container(); } @@ -266,18 +369,17 @@ class _HomeState extends State { onTap: onItemTapped, items: getNavigationBarItems(), ), - body: Center( - child: getWidgetOptions(_selectedIndex), - ), + body: getWidgetOptions(_selectedIndex), ), ); } Future _onWillPop() async { - if (_isSearching || _isChannelView) { + if (_isSearching || _isChannelView || _isCreatorView) { setState(() { _isSearching = false; _isChannelView = false; + _isCreatorView = false; _searchQuery = ""; _searchSuggestions.value = []; _searchQueryController.clear(); @@ -289,12 +391,13 @@ class _HomeState extends State { } Widget buildAppBarLeading() { - if (_isSearching || _isChannelView) { + if (_isSearching || _isChannelView || _isCreatorView) { return BackButton( onPressed: () { setState(() { _isSearching = false; _isChannelView = false; + _isCreatorView = false; _searchQuery = ""; _searchSuggestions.value = []; _searchQueryController.clear(); @@ -338,6 +441,16 @@ class _HomeState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, ); + } else if (_isCreatorView) { + return Text( + "Card Creator", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); } else { return Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -388,8 +501,12 @@ class _HomeState extends State { "Error getting channels", Icons.error, ); + Widget emptyMessage = centerMessage( + "No channels listed", + Icons.subscriptions_sharp, + ); Widget videoMessage = centerMessage( - _isOldest ? "Listing oldest videos..." : "Listing recent videos...", + _isOldest ? "Listing oldest videos..." : "Listing latest videos...", Icons.subscriptions_sharp, ); @@ -427,25 +544,33 @@ class _HomeState extends State { if (!snapshot.hasData) { return errorMessage; } - return ListView.builder( - addAutomaticKeepAlives: true, - itemCount: snapshot.data.length + 1, - itemBuilder: (BuildContext context, int index) { - if (index == 0) { - return buildNewChannelRow(); - } - Channel result = results[index - 1]; - print("CHANNEL LISTED: $result"); + if (snapshot.data.isNotEmpty) { + return ListView.builder( + addAutomaticKeepAlives: true, + itemCount: snapshot.data.length + 1, + itemBuilder: (BuildContext context, int index) { + if (index == 0) { + return buildNewChannelRow(); + } + + Channel result = results[index - 1]; + print("CHANNEL LISTED: $result"); - return ChannelResult( - result, - setChannelVideoSearch, - setStateFromResult, - index, - ); - }, - ); + return ChannelResult( + result, + setChannelVideoSearch, + setStateFromResult, + index, + ); + }, + ); + } else { + return Column(children: [ + buildNewChannelRow(), + Expanded(child: emptyMessage), + ]); + } } }, ); @@ -560,6 +685,76 @@ class _HomeState extends State { }); } + Future setCreatorView( + DictionaryEntry dictionaryEntry, + File file, + ) async { + try { + await getDecks(); + setState(() { + _isCreatorView = true; + _creatorDictionaryEntry = dictionaryEntry; + _creatorFile = file; + }); + } catch (e) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), + content: Text( + "Failed to communicate with the AnkiDroid background service, " + "which is necessary to use the card creator. Please launch " + "AnkiDroid and try again.", + textAlign: TextAlign.justify, + ), + actions: [ + new TextButton( + child: Text( + 'CANCEL', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + style: TextButton.styleFrom( + textStyle: TextStyle( + color: Colors.white, + ), + ), + onPressed: () async { + Navigator.pop(context); + }, + ), + new TextButton( + child: Text( + 'LAUNCH ANKIDROID', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + style: TextButton.styleFrom( + textStyle: TextStyle( + color: Colors.white, + ), + ), + onPressed: () async { + await LaunchApp.openApp( + androidPackageName: 'com.ichi2.anki', + openStore: true, + ); + Navigator.pop(context); + }, + ), + ], + ); + }); + } + } + Widget generateSuggestions() { return ValueListenableBuilder( valueListenable: _searchSuggestions, @@ -2031,10 +2226,17 @@ class _HistoryState extends State { } class ClipboardMenu extends StatefulWidget { - _ClipboardState createState() => _ClipboardState(); + final CreatorCallback creatorCallback; + + ClipboardMenu(this.creatorCallback); + + _ClipboardState createState() => _ClipboardState(this.creatorCallback); } class _ClipboardState extends State { + final CreatorCallback creatorCallback; + _ClipboardState(this.creatorCallback); + @override Widget build(BuildContext context) { List entries = getDictionaryHistory().reversed.toList(); @@ -2067,15 +2269,59 @@ class _ClipboardState extends State { Icons.paste_sharp, ); + Widget cardCreatorButton() { + return Container( + width: double.infinity, + margin: EdgeInsets.only(bottom: 12), + color: Colors.grey[800].withOpacity(0.2), + child: InkWell( + child: Padding( + padding: EdgeInsets.all(16), + child: InkWell( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.note_add_sharp, size: 16), + SizedBox(width: 5), + Text( + "Card Creator", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + onTap: () async { + creatorCallback( + DictionaryEntry( + word: "", + reading: "", + meaning: "", + ), + null, + ); + }, + ), + ); + } + if (entries.isEmpty) { return emptyMessage; } return ListView.builder( key: UniqueKey(), - itemCount: entries.length, + itemCount: entries.length + 1, itemBuilder: (BuildContext context, int index) { - DictionaryEntry entry = entries[index]; + if (index == 0) { + return cardCreatorButton(); + } + + DictionaryEntry entry = entries[index - 1]; print("ENTRY LISTED: $entry"); return Container( @@ -2083,7 +2329,7 @@ class _ClipboardState extends State { color: Colors.grey[800].withOpacity(0.2), child: InkWell( onTap: () { - Clipboard.setData(new ClipboardData(text: entry.word)); + creatorCallback(entry, null); }, child: Padding( padding: EdgeInsets.all(16), @@ -2091,15 +2337,15 @@ class _ClipboardState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - SelectableText( + Text( entry.word, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 20, ), ), - SelectableText(entry.reading), - SelectableText("\n${entry.meaning}\n"), + Text(entry.reading), + Text("\n${entry.meaning}\n"), ], ), ), @@ -2211,3 +2457,777 @@ class _LazyResultsState extends State { ); } } + +class Creator extends StatefulWidget { + final String initialSentence; + final DictionaryEntry initialDictionaryEntry; + final File initialFile; + + Creator( + this.initialSentence, + this.initialDictionaryEntry, + this.initialFile, + ); + + _CreatorState createState() => _CreatorState( + this.initialSentence, + this.initialDictionaryEntry, + this.initialFile, + ); +} + +class _CreatorState extends State { + final String initialSentence; + final DictionaryEntry initialDictionaryEntry; + final File initialFile; + + List decks; + List imageURLs; + String searchTerm; + TextEditingController _imageSearchController; + + TextEditingController _sentenceController; + TextEditingController _wordController; + TextEditingController _readingController; + TextEditingController _meaningController; + ValueNotifier _selectedDeck; + + String lastDeck = getLastDeck(); + + ValueNotifier _selectedEntry; + ValueNotifier _selectedIndex = ValueNotifier(0); + ValueNotifier _justExported = ValueNotifier(false); + bool _isFileImage = false; + File _fileImage; + String _networkImageURL; + + _CreatorState( + this.initialSentence, + this.initialDictionaryEntry, + this.initialFile, + ); + + @override + initState() { + super.initState(); + _imageSearchController = TextEditingController(text: searchTerm); + _sentenceController = TextEditingController(text: initialSentence); + _wordController = TextEditingController(text: initialDictionaryEntry.word); + _readingController = + TextEditingController(text: initialDictionaryEntry.reading); + _meaningController = + TextEditingController(text: initialDictionaryEntry.meaning); + + _selectedEntry = new ValueNotifier(initialDictionaryEntry); + _selectedDeck = new ValueNotifier(lastDeck); + + if (initialDictionaryEntry.word == "") { + _isFileImage = true; + } + if (initialFile != null) { + _isFileImage = true; + _fileImage = initialFile; + } + + if (searchTerm == null) { + if (initialDictionaryEntry.word.contains(";")) { + searchTerm = initialDictionaryEntry.word.split(";").first; + } else if (initialDictionaryEntry.word.contains("/")) { + searchTerm = initialDictionaryEntry.word.split("/").first; + } else { + searchTerm = initialDictionaryEntry.word; + } + } + } + + @override + build(BuildContext context) { + if (decks == null) { + return FutureBuilder( + future: getDecks(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + switch (snapshot.connectionState) { + case ConnectionState.waiting: + return buildWaitingMessage(); + break; + default: + decks = snapshot.data; + return buildEditor(); + } + }, + ); + } else { + return buildEditor(); + } + } + + Widget buildWaitingMessage() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.note_add_sharp, + color: Colors.grey, + size: 72, + ), + const SizedBox(height: 6), + Text( + "Preparing card creator...", + style: TextStyle( + color: Colors.grey, + fontSize: 20, + ), + ) + ], + ), + ); + } + + Widget buildSearchingMessage() { + return ListView( + shrinkWrap: true, + children: [ + Container( + height: MediaQuery.of(context).size.height / 5, + ), + SizedBox(height: 13), + Text( + "Searching for image...", + style: TextStyle( + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + void showDictionaryDialog(List results) { + ValueNotifier _dialogIndex = ValueNotifier(0); + ValueNotifier _dialogEntry = + ValueNotifier(results[0]); + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), + content: ValueListenableBuilder( + valueListenable: _dialogIndex, + builder: (BuildContext context, int _, Widget widget) { + _dialogEntry.value = results[_dialogIndex.value]; + addDictionaryEntryToHistory(_dialogEntry.value); + + return Container( + child: GestureDetector( + onHorizontalDragEnd: (details) { + if (details.primaryVelocity == 0) return; + + if (details.primaryVelocity.compareTo(0) == -1) { + if (_dialogIndex.value == results.length - 1) { + _dialogIndex.value = 0; + } else { + _dialogIndex.value += 1; + } + } else { + if (_dialogIndex.value == 0) { + _dialogIndex.value = results.length - 1; + } else { + _dialogIndex.value -= 1; + } + } + }, + child: Container( + color: Colors.grey[800].withOpacity(0.6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + results[_dialogIndex.value].word, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + Text(results[_dialogIndex.value].reading), + Flexible( + child: SingleChildScrollView( + child: gCustomDictionary.isNotEmpty || + getMonolingualMode() + ? SelectableText( + "\n${results[_dialogIndex.value].meaning}\n") + : Text( + "\n${results[_dialogIndex.value].meaning}\n"), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Showing search result ", + style: TextStyle( + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + Text( + "${_dialogIndex.value + 1} ", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + Text( + "out of ", + style: TextStyle( + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + Text( + "${results.length} ", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + Text( + "found for", + style: TextStyle( + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + Text( + "『${results[_dialogIndex.value].searchTerm}』", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + ], + ) + ], + ), + ), + ), + ); + }, + ), + actions: [ + TextButton( + child: Text('CANCEL', style: TextStyle(color: Colors.white)), + onPressed: () { + Navigator.pop(context); + }, + ), + TextButton( + child: Text('SELECT', style: TextStyle(color: Colors.white)), + onPressed: () async { + _selectedEntry.value = _dialogEntry.value; + _wordController = + TextEditingController(text: _selectedEntry.value.word); + _readingController = + TextEditingController(text: _selectedEntry.value.reading); + _meaningController = + TextEditingController(text: _selectedEntry.value.meaning); + + if (!_isFileImage) { + if (_selectedEntry.value.word.contains(";")) { + searchTerm = _selectedEntry.value.word.split(";").first; + } else if (_selectedEntry.value.word.contains("/")) { + searchTerm = _selectedEntry.value.word.split("/").first; + } else { + searchTerm = _selectedEntry.value.word; + } + _selectedIndex.value = 0; + } + + setState(() {}); + Navigator.pop(context); + }, + ), + ], + ); + }, + ); + } + + Widget buildEditor() { + Widget displayField( + String labelText, + String hintText, + IconData icon, + TextEditingController controller, + ) { + return TextFormField( + keyboardType: TextInputType.multiline, + maxLines: null, + controller: controller, + decoration: InputDecoration( + prefixIcon: Icon(icon), + suffixIcon: IconButton( + iconSize: 18, + onPressed: () => controller.clear(), + icon: Icon(Icons.clear, color: Colors.white), + ), + labelText: labelText, + hintText: hintText, + ), + ); + } + + Widget imageSearchField = TextFormField( + keyboardType: TextInputType.multiline, + maxLines: null, + controller: _imageSearchController, + decoration: InputDecoration( + prefixIcon: Icon(Icons.image, color: Colors.white), + suffixIcon: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + iconSize: 18, + onPressed: () { + setState(() { + _isFileImage = false; + _fileImage = null; + searchTerm = _imageSearchController.text; + _selectedIndex.value = 0; + }); + }, + icon: Icon(Icons.search, color: Colors.white), + ), + IconButton( + iconSize: 18, + onPressed: () async { + final _picker = ImagePicker(); + final pickedFile = + await _picker.getImage(source: ImageSource.camera); + + setState(() { + _fileImage = File(pickedFile.path); + _isFileImage = true; + _networkImageURL = null; + }); + }, + icon: Icon(Icons.add_a_photo, color: Colors.white), + ), + IconButton( + iconSize: 18, + onPressed: () async { + final _picker = ImagePicker(); + final pickedFile = + await _picker.getImage(source: ImageSource.gallery); + + setState(() { + _fileImage = File(pickedFile.path); + _isFileImage = true; + _networkImageURL = null; + }); + }, + icon: Icon(Icons.file_upload, color: Colors.white), + ), + IconButton( + iconSize: 18, + onPressed: () { + setState(() { + _isFileImage = true; + _fileImage = null; + _networkImageURL = null; + _imageSearchController.clear(); + }); + }, + icon: Icon(Icons.clear, color: Colors.white), + ), + ], + ), + labelText: "Image", + hintText: "Enter search term here", + ), + ); + Widget sentenceField = displayField( + "Sentence", + "Enter front of card or sentence here", + Icons.format_align_center_rounded, + _sentenceController, + ); + + Widget wordField = displayField( + "Word", + "Enter the word in the back here", + Icons.speaker_notes_outlined, + _wordController, + ); + + wordField = TextFormField( + keyboardType: TextInputType.multiline, + maxLines: null, + controller: _wordController, + decoration: InputDecoration( + prefixIcon: Icon( + Icons.speaker_notes_outlined, + ), + suffixIcon: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + iconSize: 18, + onPressed: () async { + String searchTerm = _wordController.text; + showDictionaryDialog(await getWordDetails(searchTerm)); + }, + icon: Text("A⌕", style: TextStyle(color: Colors.white)), + ), + IconButton( + iconSize: 18, + onPressed: () async { + String searchTerm = _wordController.text; + showDictionaryDialog( + await getMonolingualWordDetails(searchTerm, false)); + }, + icon: Text("あ⌕", style: TextStyle(color: Colors.white)), + ), + IconButton( + iconSize: 18, + onPressed: () => _wordController.clear(), + icon: Icon(Icons.clear, color: Colors.white), + ), + ], + ), + labelText: "Word", + hintText: "Enter the word in the back here", + ), + ); + + Widget readingField = displayField( + "Reading", + "Enter the reading of the word here", + Icons.surround_sound_outlined, + _readingController, + ); + Widget meaningField = displayField( + "Meaning", + "Enter the meaning in the back here", + Icons.translate_rounded, + _meaningController, + ); + + Widget showFileImage() { + if (_fileImage == null) { + return Container(); + } + + return ListView( + shrinkWrap: true, + children: [ + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PhotoView( + initialScale: PhotoViewComputedScale.contained * 1, + minScale: PhotoViewComputedScale.contained * 1, + imageProvider: FileImage(_fileImage), + ), + ), + ); + }, + child: FadeInImage( + fadeOutDuration: Duration(milliseconds: 10), + fadeInDuration: Duration(milliseconds: 50), + placeholder: MemoryImage(kTransparentImage), + image: FileImage(_fileImage), + fit: BoxFit.fitHeight, + height: MediaQuery.of(context).size.height / 5, + ), + ), + SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Showing local image", + style: TextStyle( + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + ], + ), + ], + ); + } + + Widget showNetworkImage() { + return FutureBuilder( + future: scrapeBingImages(searchTerm), + builder: (BuildContext context, AsyncSnapshot snapshot) { + switch (snapshot.connectionState) { + case ConnectionState.waiting: + return buildSearchingMessage(); + break; + default: + if (snapshot.data == null || snapshot.data.isEmpty) { + _fileImage = null; + return showFileImage(); + } + + imageURLs = snapshot.data; + return ListView( + shrinkWrap: true, + children: [ + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PhotoView( + initialScale: + PhotoViewComputedScale.contained * 1, + minScale: PhotoViewComputedScale.contained * 1, + imageProvider: NetworkImage( + imageURLs[_selectedIndex.value], + ), + ), + ), + ); + }, + onHorizontalDragEnd: (details) { + if (details.primaryVelocity == 0) return; + + if (details.primaryVelocity.compareTo(0) == -1) { + if (_selectedIndex.value == imageURLs.length - 1) { + _selectedIndex.value = 0; + } else { + _selectedIndex.value += 1; + } + } else { + if (_selectedIndex.value == 0) { + _selectedIndex.value = imageURLs.length - 1; + } else { + _selectedIndex.value -= 1; + } + } + }, + child: ValueListenableBuilder( + valueListenable: _selectedIndex, + builder: (BuildContext context, value, Widget child) { + _networkImageURL = imageURLs[_selectedIndex.value]; + + return FadeInImage( + fadeOutDuration: Duration(milliseconds: 10), + fadeInDuration: Duration(milliseconds: 50), + placeholder: MemoryImage(kTransparentImage), + image: NetworkImage(_networkImageURL), + fit: BoxFit.fitHeight, + height: MediaQuery.of(context).size.height / 5, + ); + }, + )), + SizedBox(height: 10), + ValueListenableBuilder( + valueListenable: _selectedIndex, + builder: (BuildContext context, value, Widget child) { + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Selecting image ", + style: TextStyle( + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + Text( + "${_selectedIndex.value + 1} ", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + Text( + "out of ", + style: TextStyle( + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + Text( + "${imageURLs.length} ", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + Text( + "found for", + style: TextStyle( + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + Text( + "『$searchTerm』", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + ], + ); + }, + ), + ], + ); + } + }, + ); + } + + Widget showImagePreview() { + if (_isFileImage) { + return showFileImage(); + } else { + return showNetworkImage(); + } + } + + Widget showExportButton() { + return ValueListenableBuilder( + valueListenable: _justExported, + builder: (BuildContext context, bool exported, ___) { + return Container( + width: double.infinity, + margin: EdgeInsets.only(bottom: 12), + color: Colors.grey[800].withOpacity(0.2), + child: InkWell( + enableFeedback: !exported, + child: Padding( + padding: EdgeInsets.all(16), + child: InkWell( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.note_add_sharp, + size: 16, + color: exported ? Colors.grey : Colors.white), + SizedBox(width: 5), + Text( + exported ? "Card Exported" : "Export Card", + style: TextStyle( + color: exported ? Colors.grey : Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + onTap: () async { + if (_sentenceController.text == "" && + _wordController.text == "" && + _readingController.text == "" && + _meaningController.text == "" && + _fileImage == null) { + return; + } + + if (_fileImage == null && _networkImageURL != null) { + var response = await http.get(Uri.tryParse(_networkImageURL)); + + File previewImageFile = File(getPreviewImagePath()); + if (previewImageFile.existsSync()) { + previewImageFile.deleteSync(); + } + + previewImageFile.writeAsBytesSync(response.bodyBytes); + _fileImage = previewImageFile; + } + + try { + exportCreatorAnkiCard( + _selectedDeck.value, + _sentenceController.text, + _wordController.text, + _readingController.text, + _meaningController.text, + _fileImage, + ); + + setState(() { + _isFileImage = true; + _fileImage = null; + + _sentenceController.clear(); + _wordController.clear(); + _readingController.clear(); + _meaningController.clear(); + }); + + _justExported.value = true; + Future.delayed(Duration(seconds: 2), () { + _justExported.value = false; + }); + } catch (e) { + print(e); + } + }, + ), + ); + }, + ); + } + + return Scaffold( + backgroundColor: Colors.black, + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + showImagePreview(), + DeckDropDown( + decks: decks, + selectedDeck: _selectedDeck, + ), + imageSearchField, + sentenceField, + wordField, + readingField, + meaningField, + SizedBox(height: 10), + ], + ), + ), + ), + showExportButton(), + ], + ), + ); + } +} diff --git a/lib/player.dart b/lib/player.dart index 5ceb6de70..b655a5d10 100644 --- a/lib/player.dart +++ b/lib/player.dart @@ -277,7 +277,8 @@ class _VideoPlayerState extends State { FocusNode _subtitleFocusNode = new FocusNode(); bool networkNotSet = true; ValueNotifier _wasPlaying = ValueNotifier(false); - ValueNotifier _widgetVisbility = ValueNotifier(false); + ValueNotifier _widgetVisibility = ValueNotifier(false); + ValueNotifier _shadowingSubtitle = ValueNotifier(null); int audioAllowance = getAudioAllowance(); Timer durationTimer; @@ -347,6 +348,15 @@ class _VideoPlayerState extends State { void visibilityTimerAction() { if (getVideoPlayerController().value.isInitialized) { + if (_shadowingSubtitle.value != null) { + if (getVideoPlayerController().value.position > + _shadowingSubtitle.value.endTime || + getVideoPlayerController().value.position < + _shadowingSubtitle.value.startTime - Duration(seconds: 10)) { + getVideoPlayerController().seekTo(_shadowingSubtitle.value.startTime); + } + } + Duration cutOffStart = _currentSubtitle.value.startTime - Duration(milliseconds: 100); Duration cutOffEnd = @@ -603,6 +613,14 @@ class _VideoPlayerState extends State { return _videoPlayerController; } + void toggleShadowingMode() { + if (_shadowingSubtitle.value == null) { + _shadowingSubtitle.value = _currentSubtitle.value; + } else { + _shadowingSubtitle.value = null; + } + } + ChewieController getChewieController() { _chewieController ??= ChewieController( videoPlayerController: getVideoPlayerController(), @@ -615,6 +633,8 @@ class _VideoPlayerState extends State { playExternalSubtitles: playExternalSubtitles, retimeSubtitles: retimeSubtitles, exportSingleCallback: exportSingleCallback, + toggleShadowingMode: toggleShadowingMode, + shadowingSubtitle: _shadowingSubtitle, streamData: streamData, aspectRatio: getVideoPlayerController().value.aspectRatio, autoPlay: true, @@ -640,7 +660,7 @@ class _VideoPlayerState extends State { subtitleDecoder: SubtitleDecoder.utf8, subtitleType: SubtitleType.srt, subtitlesOffset: getSubtitleDelay(), - widgetVisibility: _widgetVisbility, + widgetVisibility: _widgetVisibility, ); return _subTitleController; diff --git a/lib/preferences.dart b/lib/preferences.dart index e9b84d260..725876336 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -146,6 +146,16 @@ Directory getTermBankDirectory() { return directory; } +Future setLastDeck(String selectedDeck) async { + await gSharedPrefs.setString("lastDeck", selectedDeck); +} + +String getLastDeck() { + String lastDeck = gSharedPrefs.getString('lastDeck') ?? 'Default'; + + return lastDeck; +} + Future toggleSelectMode() async { await gSharedPrefs.setBool("selectMode", !getSelectMode()); } diff --git a/lib/util.dart b/lib/util.dart index f289dce20..913c4d313 100644 --- a/lib/util.dart +++ b/lib/util.dart @@ -1,6 +1,10 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'package:html/parser.dart' as parser; +import 'package:html/dom.dart' as dom; +import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; import 'package:flutter_ffmpeg/flutter_ffmpeg.dart'; import 'package:mecab_dart/mecab_dart.dart'; @@ -422,3 +426,26 @@ String getBetterNumberTag(String text) { return text; } + +Future> scrapeBingImages(String searchTerm) async { + List entries = []; + + var client = http.Client(); + http.Response response = await client.get(Uri.parse( + 'https://www.bing.com/images/search?q=$searchTerm&FORM=HDRSC2')); + var document = parser.parse(response.body); + + List imgElements = document.getElementsByClassName("iusc"); + + if (imgElements == null) { + return []; + } + + for (dom.Element imgElement in imgElements) { + Map imgMap = jsonDecode(imgElement.attributes["m"]); + String imageURL = imgMap["turl"]; + entries.add(imageURL); + } + + return entries; +} diff --git a/pubspec.lock b/pubspec.lock index efa8938ea..5a6bf29b1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -321,6 +321,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.7+22" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.6" import_js_library: dependency: transitive description: @@ -470,6 +484,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.1.0" + photo_view: + dependency: "direct main" + description: + name: photo_view + url: "https://pub.dartlang.org" + source: hosted + version: "0.11.1" platform: dependency: "direct overridden" description: @@ -498,6 +519,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.3.3" + receive_sharing_intent: + dependency: "direct main" + description: + name: receive_sharing_intent + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.5" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a6e908008..6ab814e9d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: jidoujisho description: A mobile video player tailored for Japanese language learners. publish_to: none -version: 0.14.2+16 +version: 0.15.0+17 environment: sdk: ">=2.7.0 <3.0.0" @@ -28,6 +28,7 @@ dependencies: url: https://github.com/project-violet/flutter-youtube-dl ref: 046b5e6c051b3fda333c1f366e2640cd04f763b8 fuzzy: + image_picker: intl: http: lazy_load_scrollview: ^1.3.0 @@ -38,6 +39,8 @@ dependencies: package_info: ^0.4.3+4 path_provider: permission_handler: + photo_view: + receive_sharing_intent: scrollable_positioned_list: ^0.1.9 shell: shared_preferences: