mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00
feat: Ability to change download location added
This commit is contained in:
parent
cb58166244
commit
816707c643
1
android/app/proguard-rules.pro
vendored
Normal file
1
android/app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
-keep class androidx.lifecycle.DefaultLifecycleObserver
|
@ -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,
|
||||||
|
@ -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
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
14
pubspec.lock
14
pubspec.lock
@ -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:
|
||||||
|
@ -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:
|
||||||
|
Loading…
Reference in New Issue
Block a user