Skip to content

Commit

Permalink
Merge pull request #590 from lamarios/feature/improve-downloads
Browse files Browse the repository at this point in the history
add more video download options
  • Loading branch information
lamarios authored Sep 16, 2024
2 parents d868d25 + 4014c43 commit f7a30d6
Show file tree
Hide file tree
Showing 18 changed files with 500 additions and 114 deletions.
2 changes: 1 addition & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ android {

defaultConfig {
applicationId "com.github.lamarios.clipious"
minSdkVersion 21
minSdkVersion 24
targetSdkVersion 34
versionCode buildNumber
versionName flutterVersionName
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>

<uses-feature
android:name="android.software.leanback"
Expand Down
2 changes: 1 addition & 1 deletion android/settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.0" apply false
id "org.jetbrains.kotlin.android" version "1.7.10" apply false
id "org.jetbrains.kotlin.android" version "1.9.0" apply false
}

include ":app"
22 changes: 20 additions & 2 deletions lib/downloads/models/downloaded_video.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,27 @@ class DownloadedVideo extends IdedVideo {
String get videoId => super.videoId;

@JsonKey(includeFromJson: false, includeToJson: false)
Future<String> get mediaPath async {
Future<String> get downloadPath async {
Directory dir = await _getDownloadFolder();
return "${dir.path}/$videoId.${audioOnly ? 'webm' : 'mp4'}";

return "${dir.path}/$videoId.${audioOnly ? 'webm' : 'webm'}";
}

/// Effective path for legacy reasons, old versions videos would be mp4, now it's all webm
@JsonKey(includeFromJson: false, includeToJson: false)
Future<String> get effectivePath async {
Directory dir = await _getDownloadFolder();

if (audioOnly) {
return "${dir.path}/$videoId.webm";
} else {
final path = "${dir.path}/$videoId.webm";
if (!await File(path).exists()) {
return "${dir.path}/$videoId.mp4";
}

return path;
}
}

@JsonKey(includeFromJson: false, includeToJson: false)
Expand Down
133 changes: 106 additions & 27 deletions lib/downloads/states/download_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import 'dart:io';

import 'package:bloc/bloc.dart';
import 'package:dio/dio.dart';
import 'package:downloadsfolder/downloadsfolder.dart';
import 'package:easy_debounce/easy_throttle.dart';
import 'package:ffmpeg_kit_flutter_full/ffmpeg_kit.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:invidious/downloads/models/downloaded_video.dart';
import 'package:invidious/extensions.dart';
import 'package:invidious/globals.dart';
import 'package:invidious/utils/models/image_object.dart';
import 'package:invidious/videos/models/format_stream.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

import '../../player/states/player.dart';
import '../../videos/models/adaptive_format.dart';
Expand Down Expand Up @@ -71,8 +74,9 @@ class DownloadManagerCubit extends Cubit<DownloadManagerState> {
.toList());
}

onProgress(int count, int total, DownloadedVideo video) async {
if (count == total) {
onProgress(int count, int total, DownloadedVideo video,
{required int step, required int totalSteps}) async {
if (count == total && step == totalSteps) {
var progresses =
Map<String, DownloadProgress>.from(state.downloadProgresses);
var downloadProgress = progresses[video.videoId];
Expand Down Expand Up @@ -119,18 +123,23 @@ class DownloadManagerCubit extends Cubit<DownloadManagerState> {
quality: quality);
await db.upsertDownload(downloadedVideo);

String contentUrl;
String? videoUrl;

if (!audioOnly) {
FormatStream stream = vid.formatStreams
.firstWhere((element) => element.resolution == quality);
contentUrl = stream.url;
} else {
AdaptiveFormat audio = vid.adaptiveFormats
.sortByReversed((e) => int.parse(e.bitrate ?? "0"))
.firstWhere((element) => element.type.contains("audio"));
contentUrl = audio.url;
// FormatStream stream = vid.formatStreams
// .firstWhere((element) => element.resolution == quality);

final stream = vid.adaptiveFormats.firstWhere((element) =>
element.encoding == 'vp9' &&
element.qualityLabel == quality &&
element.type.contains('video/webm'));

videoUrl = stream.url;
}
AdaptiveFormat audio = vid.adaptiveFormats
.sortByReversed((e) => int.parse(e.bitrate ?? "0"))
.firstWhere((element) => element.type.contains("audio"));
String audioUrl = audio.url;

Dio dio = Dio();
CancelToken cancelToken = CancelToken();
Expand All @@ -152,22 +161,74 @@ class DownloadManagerCubit extends Cubit<DownloadManagerState> {
}

// download video
var videoPath = await downloadedVideo.mediaPath;
var mediaPath = await downloadedVideo.downloadPath;

final tempDir = await getTemporaryDirectory();
final audioPath = '${tempDir.path}/${videoId}_audio.webm';
final videoPath = '${tempDir.path}/${videoId}_video.webm';

log.info(
"Downloading video ${vid.title}, audioOnly ? $audioOnly, quality: $quality (if not only audio) to path: $videoPath");
dio
.download(contentUrl, videoPath,
onReceiveProgress: (count, total) =>
onProgress(count, total, downloadedVideo),
cancelToken: cancelToken,
deleteOnError: true)
.catchError((err) {
onDownloadError(err, downloadedVideo);
return Response<void>(requestOptions: RequestOptions());
});
"Downloading video ${vid.title}, audioOnly ? $audioOnly, quality: $quality to path: $tempDir");
try {
await dio
.download(audioUrl, audioPath,
onReceiveProgress: (count, total) => onProgress(
count, total, downloadedVideo,
step: 1, totalSteps: audioOnly ? 2 : 3),
cancelToken: cancelToken,
deleteOnError: true)
.catchError((err) {
onDownloadError(err, downloadedVideo);
return Response<void>(requestOptions: RequestOptions());
});

if (videoUrl != null) {
await dio
.download(videoUrl, videoPath,
onReceiveProgress: (count, total) => onProgress(
count,
total,
downloadedVideo,
step: 2,
totalSteps: 3,
),
cancelToken: cancelToken,
deleteOnError: true)
.catchError((err) {
onDownloadError(err, downloadedVideo);
return Response<void>(requestOptions: RequestOptions());
});
}

if (audioOnly) {
final audio = File(audioPath);
await audio.copy(mediaPath);
onProgress(1, 1, downloadedVideo, step: 2, totalSteps: 2);
return true;
} else {
final session = await FFmpegKit.execute(
'-y -i $videoPath -i $audioPath -c:v copy -c:a copy $mediaPath');

final returnCode = await session.getReturnCode();

onProgress(1, 1, downloadedVideo, step: 3, totalSteps: 3);

return returnCode?.isValueSuccess() ?? false;
}
} catch (e) {
rethrow;
} finally {
final audio = File(audioPath);
final video = File(videoPath);

if (await audio.exists()) {
await audio.delete();
}

return true;
if (await video.exists()) {
await video.delete();
}
}
}
}

Expand All @@ -182,7 +243,7 @@ class DownloadManagerCubit extends Cubit<DownloadManagerState> {
progresses.remove(vid.videoId);

try {
String path = await vid.mediaPath;
String path = await vid.effectivePath;
await File(path).delete();
} catch (e) {
log.fine('File might not be available, that\'s ok');
Expand Down Expand Up @@ -210,7 +271,7 @@ class DownloadManagerCubit extends Cubit<DownloadManagerState> {
"Failed to download video ${vid.title}, removing it", err.stackTrace);
vid.downloadFailed = true;
vid.downloadComplete = false;
onProgress(1, 1, vid);
onProgress(1, 1, vid, step: 1, totalSteps: 1);
var progresses =
Map<String, DownloadProgress>.from(state.downloadProgresses);
progresses.remove(vid.videoId);
Expand Down Expand Up @@ -245,6 +306,24 @@ class DownloadManagerCubit extends Cubit<DownloadManagerState> {

bool canPlayAll() =>
state.videos.where((element) => element.downloadComplete).isNotEmpty;

Future<void> copyToDownloadFolder(DownloadedVideo v) async {
final downloads = await getDownloadsDirectory();
if (downloads != null) {
if (!await downloads.exists()) {
downloads.create(recursive: true);
}
final file = File(await v.effectivePath);
if (await file.exists()) {
final fileName =
'${v.title.replaceAll(RegExp(r'[^a-zA-Z0-9]'), '_').replaceAll(RegExp(r'_{2,}'), '')}_${v.videoId}${p.extension(file.path)}';

bool? success = await copyFileIntoDownloadFolder(file.path, fileName);
log.info(
"file ${file.path} copied to download folder as $fileName (success ?$success)");
}
}
}
}

@freezed
Expand Down
66 changes: 65 additions & 1 deletion lib/downloads/views/components/downloaded_video.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,61 @@ class DownloadedVideoView extends StatelessWidget {

const DownloadedVideoView({super.key, required this.video});

openVideoSheet(BuildContext context, DownloadedVideo v) {
var cubit = context.read<DownloadManagerCubit>();
final locals = AppLocalizations.of(context)!;
showModalBottomSheet(
enableDrag: true,
showDragHandle: true,
context: context,
builder: (ctx) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Wrap(
alignment: WrapAlignment.center,
runSpacing: 16,
spacing: 16,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
onPressed: () async {
Navigator.of(ctx).pop();
await cubit.copyToDownloadFolder(v);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content:
Text(locals.fileCopiedToDownloadFolder)));
}
},
icon: const Icon(Icons.copy)),
Text(locals.copyToDownloadFolder)
],
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
onPressed: () async {
Navigator.of(ctx).pop();
await cubit.deleteVideo(v);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locals.videoDeleted)));
}
},
icon: const Icon(Icons.delete)),
Text(locals.delete)
],
)
],
),
);
},
);
}

@override
Widget build(BuildContext context) {
var colors = Theme.of(context).colorScheme;
Expand Down Expand Up @@ -69,7 +124,16 @@ class DownloadedVideoView extends StatelessWidget {
);
}),
),
)
),
if (state.video?.downloadComplete ?? false)
IconButton(
onPressed: () => openVideoSheet(context, video),
icon: Icon(
Icons.more_vert,
color: colors.secondary,
),
visualDensity: VisualDensity.compact,
)
],
),
),
Expand Down
23 changes: 4 additions & 19 deletions lib/downloads/views/screens/download_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import 'package:auto_route/annotations.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_swipe_action_cell/core/cell.dart';
import 'package:invidious/downloads/views/components/downloaded_video.dart';
import 'package:invidious/globals.dart';

Expand Down Expand Up @@ -48,24 +47,10 @@ class DownloadManagerScreen extends StatelessWidget {
itemCount: state.videos.length,
itemBuilder: (context, index) {
var v = state.videos[index];
return SwipeActionCell(
key:
ValueKey('downloaded-video-${v.videoId}'),
trailingActions: [
SwipeAction(
performsFirstActionWithFullSwipe: true,
icon: const Icon(Icons.delete,
color: Colors.white),
onTap: (handler) async {
await handler(true);
cubit.deleteVideo(v);
},
)
],
child: DownloadedVideoView(
key: ValueKey(v.videoId),
video: v,
));
return DownloadedVideoView(
key: ValueKey(v.videoId),
video: v,
);
},
),
),
Expand Down
5 changes: 4 additions & 1 deletion lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1371,5 +1371,8 @@
"serverAlreadyExists": "Server already exists in settings",
"wrongThumbnailConfiguration": "The server is reachable but is not configured properly, the video and channel thumbnails will not be displayed properly. Disable the server test configuration if you are OK with this, fix your server otherwise",
"openWikiLink": "Open wiki for help",
"serverUnreachable": "Server is unreachable, or is not a valid invidious server"
"serverUnreachable": "Server is unreachable, or is not a valid invidious server",
"copyToDownloadFolder": "Copy to download folder",
"fileCopiedToDownloadFolder": "File copied to download folder",
"videoDeleted": "Video deleted"
}
2 changes: 1 addition & 1 deletion lib/player/states/audio_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ class AudioPlayerCubit extends MediaPlayerCubit<AudioPlayerState> {
}
}
} else {
String path = await state.offlineVideo!.mediaPath;
String path = await state.offlineVideo!.effectivePath;
source = AudioSource.file(path);
}

Expand Down
2 changes: 1 addition & 1 deletion lib/player/states/video_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ class VideoPlayerCubit extends MediaPlayerCubit<VideoPlayerState> {

// getting data sources
if (offline) {
String videoPath = await newState.offlineVideo!.mediaPath;
String videoPath = await newState.offlineVideo!.effectivePath;

betterPlayerDataSource = BetterPlayerDataSource(
BetterPlayerDataSourceType.file,
Expand Down
Loading

0 comments on commit f7a30d6

Please sign in to comment.