diff --git a/lib/components/AlbumScreen/download_dialog.dart b/lib/components/AlbumScreen/download_dialog.dart index bc0c28d83..dc34f48ba 100644 --- a/lib/components/AlbumScreen/download_dialog.dart +++ b/lib/components/AlbumScreen/download_dialog.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:file_sizes/file_sizes.dart'; +import 'package:finamp/models/jellyfin_models.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; @@ -8,6 +10,7 @@ import '../../models/finamp_models.dart'; import '../../services/finamp_settings_helper.dart'; import '../../services/finamp_user_helper.dart'; import '../../services/isar_downloads.dart'; +import '../../services/jellyfin_api_helper.dart'; import '../global_snackbar.dart'; class DownloadDialog extends StatefulWidget { @@ -15,10 +18,16 @@ class DownloadDialog extends StatefulWidget { super.key, required this.item, required this.viewId, + required this.needDirectory, + required this.needTranscode, + required this.children, }); final DownloadStub item; final String viewId; + final bool needDirectory; + final bool needTranscode; + final List? children; @override State createState() => _DownloadDialogState(); @@ -29,27 +38,50 @@ class DownloadDialog extends StatefulWidget { final finampUserHelper = GetIt.instance(); viewId = finampUserHelper.currentUser!.currentViewId; } - if (FinampSettingsHelper.finampSettings.downloadLocationsMap.values + bool needTranscode = + FinampSettingsHelper.finampSettings.shouldTranscodeDownloads == + TranscodeDownloadsSetting.ask; + bool needDownload = FinampSettingsHelper + .finampSettings.downloadLocationsMap.values .where((element) => element.baseDirectory != DownloadLocationType.internalDocuments) - .length == - 1) { + .length != + 1; + if (!needTranscode && !needDownload) { final isarDownloads = GetIt.instance(); unawaited(isarDownloads .addDownload( stub: item, viewId: viewId!, downloadLocation: - FinampSettingsHelper.finampSettings.internalSongDir) + FinampSettingsHelper.finampSettings.internalSongDir, + transcodeProfile: FinampSettingsHelper + .finampSettings.shouldTranscodeDownloads == + TranscodeDownloadsSetting.always + ? FinampSettingsHelper + .finampSettings.downloadTranscodingProfile + : null) .then((value) => GlobalSnackbar.message( (scaffold) => AppLocalizations.of(scaffold)!.downloadsAdded))); } else { + JellyfinApiHelper jellyfinApiHelper = GetIt.instance(); + List? children; + if (item.baseItemType == BaseItemDtoType.album || + item.baseItemType == BaseItemDtoType.playlist) { + children = await jellyfinApiHelper.getItems( + parentItem: item.baseItem!, + includeItemTypes: "Audio", + fields: "${jellyfinApiHelper.defaultFields},MediaSources"); + } + if (!context.mounted) return; await showDialog( context: context, builder: (context) => DownloadDialog._build( - item: item, - viewId: viewId!, - ), + item: item, + viewId: viewId!, + needDirectory: needDownload, + needTranscode: needTranscode, + children: children), ); } } @@ -57,34 +89,99 @@ class DownloadDialog extends StatefulWidget { class _DownloadDialogState extends State { DownloadLocation? selectedDownloadLocation; + bool? transcode; @override Widget build(BuildContext context) { + String originalDescription = "null"; + String transcodeDescription = "null"; + var profile = + FinampSettingsHelper.finampSettings.downloadTranscodingProfile; + + if (widget.children != null) { + final originalFileSize = widget.children! + .map((e) => e.mediaSources?.first.size ?? 0) + .fold(0, (a, b) => a + b); + + final transcodedFileSize = widget.children! + .map((e) => e.mediaSources?.first.transcodedSize(FinampSettingsHelper + .finampSettings.downloadTranscodingProfile.bitrateChannels)) + .fold(0, (a, b) => a + (b ?? 0)); + + final originalFileSizeFormatted = FileSize.getSize( + originalFileSize, + precision: PrecisionValue.None, + ); + + final formats = widget.children! + .map((e) => e.mediaSources?.first.mediaStreams.first.codec) + .toSet(); + + transcodeDescription = FileSize.getSize( + transcodedFileSize, + precision: PrecisionValue.None, + ); + + originalDescription = + "$originalFileSizeFormatted ${formats.length == 1 ? formats.first!.toUpperCase() : "null"}"; + } + return AlertDialog( title: Text(AppLocalizations.of(context)!.addDownloads), - content: DropdownButton( - hint: Text(AppLocalizations.of(context)!.location), - isExpanded: true, - onChanged: (value) => setState(() { - selectedDownloadLocation = value; - }), - value: selectedDownloadLocation, - items: FinampSettingsHelper.finampSettings.downloadLocationsMap.values - .where((element) => - element.baseDirectory != - DownloadLocationType.internalDocuments) - .map((e) => DropdownMenuItem( - value: e, - child: Text(e.name), - )) - .toList()), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.needDirectory) + DropdownButton( + hint: Text(AppLocalizations.of(context)!.location), + isExpanded: true, + onChanged: (value) => setState(() { + selectedDownloadLocation = value; + }), + value: selectedDownloadLocation, + items: FinampSettingsHelper + .finampSettings.downloadLocationsMap.values + .where((element) => + element.baseDirectory != + DownloadLocationType.internalDocuments) + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.name), + )) + .toList()), + if (widget.needTranscode) + DropdownButton( + hint: Text(AppLocalizations.of(context)!.transcodeHint), + isExpanded: true, + onChanged: (value) => setState(() { + transcode = value; + }), + value: transcode, + items: [ + DropdownMenuItem( + value: true, + child: Text(AppLocalizations.of(context)!.doTranscode( + profile.bitrateKbps, + profile.codec.name.toUpperCase(), + transcodeDescription)), + ), + DropdownMenuItem( + value: false, + child: Text(AppLocalizations.of(context)! + .dontTranscode(originalDescription)), + ) + ]), + ], + ), actions: [ TextButton( child: Text(MaterialLocalizations.of(context).cancelButtonLabel), onPressed: () => Navigator.of(context).pop(), ), TextButton( - onPressed: selectedDownloadLocation == null + onPressed: (selectedDownloadLocation == null && + widget.needDirectory) || + (transcode == null && widget.needTranscode) ? null : () async { Navigator.of(context).pop(); @@ -93,7 +190,17 @@ class _DownloadDialogState extends State { .addDownload( stub: widget.item, viewId: widget.viewId, - downloadLocation: selectedDownloadLocation!) + downloadLocation: (widget.needDirectory + ? selectedDownloadLocation + : FinampSettingsHelper + .finampSettings.internalSongDir)!, + transcodeProfile: (widget.needTranscode + ? transcode + : FinampSettingsHelper.finampSettings + .shouldTranscodeDownloads == + TranscodeDownloadsSetting.always)! + ? profile + : null) .onError( (error, stackTrace) => GlobalSnackbar.error(error)); diff --git a/lib/components/AlbumScreen/item_info.dart b/lib/components/AlbumScreen/item_info.dart index 84b3d0491..08f516004 100644 --- a/lib/components/AlbumScreen/item_info.dart +++ b/lib/components/AlbumScreen/item_info.dart @@ -36,10 +36,7 @@ class ItemInfo extends StatelessWidget { ), IconAndText( iconData: Icons.timer, - text: printDuration(Duration( - microseconds: - item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10, - )), + text: printDuration(item.runTimeTicksDuration()), ), if (item.type != "Playlist") IconAndText(iconData: Icons.event, text: item.productionYearString) diff --git a/lib/components/AlbumScreen/song_list_tile.dart b/lib/components/AlbumScreen/song_list_tile.dart index e808f7b46..a1bf81967 100644 --- a/lib/components/AlbumScreen/song_list_tile.dart +++ b/lib/components/AlbumScreen/song_list_tile.dart @@ -158,10 +158,7 @@ class _SongListTileState extends ConsumerState alignment: PlaceholderAlignment.top, ), TextSpan( - text: printDuration(Duration( - microseconds: (widget.item.runTimeTicks == null - ? 0 - : widget.item.runTimeTicks! ~/ 10))), + text: printDuration(widget.item.runTimeTicksDuration()), style: TextStyle( color: Theme.of(context) .textTheme @@ -215,10 +212,10 @@ class _SongListTileState extends ConsumerState : (details) async { unawaited(Feedback.forLongPress(context)); await showModalSongMenu( - context: context, - item: widget.item, - isInPlaylist: widget.isInPlaylist, - onRemoveFromList: widget.onRemoveFromList, + context: context, + item: widget.item, + isInPlaylist: widget.isInPlaylist, + onRemoveFromList: widget.onRemoveFromList, ); }, onTap: () async { diff --git a/lib/components/DownloadsScreen/downloaded_items_list.dart b/lib/components/DownloadsScreen/downloaded_items_list.dart index 3416b17e9..8a24ec4df 100644 --- a/lib/components/DownloadsScreen/downloaded_items_list.dart +++ b/lib/components/DownloadsScreen/downloaded_items_list.dart @@ -28,7 +28,7 @@ class _DownloadedItemsListState extends State { return SliverList( delegate: SliverChildBuilderDelegate( (context, index) { - DownloadStub album = snapshot.data!.elementAt(index); + DownloadItem album = snapshot.data!.elementAt(index); return ExpansionTile( key: PageStorageKey(album.id), leading: AlbumImage(item: album.baseItem), diff --git a/lib/components/DownloadsScreen/item_file_size.dart b/lib/components/DownloadsScreen/item_file_size.dart index 746dcb8f6..0577a2fd7 100644 --- a/lib/components/DownloadsScreen/item_file_size.dart +++ b/lib/components/DownloadsScreen/item_file_size.dart @@ -10,7 +10,7 @@ import '../../services/isar_downloads.dart'; class ItemFileSize extends ConsumerWidget { const ItemFileSize({super.key, required this.item}); - final DownloadStub item; + final DownloadItem item; @override Widget build(BuildContext context, WidgetRef ref) { @@ -32,12 +32,14 @@ class ItemFileSize extends ConsumerWidget { case DownloadItemState.failed: case DownloadItemState.complete: if (item.type == DownloadItemType.song) { - var mediaSourceInfo = item.baseItem?.mediaSources?[0]; - if (mediaSourceInfo == null) { - return "??? MB Unknown"; - } else { - return "${FileSize.getSize(mediaSourceInfo.size)} ${mediaSourceInfo.container?.toUpperCase()}"; - } + var codec = item.transcodingProfile?.codec.name ?? + item.baseItem?.mediaSources?[0].container ?? + ""; + return isarDownloader.getFileSize(item).then((value) => + AppLocalizations.of(context)!.downloadInfo( + item.transcodingProfile?.bitrateKbps ?? "null", + codec.toUpperCase(), + FileSize.getSize(value))); } else { return isarDownloader .getFileSize(item) diff --git a/lib/components/TranscodingSettingsScreen/bitrate_selector.dart b/lib/components/TranscodingSettingsScreen/bitrate_selector.dart index 602a0f32a..022f84a82 100644 --- a/lib/components/TranscodingSettingsScreen/bitrate_selector.dart +++ b/lib/components/TranscodingSettingsScreen/bitrate_selector.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:hive/hive.dart'; -import '../../services/finamp_settings_helper.dart'; import '../../models/finamp_models.dart'; +import '../../services/finamp_settings_helper.dart'; class BitrateSelector extends StatelessWidget { const BitrateSelector({Key? key}) : super(key: key); diff --git a/lib/components/album_list_tile.dart b/lib/components/album_list_tile.dart index 19633ad01..c096a4382 100644 --- a/lib/components/album_list_tile.dart +++ b/lib/components/album_list_tile.dart @@ -108,8 +108,7 @@ class _AlbumListTileState extends State { ?.withOpacity(0.7)), ), TextSpan( - text: - " · ${printDuration(Duration(microseconds: (widget.item.runTimeTicks == null ? 0 : widget.item.runTimeTicks! ~/ 10)))}", + text: " · ${printDuration(widget.item.runTimeTicksDuration())}", style: TextStyle(color: Theme.of(context).disabledColor), ), ], diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f51cf7e49..d9ed3d858 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1031,5 +1031,80 @@ } }, "description": "Localized names of special downloadable collections like favorites." + }, + "downloadTranscodeEnableTitle": "Enable Transcoded Downloads", + "@downloadTranscodeEnableTitle": { + "description": "Title for Enable Transcoded Downloads dropdown" + }, + "downloadTranscodeCodecTitle": "Select Download Codec", + "@downloadTranscodeCodecTitle": { + "description": "Title for Select Download Codec dropdown" + }, + "downloadTranscodeEnableOption": "{option, select, always{Always} never{Never} ask{Ask} other{{option}} }", + "@downloadTranscodeEnableOption": { + "placeholders": { + "option": { + "type": "String" + } + }, + "description": "Options in Select Download Codec dropdown" + }, + "downloadBitrate": "Download Bitrate", + "@downloadBitrate": { + "description": "Title for Download Bitrate settings slider" + }, + "downloadBitrateSubtitle": "A higher bitrate gives higher quality audio at the cost of larger storage requirements.", + "@downloadBitrateSubtitle": { + "description": "subtitle for Download Bitrate settings slider" + }, + "transcodeHint": "Transcode?", + "@transcodeHint": { + "description": "Initial text in downloads dropdown when 'Enable Transcoded Downloads' is set to ask." + }, + "doTranscode": "Download as {bitrate} {codec}{size, select, null{} other{ - ~{size}}}", + "@doTranscode": { + "placeholders": { + "bitrate": { + "type": "String", + "example": "123kbps" + }, + "codec": { + "type": "String", + "example": "OPUS" + }, + "size": { + "type": "String", + "example": "167 MB" + } + }, + "description": "Dropdown option to download transcoded version of songs" + }, + "downloadInfo": "{size} {codec} {bitrate, select, null{} other{{bitrate} Transcoded}}", + "@downloadInfo": { + "placeholders": { + "bitrate": { + "type": "String", + "example": "234kbps" + }, + "codec": { + "type": "String", + "example": "FLAC" + }, + "size": { + "type": "String", + "example": "456" + } + }, + "description": "Song info line in downloads screen lists" + }, + "dontTranscode": "Download original{description, select, null{} other { - {description}}}", + "@dontTranscode": { + "placeholders": { + "description": { + "type": "String", + "example": "456 MB FLAC" + } + }, + "description": "Dropdown option to download original version of songs" } } diff --git a/lib/main.dart b/lib/main.dart index c413c31f7..5d5748104 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -185,6 +185,8 @@ Future setupHive() async { Hive.registerAdapter(QueueItemSourceNameTypeAdapter()); Hive.registerAdapter(OfflineListenAdapter()); Hive.registerAdapter(DownloadLocationTypeAdapter()); + Hive.registerAdapter(FinampTranscodingCodecAdapter()); + Hive.registerAdapter(TranscodeDownloadsSettingAdapter()); await Future.wait([ Hive.openBox("FinampSettings"), Hive.openBox("ThemeMode"), diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index a191b634a..a32642046 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -76,6 +76,7 @@ const _bufferDurationSeconds = 600; const _tabOrder = TabContentType.values; const _defaultLoopMode = FinampLoopMode.all; const _autoLoadLastQueueOnStartup = true; +const _shouldTranscodeDownloadsDefault = TranscodeDownloadsSetting.never; @HiveType(typeId: 28) class FinampSettings { @@ -121,6 +122,9 @@ class FinampSettings { this.resyncOnStartup = false, this.preferQuickSyncs = true, this.hasCompletedIsarUserMigration = true, + this.downloadTranscodingCodec, + this.downloadTranscodeBitrate, + this.shouldTranscodeDownloads = _shouldTranscodeDownloadsDefault, }); @HiveField(0, defaultValue: _isOfflineDefault) @@ -249,6 +253,15 @@ class FinampSettings { @HiveField(36, defaultValue: false) bool hasCompletedIsarUserMigration; + @HiveField(37) + FinampTranscodingCodec? downloadTranscodingCodec; + + @HiveField(38, defaultValue: _shouldTranscodeDownloadsDefault) + TranscodeDownloadsSetting shouldTranscodeDownloads; + + @HiveField(39) + int? downloadTranscodeBitrate; + static Future create() async { final downloadLocation = await DownloadLocation.create( name: "Internal Storage", @@ -270,6 +283,11 @@ class FinampSettings { ); } + FinampTranscodingProfile get downloadTranscodingProfile => + FinampTranscodingProfile( + transcodeCodec: downloadTranscodingCodec, + bitrate: downloadTranscodeBitrate); + /// Returns the DownloadLocation that is the internal song dir. This can /// technically throw a StateError, but that should never happen™. DownloadLocation get internalSongDir => @@ -784,7 +802,8 @@ class DownloadStub { int get hashCode => isarId; /// For use by IsarDownloads during database inserts. Do not call directly. - DownloadItem asItem(String? downloadLocationId) { + DownloadItem asItem(String? downloadLocationId, + FinampTranscodingProfile? transcodingProfile) { return DownloadItem( id: id, type: type, @@ -799,6 +818,7 @@ class DownloadStub { orderedChildren: null, path: null, viewId: null, + transcodingProfile: transcodingProfile, ); } @@ -825,7 +845,8 @@ class DownloadItem extends DownloadStub { required this.parentIndexNumber, required this.orderedChildren, required this.path, - required this.viewId}) + required this.viewId, + required this.transcodingProfile}) : super._build() { assert(!(type == DownloadItemType.collection && baseItemType == BaseItemDtoType.playlist) || @@ -864,6 +885,8 @@ class DownloadItem extends DownloadStub { /// and child elements with no non-playlist parents. String? viewId; + FinampTranscodingProfile? transcodingProfile; + @ignore DownloadLocation? get downloadLocation => FinampSettingsHelper .finampSettings.downloadLocationsMap[downloadLocationId]; @@ -886,7 +909,8 @@ class DownloadItem extends DownloadStub { DownloadItem? copyWith( {BaseItemDto? item, List? orderedChildItems, - String? viewId}) { + String? viewId, + FinampTranscodingProfile? transcodingProfile}) { String? json; if (type == DownloadItemType.image) { // Images do not have any attributes we might want to update @@ -910,10 +934,13 @@ class DownloadItem extends DownloadStub { item.mediaSources!.isNotEmpty); var orderedChildren = orderedChildItems?.map((e) => e.isarId).toList(); if (viewId == null || viewId == this.viewId) { - if (item == null || baseItem!.mostlyEqual(item)) { - var equal = const DeepCollectionEquality().equals; - if (equal(orderedChildren, this.orderedChildren)) { - return null; + if (transcodingProfile == null || + transcodingProfile == this.transcodingProfile) { + if (item == null || baseItem!.mostlyEqual(item)) { + var equal = const DeepCollectionEquality().equals; + if (equal(orderedChildren, this.orderedChildren)) { + return null; + } } } } @@ -931,6 +958,7 @@ class DownloadItem extends DownloadStub { state: state, type: type, viewId: viewId ?? this.viewId, + transcodingProfile: transcodingProfile ?? this.transcodingProfile, ); } } @@ -1437,3 +1465,88 @@ enum DownloadLocationType { final bool useHumanReadableNames; final BaseDirectory baseDirectory; } + +@HiveType(typeId: 64) +enum FinampTranscodingCodec { + @HiveField(0) + aac("m4a", true), + @HiveField(1) + mp3("mp3", true), + @HiveField(2) + opus("ogg", false); + + const FinampTranscodingCodec(this.container, this.iosCompatible); + + /// The container to use for the given codec + final String container; + + final bool iosCompatible; +} + +@embedded +class FinampTranscodingProfile { + FinampTranscodingProfile({ + FinampTranscodingCodec? transcodeCodec, + int? bitrate, + }) { + codec = transcodeCodec ?? + (Platform.isIOS || Platform.isMacOS + ? FinampTranscodingCodec.aac + : FinampTranscodingCodec.opus); + stereoBitrate = + bitrate ?? (Platform.isIOS || Platform.isMacOS ? 256000 : 128000); + } + + /// The codec to use for the given transcoding job + @Enumerated(EnumType.ordinal) + late FinampTranscodingCodec codec; + + /// The bitrate of the file, in bits per second (i.e. 320000 for 320kbps). + /// This bitrate is used for stereo, use [bitrateChannels] to get a + /// channel-dependent bitrate + late int stereoBitrate; + + /// [bitrate], but multiplied to handle multiple channels. The current + /// implementation returns the unmodified bitrate if [channels] is 2 or below + /// (stereo/mono), doubles it if under 6, and triples it otherwise. This + /// *should* handle the 5.1/7.1 case, apologies if you're reading this after + /// wondering why your cinema-grade ∞-channel song sounds terrible when + /// transcoded. + int bitrateChannels(int channels) { + // If stereo/mono, return the base bitrate + if (channels <= 2) { + return stereoBitrate; + } + + // If 5.1, return the bitrate doubled + if (channels <= 6) { + return stereoBitrate * 2; + } + + // Otherwise, triple the bitrate + return stereoBitrate * 3; + } + + String get bitrateKbps => "${stereoBitrate ~/ 1000}kbps"; + + @override + bool operator ==(Object other) { + return other is FinampTranscodingProfile && + other.stereoBitrate == stereoBitrate && + other.codec == codec; + } + + @override + @ignore + int get hashCode => Object.hash(stereoBitrate, codec); +} + +@HiveType(typeId: 65) +enum TranscodeDownloadsSetting { + @HiveField(0) + always, + @HiveField(1) + never, + @HiveField(2) + ask; +} diff --git a/lib/models/finamp_models.g.dart b/lib/models/finamp_models.g.dart index 8773b71df..e9f16c4ad 100644 --- a/lib/models/finamp_models.g.dart +++ b/lib/models/finamp_models.g.dart @@ -130,6 +130,11 @@ class FinampSettingsAdapter extends TypeAdapter { preferQuickSyncs: fields[35] == null ? true : fields[35] as bool, hasCompletedIsarUserMigration: fields[36] == null ? false : fields[36] as bool, + downloadTranscodingCodec: fields[37] as FinampTranscodingCodec?, + downloadTranscodeBitrate: fields[39] as int?, + shouldTranscodeDownloads: fields[38] == null + ? TranscodeDownloadsSetting.never + : fields[38] as TranscodeDownloadsSetting, ) ..disableGesture = fields[19] == null ? false : fields[19] as bool ..showFastScroller = fields[25] == null ? true : fields[25] as bool; @@ -138,7 +143,7 @@ class FinampSettingsAdapter extends TypeAdapter { @override void write(BinaryWriter writer, FinampSettings obj) { writer - ..writeByte(37) + ..writeByte(40) ..writeByte(0) ..write(obj.isOffline) ..writeByte(1) @@ -212,7 +217,13 @@ class FinampSettingsAdapter extends TypeAdapter { ..writeByte(35) ..write(obj.preferQuickSyncs) ..writeByte(36) - ..write(obj.hasCompletedIsarUserMigration); + ..write(obj.hasCompletedIsarUserMigration) + ..writeByte(37) + ..write(obj.downloadTranscodingCodec) + ..writeByte(38) + ..write(obj.shouldTranscodeDownloads) + ..writeByte(39) + ..write(obj.downloadTranscodeBitrate); } @override @@ -1320,6 +1331,96 @@ class DownloadLocationTypeAdapter extends TypeAdapter { typeId == other.typeId; } +class FinampTranscodingCodecAdapter + extends TypeAdapter { + @override + final int typeId = 64; + + @override + FinampTranscodingCodec read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return FinampTranscodingCodec.aac; + case 1: + return FinampTranscodingCodec.mp3; + case 2: + return FinampTranscodingCodec.opus; + default: + return FinampTranscodingCodec.aac; + } + } + + @override + void write(BinaryWriter writer, FinampTranscodingCodec obj) { + switch (obj) { + case FinampTranscodingCodec.aac: + writer.writeByte(0); + break; + case FinampTranscodingCodec.mp3: + writer.writeByte(1); + break; + case FinampTranscodingCodec.opus: + writer.writeByte(2); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FinampTranscodingCodecAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class TranscodeDownloadsSettingAdapter + extends TypeAdapter { + @override + final int typeId = 65; + + @override + TranscodeDownloadsSetting read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return TranscodeDownloadsSetting.always; + case 1: + return TranscodeDownloadsSetting.never; + case 2: + return TranscodeDownloadsSetting.ask; + default: + return TranscodeDownloadsSetting.always; + } + } + + @override + void write(BinaryWriter writer, TranscodeDownloadsSetting obj) { + switch (obj) { + case TranscodeDownloadsSetting.always: + writer.writeByte(0); + break; + case TranscodeDownloadsSetting.never: + writer.writeByte(1); + break; + case TranscodeDownloadsSetting.ask: + writer.writeByte(2); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TranscodeDownloadsSettingAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + // ************************************************************************** // IsarCollectionGenerator // ************************************************************************** @@ -2744,14 +2845,20 @@ const DownloadItemSchema = CollectionSchema( type: IsarType.byte, enumMap: _DownloadItemstateEnumValueMap, ), - r'type': PropertySchema( + r'transcodingProfile': PropertySchema( id: 10, + name: r'transcodingProfile', + type: IsarType.object, + target: r'FinampTranscodingProfile', + ), + r'type': PropertySchema( + id: 11, name: r'type', type: IsarType.byte, enumMap: _DownloadItemtypeEnumValueMap, ), r'viewId': PropertySchema( - id: 11, + id: 12, name: r'viewId', type: IsarType.string, ) @@ -2817,7 +2924,9 @@ const DownloadItemSchema = CollectionSchema( linkName: r'info', ) }, - embeddedSchemas: {}, + embeddedSchemas: { + r'FinampTranscodingProfile': FinampTranscodingProfileSchema + }, getId: _downloadItemGetId, getLinks: _downloadItemGetLinks, attach: _downloadItemAttach, @@ -2856,6 +2965,14 @@ int _downloadItemEstimateSize( bytesCount += 3 + value.length * 3; } } + { + final value = object.transcodingProfile; + if (value != null) { + bytesCount += 3 + + FinampTranscodingProfileSchema.estimateSize( + value, allOffsets[FinampTranscodingProfile]!, allOffsets); + } + } { final value = object.viewId; if (value != null) { @@ -2881,8 +2998,14 @@ void _downloadItemSerialize( writer.writeLong(offsets[7], object.parentIndexNumber); writer.writeString(offsets[8], object.path); writer.writeByte(offsets[9], object.state.index); - writer.writeByte(offsets[10], object.type.index); - writer.writeString(offsets[11], object.viewId); + writer.writeObject( + offsets[10], + allOffsets, + FinampTranscodingProfileSchema.serialize, + object.transcodingProfile, + ); + writer.writeByte(offsets[11], object.type.index); + writer.writeString(offsets[12], object.viewId); } DownloadItem _downloadItemDeserialize( @@ -2906,9 +3029,14 @@ DownloadItem _downloadItemDeserialize( path: reader.readStringOrNull(offsets[8]), state: _DownloadItemstateValueEnumMap[reader.readByteOrNull(offsets[9])] ?? DownloadItemState.notDownloaded, - type: _DownloadItemtypeValueEnumMap[reader.readByteOrNull(offsets[10])] ?? + transcodingProfile: reader.readObjectOrNull( + offsets[10], + FinampTranscodingProfileSchema.deserialize, + allOffsets, + ), + type: _DownloadItemtypeValueEnumMap[reader.readByteOrNull(offsets[11])] ?? DownloadItemType.collection, - viewId: reader.readStringOrNull(offsets[11]), + viewId: reader.readStringOrNull(offsets[12]), ); return object; } @@ -2944,9 +3072,15 @@ P _downloadItemDeserializeProp

( return (_DownloadItemstateValueEnumMap[reader.readByteOrNull(offset)] ?? DownloadItemState.notDownloaded) as P; case 10: + return (reader.readObjectOrNull( + offset, + FinampTranscodingProfileSchema.deserialize, + allOffsets, + )) as P; + case 11: return (_DownloadItemtypeValueEnumMap[reader.readByteOrNull(offset)] ?? DownloadItemType.collection) as P; - case 11: + case 12: return (reader.readStringOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -4500,6 +4634,24 @@ extension DownloadItemQueryFilter }); } + QueryBuilder + transcodingProfileIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'transcodingProfile', + )); + }); + } + + QueryBuilder + transcodingProfileIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'transcodingProfile', + )); + }); + } + QueryBuilder typeEqualTo( DownloadItemType value) { return QueryBuilder.apply(this, (query) { @@ -4709,7 +4861,14 @@ extension DownloadItemQueryFilter } extension DownloadItemQueryObject - on QueryBuilder {} + on QueryBuilder { + QueryBuilder + transcodingProfile(FilterQuery q) { + return QueryBuilder.apply(this, (query) { + return query.object(q, r'transcodingProfile'); + }); + } +} extension DownloadItemQueryLinks on QueryBuilder { @@ -5412,6 +5571,13 @@ extension DownloadItemQueryProperty }); } + QueryBuilder + transcodingProfileProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'transcodingProfile'); + }); + } + QueryBuilder typeProperty() { return QueryBuilder.apply(this, (query) { @@ -5426,6 +5592,362 @@ extension DownloadItemQueryProperty } } +// ************************************************************************** +// IsarEmbeddedGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +const FinampTranscodingProfileSchema = Schema( + name: r'FinampTranscodingProfile', + id: 7911295834764788060, + properties: { + r'bitrateKbps': PropertySchema( + id: 0, + name: r'bitrateKbps', + type: IsarType.string, + ), + r'codec': PropertySchema( + id: 1, + name: r'codec', + type: IsarType.byte, + enumMap: _FinampTranscodingProfilecodecEnumValueMap, + ), + r'stereoBitrate': PropertySchema( + id: 2, + name: r'stereoBitrate', + type: IsarType.long, + ) + }, + estimateSize: _finampTranscodingProfileEstimateSize, + serialize: _finampTranscodingProfileSerialize, + deserialize: _finampTranscodingProfileDeserialize, + deserializeProp: _finampTranscodingProfileDeserializeProp, +); + +int _finampTranscodingProfileEstimateSize( + FinampTranscodingProfile object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + bytesCount += 3 + object.bitrateKbps.length * 3; + return bytesCount; +} + +void _finampTranscodingProfileSerialize( + FinampTranscodingProfile object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.bitrateKbps); + writer.writeByte(offsets[1], object.codec.index); + writer.writeLong(offsets[2], object.stereoBitrate); +} + +FinampTranscodingProfile _finampTranscodingProfileDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = FinampTranscodingProfile(); + object.codec = _FinampTranscodingProfilecodecValueEnumMap[ + reader.readByteOrNull(offsets[1])] ?? + FinampTranscodingCodec.aac; + object.stereoBitrate = reader.readLong(offsets[2]); + return object; +} + +P _finampTranscodingProfileDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readString(offset)) as P; + case 1: + return (_FinampTranscodingProfilecodecValueEnumMap[ + reader.readByteOrNull(offset)] ?? + FinampTranscodingCodec.aac) as P; + case 2: + return (reader.readLong(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +const _FinampTranscodingProfilecodecEnumValueMap = { + 'aac': 0, + 'mp3': 1, + 'opus': 2, +}; +const _FinampTranscodingProfilecodecValueEnumMap = { + 0: FinampTranscodingCodec.aac, + 1: FinampTranscodingCodec.mp3, + 2: FinampTranscodingCodec.opus, +}; + +extension FinampTranscodingProfileQueryFilter on QueryBuilder< + FinampTranscodingProfile, FinampTranscodingProfile, QFilterCondition> { + QueryBuilder bitrateKbpsEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'bitrateKbps', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder bitrateKbpsGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'bitrateKbps', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder bitrateKbpsLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'bitrateKbps', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder bitrateKbpsBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'bitrateKbps', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder bitrateKbpsStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'bitrateKbps', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder bitrateKbpsEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'bitrateKbps', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + bitrateKbpsContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'bitrateKbps', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + bitrateKbpsMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'bitrateKbps', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder bitrateKbpsIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'bitrateKbps', + value: '', + )); + }); + } + + QueryBuilder bitrateKbpsIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'bitrateKbps', + value: '', + )); + }); + } + + QueryBuilder codecEqualTo(FinampTranscodingCodec value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'codec', + value: value, + )); + }); + } + + QueryBuilder codecGreaterThan( + FinampTranscodingCodec value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'codec', + value: value, + )); + }); + } + + QueryBuilder codecLessThan( + FinampTranscodingCodec value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'codec', + value: value, + )); + }); + } + + QueryBuilder codecBetween( + FinampTranscodingCodec lower, + FinampTranscodingCodec upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'codec', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder stereoBitrateEqualTo(int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'stereoBitrate', + value: value, + )); + }); + } + + QueryBuilder stereoBitrateGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'stereoBitrate', + value: value, + )); + }); + } + + QueryBuilder stereoBitrateLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'stereoBitrate', + value: value, + )); + }); + } + + QueryBuilder stereoBitrateBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'stereoBitrate', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } +} + +extension FinampTranscodingProfileQueryObject on QueryBuilder< + FinampTranscodingProfile, FinampTranscodingProfile, QFilterCondition> {} + // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** diff --git a/lib/models/jellyfin_models.dart b/lib/models/jellyfin_models.dart index edd9b5bdd..b2d151273 100644 --- a/lib/models/jellyfin_models.dart +++ b/lib/models/jellyfin_models.dart @@ -16,6 +16,22 @@ import 'package:json_annotation/json_annotation.dart'; part 'jellyfin_models.g.dart'; +/// An abstract class to implement converting runTimeTicks into a duration. +/// Ideally, we'd hold runTimeTicks here, but that would break offline storage +/// as everything is already set up to have runTimeTicks in its own class. +/// +/// To extend this class, override [runTimeTicksDuration] to call +/// [_runTimeTicksDuration] with the class's runTimeTicks. +mixin RunTimeTickDuration { + /// Returns a duration of the item's runtime. We define a getter for this + /// since Jellyfin returns microseconds * 10 for some reason, and manually + /// making durations for everything was clunky. + Duration? runTimeTicksDuration() => + runTimeTicks == null ? null : Duration(microseconds: runTimeTicks! ~/ 10); + + abstract int? runTimeTicks; +} + @JsonSerializable( fieldRename: FieldRename.pascal, explicitToJson: true, @@ -1433,7 +1449,7 @@ class SubtitleProfile { includeIfNull: false, ) @HiveType(typeId: 0) -class BaseItemDto { +class BaseItemDto with RunTimeTickDuration { BaseItemDto({ this.name, this.originalTitle, @@ -2251,7 +2267,7 @@ class ExternalUrl { includeIfNull: false, ) @HiveType(typeId: 5) -class MediaSourceInfo { +class MediaSourceInfo with RunTimeTickDuration { MediaSourceInfo({ required this.protocol, this.id, @@ -2436,6 +2452,24 @@ class MediaSourceInfo { @HiveField(41) List? mediaAttachments; + /// The file size of the source if it was transcoded with [bitrate] (in bits + /// per second). + /// + /// This function gets the channels from the first audio stream - other audio + /// streams are ignored. If the item has multiple audio streams, this may be + /// an issue as they are not counted in the size. Attachments are also not + /// counted, as [mediaStreams] doesn't seem to note their size. + int transcodedSize(int Function(int channels) bitrateChannels) { + final channels = mediaStreams + .firstWhere((element) => element.type == "Audio") + .channels ?? + 2; + final bitrate = bitrateChannels(channels); + + // Divide by 8 to get bytes/sec + return (runTimeTicksDuration()?.inSeconds ?? 0) * bitrate ~/ 8; + } + factory MediaSourceInfo.fromJson(Map json) => _$MediaSourceInfoFromJson(json); Map toJson() => _$MediaSourceInfoToJson(this); @@ -3646,7 +3680,7 @@ class QuickConnectState { @HiveField(1) String? secret; - + @HiveField(2) String? code; @@ -3677,7 +3711,6 @@ class QuickConnectState { ) @HiveType(typeId: 43) class ClientDiscoveryResponse { - ClientDiscoveryResponse({ this.address, this.id, @@ -3699,5 +3732,4 @@ class ClientDiscoveryResponse { factory ClientDiscoveryResponse.fromJson(Map json) => _$ClientDiscoveryResponseFromJson(json); - } diff --git a/lib/screens/transcoding_settings_screen.dart b/lib/screens/transcoding_settings_screen.dart index 2f0a829e6..c3dba4e24 100644 --- a/lib/screens/transcoding_settings_screen.dart +++ b/lib/screens/transcoding_settings_screen.dart @@ -1,11 +1,16 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; import '../components/TranscodingSettingsScreen/bitrate_selector.dart'; import '../components/TranscodingSettingsScreen/transcode_switch.dart'; +import '../models/finamp_models.dart'; +import '../services/finamp_settings_helper.dart'; class TranscodingSettingsScreen extends StatelessWidget { - const TranscodingSettingsScreen({Key? key}) : super(key: key); + const TranscodingSettingsScreen({super.key}); static const routeName = "/settings/transcoding"; @@ -28,9 +33,137 @@ class TranscodingSettingsScreen extends StatelessWidget { textAlign: TextAlign.center, ), ), + const DownloadTranscodeEnableDropdownListTile(), + const DownloadTranscodeCodecDropdownListTile(), + const DownloadBitrateSelector(), ], ), ), ); } } + +class DownloadBitrateSelector extends StatelessWidget { + const DownloadBitrateSelector({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListTile( + title: Text(AppLocalizations.of(context)!.downloadBitrate), + subtitle: Text(AppLocalizations.of(context)!.downloadBitrateSubtitle), + ), + ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, child) { + final finampSettings = box.get("FinampSettings")!; + + // We do all of this division/multiplication because Jellyfin wants us to specify bitrates in bits, not kilobits. + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Slider( + min: 64, + max: 320, + value: + finampSettings.downloadTranscodingProfile.stereoBitrate / + 1000, + divisions: 8, + label: finampSettings.downloadTranscodingProfile.bitrateKbps, + onChanged: (value) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.downloadTranscodeBitrate = + (value * 1000).toInt(); + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + }, + ), + Text( + finampSettings.downloadTranscodingProfile.bitrateKbps, + style: Theme.of(context).textTheme.titleLarge, + ) + ], + ); + }, + ), + ], + ); + } +} + +class DownloadTranscodeEnableDropdownListTile extends StatelessWidget { + const DownloadTranscodeEnableDropdownListTile({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + final finampSettings = box.get("FinampSettings")!; + + return ListTile( + title: + Text(AppLocalizations.of(context)!.downloadTranscodeEnableTitle), + trailing: DropdownButton( + value: finampSettings.shouldTranscodeDownloads, + items: TranscodeDownloadsSetting.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(AppLocalizations.of(context)! + .downloadTranscodeEnableOption(e.name)), + )) + .toList(), + onChanged: (value) { + if (value != null) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.shouldTranscodeDownloads = value; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + }, + ), + ); + }, + ); + } +} + +class DownloadTranscodeCodecDropdownListTile extends StatelessWidget { + const DownloadTranscodeCodecDropdownListTile({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + final finampSettings = box.get("FinampSettings")!; + + return ListTile( + title: + Text(AppLocalizations.of(context)!.downloadTranscodeCodecTitle), + subtitle: Text("AAC does not work until jellyfin 10.9"), + trailing: DropdownButton( + value: finampSettings.downloadTranscodingProfile.codec, + items: FinampTranscodingCodec.values + .where((element) => !Platform.isIOS || element.iosCompatible) + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.name.toUpperCase()), + )) + .toList(), + onChanged: (value) { + if (value != null) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.downloadTranscodingCodec = value; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + }, + ), + ); + }, + ); + } +} diff --git a/lib/services/isar_downloads.dart b/lib/services/isar_downloads.dart index 3cd87f08b..547253b04 100644 --- a/lib/services/isar_downloads.dart +++ b/lib/services/isar_downloads.dart @@ -331,6 +331,7 @@ class IsarDownloads { required DownloadStub stub, required DownloadLocation downloadLocation, required String viewId, + required FinampTranscodingProfile? transcodeProfile, }) async { // Comment https://github.com/jmshrv/finamp/issues/134#issuecomment-1563441355 // suggests this does not make a request and always returns failure @@ -343,10 +344,11 @@ class IsarDownloads { }*/ _isar.writeTxnSync(() { DownloadItem canonItem = _isar.downloadItems.getSync(stub.isarId) ?? - stub.asItem(downloadLocation.id); + stub.asItem(downloadLocation.id, transcodeProfile); canonItem.downloadLocationId = downloadLocation.id; + canonItem.transcodingProfile = transcodeProfile; _isar.downloadItems.putSync(canonItem); - var anchorItem = _anchor.asItem(null); + var anchorItem = _anchor.asItem(null, null); // This may be the first download ever, so the anchor might not be present _isar.downloadItems.putSync(anchorItem); anchorItem.requires.updateSync(link: [canonItem]); @@ -360,11 +362,12 @@ class IsarDownloads { /// item may be required by other collections. Future deleteDownload({required DownloadStub stub}) async { _isar.writeTxnSync(() { - var anchorItem = _anchor.asItem(null); + var anchorItem = _anchor.asItem(null, null); // This is required to trigger status recalculation _isar.downloadItems.putSync(anchorItem); deleteBuffer.addAll([stub.isarId]); - anchorItem.requires.updateSync(unlink: [stub.asItem(null)]); + // Actual item is not required for updating links + anchorItem.requires.updateSync(unlink: [stub.asItem(null, null)]); }); fullSpeedSync = true; try { @@ -809,7 +812,7 @@ class IsarDownloads { var isarItem = DownloadStub.fromItem(type: DownloadItemType.image, item: baseItem) - .asItem(image.downloadLocationId); + .asItem(image.downloadLocationId, null); isarItem.path = (image.downloadLocationId == FinampSettingsHelper.finampSettings.downloadLocationsMap.values .where((element) => @@ -842,7 +845,7 @@ class IsarDownloads { baseItem.mediaSources = [song.mediaSourceInfo]; var isarItem = DownloadStub.fromItem(type: DownloadItemType.song, item: baseItem) - .asItem(song.downloadLocationId); + .asItem(song.downloadLocationId, null); String? newPath; if (song.downloadLocationId == null) { for (MapEntry entry in FinampSettingsHelper @@ -914,22 +917,22 @@ class IsarDownloads { } var isarItem = DownloadStub.fromItem( type: DownloadItemType.collection, item: parent.item) - .asItem(song.downloadLocationId); + .asItem(song.downloadLocationId, null); List required = parent.downloadedChildren.values .map((e) => DownloadStub.fromItem(type: DownloadItemType.song, item: e) - .asItem(song.downloadLocationId)) + .asItem(song.downloadLocationId, null)) .toList(); isarItem.orderedChildren = required.map((e) => e.isarId).toList(); required.add( DownloadStub.fromItem(type: DownloadItemType.image, item: parent.item) - .asItem(song.downloadLocationId)); + .asItem(song.downloadLocationId, null)); isarItem.state = DownloadItemState.complete; isarItem.viewId = parent.viewId; _isar.writeTxnSync(() { _isar.downloadItems.putSync(isarItem); - var anchorItem = _anchor.asItem(null); + var anchorItem = _anchor.asItem(null, null); _isar.downloadItems.putSync(anchorItem); anchorItem.requires.updateSync(link: [isarItem]); var existing = _isar.downloadItems @@ -945,11 +948,12 @@ class IsarDownloads { } /// Get all user-downloaded items. Used to show items on downloads screen. - Future> getUserDownloaded() => getVisibleChildren(_anchor); + /// eturns downloadItem include transcode information. + Future> getUserDownloaded() => getVisibleChildren(_anchor); /// Get all non-image children of an item. Used to show item children on - /// downloads screen. - Future> getVisibleChildren(DownloadStub stub) { + /// downloads screen. Returns downloadItem include transcode information. + Future> getVisibleChildren(DownloadStub stub) { return _isar.downloadItems .where() .typeNotEqualTo(DownloadItemType.image) @@ -1167,7 +1171,18 @@ class IsarDownloads { } if (item.type == DownloadItemType.song && item.state == DownloadItemState.complete) { - size += item.baseItem?.mediaSources?[0].size ?? 0; + if (item.transcodingProfile != null) { + var statSize = + await item.file?.stat().then((value) => value.size).catchError((e) { + _downloadsLogger.fine( + "No file for song ${item.name} when calculating size."); + return 0; + }) ?? + 0; + size += statSize; + } else { + size += item.baseItem?.mediaSources?[0].size ?? 0; + } } if (item.type == DownloadItemType.image && item.state == DownloadItemState.complete) { diff --git a/lib/services/isar_downloads_backend.dart b/lib/services/isar_downloads_backend.dart index 75db28542..c4110a7e0 100644 --- a/lib/services/isar_downloads_backend.dart +++ b/lib/services/isar_downloads_backend.dart @@ -323,8 +323,11 @@ class IsarTaskQueue implements TaskQueue { // Base URL shouldn't be null at this point (user has to be logged in // to get to the point where they can add downloads). var url = switch (task.type) { - DownloadItemType.song => - "${_finampUserHelper.currentUser!.baseUrl}/Items/${task.id}/File", + DownloadItemType.song => _jellyfinApiData + .getsongDownloadUrl( + item: task.baseItem!, + transcodingProfile: task.transcodingProfile) + .toString(), DownloadItemType.image => _jellyfinApiData .getImageUrl( item: task.baseItem!, @@ -992,7 +995,6 @@ class IsarSyncBuffer { // Allow database work to be scheduled instead of immediately processing // once network requests come back. await SchedulerBinding.instance.scheduleTask(() async { - DownloadLocation? downloadLocation; DownloadItem? canonParent; if (updateChildren) { _isar.writeTxnSync(() { @@ -1015,7 +1017,6 @@ class IsarSyncBuffer { _syncLogger.warning(e); } - downloadLocation = canonParent!.downloadLocation; viewId ??= canonParent!.viewId; // Run appropriate _updateChildren calls and store changes to allow skipping @@ -1077,11 +1078,11 @@ class IsarSyncBuffer { // Download item files if needed // if (canonParent != null && canonParent!.type.hasFiles && asRequired) { - if (downloadLocation == null) { + if (canonParent!.downloadLocation == null) { _syncLogger.severe( "could not download ${parent.id}, no download location found."); } else { - await _initiateDownload(canonParent!, downloadLocation!); + await _initiateDownload(canonParent!); } } // Set priority high to prevent stalling, but lower than creating network requests @@ -1118,13 +1119,16 @@ class IsarSyncBuffer { // This is only used for IsarLink.update, which only cares about ID, so stubs are fine var childrenToLink = children .where((element) => childIdsToLink.contains(element.isarId)) - .map((e) => e.asItem(parent.downloadLocationId)) + .map((e) => e.asItem(null, null)) .toList(); var childrenToPutAndLink = children .where((element) => missingChildIds.contains(element.isarId) && !childIdsToLink.contains(element.isarId)) - .map((e) => e.asItem(parent.downloadLocationId)) + .map((e) => + // TODO figure out a better way to assign these. Info items shouldn't + // persist download settings, but they currently do. + e.asItem(parent.downloadLocationId, parent.transcodingProfile)) .toList(); assert(childIdsToLink.length + childrenToPutAndLink.length == missingChildIds.length); @@ -1311,8 +1315,7 @@ class IsarSyncBuffer { /// Ensures the given node is downloaded. Called on all required nodes with files /// by [_syncDownload]. Items enqueued/downloading/failed are validated and cleaned /// up before re-initiating download if needed. - Future _initiateDownload( - DownloadItem item, DownloadLocation downloadLocation) async { + Future _initiateDownload(DownloadItem item) async { switch (item.state) { case DownloadItemState.complete: return; @@ -1331,9 +1334,9 @@ class IsarSyncBuffer { switch (item.type) { case DownloadItemType.song: - return _downloadSong(item, downloadLocation); + return _downloadSong(item); case DownloadItemType.image: - return _downloadImage(item, downloadLocation); + return _downloadImage(item); case _: throw StateError("???"); } @@ -1346,10 +1349,11 @@ class IsarSyncBuffer { /// Prepares for downloading of a given song by filling in the path information /// and media sources, and marking item as enqueued in isar. - Future _downloadSong( - DownloadItem downloadItem, DownloadLocation downloadLocation) async { - assert(downloadItem.type == DownloadItemType.song); + Future _downloadSong(DownloadItem downloadItem) async { + assert(downloadItem.type == DownloadItemType.song && + downloadItem.downloadLocation != null); var item = downloadItem.baseItem!; + var downloadLocation = downloadItem.downloadLocation!; if (downloadItem.baseItem!.mediaSources == null && FinampSettingsHelper.finampSettings.isOffline) { @@ -1369,6 +1373,10 @@ class IsarSyncBuffer { "${_jellyfinApiData.defaultFields},MediaSources,SortName")) ?.mediaSources; + String container = downloadItem.transcodingProfile?.codec.container ?? + mediaSources?[0].container ?? + 'song'; + String fileName; String subDirectory; if (downloadLocation.useHumanReadableNames) { @@ -1380,9 +1388,9 @@ class IsarSyncBuffer { path_helper.join("finamp", _filesystemSafe(item.albumArtist)); // We use a regex to filter out bad characters from song/album names. fileName = _filesystemSafe( - "${item.album} - ${item.indexNumber ?? 0} - ${item.name}.${mediaSources?[0].container ?? 'song'}")!; + "${item.album} - ${item.indexNumber ?? 0} - ${item.name}.$container")!; } else { - fileName = "${item.id}.${mediaSources?[0].container ?? 'song'}"; + fileName = "${item.id}.$container"; subDirectory = "songs"; } @@ -1423,10 +1431,11 @@ class IsarSyncBuffer { /// Prepares for downloading of a given song by filling in the path information /// and marking item as enqueued in isar. - Future _downloadImage( - DownloadItem downloadItem, DownloadLocation downloadLocation) async { - assert(downloadItem.type == DownloadItemType.image); + Future _downloadImage(DownloadItem downloadItem) async { + assert(downloadItem.type == DownloadItemType.image && + downloadItem.downloadLocation != null); var item = downloadItem.baseItem!; + var downloadLocation = downloadItem.downloadLocation!; String subDirectory; if (downloadLocation.useHumanReadableNames) { diff --git a/lib/services/jellyfin_api_helper.dart b/lib/services/jellyfin_api_helper.dart index fe85e6d75..d1e39fd99 100644 --- a/lib/services/jellyfin_api_helper.dart +++ b/lib/services/jellyfin_api_helper.dart @@ -692,4 +692,40 @@ class JellyfinApiHelper { if (maxHeight != null) "MaxHeight": maxHeight.toString(), }); } + + /// Returns the correct URL for the given item. + Uri getsongDownloadUrl({ + required BaseItemDto item, + required FinampTranscodingProfile? transcodingProfile, + }) { + Uri uri = Uri.parse(_finampUserHelper.currentUser!.baseUrl); + + if (transcodingProfile != null) { + // uri.queryParameters is unmodifiable, so we copy the contents into a new + // map + final queryParameters = Map.of(uri.queryParameters); + + // iOS/macOS doesn't support OPUS (except in CAF, which doesn't work from + // Jellyfin). Once https://github.com/jellyfin/jellyfin/pull/9192 lands, + // we could use M4A/AAC. + + queryParameters.addAll({ + "transcodingContainer": transcodingProfile.codec.container, + "audioCodec": transcodingProfile.codec.name, + "audioBitRate": transcodingProfile.stereoBitrate.toString(), + }); + + uri = uri.replace( + pathSegments: + uri.pathSegments.followedBy(["Audio", item.id, "Universal"]), + queryParameters: queryParameters, + ); + } else { + uri = uri.replace( + pathSegments: uri.pathSegments.followedBy(["Items", item.id, "File"]), + ); + } + + return uri; + } } diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index ecf0bb465..d3716ffa8 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -840,10 +840,7 @@ class QueueService { "isOffline": FinampSettingsHelper.finampSettings.isOffline, }, // Jellyfin returns microseconds * 10 for some reason - duration: Duration( - microseconds: - (item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10), - ), + duration: item.runTimeTicksDuration(), ); }