refactor(metadata): flatten track actions and remove similar songs

This commit is contained in:
zarzet committed 2026-10-03 03:26:35 +07:00
1 parent 2e71e88a9a
commit cc3fe42df2
10 files changed
+113 -429

No files matched your search

-5
View File
@@ -7039,11 +7039,6 @@
"artistAllUpcomingConcerts": "All Upcoming Concerts",
"artistNoUpcomingConcerts": "No upcoming concerts.",
"motionArtwork": "Animated artwork",
"trackEditAudioFile": "Edit audio file",
"relatedLibraryTracks": "Similar songs in Library",
"relatedLibraryDescription": "Songs you already have with related artist, album or genre tags. No online music is searched.",
"relatedLibraryEmpty": "No related songs found in your Library.",
"relatedLibraryLoadError": "Could not find related songs. Try again.",
"listeningStats": "Listening statistics",
"listeningStatsRecord": "Record listening",
"listeningStatsDescription": "Stored only on this device, starting now. Records the built-in player only; pauses, buffering and seeking add no listening time. A play counts after 30 seconds.",
-5
View File
@@ -6958,11 +6958,6 @@
"hiResCheckRateEvidence": "Penurunan tajam di sekitar {cutoff} dan noise floor yang terukur mengindikasikan sumber dengan sample rate lebih rendah.",
"hiResCheckSteadyEnergy": "Energi di atas {cutoff} relatif konstan pada bagian yang dianalisis dan mungkin berupa noise.",
"motionArtwork": "Artwork bergerak",
"trackEditAudioFile": "Ubah file audio",
"relatedLibraryTracks": "Lagu serupa di Library",
"relatedLibraryDescription": "Lagu yang sudah Anda miliki dengan tag artis, album, atau genre terkait. Tidak mencari musik online.",
"relatedLibraryEmpty": "Tidak ada lagu terkait di Library Anda.",
"relatedLibraryLoadError": "Tidak dapat mencari lagu terkait. Coba lagi.",
"listeningStats": "Statistik mendengarkan",
"listeningStatsRecord": "Catat waktu mendengarkan",
"listeningStatsDescription": "Disimpan hanya di perangkat ini, mulai sekarang. Hanya mencatat player bawaan; pause, buffering, dan seek tidak menambah waktu dengar. Pemutaran dihitung setelah 30 detik.",
@@ -1,107 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/related_library_tracks.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
final relatedLibraryTracksProvider = FutureProvider.autoDispose
.family<List<UnifiedLibraryItem>, UnifiedLibraryItem>((ref, seed) {
final includeLocal = ref.watch(
settingsProvider.select((s) => s.localLibraryEnabled),
);
return loadRelatedLibraryTracks(seed, includeLocal: includeLocal);
});
class RelatedLibraryTracksScreen extends ConsumerWidget {
const RelatedLibraryTracksScreen({super.key, required this.seed});
final UnifiedLibraryItem seed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tracks = ref.watch(relatedLibraryTracksProvider(seed));
return Scaffold(
body: CustomScrollView(
slivers: [
AppSliverHeader.page(title: context.l10n.relatedLibraryTracks),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(context.l10n.relatedLibraryDescription),
),
),
...tracks.when(
loading: () => [
const SliverToBoxAdapter(
child: Center(child: AppLoadingIndicator()),
),
],
error: (_, _) => [
SliverToBoxAdapter(
child: ListTile(
title: Text(context.l10n.relatedLibraryLoadError),
trailing: IconButton(
icon: const Icon(Icons.refresh),
onPressed: () =>
ref.invalidate(relatedLibraryTracksProvider(seed)),
),
),
),
],
data: (items) => [
if (items.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(context.l10n.relatedLibraryEmpty),
),
),
SliverList.list(
children: [
for (final item in items)
ListTile(
leading: CachedCoverImage(
imageUrl: item.localCoverPath ?? item.coverUrl ?? '',
width: 48,
height: 48,
borderRadius: BorderRadius.circular(8),
),
title: Text(item.trackName),
subtitle: Text(item.artistName),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TrackMetadataScreen(
item: item.historyItem,
localItem: item.localItem,
),
),
),
trailing: IconButton(
tooltip: context.l10n.trackMetadataPlay,
icon: const Icon(Icons.play_arrow),
onPressed: () =>
ref.read(musicPlayerControllerProvider).playAll([
if (item.localItem != null)
playableFromLocal(item.localItem!)
else if (item.historyItem != null)
playableFromHistory(item.historyItem!),
]),
),
),
],
),
],
),
const NavBarSliverSpacer(),
],
),
);
}
}
-2
View File
@@ -76,8 +76,6 @@ import 'package:spotiflac_android/widgets/metadata_barcode.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/constants/music_services.dart';
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
import 'package:spotiflac_android/screens/related_library_tracks_screen.dart';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
part 'track_metadata_screen_cover.dart';
+105 -137
View File
@@ -8,148 +8,118 @@ extension _TrackMetadataMenu on _TrackMetadataScreenState {
WidgetRef ref,
ColorScheme colorScheme, {
Rect? anchor,
bool fileActionsOnly = false,
}) {
Widget buildMenu(BuildContext sheetContext) {
final l10n = sheetContext.l10n;
final options = <_MetadataOption>[
if (!fileActionsOnly) ...[
if (_fileExists)
_MetadataOption(
icon: Icons.playlist_play,
label: l10n.trackPlayNext,
onTap: () => _enqueueThis(ref, playNext: true),
),
if (_fileExists)
_MetadataOption(
icon: Icons.queue_music,
label: l10n.trackAddToQueue,
onTap: () => _enqueueThis(ref, playNext: false),
),
if (albumName.trim().isNotEmpty)
_MetadataOption(
icon: Icons.album_outlined,
label: l10n.homeGoToAlbum,
onTap: () => navigateToAlbum(
screenContext,
albumName: albumName,
artistName: albumArtist ?? artistName,
coverUrl: _coverUrl,
),
),
if (_fileExists)
_MetadataOption(
icon: Icons.person_outline,
label: l10n.mornyeGoToArtist,
onTap: () =>
navigateToArtistCredits(screenContext, artistNames: artistName),
icon: Icons.playlist_play,
label: l10n.trackPlayNext,
onTap: () => _enqueueThis(ref, playNext: true),
),
if (_fileExists)
_MetadataOption(
icon: Icons.tune,
label: l10n.trackEditAudioFile,
onTap: () => _showOptionsMenu(
icon: Icons.queue_music,
label: l10n.trackAddToQueue,
onTap: () => _enqueueThis(ref, playNext: false),
),
if (albumName.trim().isNotEmpty)
_MetadataOption(
icon: Icons.album_outlined,
label: l10n.homeGoToAlbum,
onTap: () => navigateToAlbum(
screenContext,
ref,
colorScheme,
fileActionsOnly: true,
albumName: albumName,
artistName: albumArtist ?? artistName,
coverUrl: _coverUrl,
),
),
_MetadataOption(
icon: Icons.person_outline,
label: l10n.mornyeGoToArtist,
onTap: () =>
navigateToArtistCredits(screenContext, artistNames: artistName),
),
_MetadataOption(
icon: Icons.copy_outlined,
label: l10n.trackCopyFilePath,
dividerAbove: _fileExists,
onTap: () => _copyToClipboard(screenContext, cleanFilePath),
),
if (_fileExists)
_MetadataOption(
icon: Icons.library_music_outlined,
label: l10n.relatedLibraryTracks,
onTap: () => Navigator.of(screenContext).push(
MaterialPageRoute<void>(
builder: (_) => RelatedLibraryTracksScreen(
seed: widget.localItem != null
? UnifiedLibraryItem.fromLocalLibrary(widget.localItem!)
: UnifiedLibraryItem.fromDownloadHistory(widget.item!),
),
),
),
icon: Icons.edit_outlined,
label: l10n.trackEditMetadata,
onTap: () =>
_showEditMetadataSheet(screenContext, ref, colorScheme),
),
],
if (fileActionsOnly) ...[
if (!_isLocalItem && (_coverUrl != null || _fileExists))
_MetadataOption(
icon: Icons.copy_outlined,
label: l10n.trackCopyFilePath,
dividerAbove: _fileExists,
onTap: () => _copyToClipboard(screenContext, cleanFilePath),
icon: Icons.image_outlined,
label: l10n.trackSaveCoverArt,
onTap: _saveCoverArt,
),
if (_fileExists)
_MetadataOption(
icon: Icons.edit_outlined,
label: l10n.trackEditMetadata,
onTap: () =>
_showEditMetadataSheet(screenContext, ref, colorScheme),
),
if (!_isLocalItem && (_coverUrl != null || _fileExists))
_MetadataOption(
icon: Icons.image_outlined,
label: l10n.trackSaveCoverArt,
onTap: _saveCoverArt,
),
if (!_isLocalItem)
_MetadataOption(
icon: Icons.lyrics_outlined,
label: l10n.trackSaveLyrics,
onTap: _saveLyrics,
),
if (_fileExists)
_MetadataOption(
icon: Icons.travel_explore,
label: l10n.trackReEnrich,
onTap: _reEnrichMetadata,
),
if (_fileExists && _isConvertibleFormat)
_MetadataOption(
icon: Icons.swap_horiz,
label: l10n.trackConvertFormat,
onTap: () => _showConvertSheet(screenContext),
),
if (_fileExists && !_isCueFile)
_MetadataOption(
icon: Icons.graphic_eq,
label: l10n.trackReplayGain,
onTap: () => _updateReplayGain(),
),
if (_fileExists && !_isCueFile)
_MetadataOption(
icon: Icons.remove_circle_outline,
label: l10n.trackRemoveReplayGain,
onTap: () => _removeReplayGain(),
),
if (_fileExists && _isCueFile)
_MetadataOption(
icon: Icons.call_split,
label: l10n.cueSplitTitle,
onTap: () => _showCueSplitSheet(screenContext),
),
],
if (!fileActionsOnly) ...[
if (_spotifyId != null || (isrc?.isNotEmpty ?? false))
_MetadataOption(
icon: Icons.open_in_new,
label: l10n.trackOpenOn,
dividerAbove: true,
onTap: () => OpenOnPlatformSheet.show(
screenContext,
spotifyId: _spotifyId ?? '',
isrc: isrc ?? '',
),
),
if (!_isLocalItem)
_MetadataOption(
icon: Icons.share_outlined,
label: l10n.trackMetadataShare,
dividerAbove: _spotifyId == null && !(isrc?.isNotEmpty ?? false),
onTap: () => _shareFile(screenContext),
icon: Icons.lyrics_outlined,
label: l10n.trackSaveLyrics,
onTap: _saveLyrics,
),
if (_fileExists)
_MetadataOption(
icon: Icons.delete_outline,
label: l10n.trackRemoveFromDevice,
destructive: true,
onTap: () => _confirmDelete(screenContext, ref, colorScheme),
icon: Icons.travel_explore,
label: l10n.trackReEnrich,
onTap: _reEnrichMetadata,
),
],
if (_fileExists && _isConvertibleFormat)
_MetadataOption(
icon: Icons.swap_horiz,
label: l10n.trackConvertFormat,
onTap: () => _showConvertSheet(screenContext),
),
if (_fileExists && !_isCueFile)
_MetadataOption(
icon: Icons.graphic_eq,
label: l10n.trackReplayGain,
onTap: () => _updateReplayGain(),
),
if (_fileExists && !_isCueFile)
_MetadataOption(
icon: Icons.remove_circle_outline,
label: l10n.trackRemoveReplayGain,
onTap: () => _removeReplayGain(),
),
if (_fileExists && _isCueFile)
_MetadataOption(
icon: Icons.call_split,
label: l10n.cueSplitTitle,
onTap: () => _showCueSplitSheet(screenContext),
),
if (_spotifyId != null || (isrc?.isNotEmpty ?? false))
_MetadataOption(
icon: Icons.open_in_new,
label: l10n.trackOpenOn,
dividerAbove: true,
onTap: () => OpenOnPlatformSheet.show(
screenContext,
spotifyId: _spotifyId ?? '',
isrc: isrc ?? '',
),
),
_MetadataOption(
icon: Icons.share_outlined,
label: l10n.trackMetadataShare,
dividerAbove: _spotifyId == null && !(isrc?.isNotEmpty ?? false),
onTap: () => _shareFile(screenContext),
),
_MetadataOption(
icon: Icons.delete_outline,
label: l10n.trackRemoveFromDevice,
destructive: true,
onTap: () => _confirmDelete(screenContext, ref, colorScheme),
),
];
if (sheetContext.isMornye) {
@@ -173,26 +143,24 @@ extension _TrackMetadataMenu on _TrackMetadataScreenState {
}
return MornyeContextMenu(
quickActions: [
if (!fileActionsOnly) ...[
if (_fileExists) ...[
MornyeMenuAction(
icon: CupertinoIcons.play_fill,
label: l10n.trackMetadataPlay,
onPressed: () => _closeOptionsMenuAndRun(
sheetContext,
() => _openFile(screenContext, rawFilePath),
),
),
],
if (_fileExists) ...[
MornyeMenuAction(
icon: CupertinoIcons.share_solid,
label: l10n.trackMetadataShare,
icon: CupertinoIcons.play_fill,
label: l10n.trackMetadataPlay,
onPressed: () => _closeOptionsMenuAndRun(
sheetContext,
() => _shareFile(screenContext),
() => _openFile(screenContext, rawFilePath),
),
),
],
MornyeMenuAction(
icon: CupertinoIcons.share_solid,
label: l10n.trackMetadataShare,
onPressed: () => _closeOptionsMenuAndRun(
sheetContext,
() => _shareFile(screenContext),
),
),
],
groups: groups,
);
-108
View File
@@ -1,108 +0,0 @@
import 'dart:math';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
String _normalized(String? value) => (value ?? '').trim().toLowerCase();
/// Only related, locally stored songs qualify. A sparse library can return
/// an empty result instead of silently looking up online tracks.
List<UnifiedLibraryItem> rankRelatedLibraryTracks(
UnifiedLibraryItem seed,
List<UnifiedLibraryItem> candidates,
) {
final artists = splitArtistNames(seed.artistName).map(_normalized).toSet();
final seenPaths = {seed.filePath};
final seenSongs = {
'${_normalized(seed.artistName)}|${_normalized(seed.trackName)}',
};
final scored = <(UnifiedLibraryItem, int)>[];
for (final item in candidates) {
final path = item.filePath.trim();
if (path.isEmpty ||
path.startsWith('http://') ||
path.startsWith('https://')) {
continue;
}
final sameArtist = splitArtistNames(
item.artistName,
).map(_normalized).any(artists.contains);
final sameGenre =
_normalized(seed.genre).isNotEmpty &&
_normalized(seed.genre) == _normalized(item.genre);
final sameAlbum =
sameArtist &&
_normalized(seed.albumName).isNotEmpty &&
_normalized(seed.albumName) == _normalized(item.albumName);
final score =
(sameArtist ? 5 : 0) + (sameGenre ? 4 : 0) + (sameAlbum ? 3 : 0);
if (score == 0 || !seenPaths.add(path)) continue;
final song =
'${_normalized(item.artistName)}|${_normalized(item.trackName)}';
if (!seenSongs.add(song)) continue;
scored.add((item, score));
}
scored.sort((a, b) {
final score = b.$2.compareTo(a.$2);
return score != 0 ? score : a.$1.trackName.compareTo(b.$1.trackName);
});
return scored.map((entry) => entry.$1).toList(growable: false);
}
Future<List<UnifiedLibraryItem>> loadRelatedLibraryTracks(
UnifiedLibraryItem seed, {
required bool includeLocal,
}) async {
final database = LibraryDatabase.instance;
final count = (await database.getQueueCounts(
QueueLibraryDbQuery(includeLocal: includeLocal),
)).allTrackCount;
final rows = <Map<String, dynamic>>[];
for (final artist in splitArtistNames(seed.artistName).take(4)) {
rows.addAll(
await database.getQueueTrackPage(
QueueLibraryDbQuery(
includeLocal: includeLocal,
searchQuery: artist,
limit: 96,
),
),
);
}
rows.addAll(
await database.getQueueTrackPage(
QueueLibraryDbQuery(
includeLocal: includeLocal,
offset: count > 256 ? Random().nextInt(count - 255) : 0,
limit: 256,
),
),
);
final candidates = <UnifiedLibraryItem>[];
for (final row in rows) {
final json = Map<String, dynamic>.from(row['item'] as Map);
if (row['source'] == 'local') {
candidates.add(
UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem.fromJson(json)),
);
} else if (row['source'] == 'downloaded') {
candidates.add(
UnifiedLibraryItem.fromDownloadHistory(
DownloadHistoryItem.fromJson(json),
),
);
}
}
// Bound storage-provider probes as well as database pages.
final ranked = rankRelatedLibraryTracks(seed, candidates).take(48).toList();
final exists = await fileExistenceByPath(
ranked.map((item) => item.filePath).toList(),
);
return ranked
.where((item) => exists[item.filePath] == true)
.take(24)
.toList(growable: false);
}
@@ -3,69 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/related_library_tracks.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
UnifiedLibraryItem _track(
String id, {
String artist = 'First Artist',
String album = 'Album',
String? genre,
String? path,
}) => UnifiedLibraryItem.fromLocalLibrary(
LocalLibraryItem(
id: id,
trackName: id,
artistName: artist,
albumName: album,
genre: genre,
filePath: path ?? '/$id.flac',
scannedAt: DateTime(2026),
),
);
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
test(
'related Library songs rank credits, deduplicate and exclude remote files',
() {
final seed = _track(
'seed',
artist: 'First Artist feat. Second Artist',
genre: 'Rock',
);
final album = _track('album', genre: 'Rock');
final genre = _track(
'genre',
artist: 'Other',
album: 'Other Album',
genre: 'Rock',
);
final collaborator = _track(
'collaborator',
artist: 'Second Artist',
album: 'Other Album',
);
final results = rankRelatedLibraryTracks(seed, [
seed,
album,
album,
genre,
collaborator,
_track('unrelated', artist: 'Other', album: 'Other Album'),
_track('remote', path: 'https://example.com/music.flac'),
_track('album', path: '/duplicate.flac', genre: 'Rock'),
]);
expect(results.map((item) => item.trackName), [
'album',
'collaborator',
'genre',
]);
},
);
for (final mornye in [false, true]) {
testWidgets('artist picker offers each featured credit ($mornye)', (
+3
View File
@@ -264,6 +264,9 @@ void main() {
);
await tester.pumpAndSettle();
await _tapVisible(tester, find.byIcon(CupertinoIcons.ellipsis));
expect(find.text('Edit audio file'), findsNothing);
expect(find.text('Similar songs in Library'), findsNothing);
expect(find.text('Edit Metadata'), findsOneWidget);
final lastAction = find.text('Remove from device');
expect(lastAction.hitTestable(), findsNothing);
expect(find.byType(MornyeContextMenu), findsOneWidget);
+2 -3
View File
@@ -165,9 +165,8 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Edit audio file'));
await tester.tap(find.text('Edit audio file'));
await tester.pumpAndSettle();
expect(find.text('Edit audio file'), findsNothing);
expect(find.text('Similar songs in Library'), findsNothing);
await tester.ensureVisible(find.text('Edit Metadata'));
await tester.tap(find.text('Edit Metadata'));
await _settleFileIo(tester);
+3 -4
View File
@@ -142,10 +142,9 @@ void main() {
Future<void> openRemoval() async {
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
expect(find.text('Remove ReplayGain'), findsNothing);
await tester.ensureVisible(find.text('Edit audio file'));
await tester.tap(find.text('Edit audio file'));
await tester.pumpAndSettle();
expect(find.text('Edit audio file'), findsNothing);
expect(find.text('Similar songs in Library'), findsNothing);
expect(find.text('Remove ReplayGain'), findsOneWidget);
await tester.ensureVisible(find.text('Remove ReplayGain'));
await tester.tap(find.text('Remove ReplayGain'));
await tester.pumpAndSettle();