mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-27 21:30:23 +02:00
feat(metadata): show explicit badges in track titles
This commit is contained in:
@@ -39,7 +39,7 @@ object NativeDownloadFinalizer {
|
||||
const val NATIVE_WORKER_CONTRACT_VERSION = 1
|
||||
// Native finalizer owns background-safe history writes while Flutter may be suspended.
|
||||
// Keep this schema contract in sync with Dart HistoryDatabase before bumping either side.
|
||||
const val HISTORY_SCHEMA_VERSION = 11
|
||||
const val HISTORY_SCHEMA_VERSION = 12
|
||||
internal val activeFFmpegSessionIds = mutableSetOf<Long>()
|
||||
internal val nativeFFmpegSessionIds = mutableSetOf<Long>()
|
||||
internal val activeFFmpegSessionLock = Any()
|
||||
@@ -89,6 +89,7 @@ object NativeDownloadFinalizer {
|
||||
"composer",
|
||||
"label",
|
||||
"copyright",
|
||||
"explicit",
|
||||
"spotify_id_norm",
|
||||
"isrc_norm",
|
||||
"match_key",
|
||||
@@ -1304,6 +1305,14 @@ object NativeDownloadFinalizer {
|
||||
values.put("composer", normalizeOptional(resultString(input, "composer").ifBlank { trackString(input, "composer", requestString(input, "composer")) }))
|
||||
values.put("label", normalizeOptional(result.optString("label", "").ifBlank { input.request.optString("label", "") }))
|
||||
values.put("copyright", normalizeOptional(result.optString("copyright", "").ifBlank { input.request.optString("copyright", "") }))
|
||||
values.put(
|
||||
"explicit",
|
||||
if (
|
||||
result.optBoolean("explicit", false) ||
|
||||
input.track.optBoolean("explicit", false) ||
|
||||
input.request.optBoolean("explicit", false)
|
||||
) 1 else 0,
|
||||
)
|
||||
putNormalizedHistoryColumns(values)
|
||||
return values
|
||||
}
|
||||
@@ -1366,6 +1375,7 @@ object NativeDownloadFinalizer {
|
||||
composer TEXT,
|
||||
label TEXT,
|
||||
copyright TEXT,
|
||||
explicit INTEGER NOT NULL DEFAULT 0,
|
||||
spotify_id_norm TEXT,
|
||||
isrc_norm TEXT,
|
||||
match_key TEXT,
|
||||
@@ -1403,6 +1413,7 @@ object NativeDownloadFinalizer {
|
||||
ensureHistoryColumn(db, "sort_genre", "ALTER TABLE history ADD COLUMN sort_genre TEXT")
|
||||
ensureHistoryColumn(db, "sort_release", "ALTER TABLE history ADD COLUMN sort_release TEXT")
|
||||
ensureHistoryColumn(db, "sort_added", "ALTER TABLE history ADD COLUMN sort_added INTEGER")
|
||||
ensureHistoryColumn(db, "explicit", "ALTER TABLE history ADD COLUMN explicit INTEGER NOT NULL DEFAULT 0")
|
||||
ensureHistoryPathKeyTable(db)
|
||||
if (needsBackfill) {
|
||||
backfillNormalizedHistoryColumns(db)
|
||||
|
||||
@@ -32,6 +32,7 @@ class DownloadHistoryItem {
|
||||
final String? composer;
|
||||
final String? label;
|
||||
final String? copyright;
|
||||
final bool explicit;
|
||||
|
||||
const DownloadHistoryItem({
|
||||
required this.id,
|
||||
@@ -65,6 +66,7 @@ class DownloadHistoryItem {
|
||||
this.composer,
|
||||
this.label,
|
||||
this.copyright,
|
||||
this.explicit = false,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -99,6 +101,7 @@ class DownloadHistoryItem {
|
||||
'composer': composer,
|
||||
'label': label,
|
||||
'copyright': copyright,
|
||||
'explicit': explicit,
|
||||
};
|
||||
|
||||
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) =>
|
||||
@@ -134,6 +137,7 @@ class DownloadHistoryItem {
|
||||
composer: json['composer'] as String?,
|
||||
label: json['label'] as String?,
|
||||
copyright: json['copyright'] as String?,
|
||||
explicit: parseExplicitFlag(json['explicit']) == true,
|
||||
);
|
||||
|
||||
DownloadHistoryItem copyWith({
|
||||
@@ -165,6 +169,7 @@ class DownloadHistoryItem {
|
||||
String? composer,
|
||||
String? label,
|
||||
String? copyright,
|
||||
bool? explicit,
|
||||
}) {
|
||||
return DownloadHistoryItem(
|
||||
id: id,
|
||||
@@ -198,6 +203,7 @@ class DownloadHistoryItem {
|
||||
composer: composer ?? this.composer,
|
||||
label: label ?? this.label,
|
||||
copyright: copyright ?? this.copyright,
|
||||
explicit: explicit ?? this.explicit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +201,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
copyright:
|
||||
normalizeOptionalString(item.copyright) ??
|
||||
normalizeOptionalString(existing.copyright),
|
||||
explicit: item.explicit || existing.explicit,
|
||||
);
|
||||
return (item: mergedItem, existingId: existing?.id);
|
||||
}
|
||||
@@ -477,6 +478,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
int? totalDiscs,
|
||||
int? duration,
|
||||
String? composer,
|
||||
bool? explicit,
|
||||
}) async {
|
||||
final target = await _historyItemForUpdate(id);
|
||||
if (target == null) {
|
||||
@@ -499,6 +501,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
totalDiscs: totalDiscs,
|
||||
duration: duration,
|
||||
composer: composer,
|
||||
explicit: explicit,
|
||||
);
|
||||
|
||||
if (updated.quality == current.quality &&
|
||||
@@ -511,7 +514,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
updated.discNumber == current.discNumber &&
|
||||
updated.totalDiscs == current.totalDiscs &&
|
||||
updated.duration == current.duration &&
|
||||
updated.composer == current.composer) {
|
||||
updated.composer == current.composer &&
|
||||
updated.explicit == current.explicit) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -545,6 +549,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
String? composer,
|
||||
String? label,
|
||||
String? copyright,
|
||||
bool? explicit,
|
||||
}) async {
|
||||
final target = await _historyItemForUpdate(id);
|
||||
if (target == null) {
|
||||
@@ -568,6 +573,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
composer: composer,
|
||||
label: label,
|
||||
copyright: copyright,
|
||||
explicit: explicit,
|
||||
);
|
||||
|
||||
final updatedItems = target.index >= 0
|
||||
|
||||
@@ -340,6 +340,9 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
composer: historyComposer,
|
||||
label: label,
|
||||
copyright: copyright,
|
||||
explicit:
|
||||
trackToDownload.isExplicit ||
|
||||
parseExplicitFlag(result['explicit']) == true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ PlayableMedia playableFromHistory(DownloadHistoryItem item) {
|
||||
sampleRate: item.sampleRate,
|
||||
bitrate: item.bitrate,
|
||||
format: item.format,
|
||||
explicit: item.explicit,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,5 +194,6 @@ PlayableMedia playableFromLocal(LocalLibraryItem item) {
|
||||
sampleRate: item.sampleRate,
|
||||
bitrate: item.bitrate,
|
||||
format: item.format,
|
||||
explicit: item.explicit,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ class PlaybackController extends Notifier<PlaybackState> {
|
||||
String album = '',
|
||||
String coverUrl = '',
|
||||
Track? track,
|
||||
bool? explicit,
|
||||
}) async {
|
||||
if (isCueVirtualPath(path)) {
|
||||
throw Exception(cueVirtualTrackRequiresSplitMessage);
|
||||
@@ -64,6 +65,7 @@ class PlaybackController extends Notifier<PlaybackState> {
|
||||
duration: (track != null && track.duration > 0)
|
||||
? Duration(seconds: track.duration)
|
||||
: null,
|
||||
explicit: explicit ?? track?.isExplicit ?? false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -168,6 +170,7 @@ class PlaybackController extends Notifier<PlaybackState> {
|
||||
duration: track.duration > 0
|
||||
? Duration(seconds: track.duration)
|
||||
: null,
|
||||
explicit: track.isExplicit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
@@ -327,6 +328,14 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
bool _isExplicit(MediaItem mediaItem) {
|
||||
final source = mediaItem.extras?['source']?.toString();
|
||||
final loadedMetadata = source == _loadedSource ? _metadata : null;
|
||||
return parseExplicitFlag(loadedMetadata?['explicit']) ??
|
||||
parseExplicitFlag(mediaItem.extras?['explicit']) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -624,8 +633,9 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
child: Column(
|
||||
key: ValueKey(mediaItem.id),
|
||||
children: [
|
||||
Text(
|
||||
mediaItem.title,
|
||||
ExplicitTrackTitle(
|
||||
title: mediaItem.title,
|
||||
explicit: _isExplicit(mediaItem),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
|
||||
@@ -217,6 +217,7 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState {
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
coverUrl: playbackCover,
|
||||
explicit: isExplicit,
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -195,8 +195,9 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
trackName,
|
||||
ExplicitTrackTitle(
|
||||
title: trackName,
|
||||
explicit: isExplicit,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
@@ -206,6 +207,7 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
colorScheme: _trackMetadataHeroScheme,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
|
||||
@@ -1196,7 +1196,28 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
|
||||
}
|
||||
|
||||
Future<void> _syncDownloadHistoryMetadata() async {
|
||||
if (_isLocalItem || _downloadItem == null) return;
|
||||
if (_isLocalItem) {
|
||||
final item = _localLibraryItem;
|
||||
if (item == null) return;
|
||||
try {
|
||||
await LibraryDatabase.instance.updateAudioMetadata(
|
||||
item.id,
|
||||
explicit: isExplicit,
|
||||
);
|
||||
if (mounted) {
|
||||
_setState(() {
|
||||
_currentLocalLibraryItem = item.withAudioMetadata(
|
||||
explicit: isExplicit,
|
||||
);
|
||||
});
|
||||
}
|
||||
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||
} catch (e) {
|
||||
_log.w('Failed to sync local library explicit metadata: $e');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_downloadItem == null) return;
|
||||
|
||||
String? normalizedOrNull(String? value) {
|
||||
if (value == null) return null;
|
||||
@@ -1224,6 +1245,7 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
|
||||
composer: normalizedOrNull(composer),
|
||||
label: normalizedOrNull(label),
|
||||
copyright: normalizedOrNull(copyright),
|
||||
explicit: isExplicit,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to sync download history metadata: $e');
|
||||
|
||||
@@ -44,6 +44,7 @@ import 'package:spotiflac_android/theme/cover_palette.dart' show HeaderPalette;
|
||||
import 'package:spotiflac_android/widgets/album_detail_header.dart'
|
||||
show HeaderMetaRow, HeaderMetaItem;
|
||||
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:spotiflac_android/widgets/open_on_platform_sheet.dart';
|
||||
@@ -361,6 +362,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
final resolvedUPC = (metadata['upc'] ?? metadata['barcode'])?.toString();
|
||||
final resolvedComment = metadata['comment']?.toString();
|
||||
final resolvedExplicit = parseExplicitFlag(metadata['explicit']);
|
||||
final needsExplicit =
|
||||
resolvedExplicit != null && resolvedExplicit != isExplicit;
|
||||
final needsTrackNumber =
|
||||
resolvedTrackNumber != null &&
|
||||
resolvedTrackNumber > 0 &&
|
||||
@@ -400,7 +403,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
final fileHasAlbumType = present(resolvedAlbumType);
|
||||
final fileHasUPC = present(resolvedUPC);
|
||||
final fileHasComment = present(resolvedComment);
|
||||
final fileHasExplicit = resolvedExplicit == true;
|
||||
final fileHasTrackNumber =
|
||||
resolvedTrackNumber != null && resolvedTrackNumber > 0;
|
||||
final fileHasTotalTracks =
|
||||
@@ -422,6 +424,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
needsTotalDiscs ||
|
||||
needsDuration ||
|
||||
needsComposer ||
|
||||
needsExplicit ||
|
||||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null));
|
||||
final localItem = _localLibraryItem;
|
||||
final localAudioMetadataChanged =
|
||||
@@ -432,6 +435,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
resolvedSampleRate != localItem.sampleRate) ||
|
||||
(resolvedBitrate != null &&
|
||||
resolvedBitrate != localItem.bitrate) ||
|
||||
needsExplicit ||
|
||||
needsDuration ||
|
||||
formatChanged);
|
||||
|
||||
@@ -456,7 +460,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
fileHasAlbumType ||
|
||||
fileHasUPC ||
|
||||
fileHasComment ||
|
||||
fileHasExplicit ||
|
||||
needsExplicit ||
|
||||
isPlaceholderQualityLabel(_quality)) &&
|
||||
mounted) {
|
||||
setState(() {
|
||||
@@ -506,6 +510,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null,
|
||||
duration: needsDuration ? resolvedDuration : null,
|
||||
composer: needsComposer ? resolvedComposer : null,
|
||||
explicit: needsExplicit ? resolvedExplicit : null,
|
||||
);
|
||||
if (mounted && _downloadItem != null) {
|
||||
setState(() {
|
||||
@@ -521,6 +526,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null,
|
||||
duration: needsDuration ? resolvedDuration : null,
|
||||
composer: needsComposer ? resolvedComposer : null,
|
||||
explicit: needsExplicit ? resolvedExplicit : null,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -531,6 +537,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
bitDepth: resolvedBitDepth,
|
||||
sampleRate: resolvedSampleRate,
|
||||
bitrate: resolvedBitrate,
|
||||
explicit: needsExplicit ? resolvedExplicit : null,
|
||||
format: formatChanged ? resolvedFormat : null,
|
||||
);
|
||||
if (mounted &&
|
||||
@@ -542,6 +549,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
bitDepth: resolvedBitDepth,
|
||||
sampleRate: resolvedSampleRate,
|
||||
bitrate: resolvedBitrate,
|
||||
explicit: needsExplicit ? resolvedExplicit : null,
|
||||
format: resolvedFormat,
|
||||
);
|
||||
});
|
||||
@@ -649,8 +657,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: showTitleInAppBar ? 1.0 : 0.0,
|
||||
child: Text(
|
||||
trackName,
|
||||
child: ExplicitTrackTitle(
|
||||
title: trackName,
|
||||
explicit: isExplicit,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -305,7 +305,8 @@ extension _TrackMetadataCover on _TrackMetadataScreenState {
|
||||
String? get comment =>
|
||||
normalizeOptionalString(_editedMetadata?['comment']?.toString());
|
||||
bool get isExplicit =>
|
||||
parseExplicitFlag(_editedMetadata?['explicit']) == true;
|
||||
parseExplicitFlag(_editedMetadata?['explicit']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.explicit : _downloadItem!.explicit);
|
||||
int? get duration =>
|
||||
readPositiveInt(_editedMetadata?['duration']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration);
|
||||
|
||||
@@ -65,7 +65,7 @@ class HistoryBatchLookupRequest {
|
||||
}
|
||||
|
||||
class HistoryDatabase {
|
||||
static const int schemaVersion = 11;
|
||||
static const int schemaVersion = 12;
|
||||
static final HistoryDatabase instance = HistoryDatabase._init();
|
||||
static final sqlite.SingleFlightInitializer<Database> _database =
|
||||
sqlite.SingleFlightInitializer<Database>();
|
||||
@@ -119,6 +119,7 @@ class HistoryDatabase {
|
||||
composer TEXT,
|
||||
label TEXT,
|
||||
copyright TEXT,
|
||||
explicit INTEGER NOT NULL DEFAULT 0,
|
||||
spotify_id_norm TEXT,
|
||||
isrc_norm TEXT,
|
||||
match_key TEXT,
|
||||
@@ -229,6 +230,15 @@ class HistoryDatabase {
|
||||
await _createQueueIndexes(db);
|
||||
_log.i('Added persisted queue sort columns');
|
||||
}
|
||||
if (oldVersion < 12) {
|
||||
await sqlite.addColumnIfMissing(
|
||||
db,
|
||||
'history',
|
||||
'explicit',
|
||||
'INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
_log.i('Added explicit-content metadata');
|
||||
}
|
||||
}
|
||||
|
||||
static String normalizeLookupText(String? value) =>
|
||||
@@ -617,6 +627,7 @@ class HistoryDatabase {
|
||||
'composer': json['composer'],
|
||||
'label': json['label'],
|
||||
'copyright': json['copyright'],
|
||||
'explicit': json['explicit'] == true ? 1 : 0,
|
||||
};
|
||||
row.addAll(
|
||||
_queueSortColumns(
|
||||
@@ -675,6 +686,7 @@ class HistoryDatabase {
|
||||
'composer': row['composer'],
|
||||
'label': row['label'],
|
||||
'copyright': row['copyright'],
|
||||
'explicit': row['explicit'] == 1 || row['explicit'] == true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ final _log = AppLogger('LibraryDatabase');
|
||||
|
||||
class LibraryDatabase {
|
||||
static final LibraryDatabase instance = LibraryDatabase._init();
|
||||
static const int schemaVersion = 11;
|
||||
static const int schemaVersion = 12;
|
||||
static const String legacySourceId = LocalLibraryItem.legacySourceId;
|
||||
static const String visibleLibraryView = 'library_visible';
|
||||
static const int audioMetadataScanVersion = 1;
|
||||
static const int audioMetadataScanVersion = 2;
|
||||
static final sqlite.SingleFlightInitializer<Database> _database =
|
||||
sqlite.SingleFlightInitializer<Database>();
|
||||
bool _historyAttached = false;
|
||||
@@ -83,8 +83,9 @@ class LibraryDatabase {
|
||||
composer TEXT,
|
||||
label TEXT,
|
||||
copyright TEXT,
|
||||
explicit INTEGER NOT NULL DEFAULT 0,
|
||||
format TEXT,
|
||||
audio_metadata_scan_version INTEGER NOT NULL DEFAULT 1,
|
||||
audio_metadata_scan_version INTEGER NOT NULL DEFAULT 2,
|
||||
track_name_norm TEXT,
|
||||
artist_name_norm TEXT,
|
||||
album_name_norm TEXT,
|
||||
@@ -201,6 +202,15 @@ class LibraryDatabase {
|
||||
await _createLibrarySources(db);
|
||||
_log.i('Added multiple local library sources');
|
||||
}
|
||||
if (oldVersion < 12) {
|
||||
await sqlite.addColumnIfMissing(
|
||||
db,
|
||||
'library',
|
||||
'explicit',
|
||||
'INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
_log.i('Added explicit-content metadata');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createLibrarySources(DatabaseExecutor db) async {
|
||||
@@ -459,6 +469,7 @@ class LibraryDatabase {
|
||||
'composer': json['composer'],
|
||||
'label': json['label'],
|
||||
'copyright': json['copyright'],
|
||||
'explicit': json['explicit'] == true || json['explicit'] == 1 ? 1 : 0,
|
||||
'format': json['format'],
|
||||
'audio_metadata_scan_version':
|
||||
(json['audioMetadataScanVersion'] as num?)?.toInt() ??
|
||||
@@ -513,6 +524,7 @@ class LibraryDatabase {
|
||||
'composer': row['composer'],
|
||||
'label': row['label'],
|
||||
'copyright': row['copyright'],
|
||||
'explicit': row['explicit'] == 1 || row['explicit'] == true,
|
||||
'format': row['format'],
|
||||
};
|
||||
}
|
||||
@@ -1473,6 +1485,7 @@ class LibraryDatabase {
|
||||
int? bitDepth,
|
||||
int? sampleRate,
|
||||
int? bitrate,
|
||||
bool? explicit,
|
||||
String? format,
|
||||
}) async {
|
||||
final values = <String, dynamic>{};
|
||||
@@ -1488,6 +1501,9 @@ class LibraryDatabase {
|
||||
if (bitrate != null && bitrate > 0) {
|
||||
values['bitrate'] = bitrate;
|
||||
}
|
||||
if (explicit != null) {
|
||||
values['explicit'] = explicit ? 1 : 0;
|
||||
}
|
||||
final normalizedFormat = normalizeAudioFormatValue(format);
|
||||
if (normalizedFormat != null) {
|
||||
values['format'] = normalizedFormat;
|
||||
|
||||
@@ -38,6 +38,7 @@ class LocalLibraryItem {
|
||||
final String? composer;
|
||||
final String? label;
|
||||
final String? copyright;
|
||||
final bool explicit;
|
||||
final String? format; // flac, alac, eac3, ac3, ac4, mp3, opus, m4a
|
||||
|
||||
const LocalLibraryItem({
|
||||
@@ -65,6 +66,7 @@ class LocalLibraryItem {
|
||||
this.composer,
|
||||
this.label,
|
||||
this.copyright,
|
||||
this.explicit = false,
|
||||
this.format,
|
||||
});
|
||||
|
||||
@@ -93,6 +95,7 @@ class LocalLibraryItem {
|
||||
'composer': composer,
|
||||
'label': label,
|
||||
'copyright': copyright,
|
||||
'explicit': explicit,
|
||||
'format': format,
|
||||
};
|
||||
|
||||
@@ -122,6 +125,7 @@ class LocalLibraryItem {
|
||||
composer: json['composer'] as String?,
|
||||
label: json['label'] as String?,
|
||||
copyright: json['copyright'] as String?,
|
||||
explicit: json['explicit'] == true || json['explicit'] == 1,
|
||||
format: json['format'] as String?,
|
||||
);
|
||||
|
||||
@@ -130,6 +134,7 @@ class LocalLibraryItem {
|
||||
int? bitDepth,
|
||||
int? sampleRate,
|
||||
int? bitrate,
|
||||
bool? explicit,
|
||||
String? format,
|
||||
}) {
|
||||
return LocalLibraryItem(
|
||||
@@ -157,6 +162,7 @@ class LocalLibraryItem {
|
||||
composer: composer,
|
||||
label: label,
|
||||
copyright: copyright,
|
||||
explicit: explicit ?? this.explicit,
|
||||
format: format ?? this.format,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:spotiflac_android/services/app_state_database.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
final _log = AppLogger('MusicPlayer');
|
||||
|
||||
@@ -56,6 +57,7 @@ class PlayableMedia {
|
||||
final int? sampleRate;
|
||||
final int? bitrate;
|
||||
final String? format;
|
||||
final bool explicit;
|
||||
|
||||
const PlayableMedia({
|
||||
required this.id,
|
||||
@@ -69,6 +71,7 @@ class PlayableMedia {
|
||||
this.sampleRate,
|
||||
this.bitrate,
|
||||
this.format,
|
||||
this.explicit = false,
|
||||
});
|
||||
|
||||
bool get isContentUri => source.startsWith('content://');
|
||||
@@ -85,6 +88,7 @@ class PlayableMedia {
|
||||
if (sampleRate != null && sampleRate! > 0) 'sampleRate': sampleRate,
|
||||
if (bitrate != null && bitrate! > 0) 'bitrate': bitrate,
|
||||
if (format != null && format!.trim().isNotEmpty) 'format': format,
|
||||
if (explicit) 'explicit': true,
|
||||
};
|
||||
|
||||
static PlayableMedia? fromJson(Map<String, dynamic> json) {
|
||||
@@ -108,6 +112,7 @@ class PlayableMedia {
|
||||
sampleRate: readPositiveInt(json['sampleRate']),
|
||||
bitrate: readPositiveInt(json['bitrate']),
|
||||
format: json['format']?.toString(),
|
||||
explicit: parseExplicitFlag(json['explicit']) == true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,6 +135,7 @@ class PlayableMedia {
|
||||
if (bitrate != null && bitrate! > 0) 'bitrate': bitrate,
|
||||
if (format != null && format!.trim().isNotEmpty)
|
||||
'format': format!.trim(),
|
||||
if (explicit) 'explicit': true,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -146,10 +152,12 @@ Map<String, dynamic> playbackAudioMetadataFromMediaItem(MediaItem item) {
|
||||
final sampleRate = readPositiveInt(extras['sample_rate']);
|
||||
final bitrate = readPositiveInt(extras['bitrate']);
|
||||
final format = extras['format']?.toString().trim();
|
||||
final explicit = parseExplicitFlag(extras['explicit']);
|
||||
if (bitDepth != null) metadata['bit_depth'] = bitDepth;
|
||||
if (sampleRate != null) metadata['sample_rate'] = sampleRate;
|
||||
if (bitrate != null) metadata['bitrate'] = bitrate;
|
||||
if (format != null && format.isNotEmpty) metadata['format'] = format;
|
||||
if (explicit != null) metadata['explicit'] = explicit;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
|
||||
class AudioQualityBadge extends StatelessWidget {
|
||||
@@ -39,23 +40,30 @@ class ExplicitBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = context.l10n.metadataExplicitValue;
|
||||
return Tooltip(
|
||||
message: 'Explicit',
|
||||
child: Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
|
||||
borderRadius: context.tokens.borderRadiusBadge,
|
||||
),
|
||||
child: Text(
|
||||
'E',
|
||||
style: TextStyle(
|
||||
fontSize: context.tokens.badgeFontSize,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.surface,
|
||||
height: 1.0,
|
||||
message: label,
|
||||
excludeFromSemantics: true,
|
||||
child: Semantics(
|
||||
label: label,
|
||||
child: ExcludeSemantics(
|
||||
child: Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
|
||||
borderRadius: context.tokens.borderRadiusBadge,
|
||||
),
|
||||
child: Text(
|
||||
'E',
|
||||
style: TextStyle(
|
||||
fontSize: context.tokens.badgeFontSize,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.surface,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -63,6 +71,52 @@ class ExplicitBadge extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class ExplicitTrackTitle extends StatelessWidget {
|
||||
final String title;
|
||||
final bool explicit;
|
||||
final TextStyle? style;
|
||||
final TextAlign? textAlign;
|
||||
final int? maxLines;
|
||||
final TextOverflow overflow;
|
||||
final ColorScheme? colorScheme;
|
||||
|
||||
const ExplicitTrackTitle({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.explicit,
|
||||
this.style,
|
||||
this.textAlign,
|
||||
this.maxLines,
|
||||
this.overflow = TextOverflow.clip,
|
||||
this.colorScheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: title),
|
||||
if (explicit)
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: ExplicitBadge(
|
||||
colorScheme: colorScheme ?? Theme.of(context).colorScheme,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: style,
|
||||
textAlign: textAlign,
|
||||
maxLines: maxLines,
|
||||
overflow: overflow,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DolbyAtmosBadge extends StatelessWidget {
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/screens/now_playing_screen.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
@@ -94,8 +96,13 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
mediaItem.title,
|
||||
ExplicitTrackTitle(
|
||||
title: mediaItem.title,
|
||||
explicit:
|
||||
parseExplicitFlag(
|
||||
mediaItem.extras?['explicit'],
|
||||
) ==
|
||||
true,
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
|
||||
@@ -114,6 +114,16 @@ void main() {
|
||||
expect(restored.format, 'flac');
|
||||
});
|
||||
|
||||
test('explicit metadata survives history serialization', () {
|
||||
final item = _historyItem(
|
||||
id: 'explicit',
|
||||
filePath: '/music/Album/Explicit Song.flac',
|
||||
downloadedAt: DateTime.utc(2026, 7, 23),
|
||||
).copyWith(explicit: true);
|
||||
|
||||
expect(DownloadHistoryItem.fromJson(item.toJson()).explicit, isTrue);
|
||||
});
|
||||
|
||||
test('placeholder refresh cannot erase an existing measured quality', () {
|
||||
expect(
|
||||
resolvePersistedHistoryQuality(
|
||||
|
||||
@@ -128,6 +128,7 @@ void main() {
|
||||
scannedAt: DateTime(2026),
|
||||
bitDepth: 24,
|
||||
sampleRate: 96000,
|
||||
explicit: true,
|
||||
);
|
||||
|
||||
final updated = item.withAudioMetadata(bitrate: 1840);
|
||||
@@ -135,6 +136,7 @@ void main() {
|
||||
expect(updated.bitrate, 1840);
|
||||
expect(updated.bitDepth, 24);
|
||||
expect(updated.sampleRate, 96000);
|
||||
expect(updated.explicit, isTrue);
|
||||
expect(updated.trackName, 'Song');
|
||||
expect(updated.filePath, '/music/song.flac');
|
||||
expect(updated.sourceId, 'external-ssd');
|
||||
@@ -142,6 +144,7 @@ void main() {
|
||||
LocalLibraryItem.fromJson(updated.toJson()).sourceId,
|
||||
'external-ssd',
|
||||
);
|
||||
expect(LocalLibraryItem.fromJson(updated.toJson()).explicit, isTrue);
|
||||
});
|
||||
|
||||
test('recognizes a retained external source index', () {
|
||||
|
||||
@@ -11,6 +11,7 @@ void main() {
|
||||
sampleRate: 96000,
|
||||
bitrate: 2860,
|
||||
format: 'flac',
|
||||
explicit: true,
|
||||
);
|
||||
|
||||
test('queue media exposes technical quality to Now Playing immediately', () {
|
||||
@@ -21,6 +22,7 @@ void main() {
|
||||
'sample_rate': 96000,
|
||||
'bitrate': 2860,
|
||||
'format': 'flac',
|
||||
'explicit': true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +34,7 @@ void main() {
|
||||
expect(restored.sampleRate, 96000);
|
||||
expect(restored.bitrate, 2860);
|
||||
expect(restored.format, 'flac');
|
||||
expect(restored.explicit, isTrue);
|
||||
});
|
||||
|
||||
test('file probe cannot erase valid queue quality with empty values', () {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('metadata hero keeps technical text legible in light theme', (
|
||||
@@ -25,6 +26,7 @@ void main() {
|
||||
bitDepth: 16,
|
||||
sampleRate: 44100,
|
||||
format: 'flac',
|
||||
explicit: true,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
@@ -41,6 +43,7 @@ void main() {
|
||||
|
||||
final headerMeta = find.byType(HeaderMetaRow);
|
||||
expect(headerMeta, findsOneWidget);
|
||||
expect(find.byType(ExplicitBadge), findsNWidgets(2));
|
||||
for (final label in const ['16-bit/44.1kHz', '4:10', 'Tidal-web']) {
|
||||
final text = tester.widget<Text>(
|
||||
find.descendant(of: headerMeta, matching: find.text(label)),
|
||||
|
||||
Reference in New Issue
Block a user