Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ feat: download capability #35

Merged
merged 4 commits into from
Oct 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ios/Podfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '11.0'
platform :ios, '13.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
Expand Down
8 changes: 7 additions & 1 deletion ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ PODS:
- Flutter
- audio_session (0.0.1):
- Flutter
- background_downloader (0.0.1):
- Flutter
- Firebase/Auth (10.15.0):
- Firebase/CoreOnly
- FirebaseAuth (~> 10.15.0)
Expand Down Expand Up @@ -83,6 +85,7 @@ PODS:
DEPENDENCIES:
- audio_service (from `.symlinks/plugins/audio_service/ios`)
- audio_session (from `.symlinks/plugins/audio_session/ios`)
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- firebase_auth (from `.symlinks/plugins/firebase_auth/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- Flutter (from `Flutter`)
Expand Down Expand Up @@ -114,6 +117,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/audio_service/ios"
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
background_downloader:
:path: ".symlinks/plugins/background_downloader/ios"
firebase_auth:
:path: ".symlinks/plugins/firebase_auth/ios"
firebase_core:
Expand All @@ -137,6 +142,7 @@ SPEC CHECKSUMS:
AppAuth: 3bb1d1cd9340bd09f5ed189fb00b1cc28e1e8570
audio_service: f509d65da41b9521a61f1c404dd58651f265a567
audio_session: 4f3e461722055d21515cf3261b64c973c062f345
background_downloader: 6f55e5548875be2ad4bb91b542558b5be22f339a
Firebase: 66043bd4579e5b73811f96829c694c7af8d67435
firebase_auth: b62e99e6ece589afe88ebe8919eb9563b52c384c
firebase_core: 28e84c2a4fcf6a50ef83f47b145ded8c1fa331e4
Expand All @@ -159,6 +165,6 @@ SPEC CHECKSUMS:
RecaptchaInterop: 7d1a4a01a6b2cb1610a47ef3f85f0c411434cb21
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a

PODFILE CHECKSUM: 70d9d25280d0dd177a5f637cdb0f0b0b12c6a189
PODFILE CHECKSUM: a57f30d18f102dd3ce366b1d62a55ecbef2158e5

COCOAPODS: 1.13.0
132 changes: 65 additions & 67 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
@@ -1,71 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>$(BUNDLE_DISPLAY_NAME)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(BUNDLE_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>$(LAUNCH_SCREEN)</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIStatusBarHidden</key>
<false/>
<!-- Google Sign-in Section -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
<string>$(GOOGLE_REVERSED_CLIENT_ID)</string>
</array>
</dict>
</array>
<!-- End of the Google Sign-in Section -->
</dict>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>$(BUNDLE_DISPLAY_NAME)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(BUNDLE_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>$(GOOGLE_REVERSED_CLIENT_ID)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>$(LAUNCH_SCREEN)</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
6 changes: 4 additions & 2 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:varanasi_mobile_app/utils/router.dart';
import 'package:varanasi_mobile_app/widgets/responsive_sizer.dart';

import 'cubits/config/config_cubit.dart';
import 'cubits/download/download_cubit.dart';
import 'cubits/player/player_cubit.dart';
import 'utils/theme.dart';

Expand All @@ -19,14 +20,15 @@ class Varanasi extends StatelessWidget {
builder: (context, orientation, screenType) {
return MultiBlocProvider(
providers: [
BlocProvider(lazy: false, create: (_) => DownloadCubit()..init()),
BlocProvider(
lazy: false,
create: (context) => ConfigCubit()..init(),
),
BlocProvider(
lazy: false,
create: (context) =>
MediaPlayerCubit(() => context.read<ConfigCubit>())..init(),
create: (ctx) =>
MediaPlayerCubit(() => ctx.read<ConfigCubit>())..init(),
),
],
child: Builder(builder: (context) {
Expand Down
176 changes: 176 additions & 0 deletions lib/cubits/download/download_cubit.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import 'dart:async';

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/download.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/logger.dart';

part 'download_state.dart';

class DownloadCubit extends AppCubit<DownloadState> {
DownloadCubit() : super(const DownloadState());

late final Box<DownloadedMedia> _downloadBox;
late final FileDownloader _downloader;

late final Map<String, Song> _songMap;

Logger get _logger => Logger.instance;

@override
FutureOr<void> init() async {
_songMap = {};
_downloadBox =
await Hive.openBox<DownloadedMedia>(AppStrings.downloadBoxName);
_downloader = FileDownloader();
_downloader.updates.listen((update) {
if (update is TaskStatusUpdate) {
_handleTaskStatusUpdate(update);
} else if (update is TaskProgressUpdate) {
_handleTaskProgressUpdate(update);
}
});
}

void _handleTaskProgressUpdate(TaskProgressUpdate update) {
final item = _downloadBox.get(update.task.taskId);
if (item != null) {
_downloadBox.put(
update.task.taskId,
item.copyWith(progress: update.progress),
);
_logger.i('Dl pro for ${item.id}: ${item.progress}');
}
}

void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
if (update.status == TaskStatus.enqueued) {
_logger.i('Download enqueued for ${update.task.taskId}');
_downloadBox.put(
update.task.taskId,
DownloadedMedia(
id: update.task.taskId,
progress: 0,
downloadComplete: false,
media: _songMap[update.task.taskId]!,
path: '',
),
);
return;
}
final item = _downloadBox.get(update.task.taskId);
if (update.status.isNotFinalState && item != null) {
_downloadBox.put(
item.id,
item.copyWith(downloading: update.status == TaskStatus.running),
);
_logger.i('Download status for ${update.task.taskId}: '
'${update.status}');
return;
}

if (item != null) {
if (update.status == TaskStatus.complete) {
final path = await update.task.filePath();
_downloadBox.put(
item.id,
item.copyWith(
downloadComplete: true,
progress: 1,
path: path,
downloading: false,
),
);
_logger.i('Download complete for ${item.id} path: $path');
} else if (update.status.isFinalState) {
_downloadBox.delete(item.id);
_logger.i('Download failed for ${item.id}');
}
}
}

String _fileNameFromSong(Song song) {
final ext = song.itemUrl.split('.').last;
return '${song.itemId}.$ext';
}

DownloadTask _songToTask(Song song) {
final fileName = _fileNameFromSong(song);
return DownloadTask(
taskId: song.itemId,
url: song.itemUrl,
filename: fileName,
updates: Updates.statusAndProgress,
);
}

Future<void> downloadSong(PlayableMedia song) async {
assert(song is Song, 'Only songs can be downloaded');
if (song is! Song) 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 songs = playlist.mediaItems ?? [];
final filteredsong = songs.whereType<Song>();
final tasks = filteredsong.map(_songToTask).toList();
for (final song in filteredsong) {
_songMap[song.itemId] = song;
}
emit(state.updateProgress(MapEntry(playlist.id!, 0)));
await _downloader.downloadBatch(
tasks,
batchProgressCallback: (succeeded, failed) {
final percentComplete = (succeeded + failed) / tasks.length;
emit(state.updateProgress(MapEntry(playlist.id!, percentComplete)));
_logger.i('Batch progress: $succeeded, $failed');
},
taskStatusCallback: _handleTaskStatusUpdate,
taskProgressCallback: _handleTaskProgressUpdate,
);
}

Future<void> cancelDownload(PlayableMedia media) =>
_downloader.cancelTaskWithId(media.itemId);

/// Returns a stream of [DownloadedMedia] for the given [song].
///
/// The stream will emit the current download status of the song and
///
/// will continue to emit updates until the download is complete or
Stream<DownloadedMedia?> listen(PlayableMedia song) {
final curr = _downloadBox.get(song.itemId);
return Rx.concat([
if (curr != null) Stream.value(curr),
_downloadBox.watch(key: song.itemId).map((e) => e.value)
]);
}

Stream<({bool downloading, bool downloaded})> listenToPlaylist(
MediaPlaylist playlist,
) {
final items = playlist.mediaItems ?? [];
return Rx.combineLatestList(items.map(listen)).map(
(items) {
final downloading = items.any((e) => e?.downloading ?? false);
final downloaded = items.every((e) => e?.downloadComplete ?? false);
return (downloaded: downloaded, downloading: downloading);
},
);
}

DownloadedMedia? getDownloadedMedia(PlayableMedia song) =>
_downloadBox.get(song.itemId);

Future<void> deleteDownloadedMedia(PlayableMedia song) =>
_downloadBox.delete(song.itemId);
}
Loading