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:flutter/material.dart' hide Page;
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:cached_network_image/cached_network_image.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/models/sideBarTiles.dart';
import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/Downloader.dart';
import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
@ -40,6 +42,9 @@ class Sidebar extends HookConsumerWidget {
final extended = useState(false);
final meSnapshot = ref.watch(currentUserQuery);
final auth = ref.watch(authProvider);
final downloadCount = ref.watch(
downloaderProvider.select((s) => s.currentlyRunning),
);
final int titleBarDragMaxWidth = useBreakpointValue(
md: 80,
@ -90,20 +95,34 @@ class Sidebar extends HookConsumerWidget {
),
Expanded(
child: NavigationRail(
destinations: sidebarTileList
.map(
(e) => NavigationRailDestination(
icon: Icon(e.icon),
label: Text(
e.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
destinations: sidebarTileList.map(
(e) {
final 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(
e.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
)
.toList(),
);
},
).toList(),
selectedIndex: selectedIndex,
onDestinationSelected: onSelectedIndexChanged,
extended: extended.value,

View File

@ -1,10 +1,13 @@
import 'package:badges/badges.dart';
import 'package:flutter/material.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/hooks/useBreakpoints.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 void Function(int) onSelectedIndexChanged;
@ -15,14 +18,36 @@ class SpotubeNavigationBar extends HookWidget {
}) : super(key: key);
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, ref) {
final downloadCount = ref.watch(
downloaderProvider.select((s) => s.currentlyRunning),
);
final breakpoint = useBreakpoints();
if (breakpoint.isMoreThan(Breakpoints.sm)) return Container();
return NavigationBar(
destinations: [
...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(
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:spotube/components/Library/UserAlbums.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/Shared/AnonymousFallback.dart';
import 'package:spotube/provider/Auth.dart';
@ -14,7 +15,7 @@ class UserLibrary extends ConsumerWidget {
return Expanded(
child: DefaultTabController(
length: 3,
length: 4,
child: SafeArea(
child: Scaffold(
appBar: TabBar(
@ -26,6 +27,7 @@ class UserLibrary extends ConsumerWidget {
Tab(text: "Playlist"),
Tab(text: "Artists"),
Tab(text: "Album"),
Tab(text: "Downloads"),
],
),
body: auth.isLoggedIn
@ -33,6 +35,7 @@ class UserLibrary extends ConsumerWidget {
const UserPlaylists(),
UserArtists(),
const UserAlbums(),
const UserDownloads(),
])
: const AnonymousFallback(),
),

View File

@ -106,7 +106,7 @@ class PlaybuttonCard extends StatelessWidget {
text: title,
style:
const TextStyle(fontWeight: FontWeight.bold),
minStartLength: 25,
minStartLength: 20,
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_hooks/flutter_hooks.dart';
import 'package:marquee/marquee.dart';
import 'package:spotube/utils/platform.dart';
class SpotubeMarqueeText extends HookWidget {
final int? minStartLength;
@ -18,46 +18,32 @@ class SpotubeMarqueeText extends HookWidget {
@override
Widget build(BuildContext context) {
final hovering = useState(false);
final isInitial = useState(true);
final uKey = useState(UniqueKey());
useEffect(() {
if (isHovering != null && isHovering != hovering.value) {
hovering.value = isHovering!;
}
return null;
uKey.value = UniqueKey();
return;
}, [isHovering]);
if ((!isInitial.value && !hovering.value && kIsDesktop) ||
minStartLength != null && text.length <= minStartLength!) {
return Text(
text,
style: style,
overflow: TextOverflow.ellipsis,
);
}
return Marquee(
text: text,
return AutoSizeText(
text,
minFontSize: 13,
style: style,
scrollAxis: Axis.horizontal,
crossAxisAlignment: CrossAxisAlignment.start,
blankSpace: 40.0,
velocity: 30.0,
accelerationDuration: const Duration(seconds: 1),
accelerationCurve: Curves.linear,
decelerationDuration: const Duration(milliseconds: 500),
decelerationCurve: Curves.easeOut,
fadingEdgeStartFraction: 0.15,
fadingEdgeEndFraction: 0.15,
showFadingOnlyWhenScrolling: true,
onDone: () {
if (isInitial.value) {
isInitial.value = false;
hovering.value = false;
}
},
numberOfRounds: hovering.value ? null : 1,
overflowReplacement: Marquee(
key: uKey.value,
text: text,
style: style,
scrollAxis: Axis.horizontal,
crossAxisAlignment: CrossAxisAlignment.start,
blankSpace: 40.0,
velocity: 30.0,
accelerationDuration: const Duration(seconds: 1),
accelerationCurve: Curves.linear,
decelerationDuration: const Duration(milliseconds: 500),
decelerationCurve: Curves.easeOut,
showFadingOnlyWhenScrolling: true,
numberOfRounds: isHovering == true ? null : 1,
),
);
}
}

View File

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

View File

@ -132,23 +132,11 @@ class TracksTableView extends HookConsumerWidget {
return const DownloadConfirmationDialog();
});
if (isConfirmed != true) return;
final queue = Queue(
delay: const Duration(seconds: 5),
);
for (final selectedTrack in selectedTracks) {
queue.add(() async {
downloader.addToQueue(
await playback.toSpotubeTrack(
selectedTrack,
noSponsorBlock: true,
),
);
});
downloader.addToQueue(selectedTrack);
}
selected.value = [];
showCheck.value = false;
await queue.onComplete;
break;
}
default:
@ -171,7 +159,15 @@ class TracksTableView extends HookConsumerWidget {
},
onTap: () {
if (showCheck.value) {
selected.value = [...selected.value, track.value.id!];
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!];
}
} else {
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:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/components/Shared/DownloadTrackButton.dart';
import 'package:spotube/entities/CacheTrack.dart';
import 'package:spotube/models/GoRouteDeclarations.dart';
import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/AudioPlayer.dart';
import 'package:spotube/provider/Downloader.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/UserPreferences.dart';
import 'package:spotube/provider/YouTube.dart';
@ -53,41 +55,85 @@ void main() async {
);
}
MobileAudioService? audioServiceHandler;
runApp(ProviderScope(
child: const Spotube(),
overrides: [
playbackProvider.overrideWithProvider(ChangeNotifierProvider(
(ref) {
final youtube = ref.watch(youtubeProvider);
final player = ref.watch(audioPlayerProvider);
runApp(
Builder(
builder: (context) {
return ProviderScope(
child: const Spotube(),
overrides: [
playbackProvider.overrideWithProvider(
ChangeNotifierProvider(
(ref) {
final youtube = ref.watch(youtubeProvider);
final player = ref.watch(audioPlayerProvider);
final playback = Playback(
player: player,
youtube: youtube,
ref: ref,
);
final playback = Playback(
player: player,
youtube: youtube,
ref: ref,
);
if (audioServiceHandler == null) {
AudioService.init(
builder: () => MobileAudioService(playback),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.krtirtho.Spotube',
androidNotificationChannelName: 'Spotube',
androidNotificationOngoing: true,
if (audioServiceHandler == null) {
AudioService.init(
builder: () => MobileAudioService(playback),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.krtirtho.Spotube',
androidNotificationChannelName: 'Spotube',
androidNotificationOngoing: true,
),
).then(
(value) {
playback.mobileAudioService = value;
audioServiceHandler = value;
},
);
}
return playback;
},
),
).then(
(value) {
playback.mobileAudioService = value;
audioServiceHandler = value;
},
);
}
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 {

View File

@ -6,20 +6,26 @@ import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:queue/queue.dart';
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/provider/Playback.dart';
import 'package:spotube/provider/UserPreferences.dart';
import 'package:spotube/provider/YouTube.dart';
import 'package:spotube/utils/platform.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 {
Ref ref;
Queue _queue;
YoutubeExplode yt;
String downloadPath;
FutureOr<bool> Function(SpotubeTrack track)? onFileExists;
Downloader(
this.ref,
this._queue, {
required this.downloadPath,
required this.yt,
@ -27,73 +33,115 @@ class Downloader with ChangeNotifier {
});
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++;
inQueue.add(track.id!);
notifyListeners();
final filename = '${track.ytTrack.title}.mp3';
if (kIsMobile) {
final url =
((await yt.videos.streamsClient.getManifest(track.ytTrack.url)))
.audioOnly
.where((audio) => audio.codec.mimeType == "audio/mp4")
.withHighestBitrate()
.url;
await FlutterDownloader.enqueue(
savedDir: downloadPath,
url: url.toString(),
fileName: filename,
openFileFromNotification: true,
showNotification: true,
);
} else {
if (inQueue.contains(track.id!)) return;
_queue.add(() async {
try {
final file = File(path.join(downloadPath, filename));
if (file.existsSync() && await onFileExists?.call(track) != true) {
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();
grabberQueue.add(() async {
final track = await ref.read(playbackProvider).toSpotubeTrack(
baseTrack,
noSponsorBlock: true,
);
IOSink outputFileStream = file.openWrite();
await audioStream.pipe(outputFileStream);
await outputFileStream.flush();
} finally {
currentlyRunning--;
inQueue.remove(track.id);
notifyListeners();
}
final filename = '${track.ytTrack.title}.mp3';
final url =
((await yt.videos.streamsClient.getManifest(track.ytTrack.url)))
.audioOnly
.where((audio) => audio.codec.mimeType == "audio/mp4")
.withHighestBitrate()
.url;
await FlutterDownloader.enqueue(
savedDir: downloadPath,
url: url.toString(),
fileName: filename,
openFileFromNotification: true,
showNotification: true,
);
});
} else {
grabberQueue.add(() async {
final track = await ref.read(playbackProvider).toSpotubeTrack(
baseTrack,
noSponsorBlock: true,
);
_queue.add(() async {
final filename = '${track.ytTrack.title}.mp3';
final file = File(path.join(downloadPath, filename));
try {
logger.v("[addToQueue] Download starting for ${file.path}");
if (file.existsSync() && await onFileExists?.call(track) != true) {
return;
}
file.createSync(recursive: true);
StreamManifest manifest =
await yt.videos.streamsClient.getManifest(track.ytTrack.url);
logger.v(
"[addToQueue] Getting download information for ${file.path}",
);
final audioStream = yt.videos.streamsClient
.get(
manifest.audioOnly
.where((audio) => audio.codec.mimeType == "audio/mp4")
.withHighestBitrate(),
)
.asBroadcastStream();
logger.v(
"[addToQueue] ${file.path} download started",
);
IOSink outputFileStream = file.openWrite();
await audioStream.pipe(outputFileStream);
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 {
currentlyRunning--;
inQueue.removeWhere((t) => t.id == track.id);
notifyListeners();
}
});
});
}
}
cancelAll() {
grabberQueue.cancel();
grabberQueue = Queue();
inQueue.clear();
currentlyRunning = 0;
if (kIsMobile) {
FlutterDownloader.cancelAll();
} else {
_queue.cancel();
_queueInstance = Queue();
_queue = _queueInstance;
queueInstance = Queue();
_queue = queueInstance;
}
notifyListeners();
}
}
final downloaderProvider = ChangeNotifierProvider(
(ref) {
return Downloader(
_queueInstance,
ref,
queueInstance,
yt: ref.watch(youtubeProvider),
downloadPath: ref.watch(
userPreferencesProvider.select(

View File

@ -218,6 +218,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: "direct main"
description:

View File

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