mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
feat: add download queue for desktop & initial playlist download support
This commit is contained in:
parent
92bc611c5e
commit
08f913e976
@ -30,6 +30,10 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
|
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
|
|
||||||
|
final bool isChecked;
|
||||||
|
final bool showCheck;
|
||||||
|
final void Function(bool?)? onCheckChange;
|
||||||
|
|
||||||
TrackTile(
|
TrackTile(
|
||||||
this.playback, {
|
this.playback, {
|
||||||
required this.track,
|
required this.track,
|
||||||
@ -40,6 +44,9 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
this.thumbnailUrl,
|
this.thumbnailUrl,
|
||||||
this.onTrackPlayButtonPressed,
|
this.onTrackPlayButtonPressed,
|
||||||
this.showAlbum = true,
|
this.showAlbum = true,
|
||||||
|
this.isChecked = false,
|
||||||
|
this.showCheck = false,
|
||||||
|
this.onCheckChange,
|
||||||
Key? key,
|
Key? key,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@ -182,6 +189,11 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
type: MaterialType.transparency,
|
type: MaterialType.transparency,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
if (showCheck)
|
||||||
|
Checkbox(
|
||||||
|
value: isChecked,
|
||||||
|
onChanged: (s) => onCheckChange?.call(s),
|
||||||
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
width: 25,
|
width: 25,
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Shared/TrackTile.dart';
|
import 'package:spotube/components/Shared/TrackTile.dart';
|
||||||
import 'package:spotube/hooks/useBreakpoints.dart';
|
import 'package:spotube/hooks/useBreakpoints.dart';
|
||||||
|
import 'package:spotube/provider/Downloader.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:spotube/utils/primitive_utils.dart';
|
import 'package:spotube/utils/primitive_utils.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
@ -26,16 +28,31 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(context, ref) {
|
Widget build(context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
Playback playback = ref.watch(playbackProvider);
|
||||||
|
final downloader = ref.watch(downloaderProvider);
|
||||||
TextStyle tableHeadStyle =
|
TextStyle tableHeadStyle =
|
||||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
|
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
|
||||||
|
|
||||||
final breakpoint = useBreakpoints();
|
final breakpoint = useBreakpoints();
|
||||||
|
|
||||||
|
final selected = useState<List<String>>([]);
|
||||||
|
final showCheck = useState<bool>(false);
|
||||||
|
|
||||||
return SliverList(
|
return SliverList(
|
||||||
delegate: SliverChildListDelegate([
|
delegate: SliverChildListDelegate([
|
||||||
if (heading != null) heading!,
|
if (heading != null) heading!,
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: selected.value.length == tracks.length,
|
||||||
|
onChanged: (checked) {
|
||||||
|
if (!showCheck.value) showCheck.value = true;
|
||||||
|
if (checked == true) {
|
||||||
|
selected.value = tracks.map((s) => s.id!).toList();
|
||||||
|
} else {
|
||||||
|
selected.value = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -75,8 +92,36 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
Text("Time", style: tableHeadStyle),
|
Text("Time", style: tableHeadStyle),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
],
|
],
|
||||||
SizedBox(
|
PopupMenuButton(
|
||||||
width: breakpoint.isLessThan(Breakpoints.lg) ? 40 : 110,
|
itemBuilder: (context) {
|
||||||
|
return [
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Row(
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.file_download_outlined),
|
||||||
|
Text("Download"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final spotubeTracks = await Future.wait(
|
||||||
|
tracks
|
||||||
|
.where(
|
||||||
|
(track) => selected.value.contains(track.id),
|
||||||
|
)
|
||||||
|
.map((track) {
|
||||||
|
return Future.delayed(const Duration(seconds: 2),
|
||||||
|
() => playback.toSpotubeTrack(track));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (var spotubeTrack in spotubeTracks) {
|
||||||
|
downloader.addToQueue(spotubeTrack);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: "download",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -87,7 +132,12 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
String duration =
|
String duration =
|
||||||
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
||||||
return TrackTile(
|
return GestureDetector(
|
||||||
|
onDoubleTap: () {
|
||||||
|
showCheck.value = true;
|
||||||
|
selected.value = [...selected.value, track.value.id!];
|
||||||
|
},
|
||||||
|
child: TrackTile(
|
||||||
playback,
|
playback,
|
||||||
playlistId: playlistId,
|
playlistId: playlistId,
|
||||||
track: track,
|
track: track,
|
||||||
@ -96,6 +146,18 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
userPlaylist: userPlaylist,
|
userPlaylist: userPlaylist,
|
||||||
isActive: playback.track?.id == track.value.id,
|
isActive: playback.track?.id == track.value.id,
|
||||||
onTrackPlayButtonPressed: onTrackPlayButtonPressed,
|
onTrackPlayButtonPressed: onTrackPlayButtonPressed,
|
||||||
|
isChecked: selected.value.contains(track.value.id),
|
||||||
|
showCheck: showCheck.value,
|
||||||
|
onCheckChange: (checked) {
|
||||||
|
if (checked == true) {
|
||||||
|
selected.value = [...selected.value, track.value.id!];
|
||||||
|
} else {
|
||||||
|
selected.value = selected.value
|
||||||
|
.where((id) => id != track.value.id)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}).toList()
|
}).toList()
|
||||||
]),
|
]),
|
||||||
|
75
lib/provider/Downloader.dart
Normal file
75
lib/provider/Downloader.dart
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:queue/queue.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:spotube/models/SpotubeTrack.dart';
|
||||||
|
import 'package:spotube/provider/UserPreferences.dart';
|
||||||
|
import 'package:spotube/provider/YouTube.dart';
|
||||||
|
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||||
|
|
||||||
|
Queue _queueInstance = Queue(delay: const Duration(seconds: 1));
|
||||||
|
|
||||||
|
class Downloader with ChangeNotifier {
|
||||||
|
Queue _queue;
|
||||||
|
YoutubeExplode yt;
|
||||||
|
String downloadPath;
|
||||||
|
Downloader(
|
||||||
|
this._queue, {
|
||||||
|
required this.downloadPath,
|
||||||
|
required this.yt,
|
||||||
|
});
|
||||||
|
|
||||||
|
int currentlyRunning = 0;
|
||||||
|
|
||||||
|
void addToQueue(SpotubeTrack track) {
|
||||||
|
currentlyRunning++;
|
||||||
|
notifyListeners();
|
||||||
|
_queue.add(() async {
|
||||||
|
try {
|
||||||
|
final file =
|
||||||
|
File(path.join(downloadPath, '${track.ytTrack.title}.mp3'));
|
||||||
|
// TODO find a way to let the UI know there's already provided file is available
|
||||||
|
if (file.existsSync()) return;
|
||||||
|
file.createSync(recursive: true);
|
||||||
|
StreamManifest manifest =
|
||||||
|
await yt.videos.streamsClient.getManifest(track.ytTrack.url);
|
||||||
|
final audioStream = yt.videos.streamsClient
|
||||||
|
.get(
|
||||||
|
manifest.audioOnly
|
||||||
|
.where((audio) => audio.codec.mimeType == "audio/mp4")
|
||||||
|
.withHighestBitrate(),
|
||||||
|
)
|
||||||
|
.asBroadcastStream();
|
||||||
|
|
||||||
|
IOSink outputFileStream = file.openWrite();
|
||||||
|
await audioStream.pipe(outputFileStream);
|
||||||
|
await outputFileStream.flush();
|
||||||
|
} finally {
|
||||||
|
currentlyRunning--;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
_queue.cancel();
|
||||||
|
_queueInstance = Queue();
|
||||||
|
_queue = _queueInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final downloaderProvider = ChangeNotifierProvider(
|
||||||
|
(ref) {
|
||||||
|
return Downloader(
|
||||||
|
_queueInstance,
|
||||||
|
yt: ref.watch(youtubeProvider),
|
||||||
|
downloadPath: ref.watch(
|
||||||
|
userPreferencesProvider.select(
|
||||||
|
(s) => s.downloadLocation,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
14
pubspec.lock
14
pubspec.lock
@ -538,6 +538,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.9"
|
version: "0.0.9"
|
||||||
|
flutter_downloader:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_downloader
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.8.1"
|
||||||
flutter_hooks:
|
flutter_hooks:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -1017,6 +1024,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
version: "1.2.0"
|
||||||
|
queue:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: queue
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0+1"
|
||||||
riverpod:
|
riverpod:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -67,6 +67,8 @@ dependencies:
|
|||||||
audio_session: ^0.1.9
|
audio_session: ^0.1.9
|
||||||
file_picker: ^4.6.1
|
file_picker: ^4.6.1
|
||||||
popover: ^0.2.6+3
|
popover: ^0.2.6+3
|
||||||
|
queue: ^3.1.0+1
|
||||||
|
flutter_downloader: ^1.8.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
Loading…
Reference in New Issue
Block a user