Skip to content

Commit

Permalink
✨ New: User specific music library (#37)
Browse files Browse the repository at this point in the history
* 🐛 Fix: download box causing issues

* ✨ Add: Download icons

* ✨ Add: User Library

* 🚧 WIP: User Library and downloads

* ✨ Add: delete all method

* 🐛 Fix: navigation for carousel when not mounted

* 🐛 Fix: current playlist not updating

* 💄 Update: Assets

* 🚧 WIP: download feature

* 🚧 Fix: download status indicator

* ✨ New: Download quality picker

* ♻️ Fix: using saved download quality

* 🐛 Fix: batch download not working

* ♻️ Update: update library when already exists

* ✨ New: User library

* 🐛 Fix: media not added to library when one song download

* ✨ New: User library

* 🐛 Fix: Android build
  • Loading branch information
devaryakjha authored Oct 22, 2023
1 parent a70868c commit fb1a0c8
Show file tree
Hide file tree
Showing 45 changed files with 1,436 additions and 258 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Project Varanasi

🛠️ Currently in development, music streaming application built with Flutter for iOS and Android. The app is designed to have all the basic functionalities of a music app, with a UI heavily inspired by the Spotify mobile app.

### Key Features:
Expand Down
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
buildscript {
ext.kotlin_version = '1.7.10'
ext.kotlin_version = '1.8.0'
repositories {
google()
mavenCentral()
Expand Down
1 change: 1 addition & 0 deletions assets/icon/circular_loader.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"v":"5.4.2","fr":60,"ip":0,"op":37,"w":600,"h":600,"nm":"loader-1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"loader","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300,300,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-82.843],[82.843,0],[0,82.843],[-82.843,0]],"o":[[0,82.843],[-82.843,0],[0,-82.843],[82.843,0]],"v":[[150,0],[0,150],[-150,0],[0,-150]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.086274512112,0.086274512112,0.086274512112,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":30,"ix":5},"lc":2,"lj":1,"ml":10,"ml2":{"a":0,"k":10,"ix":8},"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":0,"s":[10],"e":[27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":10,"s":[27],"e":[10]},{"t":40}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.5],"y":[0.889]},"o":{"x":[0.5],"y":[0.111]},"n":["0p5_0p889_0p5_0p111"],"t":0,"s":[0],"e":[360]},{"t":40}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"container","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300,300,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[74.439,0],[0,-74.439],[-74.439,0],[0,74.439]],"o":[[-74.439,0],[0,74.439],[74.439,0],[0,-74.439]],"v":[[0,-135],[-135,0],[0,135],[135,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[90.981,0],[0,90.981],[-90.981,0],[0,-90.981]],"o":[[-90.981,0],[0,-90.981],[90.981,0],[0,90.981]],"v":[[0,165],[-165,0],[0,-165],[165,0]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.909620098039,0.909620098039,0.909620098039,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0}],"markers":[]}
5 changes: 5 additions & 0 deletions assets/icon/download.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions assets/icon/download_filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions assets/icon/downloading.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 6 additions & 2 deletions lib/app.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:varanasi_mobile_app/features/user-library/cubit/user_library_cubit.dart';
import 'package:varanasi_mobile_app/utils/constants/constants.dart';
import 'package:varanasi_mobile_app/utils/router.dart';
import 'package:varanasi_mobile_app/widgets/responsive_sizer.dart';
Expand All @@ -21,14 +22,17 @@ class Varanasi extends StatelessWidget {
return MultiBlocProvider(
providers: [
BlocProvider(lazy: false, create: (_) => DownloadCubit()..init()),
BlocProvider(
lazy: false,
create: (_) => UserLibraryCubit()..init(),
),
BlocProvider(
lazy: false,
create: (context) => ConfigCubit()..init(),
),
BlocProvider(
lazy: false,
create: (ctx) =>
MediaPlayerCubit(() => ctx.read<ConfigCubit>())..init(),
create: (ctx) => MediaPlayerCubit()..init(),
),
],
child: Builder(builder: (context) {
Expand Down
21 changes: 19 additions & 2 deletions lib/cubits/config/config_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,36 @@ import 'package:audio_service/audio_service.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:carousel_slider/carousel_controller.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:sliding_up_panel/sliding_up_panel.dart';
import 'package:varanasi_mobile_app/models/app_config.dart';
import 'package:varanasi_mobile_app/models/media_playlist.dart';
import 'package:varanasi_mobile_app/models/playable_item.dart';
import 'package:varanasi_mobile_app/models/sort_type.dart';
import 'package:varanasi_mobile_app/utils/app_cubit.dart';
import 'package:varanasi_mobile_app/utils/constants/strings.dart';
import 'package:varanasi_mobile_app/utils/helpers/get_app_context.dart';
import 'package:varanasi_mobile_app/utils/logger.dart';

part 'config_state.dart';

class ConfigCubit extends AppCubit<ConfigState> {
final _configBox = AppConfig.getBox;
final _cacheBox = Hive.box(AppStrings.commonCacheBoxName);
final Map<String, CachedNetworkImageProvider> _imageProviderCache = {};
late final Expando<PaletteGenerator> _paletteGeneratorExpando;
Logger logger = Logger.instance;

Box<AppConfig> get _configBox => AppConfig.getBox;
Box get _cacheBox => Hive.box(AppStrings.commonCacheBoxName);

ConfigCubit() : super(ConfigInitial());

static ConfigCubit read() => appContext.read<ConfigCubit>();

Box get cacheBox => _cacheBox;

@override
void init() async {
final packageInfo = await PackageInfo.fromPlatform();
Expand Down Expand Up @@ -56,6 +64,8 @@ class ConfigCubit extends AppCubit<ConfigState> {
playerPageController: CarouselController(),
miniPlayerPageController: CarouselController(),
packageInfo: packageInfo,
currentQueueIndex: savedPlaylistIndex,
currentPlaylist: savedPlaylist,
));
}

Expand Down Expand Up @@ -107,10 +117,12 @@ class ConfigCubit extends AppCubit<ConfigState> {
: null;

Future<void> saveCurrentPlaylist(MediaPlaylist playlist) {
emit(configLoadedState.copyWith(currentPlaylist: playlist));
return _cacheBox.put(AppStrings.currentPlaylistCacheKey, playlist);
}

Future<void> clearCurrentPlaylist() {
emit(configLoadedState.copyWith(currentPlaylist: null));
return _cacheBox.delete(AppStrings.currentPlaylistCacheKey);
}

Expand All @@ -119,10 +131,12 @@ class ConfigCubit extends AppCubit<ConfigState> {
}

Future<void> saveCurrentPlaylistIndex(int index) {
emit(configLoadedState.copyWith(currentQueueIndex: index));
return _cacheBox.put(AppStrings.currentPlaylistIndexCacheKey, index);
}

Future<void> clearCurrentPlaylistIndex() {
emit(configLoadedState.copyWith(currentQueueIndex: null));
return _cacheBox.delete(AppStrings.currentPlaylistIndexCacheKey);
}

Expand Down Expand Up @@ -165,4 +179,7 @@ class ConfigCubit extends AppCubit<ConfigState> {

ConfigLoaded? get configOrNull =>
state is ConfigLoaded ? state as ConfigLoaded : null;

PlayableMedia? get currentMedia =>
savedPlaylist?.mediaItems?[savedPlaylistIndex ?? 0];
}
24 changes: 20 additions & 4 deletions lib/cubits/config/config_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,21 @@ final class ConfigInitial extends ConfigState {}

final class ConfigLoaded extends ConfigState {
final AppConfig config;
final CarouselController? miniPlayerPageController, playerPageController;
final CarouselController? miniPlayerPageController;
final CarouselController? playerPageController;
final PanelController panelController;
final PackageInfo packageInfo;
final MediaPlaylist? currentPlaylist;
final int? currentQueueIndex;

const ConfigLoaded({
required this.config,
this.miniPlayerPageController,
this.playerPageController,
required this.panelController,
required this.packageInfo,
this.currentPlaylist,
this.currentQueueIndex,
});

@override
Expand All @@ -31,6 +36,8 @@ final class ConfigLoaded extends ConfigState {
panelController,
playerPageController,
packageInfo,
currentPlaylist,
currentQueueIndex,
];

ConfigLoaded copyWith({
Expand All @@ -39,14 +46,23 @@ final class ConfigLoaded extends ConfigState {
CarouselController? playerPageController,
PanelController? panelController,
PackageInfo? packageInfo,
MediaPlaylist? currentPlaylist,
int? currentQueueIndex,
}) {
return ConfigLoaded(
packageInfo: packageInfo ?? this.packageInfo,
config: config ?? this.config,
playerPageController: playerPageController ?? this.playerPageController,
panelController: panelController ?? this.panelController,
miniPlayerPageController:
miniPlayerPageController ?? this.miniPlayerPageController,
playerPageController: playerPageController ?? this.playerPageController,
panelController: panelController ?? this.panelController,
packageInfo: packageInfo ?? this.packageInfo,
currentPlaylist: currentPlaylist ?? this.currentPlaylist,
currentQueueIndex: currentQueueIndex ?? this.currentQueueIndex,
);
}

PlayableMedia? get currentMedia {
if (currentPlaylist == null || currentQueueIndex == null) return null;
return currentPlaylist?.mediaItems?.elementAtOrNull(currentQueueIndex ?? 0);
}
}
118 changes: 112 additions & 6 deletions lib/cubits/download/download_cubit.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import 'dart:async';
import 'dart:io';

import 'package:background_downloader/background_downloader.dart';
import 'package:equatable/equatable.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:rxdart/rxdart.dart';
import 'package:varanasi_mobile_app/models/app_config.dart';
import 'package:varanasi_mobile_app/models/download.dart';
import 'package:varanasi_mobile_app/models/download_url.dart';
import 'package:varanasi_mobile_app/models/media_playlist.dart';
import 'package:varanasi_mobile_app/models/playable_item.dart';
import 'package:varanasi_mobile_app/models/song.dart';
import 'package:varanasi_mobile_app/utils/app_cubit.dart';
import 'package:varanasi_mobile_app/utils/constants/strings.dart';
import 'package:varanasi_mobile_app/utils/dialogs/app_dialog.dart';
import 'package:varanasi_mobile_app/utils/logger.dart';

part 'download_state.dart';
Expand All @@ -24,11 +28,12 @@ class DownloadCubit extends AppCubit<DownloadState> {

Logger get _logger => Logger.instance;

Box<DownloadedMedia> get downloadBox => _downloadBox;

@override
FutureOr<void> init() async {
_songMap = {};
_downloadBox =
await Hive.openBox<DownloadedMedia>(AppStrings.downloadBoxName);
_downloadBox = Hive.box<DownloadedMedia>(AppStrings.downloadBoxName);
_downloader = FileDownloader();
_downloader.updates.listen((update) {
if (update is TaskStatusUpdate) {
Expand Down Expand Up @@ -89,6 +94,9 @@ class DownloadCubit extends AppCubit<DownloadState> {
),
);
_logger.i('Download complete for ${item.id} path: $path');
} else if (update.status == TaskStatus.canceled) {
_downloadBox.delete(item.id);
_logger.i('Download canceled for ${item.id}');
} else if (update.status.isFinalState) {
_downloadBox.delete(item.id);
_logger.i('Download failed for ${item.id}');
Expand All @@ -103,9 +111,13 @@ class DownloadCubit extends AppCubit<DownloadState> {

DownloadTask _songToTask(Song song) {
final fileName = _fileNameFromSong(song);
final dquality = AppConfig.effectiveDlQuality!;
final dlink = song.downloadUrl?.firstWhere(
(e) => e.quality == dquality.quality,
);
return DownloadTask(
taskId: song.itemId,
url: song.itemUrl,
url: dlink?.link ?? song.itemUrl,
filename: fileName,
updates: Updates.statusAndProgress,
);
Expand All @@ -114,14 +126,18 @@ class DownloadCubit extends AppCubit<DownloadState> {
Future<void> downloadSong(PlayableMedia song) async {
assert(song is Song, 'Only songs can be downloaded');
if (song is! Song) return;
final quality = await getDownloadQuality();
if (quality == null) return;
_songMap[song.itemId] = song;
final queued = await _downloader.enqueue(_songToTask(song));
_logger.i('Queued ${song.itemId} status: $queued');
}

Future<void> batchDownload(MediaPlaylist playlist) async {
final quality = await getDownloadQuality();
if (quality == null) return;
final songs = playlist.mediaItems ?? [];
final filteredsong = songs.whereType<Song>();
final filteredsong = songs.whereType<Song>().where(_isNotDownloaded);
final tasks = filteredsong.map(_songToTask).toList();
for (final song in filteredsong) {
_songMap[song.itemId] = song;
Expand All @@ -142,6 +158,13 @@ class DownloadCubit extends AppCubit<DownloadState> {
Future<void> cancelDownload(PlayableMedia media) =>
_downloader.cancelTaskWithId(media.itemId);

Future<void> batchCancel(MediaPlaylist media) async {
await _downloader.cancelTasksWithIds(
media.mediaItems?.map((e) => e.itemId).toList() ?? [],
);
await _batchDelete(media);
}

/// Returns a stream of [DownloadedMedia] for the given [song].
///
/// The stream will emit the current download status of the song and
Expand Down Expand Up @@ -171,6 +194,89 @@ class DownloadCubit extends AppCubit<DownloadState> {
DownloadedMedia? getDownloadedMedia(PlayableMedia song) =>
_downloadBox.get(song.itemId);

Future<void> deleteDownloadedMedia(PlayableMedia song) =>
_downloadBox.delete(song.itemId);
Future<void> deleteSingle(PlayableMedia media) {
return AppDialog.showAlertDialog(
title: "Remove from Downloads?",
message: "You won't be able to listen to this song offline.",
onConfirm: () async => _deleteSingle(media),
confirmLabel: "Remove",
);
}

Future<void> _deleteSingle(PlayableMedia media) {
final item = _downloadBox.get(media.itemId);
if (item?.path.isNotEmpty ?? false) {
_deleteFile(item!.path);
}
return _downloadBox.delete(media.itemId);
}

Future<void> batchDelete(MediaPlaylist song) async {
await AppDialog.showAlertDialog(
title: "Remove from Downloads?",
message: "You won't be able to listen to these songs offline.",
onConfirm: () => _batchDelete(song),
confirmLabel: "Remove",
);
}

Future<void> _batchDelete(MediaPlaylist song) async {
final keys = song.mediaItems?.map((e) => e.itemId) ?? [];
final values = keys
.map((e) => _downloadBox.get(e))
.whereType<DownloadedMedia>()
.toList();
// delete files from disk
for (final value in values) {
if (value.path.isNotEmpty) {
_deleteFile(value.path);
}
}
return _downloadBox.deleteAll(song.mediaItems?.map((e) => e.itemId) ?? []);
}

Future<void> _deleteFile(String path) async {
try {
final file = File(path);
final exists = file.existsSync();
if (!exists) {
_logger.i('File does not exist: $path');
return Future.value(null);
}
await file.delete(recursive: true);
_logger.i('Deleted file: $path');
} catch (e) {
_logger.e('Error deleting file: $e');
return Future.value(null);
}
}

Future deleteAll() async {
final keys = _downloadBox.keys.toList();
for (final key in keys) {
final item = _downloadBox.get(key);
if (item?.path.isNotEmpty ?? false) {
await _deleteFile(item!.path);
}
}
await _downloadBox.clear();
}

bool _isDownloaded(PlayableMedia song) =>
_downloadBox.containsKey(song.itemId);

bool _isNotDownloaded(PlayableMedia song) => !_isDownloaded(song);

Future<DownloadQuality?> getDownloadQuality() async {
final savedQuality = AppConfig.effectiveDlQuality;
if (savedQuality != null) return savedQuality;
AppConfig.effectiveDlQuality = await AppDialog.showOptionsPicker(
null,
savedQuality ?? DownloadQuality.high,
DownloadQuality.values,
(q) => q.describeQuality,
title: "Select Download Quality",
);
return AppConfig.effectiveDlQuality;
}
}
Loading

0 comments on commit fb1a0c8

Please sign in to comment.