diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 000000000..116bc22f2 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +-keep class androidx.lifecycle.DefaultLifecycleObserver \ No newline at end of file diff --git a/lib/components/Settings/Settings.dart b/lib/components/Settings/Settings.dart index 4cf81865b..8798c6d82 100644 --- a/lib/components/Settings/Settings.dart +++ b/lib/components/Settings/Settings.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; @@ -31,9 +34,18 @@ class Settings extends HookConsumerWidget { }); }, []); + final pickDownloadLocation = useCallback(() async { + final dirStr = await FilePicker.platform.getDirectoryPath( + dialogTitle: "Download Location", + ); + if (dirStr == null) return; + preferences.setDownloadLocation(dirStr); + }, [preferences.downloadLocation]); + var ytSearchFormatController = useTextEditingController( text: preferences.ytSearchFormat, ); + return SafeArea( child: Scaffold( appBar: PageWindowTitleBar( @@ -148,6 +160,24 @@ class Settings extends HookConsumerWidget { ], ), ), + ListTile( + title: const Text("Download Location"), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + preferences.downloadLocation, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(width: 5), + ElevatedButton( + child: const Icon(Icons.folder_rounded), + onPressed: pickDownloadLocation, + ), + ], + ), + onTap: pickDownloadLocation, + ), Padding( padding: const EdgeInsets.symmetric( horizontal: 15.0, diff --git a/lib/components/Shared/DownloadTrackButton.dart b/lib/components/Shared/DownloadTrackButton.dart index e8f5d2952..60de226aa 100644 --- a/lib/components/Shared/DownloadTrackButton.dart +++ b/lib/components/Shared/DownloadTrackButton.dart @@ -11,7 +11,6 @@ import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; -import 'package:path_provider/path_provider.dart' as path_provider; import 'package:path/path.dart' as path; import 'package:permission_handler/permission_handler.dart'; import 'package:collection/collection.dart'; @@ -30,141 +29,146 @@ class DownloadTrackButton extends HookConsumerWidget { YoutubeExplode yt = useMemoized(() => YoutubeExplode()); final outputFile = useState(null); - final downloadFolder = useState(null); String fileName = "${track?.name} - ${TypeConversionUtils.artists_X_String(track?.artists ?? [])}"; useEffect(() { (() async { - downloadFolder.value = path.join( - Platform.isAndroid - ? "/storage/emulated/0/Download" - : (await path_provider.getDownloadsDirectory())!.path, - "Spotube"); - outputFile.value = - File(path.join(downloadFolder.value!, "$fileName.mp3")); + File(path.join(preferences.downloadLocation, "$fileName.mp3")); }()); return null; - }, [fileName, track]); + }, [fileName, track, preferences.downloadLocation]); final _downloadTrack = useCallback(() async { - if (track == null || - outputFile.value == null || - downloadFolder.value == null) return; - if ((kIsMobile) && - !await Permission.storage.isGranted && - !await Permission.storage.isPermanentlyDenied) { - final status = await Permission.storage.request(); - if (!status.isGranted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Couldn't download track. Not enough permissions"), - ), + try { + if (track == null || outputFile.value == null) return; + if ((kIsMobile) && + !await Permission.storage.isGranted && + !await Permission.storage.isPermanentlyDenied) { + final status = await Permission.storage.request(); + if (!status.isGranted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text("Couldn't download track. Not enough permissions"), + ), + ); + return; + } + } + StreamManifest manifest = await yt.videos.streamsClient + .getManifest((track as SpotubeTrack).ytTrack.url); + + File outputLyricsFile = File( + path.join(preferences.downloadLocation, "$fileName-lyrics.txt")); + + if (await outputFile.value!.exists()) { + final shouldReplace = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("Track Already Exists"), + content: const Text( + "Do you want to replace the already downloaded track?"), + actions: [ + TextButton( + child: const Text("No"), + onPressed: () { + Navigator.pop(context, false); + }, + ), + TextButton( + child: const Text("Yes"), + onPressed: () { + Navigator.pop(context, true); + }, + ) + ], + ); + }, ); - return; + if (shouldReplace != true) return; } - } - StreamManifest manifest = await yt.videos.streamsClient - .getManifest((track as SpotubeTrack).ytTrack.url); - - File outputLyricsFile = - File(path.join(downloadFolder.value!, "$fileName-lyrics.txt")); - - if (await outputFile.value!.exists()) { - final shouldReplace = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text("Track Already Exists"), - content: const Text( - "Do you want to replace the already downloaded track?"), - actions: [ - TextButton( - child: const Text("No"), - onPressed: () { - Navigator.pop(context, false); - }, - ), - TextButton( - child: const Text("Yes"), - onPressed: () { - Navigator.pop(context, true); - }, - ) - ], + + final audioStream = yt.videos.streamsClient + .get( + manifest.audioOnly + .where((audio) => audio.codec.mimeType == "audio/mp4") + .withHighestBitrate(), + ) + .asBroadcastStream(); + + final statusCb = audioStream.listen( + (event) { + if (status.value != TrackStatus.downloading) { + status.value = TrackStatus.downloading; + } + }, + onDone: () async { + status.value = TrackStatus.done; + await Future.delayed( + const Duration(seconds: 3), + () { + if (status.value == TrackStatus.done) { + status.value = TrackStatus.idle; + } + }, ); }, ); - if (shouldReplace != true) return; - } - final audioStream = yt.videos.streamsClient - .get( - manifest.audioOnly - .where((audio) => audio.codec.mimeType == "audio/mp4") - .withHighestBitrate(), - ) - .asBroadcastStream(); - - final statusCb = audioStream.listen( - (event) { - if (status.value != TrackStatus.downloading) { - status.value = TrackStatus.downloading; - } - }, - onDone: () async { - status.value = TrackStatus.done; - await Future.delayed( - const Duration(seconds: 3), - () { - if (status.value == TrackStatus.done) { - status.value = TrackStatus.idle; - } - }, - ); - }, - ); + if (!await outputFile.value!.exists()) { + await outputFile.value!.create(recursive: true); + } - if (!await outputFile.value!.exists()) { - await outputFile.value!.create(recursive: true); - } + IOSink outputFileStream = outputFile.value!.openWrite(); + await audioStream.pipe(outputFileStream); + await outputFileStream.flush(); + await outputFileStream.close().then((value) async { + if (status.value == TrackStatus.downloading) { + status.value = TrackStatus.done; + await Future.delayed( + const Duration(seconds: 3), + () { + if (status.value == TrackStatus.done) { + status.value = TrackStatus.idle; + } + }, + ); + } + return statusCb.cancel(); + }); - IOSink outputFileStream = outputFile.value!.openWrite(); - await audioStream.pipe(outputFileStream); - await outputFileStream.flush(); - await outputFileStream.close().then((value) async { - if (status.value == TrackStatus.downloading) { - status.value = TrackStatus.done; - await Future.delayed( - const Duration(seconds: 3), - () { - if (status.value == TrackStatus.done) { - status.value = TrackStatus.idle; - } - }, + if (preferences.saveTrackLyrics && playback.track != null) { + if (!await outputLyricsFile.exists()) { + await outputLyricsFile.create(recursive: true); + } + final lyrics = await ServiceUtils.getLyrics( + playback.track!.name!, + playback.track!.artists + ?.map((s) => s.name) + .whereNotNull() + .toList() ?? + [], + apiKey: preferences.geniusAccessToken, + optimizeQuery: true, ); + if (lyrics != null) { + await outputLyricsFile.writeAsString( + "$lyrics\n\nPowered by genius.com", + mode: FileMode.writeOnly, + ); + } } - return statusCb.cancel(); - }); - - if (preferences.saveTrackLyrics && playback.track != null) { - if (!await outputLyricsFile.exists()) { - await outputLyricsFile.create(recursive: true); - } - final lyrics = await ServiceUtils.getLyrics( - playback.track!.name!, - playback.track!.artists?.map((s) => s.name).whereNotNull().toList() ?? - [], - apiKey: preferences.geniusAccessToken, - optimizeQuery: true, + } on FileSystemException catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red, + content: Text("Download Failed. ${e.message} ${e.path}"), + ), ); - if (lyrics != null) { - await outputLyricsFile.writeAsString( - "$lyrics\n\nPowered by genius.com", - mode: FileMode.writeOnly, - ); - } } }, [ track, @@ -173,7 +177,7 @@ class DownloadTrackButton extends HookConsumerWidget { preferences.saveTrackLyrics, playback.track, outputFile.value, - downloadFolder.value, + preferences.downloadLocation, fileName ]); diff --git a/lib/provider/UserPreferences.dart b/lib/provider/UserPreferences.dart index a51ddf63c..d49ededd3 100644 --- a/lib/provider/UserPreferences.dart +++ b/lib/provider/UserPreferences.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotube/components/Settings/ColorSchemePickerDialog.dart'; import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/models/generated_secrets.dart'; @@ -9,6 +11,7 @@ import 'package:spotube/provider/Playback.dart'; import 'package:spotube/utils/PersistedChangeNotifier.dart'; import 'package:collection/collection.dart'; import 'package:spotube/utils/primitive_utils.dart'; +import 'package:path/path.dart' as path; class UserPreferences extends PersistedChangeNotifier { ThemeMode themeMode; @@ -23,6 +26,9 @@ class UserPreferences extends PersistedChangeNotifier { MaterialColor accentColorScheme; MaterialColor backgroundColorScheme; bool skipSponsorSegments; + + String downloadLocation; + UserPreferences({ required this.geniusAccessToken, required this.recommendationMarket, @@ -35,7 +41,16 @@ class UserPreferences extends PersistedChangeNotifier { this.trackMatchAlgorithm = SpotubeTrackMatchAlgorithm.authenticPopular, this.audioQuality = AudioQuality.high, this.skipSponsorSegments = true, - }) : super(); + this.downloadLocation = "", + }) : super() { + if (downloadLocation.isEmpty) { + _getDefaultDownloadDirectory().then( + (value) { + downloadLocation = value; + }, + ); + } + } void setThemeMode(ThemeMode mode) { themeMode = mode; @@ -103,8 +118,22 @@ class UserPreferences extends PersistedChangeNotifier { updatePersistence(); } + void setDownloadLocation(String downloadDir) { + if (downloadDir.isEmpty) return; + downloadLocation = downloadDir; + notifyListeners(); + updatePersistence(); + } + + Future _getDefaultDownloadDirectory() async { + if (Platform.isAndroid) return "/storage/emulated/0/Download/Spotube"; + return getDownloadsDirectory().then((dir) { + return path.join(dir!.path, "Spotube"); + }); + } + @override - FutureOr loadFromLocal(Map map) { + FutureOr loadFromLocal(Map map) async { saveTrackLyrics = map["saveTrackLyrics"] ?? false; recommendationMarket = map["recommendationMarket"] ?? recommendationMarket; checkUpdate = map["checkUpdate"] ?? checkUpdate; @@ -126,6 +155,8 @@ class UserPreferences extends PersistedChangeNotifier { ? AudioQuality.values[map["audioQuality"]] : audioQuality; skipSponsorSegments = map["skipSponsorSegments"] ?? skipSponsorSegments; + downloadLocation = + map["downloadLocation"] ?? await _getDefaultDownloadDirectory(); } @override @@ -142,6 +173,7 @@ class UserPreferences extends PersistedChangeNotifier { "trackMatchAlgorithm": trackMatchAlgorithm.index, "audioQuality": audioQuality.index, "skipSponsorSegments": skipSponsorSegments, + "downloadLocation": downloadLocation, }; } } diff --git a/pubspec.lock b/pubspec.lock index e5115646e..e4a61e1d1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -477,6 +477,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.2" + file_picker: + dependency: "direct main" + description: + name: file_picker + url: "https://pub.dartlang.org" + source: hosted + version: "4.6.1" fixnum: dependency: transitive description: @@ -552,6 +559,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.4" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.7" flutter_riverpod: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 3c219d922..d7cc5e77c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,7 @@ dependencies: audioplayers: ^1.0.1 introduction_screen: ^3.0.2 audio_session: ^0.1.9 + file_picker: ^4.6.1 dev_dependencies: flutter_test: