feat: add download tab on library

Addition of download badge in sidebar and navbar library icon
Fix SpotubeMarqueeText behavior using auto_size_text
This commit is contained in:
Kingkor Roy Tirtho 2022-08-19 11:53:52 +06:00
parent a23ce61446
commit 8d77b6900a
13 changed files with 376 additions and 156 deletions

View File

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/material.dart' hide Page; import 'package:flutter/material.dart' hide Page;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';

View File

@ -1,3 +1,4 @@
import 'package:badges/badges.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@ -8,6 +9,7 @@ import 'package:spotube/hooks/useBreakpointValue.dart';
import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/models/sideBarTiles.dart'; import 'package:spotube/models/sideBarTiles.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/Downloader.dart';
import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
@ -40,6 +42,9 @@ class Sidebar extends HookConsumerWidget {
final extended = useState(false); final extended = useState(false);
final meSnapshot = ref.watch(currentUserQuery); final meSnapshot = ref.watch(currentUserQuery);
final auth = ref.watch(authProvider); final auth = ref.watch(authProvider);
final downloadCount = ref.watch(
downloaderProvider.select((s) => s.currentlyRunning),
);
final int titleBarDragMaxWidth = useBreakpointValue( final int titleBarDragMaxWidth = useBreakpointValue(
md: 80, md: 80,
@ -90,10 +95,24 @@ class Sidebar extends HookConsumerWidget {
), ),
Expanded( Expanded(
child: NavigationRail( child: NavigationRail(
destinations: sidebarTileList destinations: sidebarTileList.map(
.map( (e) {
(e) => NavigationRailDestination( final icon = Icon(e.icon);
icon: Icon(e.icon), return NavigationRailDestination(
icon: e.title == "Library" && downloadCount > 0
? Badge(
badgeColor: Colors.red[100]!,
badgeContent: Text(
downloadCount.toString(),
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
animationType: BadgeAnimationType.fade,
child: icon,
)
: icon,
label: Text( label: Text(
e.title, e.title,
style: const TextStyle( style: const TextStyle(
@ -101,9 +120,9 @@ class Sidebar extends HookConsumerWidget {
fontSize: 16, fontSize: 16,
), ),
), ),
), );
) },
.toList(), ).toList(),
selectedIndex: selectedIndex, selectedIndex: selectedIndex,
onDestinationSelected: onSelectedIndexChanged, onDestinationSelected: onSelectedIndexChanged,
extended: extended.value, extended: extended.value,

View File

@ -1,10 +1,13 @@
import 'package:badges/badges.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/Home/Sidebar.dart'; import 'package:spotube/components/Home/Sidebar.dart';
import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/models/sideBarTiles.dart'; import 'package:spotube/models/sideBarTiles.dart';
import 'package:spotube/provider/Downloader.dart';
class SpotubeNavigationBar extends HookWidget { class SpotubeNavigationBar extends HookConsumerWidget {
final int selectedIndex; final int selectedIndex;
final void Function(int) onSelectedIndexChanged; final void Function(int) onSelectedIndexChanged;
@ -15,14 +18,36 @@ class SpotubeNavigationBar extends HookWidget {
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, ref) {
final downloadCount = ref.watch(
downloaderProvider.select((s) => s.currentlyRunning),
);
final breakpoint = useBreakpoints(); final breakpoint = useBreakpoints();
if (breakpoint.isMoreThan(Breakpoints.sm)) return Container(); if (breakpoint.isMoreThan(Breakpoints.sm)) return Container();
return NavigationBar( return NavigationBar(
destinations: [ destinations: [
...sidebarTileList.map( ...sidebarTileList.map(
(e) => NavigationDestination(icon: Icon(e.icon), label: e.title), (e) {
final icon = Icon(e.icon);
return NavigationDestination(
icon: e.title == "Library" && downloadCount > 0
? Badge(
badgeColor: Colors.red[100]!,
badgeContent: Text(
downloadCount.toString(),
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
animationType: BadgeAnimationType.fade,
child: icon,
)
: icon,
label: e.title,
);
},
), ),
const NavigationDestination( const NavigationDestination(
icon: Icon(Icons.settings_rounded), icon: Icon(Icons.settings_rounded),

View File

@ -0,0 +1,83 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/Downloader.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class UserDownloads extends HookConsumerWidget {
const UserDownloads({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final downloader = ref.watch(downloaderProvider);
final inQueue = downloader.inQueue.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: AutoSizeText(
"Currently downloading (${downloader.currentlyRunning})",
maxLines: 1,
style: Theme.of(context).textTheme.headline5,
),
),
const SizedBox(width: 10),
ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Colors.red[50],
onPrimary: Colors.red[400],
),
child: const Text("Cancel All"),
onPressed: downloader.currentlyRunning > 0
? downloader.cancelAll
: null,
),
],
),
),
ListView.builder(
itemCount: inQueue.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final track = inQueue[index];
return ListTile(
title: Text(track.name!),
leading: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: CachedNetworkImage(
height: 40,
width: 40,
imageUrl: TypeConversionUtils.image_X_UrlString(
track.album?.images,
),
),
),
),
trailing: const SizedBox(
width: 30,
height: 30,
child: CircularProgressIndicator.adaptive(),
),
horizontalTitleGap: 5,
subtitle: Text(
TypeConversionUtils.artists_X_String<Artist>(
track.artists ?? [],
),
),
);
},
),
],
);
}
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart' hide Image;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/components/Library/UserAlbums.dart'; import 'package:spotube/components/Library/UserAlbums.dart';
import 'package:spotube/components/Library/UserArtists.dart'; import 'package:spotube/components/Library/UserArtists.dart';
import 'package:spotube/components/Library/UserDownloads.dart';
import 'package:spotube/components/Library/UserPlaylists.dart'; import 'package:spotube/components/Library/UserPlaylists.dart';
import 'package:spotube/components/Shared/AnonymousFallback.dart'; import 'package:spotube/components/Shared/AnonymousFallback.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Auth.dart';
@ -14,7 +15,7 @@ class UserLibrary extends ConsumerWidget {
return Expanded( return Expanded(
child: DefaultTabController( child: DefaultTabController(
length: 3, length: 4,
child: SafeArea( child: SafeArea(
child: Scaffold( child: Scaffold(
appBar: TabBar( appBar: TabBar(
@ -26,6 +27,7 @@ class UserLibrary extends ConsumerWidget {
Tab(text: "Playlist"), Tab(text: "Playlist"),
Tab(text: "Artists"), Tab(text: "Artists"),
Tab(text: "Album"), Tab(text: "Album"),
Tab(text: "Downloads"),
], ],
), ),
body: auth.isLoggedIn body: auth.isLoggedIn
@ -33,6 +35,7 @@ class UserLibrary extends ConsumerWidget {
const UserPlaylists(), const UserPlaylists(),
UserArtists(), UserArtists(),
const UserAlbums(), const UserAlbums(),
const UserDownloads(),
]) ])
: const AnonymousFallback(), : const AnonymousFallback(),
), ),

View File

@ -106,7 +106,7 @@ class PlaybuttonCard extends StatelessWidget {
text: title, text: title,
style: style:
const TextStyle(fontWeight: FontWeight.bold), const TextStyle(fontWeight: FontWeight.bold),
minStartLength: 25, minStartLength: 20,
isHovering: isHovering, isHovering: isHovering,
), ),
), ),

View File

@ -1,7 +1,7 @@
import 'package:auto_size_text/auto_size_text.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:marquee/marquee.dart'; import 'package:marquee/marquee.dart';
import 'package:spotube/utils/platform.dart';
class SpotubeMarqueeText extends HookWidget { class SpotubeMarqueeText extends HookWidget {
final int? minStartLength; final int? minStartLength;
@ -18,26 +18,19 @@ class SpotubeMarqueeText extends HookWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hovering = useState(false); final uKey = useState(UniqueKey());
final isInitial = useState(true);
useEffect(() { useEffect(() {
if (isHovering != null && isHovering != hovering.value) { uKey.value = UniqueKey();
hovering.value = isHovering!; return;
}
return null;
}, [isHovering]); }, [isHovering]);
if ((!isInitial.value && !hovering.value && kIsDesktop) || return AutoSizeText(
minStartLength != null && text.length <= minStartLength!) {
return Text(
text, text,
minFontSize: 13,
style: style, style: style,
overflow: TextOverflow.ellipsis, overflowReplacement: Marquee(
); key: uKey.value,
}
return Marquee(
text: text, text: text,
style: style, style: style,
scrollAxis: Axis.horizontal, scrollAxis: Axis.horizontal,
@ -48,16 +41,9 @@ class SpotubeMarqueeText extends HookWidget {
accelerationCurve: Curves.linear, accelerationCurve: Curves.linear,
decelerationDuration: const Duration(milliseconds: 500), decelerationDuration: const Duration(milliseconds: 500),
decelerationCurve: Curves.easeOut, decelerationCurve: Curves.easeOut,
fadingEdgeStartFraction: 0.15,
fadingEdgeEndFraction: 0.15,
showFadingOnlyWhenScrolling: true, showFadingOnlyWhenScrolling: true,
onDone: () { numberOfRounds: isHovering == true ? null : 1,
if (isInitial.value) { ),
isInitial.value = false;
hovering.value = false;
}
},
numberOfRounds: hovering.value ? null : 1,
); );
} }
} }

View File

@ -193,13 +193,13 @@ class TrackTile extends HookConsumerWidget {
Checkbox( Checkbox(
value: isChecked, value: isChecked,
onChanged: (s) => onCheckChange?.call(s), onChanged: (s) => onCheckChange?.call(s),
), )
else
SizedBox( SizedBox(
height: 20, height: 20,
width: 15, width: 25,
child: Text( child: Center(
(track.key + 1).toString(), child: Text((track.key + 1).toString()),
textAlign: TextAlign.center,
), ),
), ),
if (thumbnailUrl != null) if (thumbnailUrl != null)

View File

@ -132,23 +132,11 @@ class TracksTableView extends HookConsumerWidget {
return const DownloadConfirmationDialog(); return const DownloadConfirmationDialog();
}); });
if (isConfirmed != true) return; if (isConfirmed != true) return;
final queue = Queue(
delay: const Duration(seconds: 5),
);
for (final selectedTrack in selectedTracks) { for (final selectedTrack in selectedTracks) {
queue.add(() async { downloader.addToQueue(selectedTrack);
downloader.addToQueue(
await playback.toSpotubeTrack(
selectedTrack,
noSponsorBlock: true,
),
);
});
} }
selected.value = []; selected.value = [];
showCheck.value = false; showCheck.value = false;
await queue.onComplete;
break; break;
} }
default: default:
@ -171,7 +159,15 @@ class TracksTableView extends HookConsumerWidget {
}, },
onTap: () { onTap: () {
if (showCheck.value) { if (showCheck.value) {
final alreadyChecked =
selected.value.contains(track.value.id);
if (alreadyChecked) {
selected.value = selected.value
.where((id) => id != track.value.id)
.toList();
} else {
selected.value = [...selected.value, track.value.id!]; selected.value = [...selected.value, track.value.id!];
}
} else { } else {
onTrackPlayButtonPressed?.call(track.value); onTrackPlayButtonPressed?.call(track.value);
} }

View File

@ -10,11 +10,13 @@ import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/components/Shared/DownloadTrackButton.dart';
import 'package:spotube/entities/CacheTrack.dart'; import 'package:spotube/entities/CacheTrack.dart';
import 'package:spotube/models/GoRouteDeclarations.dart'; import 'package:spotube/models/GoRouteDeclarations.dart';
import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/AudioPlayer.dart'; import 'package:spotube/provider/AudioPlayer.dart';
import 'package:spotube/provider/Downloader.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/UserPreferences.dart';
import 'package:spotube/provider/YouTube.dart'; import 'package:spotube/provider/YouTube.dart';
@ -53,10 +55,14 @@ void main() async {
); );
} }
MobileAudioService? audioServiceHandler; MobileAudioService? audioServiceHandler;
runApp(ProviderScope( runApp(
Builder(
builder: (context) {
return ProviderScope(
child: const Spotube(), child: const Spotube(),
overrides: [ overrides: [
playbackProvider.overrideWithProvider(ChangeNotifierProvider( playbackProvider.overrideWithProvider(
ChangeNotifierProvider(
(ref) { (ref) {
final youtube = ref.watch(youtubeProvider); final youtube = ref.watch(youtubeProvider);
final player = ref.watch(audioPlayerProvider); final player = ref.watch(audioPlayerProvider);
@ -85,9 +91,49 @@ void main() async {
return playback; return playback;
}, },
)) ),
),
downloaderProvider.overrideWithProvider(
ChangeNotifierProvider(
(ref) {
return Downloader(
ref,
queueInstance,
yt: ref.watch(youtubeProvider),
downloadPath: ref.watch(
userPreferencesProvider.select(
(s) => s.downloadLocation,
),
),
onFileExists: (track) {
final logger = getLogger(Downloader);
try {
logger.v(
"[onFileExists] download confirmation for ${track.name}",
);
return showDialog<bool>(
context: context,
builder: (_) =>
ReplaceDownloadedFileDialog(track: track),
).then((s) => s ?? false);
} catch (e, stack) {
logger.e(
"onFileExists",
e,
stack,
);
return false;
}
},
);
},
),
)
], ],
)); );
},
),
);
} }
class Spotube extends StatefulHookConsumerWidget { class Spotube extends StatefulHookConsumerWidget {

View File

@ -6,20 +6,26 @@ import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:queue/queue.dart'; import 'package:queue/queue.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:spotify/spotify.dart';
import 'package:spotube/models/Logger.dart';
import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/models/SpotubeTrack.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/UserPreferences.dart';
import 'package:spotube/provider/YouTube.dart'; import 'package:spotube/provider/YouTube.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart';
Queue _queueInstance = Queue(delay: const Duration(seconds: 5)); Queue queueInstance = Queue(delay: const Duration(seconds: 5));
Queue grabberQueue = Queue(delay: const Duration(seconds: 5));
class Downloader with ChangeNotifier { class Downloader with ChangeNotifier {
Ref ref;
Queue _queue; Queue _queue;
YoutubeExplode yt; YoutubeExplode yt;
String downloadPath; String downloadPath;
FutureOr<bool> Function(SpotubeTrack track)? onFileExists; FutureOr<bool> Function(SpotubeTrack track)? onFileExists;
Downloader( Downloader(
this.ref,
this._queue, { this._queue, {
required this.downloadPath, required this.downloadPath,
required this.yt, required this.yt,
@ -27,14 +33,25 @@ class Downloader with ChangeNotifier {
}); });
int currentlyRunning = 0; int currentlyRunning = 0;
Set<String> inQueue = {}; // ignore: prefer_collection_literals
Set<Track> inQueue = Set();
void addToQueue(SpotubeTrack track) async { final logger = getLogger(Downloader);
void addToQueue(Track baseTrack) async {
if (inQueue.any((t) => t.id == baseTrack.id!)) return;
inQueue.add(baseTrack);
currentlyRunning++; currentlyRunning++;
inQueue.add(track.id!);
notifyListeners(); notifyListeners();
final filename = '${track.ytTrack.title}.mp3';
if (kIsMobile) { if (kIsMobile) {
grabberQueue.add(() async {
final track = await ref.read(playbackProvider).toSpotubeTrack(
baseTrack,
noSponsorBlock: true,
);
final filename = '${track.ytTrack.title}.mp3';
final url = final url =
((await yt.videos.streamsClient.getManifest(track.ytTrack.url))) ((await yt.videos.streamsClient.getManifest(track.ytTrack.url)))
.audioOnly .audioOnly
@ -48,17 +65,27 @@ class Downloader with ChangeNotifier {
openFileFromNotification: true, openFileFromNotification: true,
showNotification: true, showNotification: true,
); );
});
} else { } else {
if (inQueue.contains(track.id!)) return; grabberQueue.add(() async {
final track = await ref.read(playbackProvider).toSpotubeTrack(
baseTrack,
noSponsorBlock: true,
);
_queue.add(() async { _queue.add(() async {
try { final filename = '${track.ytTrack.title}.mp3';
final file = File(path.join(downloadPath, filename)); final file = File(path.join(downloadPath, filename));
try {
logger.v("[addToQueue] Download starting for ${file.path}");
if (file.existsSync() && await onFileExists?.call(track) != true) { if (file.existsSync() && await onFileExists?.call(track) != true) {
return; return;
} }
file.createSync(recursive: true); file.createSync(recursive: true);
StreamManifest manifest = StreamManifest manifest =
await yt.videos.streamsClient.getManifest(track.ytTrack.url); await yt.videos.streamsClient.getManifest(track.ytTrack.url);
logger.v(
"[addToQueue] Getting download information for ${file.path}",
);
final audioStream = yt.videos.streamsClient final audioStream = yt.videos.streamsClient
.get( .get(
manifest.audioOnly manifest.audioOnly
@ -67,33 +94,54 @@ class Downloader with ChangeNotifier {
) )
.asBroadcastStream(); .asBroadcastStream();
logger.v(
"[addToQueue] ${file.path} download started",
);
IOSink outputFileStream = file.openWrite(); IOSink outputFileStream = file.openWrite();
await audioStream.pipe(outputFileStream); await audioStream.pipe(outputFileStream);
await outputFileStream.flush(); await outputFileStream.flush();
logger.v(
"[addToQueue] Download of ${file.path} is done successfully",
);
} catch (e, stack) {
logger.e(
"[addToQueue] Failed download of ${file.path}",
e,
stack,
);
rethrow;
} finally { } finally {
currentlyRunning--; currentlyRunning--;
inQueue.remove(track.id); inQueue.removeWhere((t) => t.id == track.id);
notifyListeners(); notifyListeners();
} }
}); });
});
} }
} }
cancelAll() { cancelAll() {
grabberQueue.cancel();
grabberQueue = Queue();
inQueue.clear();
currentlyRunning = 0;
if (kIsMobile) { if (kIsMobile) {
FlutterDownloader.cancelAll(); FlutterDownloader.cancelAll();
} else { } else {
_queue.cancel(); _queue.cancel();
_queueInstance = Queue(); queueInstance = Queue();
_queue = _queueInstance; _queue = queueInstance;
} }
notifyListeners();
} }
} }
final downloaderProvider = ChangeNotifierProvider( final downloaderProvider = ChangeNotifierProvider(
(ref) { (ref) {
return Downloader( return Downloader(
_queueInstance, ref,
queueInstance,
yt: ref.watch(youtubeProvider), yt: ref.watch(youtubeProvider),
downloadPath: ref.watch( downloadPath: ref.watch(
userPreferencesProvider.select( userPreferencesProvider.select(

View File

@ -218,6 +218,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
auto_size_text:
dependency: "direct main"
description:
name: auto_size_text
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
badges:
dependency: "direct main"
description:
name: badges
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.3"
bitsdojo_window: bitsdojo_window:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -69,6 +69,8 @@ dependencies:
popover: ^0.2.6+3 popover: ^0.2.6+3
queue: ^3.1.0+1 queue: ^3.1.0+1
flutter_downloader: ^1.8.1 flutter_downloader: ^1.8.1
auto_size_text: ^3.0.0
badges: ^2.0.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: