diff --git a/android/app/src/main/kotlin/com/example/namida/FAudioTagger.kt b/android/app/src/main/kotlin/com/example/namida/FAudioTagger.kt index cd58219d..3ef1444e 100644 --- a/android/app/src/main/kotlin/com/example/namida/FAudioTagger.kt +++ b/android/app/src/main/kotlin/com/example/namida/FAudioTagger.kt @@ -298,6 +298,13 @@ public class FAudioTagger : FlutterPlugin, MethodCallHandler { } catch (_: Exception) {} } } + + // -- for extra goofy fields + tag.getFields().forEach { + if (metadata[it.id] == null) { + metadata[it.id] = it.toString() + } + } } catch (_: Exception) {} if (extractArtwork) { diff --git a/lib/base/audio_handler.dart b/lib/base/audio_handler.dart index 8ab4933d..2545f8c5 100644 --- a/lib/base/audio_handler.dart +++ b/lib/base/audio_handler.dart @@ -447,6 +447,10 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { Future onItemPlaySelectable(Q pi, Selectable item, int index, bool Function() startPlaying, Function skipItem) async { final tr = item.track; videoPlayerInfo.value = null; + if (settings.player.replayGain.value) { + final gain = item.track.toTrackExt().gainData?.calculateGainAsVolume(); + _userPlayerVolume = gain ?? 0.75; // save in memory only + } final isVideo = item is Video; Lyrics.inst.resetLyrics(); WaveformController.inst.resetWaveform(); @@ -1620,6 +1624,7 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { ); double get _userPlayerVolume => settings.player.volume.value; + set _userPlayerVolume(double val) => settings.player.volume.value = val; @override bool get enableCrossFade => settings.player.enableCrossFade.value && currentItem.value is! YoutubeID; diff --git a/lib/class/faudiomodel.dart b/lib/class/faudiomodel.dart index b9679321..68ef9df0 100644 --- a/lib/class/faudiomodel.dart +++ b/lib/class/faudiomodel.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:typed_data'; +import 'package:namida/class/replay_gain_data.dart'; import 'package:namida/core/extensions.dart'; class FArtwork { @@ -70,6 +71,7 @@ class FTags { final String? recordLabel; final double? ratingPercentage; + final ReplayGainData? gainData; const FTags({ required this.path, @@ -99,6 +101,7 @@ class FTags { this.country, this.recordLabel, this.ratingPercentage, + this.gainData, }); static String? _listToString(List? list) { @@ -159,6 +162,7 @@ class FTags { country: _listToString(map["country"]) ?? map["COUNTRY"], recordLabel: _listToString(map["recordLabel"]) ?? map["RECORDLABEL"] ?? map["label"] ?? map["LABEL"], ratingPercentage: ratingUnsignedIntToPercentage(ratingString), + gainData: ReplayGainData.fromAndroidMap(map), ); } @@ -190,6 +194,7 @@ class FTags { "country": country, "recordLabel": recordLabel, "language": language, + "gainData": gainData?.toMap(), }; } } diff --git a/lib/class/media_info.dart b/lib/class/media_info.dart index ce1d997a..11b3aa7b 100644 --- a/lib/class/media_info.dart +++ b/lib/class/media_info.dart @@ -1,3 +1,5 @@ +import 'package:namida/class/replay_gain_data.dart'; + class MediaInfo { final String path; final List? streams; @@ -106,6 +108,7 @@ class MIFormatTags { final String? lyricist; final String? compatibleBrands; final String? mood; + final ReplayGainData? gainData; const MIFormatTags({ this.date, @@ -133,6 +136,7 @@ class MIFormatTags { this.lyricist, this.compatibleBrands, this.mood, + this.gainData, }); factory MIFormatTags.fromMap(Map map) => MIFormatTags( @@ -161,6 +165,7 @@ class MIFormatTags { lyricist: map.getOrLowerCase("LYRICIST"), compatibleBrands: map.getOrUpperCase("compatible_brands"), mood: map.getOrUpperCase("mood"), + gainData: ReplayGainData.fromAndroidMap(map), ); Map toMap() => { @@ -189,6 +194,7 @@ class MIFormatTags { "LYRICIST": lyricist, "compatible_brands": compatibleBrands, "mood": mood, + "gainData": gainData?.toMap(), }; } diff --git a/lib/class/replay_gain_data.dart b/lib/class/replay_gain_data.dart new file mode 100644 index 00000000..5294a961 --- /dev/null +++ b/lib/class/replay_gain_data.dart @@ -0,0 +1,74 @@ +import 'dart:math' as math; + +class ReplayGainData { + final double? trackGain, albumGain; + final double? trackPeak, albumPeak; + const ReplayGainData({ + required this.trackGain, + required this.trackPeak, + required this.albumGain, + required this.albumPeak, + }); + + double? calculateGainAsVolume({double withRespectiveVolume = 0.75}) { + final gainFinal = trackGain ?? albumGain; + if (gainFinal == null) return null; + final gainLinear = math.pow(10, gainFinal / 20).clamp(0.1, 1.0); + return gainLinear * withRespectiveVolume; + } + + static ReplayGainData? fromAndroidMap(Map map) { + double? trackGainDB = ((map['replaygain_track_gain'] ?? map['REPLAYGAIN_TRACK_GAIN']) as String?)?._parseGainValue(); // "-0.515000 dB" + double? albumGainDB = ((map['replaygain_album_gain'] ?? map['REPLAYGAIN_ALBUM_GAIN']) as String?)?._parseGainValue(); // "+0.040000 dB" + + trackGainDB ??= ((map['r128_track_gain'] ?? map['R128_TRACK_GAIN']) as String?)?._parseGainValueR128(); + albumGainDB ??= ((map['r128_album_gain'] ?? map['R128_ALBUM_GAIN']) as String?)?._parseGainValueR128(); + + final trackPeak = ((map['replaygain_track_peak'] ?? map['REPLAYGAIN_TRACK_PEAK']) as String?)?._parsePeakValue(); + final albumPeak = ((map['replaygain_album_peak'] ?? map['REPLAYGAIN_ALBUM_PEAK']) as String?)?._parsePeakValue(); + + final data = ReplayGainData( + trackGain: trackGainDB, + trackPeak: trackPeak, + albumGain: albumGainDB, + albumPeak: albumPeak, + ); + if (data.trackGain == null && data.trackPeak == null && data.albumGain == null && data.albumPeak == null) return null; + return data; + } + + factory ReplayGainData.fromMap(Map map) { + return ReplayGainData( + trackGain: map['tg'], + trackPeak: map['tp'], + albumGain: map['ag'], + albumPeak: map['ap'], + ); + } + + Map toMap() { + return { + "tg": trackGain, + "tp": trackPeak, + "ag": albumGain, + "ap": albumPeak, + }; + } +} + +extension _GainParser on String? { + double? _parseGainValueR128() { + final parsed = _parseGainValue(); + return parsed == null ? null : (parsed / 256) + 5; + } + + double? _parseGainValue() { + var text = this; + return text == null ? null : double.tryParse(text.replaceFirst(RegExp(r'[^\d.-]'), '')) ?? double.tryParse(text.split(' ').first); + } + + double? _parsePeakValue() { + var text = this; + return text == null ? null : double.tryParse(text); + } +} diff --git a/lib/class/track.dart b/lib/class/track.dart index 0b4daa21..ca7a8d2c 100644 --- a/lib/class/track.dart +++ b/lib/class/track.dart @@ -6,6 +6,7 @@ import 'package:intl/intl.dart'; import 'package:namida/class/faudiomodel.dart'; import 'package:namida/class/folder.dart'; +import 'package:namida/class/replay_gain_data.dart'; import 'package:namida/class/split_config.dart'; import 'package:namida/class/video.dart'; import 'package:namida/controller/indexer_controller.dart'; @@ -230,6 +231,7 @@ class TrackExtended { final double rating; final String? originalTags; final List tagsList; + final ReplayGainData? gainData; final bool isVideo; @@ -263,6 +265,7 @@ class TrackExtended { required this.rating, required this.originalTags, required this.tagsList, + required this.gainData, required this.isVideo, }); @@ -328,6 +331,7 @@ class TrackExtended { json['originalTags'], config: genresSplitConfig, ), + gainData: json['gainData'] == null ? null : ReplayGainData.fromMap(json['gainData']), isVideo: json['v'] ?? false, ); } @@ -359,6 +363,7 @@ class TrackExtended { if (label.isNotEmpty) 'label': label, if (rating > 0) 'rating': rating, if (originalTags?.isNotEmpty == true) 'originalTags': originalTags, + if (gainData != null) 'gainData': gainData?.toMap(), 'v': isVideo, }; } @@ -431,6 +436,40 @@ extension TrackExtUtils on TrackExtended { return tostr; } + String get audioInfoFormatted { + final trExt = this; + final initial = [ + trExt.durationMS.milliSecondsLabel, + trExt.size.fileSizeFormatted, + "${trExt.bitrate} kps", + "${trExt.sampleRate} hz", + ].join(' • '); + final gainFormatted = trExt.gainDataFormatted; + if (gainFormatted == null) return initial; + return '$initial\n$gainFormatted'; + } + + String? get gainDataFormatted { + final gain = gainData; + if (gain == null) return null; + return [ + '${gain.trackGain ?? '?'} dB gain', + if (gain.trackPeak != null) '${gain.trackPeak} peak', + if (gain.albumGain != null) '${gain.albumGain} dB gain (album)', + if (gain.albumPeak != null) '${gain.albumPeak} peak (album)', + ].join(' • '); + } + + String get audioInfoFormattedCompact { + final trExt = this; + return [ + trExt.format, + "${trExt.channels} ch", + "${trExt.bitrate} kps", + "${trExt.sampleRate / 1000} khz", + ].joinText(separator: ' • '); + } + TrackExtended copyWithTag({ required FTags tag, int? dateModified, @@ -459,6 +498,7 @@ extension TrackExtUtils on TrackExtended { rating: tag.ratingPercentage ?? rating, originalTags: tag.tags ?? originalTags, tagsList: tag.tags != null ? [tag.tags!] : tagsList, + gainData: tag.gainData ?? gainData, // -- uneditable fields bitrate: bitrate, @@ -504,6 +544,7 @@ extension TrackExtUtils on TrackExtended { double? rating, String? originalTags, List? tagsList, + ReplayGainData? gainData, bool? isVideo, }) { return TrackExtended( @@ -536,6 +577,7 @@ extension TrackExtUtils on TrackExtended { rating: rating ?? this.rating, originalTags: originalTags ?? this.originalTags, tagsList: tagsList ?? this.tagsList, + gainData: gainData ?? this.gainData, isVideo: isVideo ?? this.isVideo, ); } @@ -606,26 +648,12 @@ extension TrackUtils on Track { String get youtubeLink => toTrackExt().youtubeLink; String get youtubeID => youtubeLink.getYoutubeID; - String get audioInfoFormatted { - final trExt = toTrackExt(); - return [ - trExt.durationMS.milliSecondsLabel, - trExt.size.fileSizeFormatted, - "${trExt.bitrate} kps", - "${trExt.sampleRate} hz", - ].join(' • '); - } - - String get audioInfoFormattedCompact { - final trExt = toTrackExt(); - return [ - trExt.format, - "${trExt.channels} ch", - "${trExt.bitrate} kps", - "${trExt.sampleRate / 1000} khz", - ].join(' • '); - } + String get audioInfoFormatted => toTrackExt().audioInfoFormatted; + String? get gainDataFormatted => toTrackExt().gainDataFormatted; + String get audioInfoFormattedCompact => toTrackExt().audioInfoFormattedCompact; String get albumIdentifier => toTrackExt().albumIdentifier; String getAlbumIdentifier(List identifiers) => toTrackExt().getAlbumIdentifier(identifiers); + + ReplayGainData? get gainData => toTrackExt().gainData; } diff --git a/lib/controller/indexer_controller.dart b/lib/controller/indexer_controller.dart index 94450bf1..00ce090e 100644 --- a/lib/controller/indexer_controller.dart +++ b/lib/controller/indexer_controller.dart @@ -495,6 +495,7 @@ class Indexer { rating: 0.0, originalTags: null, tagsList: [], + gainData: null, isVideo: trackPath.isVideo(), ); if (!trackInfo.hasError) { @@ -562,6 +563,7 @@ class Indexer { rating: tags.ratingPercentage, originalTags: tags.tags, tagsList: tagsEmbedded, + gainData: tags.gainData, ); // ----- if the title || artist weren't found in the tag fields @@ -1319,6 +1321,7 @@ class Indexer { rating: 0.0, originalTags: tag, tagsList: tags, + gainData: null, isVideo: e.data.isVideo(), ); tracks.add((trext, e.id)); diff --git a/lib/controller/settings.player.dart b/lib/controller/settings.player.dart index 2f72d092..5d065dfd 100644 --- a/lib/controller/settings.player.dart +++ b/lib/controller/settings.player.dart @@ -34,6 +34,7 @@ class _PlayerSettings with SettingsFileWriter { final displayRemainingDurInsteadOfTotal = false.obs; final killAfterDismissingApp = KillAppMode.ifNotPlaying.obs; final lockscreenArtwork = true.obs; + final replayGain = false.obs; final onInterrupted = { InterruptionType.shouldPause: InterruptionAction.pause, @@ -70,6 +71,7 @@ class _PlayerSettings with SettingsFileWriter { RepeatMode? repeatMode, KillAppMode? killAfterDismissingApp, bool? lockscreenArtwork, + bool? replayGain, }) { if (enableVolumeFadeOnPlayPause != null) this.enableVolumeFadeOnPlayPause.value = enableVolumeFadeOnPlayPause; if (infiniyQueueOnNextPrevious != null) this.infiniyQueueOnNextPrevious.value = infiniyQueueOnNextPrevious; @@ -99,6 +101,7 @@ class _PlayerSettings with SettingsFileWriter { if (repeatMode != null) this.repeatMode.value = repeatMode; if (killAfterDismissingApp != null) this.killAfterDismissingApp.value = killAfterDismissingApp; if (lockscreenArtwork != null) this.lockscreenArtwork.value = lockscreenArtwork; + if (replayGain != null) this.replayGain.value = replayGain; _writeToStorage(); } @@ -140,6 +143,7 @@ class _PlayerSettings with SettingsFileWriter { displayRemainingDurInsteadOfTotal.value = json['displayRemainingDurInsteadOfTotal'] ?? displayRemainingDurInsteadOfTotal.value; killAfterDismissingApp.value = KillAppMode.values.getEnum(json['killAfterDismissingApp']) ?? killAfterDismissingApp.value; lockscreenArtwork.value = json['lockscreenArtwork'] ?? lockscreenArtwork.value; + replayGain.value = json['replayGain'] ?? replayGain.value; onInterrupted.value = getEnumMap_( json['onInterrupted'], InterruptionType.values, @@ -181,6 +185,7 @@ class _PlayerSettings with SettingsFileWriter { 'repeatMode': repeatMode.value.name, 'killAfterDismissingApp': killAfterDismissingApp.value.name, 'lockscreenArtwork': lockscreenArtwork.value, + 'replayGain': replayGain.value, 'infiniyQueueOnNextPrevious': infiniyQueueOnNextPrevious.value, 'displayRemainingDurInsteadOfTotal': displayRemainingDurInsteadOfTotal.value, 'onInterrupted': onInterrupted.map((key, value) => MapEntry(key.name, value.name)), diff --git a/lib/core/constants.dart b/lib/core/constants.dart index d68b4884..e7f9386c 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -539,6 +539,7 @@ const kDummyExtendedTrack = TrackExtended( rating: 0.0, originalTags: null, tagsList: [], + gainData: null, isVideo: false, ); diff --git a/lib/core/namida_converter_ext.dart b/lib/core/namida_converter_ext.dart index 356a5dca..7a7ddeb1 100644 --- a/lib/core/namida_converter_ext.dart +++ b/lib/core/namida_converter_ext.dart @@ -268,6 +268,7 @@ extension MediaInfoToFAudioModel on MediaInfo { mood: info.mood, country: info.country, recordLabel: info.label, + gainData: info.gainData, ), durationMS: infoFull.format?.duration?.inMilliseconds, bitRate: bitrateThousands?.round(), diff --git a/lib/core/translations/keys.dart b/lib/core/translations/keys.dart index a3cfb1bd..37f6483f 100644 --- a/lib/core/translations/keys.dart +++ b/lib/core/translations/keys.dart @@ -417,6 +417,8 @@ abstract class LanguageKeys { String get NEW_TRACKS_UNKNOWN_YEAR => _getKey('NEW_TRACKS_UNKNOWN_YEAR'); String get NEXT => _getKey('NEXT'); String get NON_FAVOURITES => _getKey('NON_FAVOURITES'); + String get NORMALIZE_AUDIO => _getKey('NORMALIZE_AUDIO'); + String get NORMALIZE_AUDIO_SUBTITLE => _getKey('NORMALIZE_AUDIO_SUBTITLE'); String get NOTIFICATION => _getKey('NOTIFICATION'); String get NO_CHANGES_FOUND => _getKey('NO_CHANGES_FOUND'); String get NO_ENOUGH_TRACKS => _getKey('NO_ENOUGH_TRACKS'); diff --git a/lib/ui/dialogs/track_info_dialog.dart b/lib/ui/dialogs/track_info_dialog.dart index 1d899d00..74a59456 100644 --- a/lib/ui/dialogs/track_info_dialog.dart +++ b/lib/ui/dialogs/track_info_dialog.dart @@ -418,7 +418,11 @@ Future showTrackInfoDialog( TrackInfoListTile( title: lang.FORMAT, - value: '${track.audioInfoFormattedCompact}\n${trackExt.extension} - ${trackExt.size.fileSizeFormatted}', + value: [ + track.audioInfoFormattedCompact, + track.gainDataFormatted, + '${trackExt.extension} - ${trackExt.size.fileSizeFormatted}', + ].joinText(separator: '\n'), icon: Broken.voice_cricle, ), diff --git a/lib/ui/pages/equalizer_page.dart b/lib/ui/pages/equalizer_page.dart index 0cc070e0..0e28f65d 100644 --- a/lib/ui/pages/equalizer_page.dart +++ b/lib/ui/pages/equalizer_page.dart @@ -90,31 +90,59 @@ class EqualizerMainSlidersColumn extends StatelessWidget { tapToUpdate: tapToUpdate, ), verticalPadding, - Obx( - (context) => _SliderTextWidget( - icon: settings.player.volume.valueR > 0 ? Broken.volume_up : Broken.volume_slash, - title: lang.VOLUME, - value: settings.player.volume.valueR, - max: 1.0, - onManualChange: (value) { - volumeKey.currentState?._updateValNoRound(value); - }, - restoreDefault: () { - Player.inst.setPlayerVolume(1.0); - settings.player.save(volume: 1.0); - volumeKey.currentState?._updateVal(1.0); - }, - ), + ObxO( + rx: settings.player.replayGain, + builder: (context, replayGainEnabled) { + final child = Obx( + (context) => _SliderTextWidget( + icon: settings.player.volume.valueR > 0 ? Broken.volume_up : Broken.volume_slash, + title: lang.VOLUME, + value: settings.player.volume.valueR, + max: 1.0, + onManualChange: (value) { + volumeKey.currentState?._updateValNoRound(value); + }, + restoreDefault: () { + Player.inst.setPlayerVolume(1.0); + settings.player.save(volume: 1.0); + volumeKey.currentState?._updateVal(1.0); + }, + ), + ); + return replayGainEnabled + ? NamidaTooltip( + message: () => lang.NORMALIZE_AUDIO, + child: AnimatedEnabled( + enabled: !replayGainEnabled, + child: child, + ), + ) + : child; + }, ), - _CuteSlider( - key: volumeKey, - max: 1.0, - initialValue: settings.player.volume.value, - onChanged: (value) { - Player.inst.setPlayerVolume(value); - settings.player.save(volume: value); + ObxO( + rx: settings.player.replayGain, + builder: (context, replayGainEnabled) { + final child = _CuteSlider( + key: volumeKey, + max: 1.0, + initialValue: settings.player.volume.value, + onChanged: (value) { + Player.inst.setPlayerVolume(value); + settings.player.save(volume: value); + }, + tapToUpdate: tapToUpdate, + ); + return replayGainEnabled + ? NamidaTooltip( + message: () => lang.NORMALIZE_AUDIO, + child: AnimatedEnabled( + enabled: !replayGainEnabled, + child: child, + ), + ) + : child; }, - tapToUpdate: tapToUpdate, ), ], ); diff --git a/lib/ui/widgets/settings/playback_settings.dart b/lib/ui/widgets/settings/playback_settings.dart index ace2fcbd..3278042f 100644 --- a/lib/ui/widgets/settings/playback_settings.dart +++ b/lib/ui/widgets/settings/playback_settings.dart @@ -29,6 +29,7 @@ enum _PlaybackSettingsKeys { killPlayerAfterDismissing, onNotificationTap, dismissibleMiniplayer, + replayGain, skipSilence, crossfade, fadeEffectOnPlayPause, @@ -62,6 +63,7 @@ class PlaybackSettings extends SettingSubpageProvider { _PlaybackSettingsKeys.killPlayerAfterDismissing: [lang.KILL_PLAYER_AFTER_DISMISSING_APP], _PlaybackSettingsKeys.onNotificationTap: [lang.ON_NOTIFICATION_TAP], _PlaybackSettingsKeys.dismissibleMiniplayer: [lang.DISMISSIBLE_MINIPLAYER], + _PlaybackSettingsKeys.replayGain: [lang.NORMALIZE_AUDIO, lang.NORMALIZE_AUDIO_SUBTITLE], _PlaybackSettingsKeys.skipSilence: [lang.SKIP_SILENCE], _PlaybackSettingsKeys.crossfade: [lang.ENABLE_CROSSFADE_EFFECT, lang.CROSSFADE_DURATION, lang.CROSSFADE_TRIGGER_SECONDS], _PlaybackSettingsKeys.fadeEffectOnPlayPause: [lang.ENABLE_FADE_EFFECT_ON_PLAY_PAUSE, lang.PLAY_FADE_DURATION, lang.PAUSE_FADE_DURATION], @@ -355,6 +357,31 @@ class PlaybackSettings extends SettingSubpageProvider { ), ), ), + getItemWrapper( + key: _PlaybackSettingsKeys.replayGain, + child: ObxO( + rx: settings.player.replayGain, + builder: (context, replayGain) => CustomSwitchListTile( + bgColor: getBgColor(_PlaybackSettingsKeys.replayGain), + leading: const StackedIcon( + baseIcon: Broken.airpods, + secondaryIcon: Broken.voice_cricle, + ), + title: lang.NORMALIZE_AUDIO, + subtitle: lang.NORMALIZE_AUDIO_SUBTITLE, + onChanged: (value) async { + final willBeTrue = !value; + settings.player.save(replayGain: !value); + if (willBeTrue) { + final gain = Player.inst.currentTrack?.track.toTrackExt().gainData?.calculateGainAsVolume() ?? 0.75; + settings.player.volume.value = gain; + await Player.inst.setVolume(gain); + } + }, + value: replayGain, + ), + ), + ), getItemWrapper( key: _PlaybackSettingsKeys.skipSilence, child: ObxO( diff --git a/pubspec.yaml b/pubspec.yaml index 3bfd1d5a..c3f338ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: namida description: A Beautiful and Feature-rich Music Player, With YouTube & Video Support Built in Flutter publish_to: "none" -version: 4.2.8-beta+240913228 +version: 4.3.0-beta+240913231 environment: sdk: ">=3.4.0 <4.0.0"