feat: Ability to change download location added

This commit is contained in:
Kingkor Roy Tirtho 2022-08-03 12:44:20 +06:00
parent cb58166244
commit 816707c643
6 changed files with 199 additions and 117 deletions

1
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1 @@
-keep class androidx.lifecycle.DefaultLifecycleObserver

View File

@ -1,3 +1,6 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.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( var ytSearchFormatController = useTextEditingController(
text: preferences.ytSearchFormat, text: preferences.ytSearchFormat,
); );
return SafeArea( return SafeArea(
child: Scaffold( child: Scaffold(
appBar: PageWindowTitleBar( 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(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 15.0, horizontal: 15.0,

View File

@ -11,7 +11,6 @@ import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.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:path/path.dart' as path;
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -30,141 +29,146 @@ class DownloadTrackButton extends HookConsumerWidget {
YoutubeExplode yt = useMemoized(() => YoutubeExplode()); YoutubeExplode yt = useMemoized(() => YoutubeExplode());
final outputFile = useState<File?>(null); final outputFile = useState<File?>(null);
final downloadFolder = useState<String?>(null);
String fileName = String fileName =
"${track?.name} - ${TypeConversionUtils.artists_X_String<Artist>(track?.artists ?? [])}"; "${track?.name} - ${TypeConversionUtils.artists_X_String<Artist>(track?.artists ?? [])}";
useEffect(() { useEffect(() {
(() async { (() async {
downloadFolder.value = path.join(
Platform.isAndroid
? "/storage/emulated/0/Download"
: (await path_provider.getDownloadsDirectory())!.path,
"Spotube");
outputFile.value = outputFile.value =
File(path.join(downloadFolder.value!, "$fileName.mp3")); File(path.join(preferences.downloadLocation, "$fileName.mp3"));
}()); }());
return null; return null;
}, [fileName, track]); }, [fileName, track, preferences.downloadLocation]);
final _downloadTrack = useCallback(() async { final _downloadTrack = useCallback(() async {
if (track == null || try {
outputFile.value == null || if (track == null || outputFile.value == null) return;
downloadFolder.value == null) return; if ((kIsMobile) &&
if ((kIsMobile) && !await Permission.storage.isGranted &&
!await Permission.storage.isGranted && !await Permission.storage.isPermanentlyDenied) {
!await Permission.storage.isPermanentlyDenied) { final status = await Permission.storage.request();
final status = await Permission.storage.request(); if (!status.isGranted) {
if (!status.isGranted) { ScaffoldMessenger.of(context).showSnackBar(
ScaffoldMessenger.of(context).showSnackBar( const SnackBar(
const SnackBar( content:
content: Text("Couldn't download track. Not enough permissions"), Text("Couldn't download track. Not enough permissions"),
), ),
); );
return; return;
}
} }
} StreamManifest manifest = await yt.videos.streamsClient
StreamManifest manifest = await yt.videos.streamsClient .getManifest((track as SpotubeTrack).ytTrack.url);
.getManifest((track as SpotubeTrack).ytTrack.url);
File outputLyricsFile = File outputLyricsFile = File(
File(path.join(downloadFolder.value!, "$fileName-lyrics.txt")); path.join(preferences.downloadLocation, "$fileName-lyrics.txt"));
if (await outputFile.value!.exists()) { if (await outputFile.value!.exists()) {
final shouldReplace = await showDialog<bool>( final shouldReplace = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
return AlertDialog( return AlertDialog(
title: const Text("Track Already Exists"), title: const Text("Track Already Exists"),
content: const Text( content: const Text(
"Do you want to replace the already downloaded track?"), "Do you want to replace the already downloaded track?"),
actions: [ actions: [
TextButton( TextButton(
child: const Text("No"), child: const Text("No"),
onPressed: () { onPressed: () {
Navigator.pop(context, false); Navigator.pop(context, false);
}, },
), ),
TextButton( TextButton(
child: const Text("Yes"), child: const Text("Yes"),
onPressed: () { onPressed: () {
Navigator.pop(context, true); Navigator.pop(context, true);
}, },
) )
], ],
);
},
);
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 (shouldReplace != true) return;
}
final audioStream = yt.videos.streamsClient if (!await outputFile.value!.exists()) {
.get( await outputFile.value!.create(recursive: true);
manifest.audioOnly }
.where((audio) => audio.codec.mimeType == "audio/mp4")
.withHighestBitrate(),
)
.asBroadcastStream();
final statusCb = audioStream.listen( IOSink outputFileStream = outputFile.value!.openWrite();
(event) { await audioStream.pipe(outputFileStream);
if (status.value != TrackStatus.downloading) { await outputFileStream.flush();
status.value = TrackStatus.downloading; 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();
onDone: () async { });
status.value = TrackStatus.done;
await Future.delayed( if (preferences.saveTrackLyrics && playback.track != null) {
const Duration(seconds: 3), if (!await outputLyricsFile.exists()) {
() { await outputLyricsFile.create(recursive: true);
if (status.value == TrackStatus.done) { }
status.value = TrackStatus.idle; final lyrics = await ServiceUtils.getLyrics(
} playback.track!.name!,
}, playback.track!.artists
); ?.map((s) => s.name)
}, .whereNotNull()
); .toList() ??
[],
if (!await outputFile.value!.exists()) { apiKey: preferences.geniusAccessToken,
await outputFile.value!.create(recursive: true); optimizeQuery: 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;
}
},
); );
if (lyrics != null) {
await outputLyricsFile.writeAsString(
"$lyrics\n\nPowered by genius.com",
mode: FileMode.writeOnly,
);
}
} }
return statusCb.cancel(); } on FileSystemException catch (e) {
}); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
if (preferences.saveTrackLyrics && playback.track != null) { behavior: SnackBarBehavior.floating,
if (!await outputLyricsFile.exists()) { backgroundColor: Colors.red,
await outputLyricsFile.create(recursive: true); content: Text("Download Failed. ${e.message} ${e.path}"),
} ),
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,
);
}
} }
}, [ }, [
track, track,
@ -173,7 +177,7 @@ class DownloadTrackButton extends HookConsumerWidget {
preferences.saveTrackLyrics, preferences.saveTrackLyrics,
playback.track, playback.track,
outputFile.value, outputFile.value,
downloadFolder.value, preferences.downloadLocation,
fileName fileName
]); ]);

View File

@ -1,7 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/components/Settings/ColorSchemePickerDialog.dart';
import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/models/SpotubeTrack.dart';
import 'package:spotube/models/generated_secrets.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:spotube/utils/PersistedChangeNotifier.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/primitive_utils.dart';
import 'package:path/path.dart' as path;
class UserPreferences extends PersistedChangeNotifier { class UserPreferences extends PersistedChangeNotifier {
ThemeMode themeMode; ThemeMode themeMode;
@ -23,6 +26,9 @@ class UserPreferences extends PersistedChangeNotifier {
MaterialColor accentColorScheme; MaterialColor accentColorScheme;
MaterialColor backgroundColorScheme; MaterialColor backgroundColorScheme;
bool skipSponsorSegments; bool skipSponsorSegments;
String downloadLocation;
UserPreferences({ UserPreferences({
required this.geniusAccessToken, required this.geniusAccessToken,
required this.recommendationMarket, required this.recommendationMarket,
@ -35,7 +41,16 @@ class UserPreferences extends PersistedChangeNotifier {
this.trackMatchAlgorithm = SpotubeTrackMatchAlgorithm.authenticPopular, this.trackMatchAlgorithm = SpotubeTrackMatchAlgorithm.authenticPopular,
this.audioQuality = AudioQuality.high, this.audioQuality = AudioQuality.high,
this.skipSponsorSegments = true, this.skipSponsorSegments = true,
}) : super(); this.downloadLocation = "",
}) : super() {
if (downloadLocation.isEmpty) {
_getDefaultDownloadDirectory().then(
(value) {
downloadLocation = value;
},
);
}
}
void setThemeMode(ThemeMode mode) { void setThemeMode(ThemeMode mode) {
themeMode = mode; themeMode = mode;
@ -103,8 +118,22 @@ class UserPreferences extends PersistedChangeNotifier {
updatePersistence(); updatePersistence();
} }
void setDownloadLocation(String downloadDir) {
if (downloadDir.isEmpty) return;
downloadLocation = downloadDir;
notifyListeners();
updatePersistence();
}
Future<String> _getDefaultDownloadDirectory() async {
if (Platform.isAndroid) return "/storage/emulated/0/Download/Spotube";
return getDownloadsDirectory().then((dir) {
return path.join(dir!.path, "Spotube");
});
}
@override @override
FutureOr<void> loadFromLocal(Map<String, dynamic> map) { FutureOr<void> loadFromLocal(Map<String, dynamic> map) async {
saveTrackLyrics = map["saveTrackLyrics"] ?? false; saveTrackLyrics = map["saveTrackLyrics"] ?? false;
recommendationMarket = map["recommendationMarket"] ?? recommendationMarket; recommendationMarket = map["recommendationMarket"] ?? recommendationMarket;
checkUpdate = map["checkUpdate"] ?? checkUpdate; checkUpdate = map["checkUpdate"] ?? checkUpdate;
@ -126,6 +155,8 @@ class UserPreferences extends PersistedChangeNotifier {
? AudioQuality.values[map["audioQuality"]] ? AudioQuality.values[map["audioQuality"]]
: audioQuality; : audioQuality;
skipSponsorSegments = map["skipSponsorSegments"] ?? skipSponsorSegments; skipSponsorSegments = map["skipSponsorSegments"] ?? skipSponsorSegments;
downloadLocation =
map["downloadLocation"] ?? await _getDefaultDownloadDirectory();
} }
@override @override
@ -142,6 +173,7 @@ class UserPreferences extends PersistedChangeNotifier {
"trackMatchAlgorithm": trackMatchAlgorithm.index, "trackMatchAlgorithm": trackMatchAlgorithm.index,
"audioQuality": audioQuality.index, "audioQuality": audioQuality.index,
"skipSponsorSegments": skipSponsorSegments, "skipSponsorSegments": skipSponsorSegments,
"downloadLocation": downloadLocation,
}; };
} }
} }

View File

@ -477,6 +477,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.2" 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: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -552,6 +559,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.4" 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: flutter_riverpod:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -65,6 +65,7 @@ dependencies:
audioplayers: ^1.0.1 audioplayers: ^1.0.1
introduction_screen: ^3.0.2 introduction_screen: ^3.0.2
audio_session: ^0.1.9 audio_session: ^0.1.9
file_picker: ^4.6.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: