mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-03 01:28:37 +02:00
856 lines
24 KiB
Dart
856 lines
24 KiB
Dart
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/models/track.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/screens/artist_screen.dart';
|
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
|
import 'package:spotiflac_android/screens/home_tab.dart'
|
|
show ExtensionArtistScreen, ExtensionAlbumScreen;
|
|
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
|
import 'package:spotiflac_android/utils/artist_utils.dart';
|
|
import 'package:spotiflac_android/utils/logger.dart';
|
|
|
|
final _log = AppLogger('ClickableMetadata');
|
|
|
|
class _MetadataSearchResult {
|
|
final String providerId;
|
|
final List<Map<String, dynamic>> items;
|
|
|
|
const _MetadataSearchResult({required this.providerId, required this.items});
|
|
}
|
|
|
|
Future<_MetadataSearchResult?> _searchMetadataProviders(
|
|
BuildContext context,
|
|
String query, {
|
|
required String filter,
|
|
int limit = 5,
|
|
String? sourceProviderId,
|
|
}) async {
|
|
final providerIds = _metadataSearchProviderCandidates(
|
|
context,
|
|
sourceProviderId: sourceProviderId,
|
|
);
|
|
|
|
for (final providerId in providerIds) {
|
|
try {
|
|
final items = await _searchMetadataProvider(
|
|
providerId,
|
|
query,
|
|
filter: filter,
|
|
limit: limit,
|
|
);
|
|
if (items.isNotEmpty) {
|
|
return _MetadataSearchResult(providerId: providerId, items: items);
|
|
}
|
|
} catch (e) {
|
|
_log.w(
|
|
'Metadata lookup failed for provider "$providerId", filter=$filter: $e',
|
|
);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> _searchMetadataProvider(
|
|
String providerId,
|
|
String query, {
|
|
required String filter,
|
|
required int limit,
|
|
}) async {
|
|
return PlatformBridge.customSearchWithExtension(
|
|
providerId,
|
|
query,
|
|
options: {'filter': filter, 'limit': limit},
|
|
);
|
|
}
|
|
|
|
List<String> _metadataSearchProviderCandidates(
|
|
BuildContext context, {
|
|
String? sourceProviderId,
|
|
}) {
|
|
final container = ProviderScope.containerOf(context, listen: false);
|
|
final extensionState = container.read(extensionProvider);
|
|
final settings = container.read(settingsProvider);
|
|
final extensionNotifier = container.read(extensionProvider.notifier);
|
|
final candidates = <String>[];
|
|
|
|
void addProvider(String? providerId) {
|
|
final normalized = providerId?.trim();
|
|
if (normalized == null ||
|
|
normalized.isEmpty ||
|
|
candidates.contains(normalized) ||
|
|
!_canSearchMetadataProvider(normalized, extensionState)) {
|
|
return;
|
|
}
|
|
candidates.add(normalized);
|
|
}
|
|
|
|
addProvider(sourceProviderId);
|
|
addProvider(settings.searchProvider);
|
|
|
|
for (final providerId in extensionState.metadataProviderPriority) {
|
|
addProvider(providerId);
|
|
}
|
|
for (final providerId in extensionNotifier.getAllMetadataProviders()) {
|
|
addProvider(providerId);
|
|
}
|
|
|
|
final searchExtensions = extensionState.extensions
|
|
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
|
.toList(growable: false);
|
|
for (final extension in searchExtensions.where(
|
|
(ext) => ext.searchBehavior?.primary == true,
|
|
)) {
|
|
addProvider(extension.id);
|
|
}
|
|
for (final extension in searchExtensions.where(
|
|
(ext) => ext.searchBehavior?.primary != true,
|
|
)) {
|
|
addProvider(extension.id);
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
bool _canSearchMetadataProvider(
|
|
String providerId,
|
|
ExtensionState extensionState,
|
|
) {
|
|
return extensionState.extensions.any(
|
|
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == providerId,
|
|
);
|
|
}
|
|
|
|
Future<void> navigateToArtist(
|
|
BuildContext context, {
|
|
required String artistName,
|
|
String? artistId,
|
|
String? coverUrl,
|
|
String? extensionId,
|
|
}) async {
|
|
if (artistName.isEmpty) return;
|
|
|
|
final normalizedArtistId = _normalizeArtistId(artistId);
|
|
|
|
if (normalizedArtistId != null &&
|
|
_canNavigateArtistDirectly(
|
|
artistId: normalizedArtistId,
|
|
extensionId: extensionId,
|
|
)) {
|
|
_pushArtistScreen(
|
|
context,
|
|
artistId: normalizedArtistId,
|
|
artistName: artistName,
|
|
coverUrl: coverUrl,
|
|
extensionId: extensionId,
|
|
);
|
|
return;
|
|
}
|
|
|
|
_showLoadingSnackBar(context, context.l10n.clickableLookingUpArtist);
|
|
try {
|
|
final searchResult = await _searchMetadataProviders(
|
|
context,
|
|
artistName,
|
|
filter: 'artist',
|
|
limit: 20,
|
|
sourceProviderId: extensionId,
|
|
);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
|
|
final artistList = searchResult?.items ?? const <Map<String, dynamic>>[];
|
|
if (artistList.isEmpty) {
|
|
_showUnavailable(context, context.l10n.trackArtist);
|
|
return;
|
|
}
|
|
|
|
final bestMatch = _pickBestResultByName(artistList, artistName);
|
|
if (bestMatch == null) {
|
|
_showUnavailable(context, context.l10n.trackArtist);
|
|
return;
|
|
}
|
|
|
|
final resolvedId = bestMatch['id'] as String? ?? '';
|
|
final resolvedName = bestMatch['name'] as String? ?? artistName;
|
|
final resolvedImage = bestMatch['images'] as String?;
|
|
final resolvedProviderId = _resolveResultProviderId(
|
|
bestMatch,
|
|
searchResult?.providerId,
|
|
);
|
|
|
|
if (resolvedId.isEmpty) {
|
|
_showUnavailable(context, context.l10n.trackArtist);
|
|
return;
|
|
}
|
|
|
|
if (!context.mounted) return;
|
|
_pushArtistScreen(
|
|
context,
|
|
artistId: resolvedId,
|
|
artistName: resolvedName,
|
|
coverUrl: resolvedImage ?? coverUrl,
|
|
extensionId: resolvedProviderId,
|
|
);
|
|
} catch (e) {
|
|
_log.e('Failed to look up artist "$artistName": $e', e);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
_showUnavailable(context, context.l10n.trackArtist);
|
|
}
|
|
}
|
|
|
|
Future<void> navigateToAlbum(
|
|
BuildContext context, {
|
|
required String albumName,
|
|
String? albumId,
|
|
String? artistName,
|
|
String? coverUrl,
|
|
String? extensionId,
|
|
}) async {
|
|
if (albumName.isEmpty) return;
|
|
|
|
if (albumId != null && albumId.isNotEmpty && !_isUnknownResourceId(albumId)) {
|
|
_pushAlbumScreen(
|
|
context,
|
|
albumId: albumId,
|
|
albumName: albumName,
|
|
coverUrl: coverUrl,
|
|
extensionId: extensionId,
|
|
);
|
|
return;
|
|
}
|
|
|
|
_showLoadingSnackBar(context, 'Looking up album...');
|
|
try {
|
|
final query = artistName != null && artistName.isNotEmpty
|
|
? '$albumName $artistName'
|
|
: albumName;
|
|
|
|
final searchResult = await _searchMetadataProviders(
|
|
context,
|
|
query,
|
|
filter: 'album',
|
|
limit: 20,
|
|
sourceProviderId: extensionId,
|
|
);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
|
|
final albumList = searchResult?.items ?? const <Map<String, dynamic>>[];
|
|
if (albumList.isEmpty) {
|
|
_showUnavailable(context, 'Album');
|
|
return;
|
|
}
|
|
|
|
final bestMatch = _pickBestResultByName(albumList, albumName);
|
|
if (bestMatch == null) {
|
|
_showUnavailable(context, 'Album');
|
|
return;
|
|
}
|
|
|
|
final resolvedId = bestMatch['id'] as String? ?? '';
|
|
final resolvedName = bestMatch['name'] as String? ?? albumName;
|
|
final resolvedImage = bestMatch['images'] as String?;
|
|
final resolvedProviderId = _resolveResultProviderId(
|
|
bestMatch,
|
|
searchResult?.providerId,
|
|
);
|
|
|
|
if (resolvedId.isEmpty) {
|
|
_showUnavailable(context, 'Album');
|
|
return;
|
|
}
|
|
|
|
if (!context.mounted) return;
|
|
_pushAlbumScreen(
|
|
context,
|
|
albumId: resolvedId,
|
|
albumName: resolvedName,
|
|
coverUrl: resolvedImage ?? coverUrl,
|
|
extensionId: resolvedProviderId,
|
|
);
|
|
} catch (e) {
|
|
_log.e('Failed to look up album "$albumName": $e', e);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
_showUnavailable(context, 'Album');
|
|
}
|
|
}
|
|
|
|
/// Opens the album for a track, enriching sparse search results first when
|
|
/// they do not include album identity. This keeps collection actions generic:
|
|
/// any metadata extension can supply the missing track fields through search.
|
|
Future<void> navigateToTrackAlbum(BuildContext context, Track track) async {
|
|
final albumName = track.albumName.trim();
|
|
final albumId = track.albumId?.trim();
|
|
final hasAlbumId = albumId != null && !_isUnknownResourceId(albumId);
|
|
|
|
if (albumName.isNotEmpty || hasAlbumId) {
|
|
await navigateToAlbum(
|
|
context,
|
|
albumName: albumName.isNotEmpty ? albumName : context.l10n.trackAlbum,
|
|
albumId: hasAlbumId ? albumId : null,
|
|
artistName: _preferredAlbumArtist(track),
|
|
coverUrl: track.coverUrl,
|
|
extensionId: track.source,
|
|
);
|
|
return;
|
|
}
|
|
|
|
final query = [
|
|
track.name.trim(),
|
|
track.artistName.trim(),
|
|
].where((part) => part.isNotEmpty).join(' ');
|
|
if (query.isEmpty) {
|
|
_showUnavailable(context, context.l10n.trackAlbum);
|
|
return;
|
|
}
|
|
|
|
_showLoadingSnackBar(context, 'Looking up album...');
|
|
try {
|
|
final searchResult = await _searchMetadataProviders(
|
|
context,
|
|
query,
|
|
filter: 'track',
|
|
limit: 20,
|
|
sourceProviderId: track.source,
|
|
);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
|
|
final matches = searchResult?.items ?? const <Map<String, dynamic>>[];
|
|
final bestMatch = _pickBestResultByName(matches, track.name);
|
|
if (bestMatch == null) {
|
|
_showUnavailable(context, context.l10n.trackAlbum);
|
|
return;
|
|
}
|
|
|
|
final providerId = _resolveResultProviderId(
|
|
bestMatch,
|
|
searchResult?.providerId,
|
|
);
|
|
final resolvedTrack = Track.fromBackendMap(bestMatch, source: providerId);
|
|
final resolvedAlbumName = resolvedTrack.albumName.trim();
|
|
final resolvedAlbumId = resolvedTrack.albumId?.trim();
|
|
final hasResolvedAlbumId =
|
|
resolvedAlbumId != null && !_isUnknownResourceId(resolvedAlbumId);
|
|
if (resolvedAlbumName.isEmpty && !hasResolvedAlbumId) {
|
|
_showUnavailable(context, context.l10n.trackAlbum);
|
|
return;
|
|
}
|
|
|
|
await navigateToAlbum(
|
|
context,
|
|
albumName: resolvedAlbumName.isNotEmpty
|
|
? resolvedAlbumName
|
|
: context.l10n.trackAlbum,
|
|
albumId: hasResolvedAlbumId ? resolvedAlbumId : null,
|
|
artistName: _preferredAlbumArtist(resolvedTrack),
|
|
coverUrl: resolvedTrack.coverUrl ?? track.coverUrl,
|
|
extensionId: providerId,
|
|
);
|
|
} catch (e) {
|
|
_log.e('Failed to resolve album for track "${track.name}": $e', e);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
_showUnavailable(context, context.l10n.trackAlbum);
|
|
}
|
|
}
|
|
|
|
String _preferredAlbumArtist(Track track) {
|
|
final albumArtist = (track.albumArtist ?? '').trim();
|
|
return albumArtist.isNotEmpty ? albumArtist : track.artistName;
|
|
}
|
|
|
|
void _pushArtistScreen(
|
|
BuildContext context, {
|
|
required String artistId,
|
|
required String artistName,
|
|
String? coverUrl,
|
|
String? extensionId,
|
|
}) {
|
|
final isExtension = extensionId != null;
|
|
final resolvedProviderId = extensionId;
|
|
|
|
pushViaPreferredNavigator(
|
|
context,
|
|
(context) => isExtension && resolvedProviderId != null
|
|
? ExtensionArtistScreen(
|
|
extensionId: resolvedProviderId,
|
|
artistId: artistId,
|
|
artistName: artistName,
|
|
coverUrl: coverUrl,
|
|
)
|
|
: ArtistScreen(
|
|
artistId: artistId,
|
|
artistName: artistName,
|
|
coverUrl: coverUrl,
|
|
extensionId: resolvedProviderId,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _pushAlbumScreen(
|
|
BuildContext context, {
|
|
required String albumId,
|
|
required String albumName,
|
|
String? coverUrl,
|
|
String? extensionId,
|
|
}) {
|
|
final isExtension = extensionId != null;
|
|
final resolvedExtensionId = extensionId;
|
|
|
|
pushViaPreferredNavigator(
|
|
context,
|
|
(context) => isExtension && resolvedExtensionId != null
|
|
? ExtensionAlbumScreen(
|
|
extensionId: resolvedExtensionId,
|
|
albumId: albumId,
|
|
albumName: albumName,
|
|
coverUrl: coverUrl,
|
|
)
|
|
: AlbumScreen(
|
|
albumId: albumId,
|
|
albumName: albumName,
|
|
coverUrl: coverUrl,
|
|
extensionId: resolvedExtensionId,
|
|
tracks: const [],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Pushes detail pages into the active tab navigator when invoked from a root
|
|
/// modal such as Now Playing. The root modal is dismissed first so the album
|
|
/// is immediately visible instead of being hidden behind it.
|
|
void pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) {
|
|
final currentNavigator = Navigator.of(context);
|
|
final rootNavigator = Navigator.of(context, rootNavigator: true);
|
|
final activeTabNavigator = ShellNavigationService.activeTabNavigator();
|
|
|
|
final shouldRouteToTabNavigator =
|
|
identical(currentNavigator, rootNavigator) && activeTabNavigator != null;
|
|
|
|
if (!shouldRouteToTabNavigator) {
|
|
currentNavigator.push(MaterialPageRoute<void>(builder: builder));
|
|
return;
|
|
}
|
|
|
|
final currentRoute = ModalRoute.of(context);
|
|
final shouldPopCurrentRoute =
|
|
currentRoute != null && currentRoute.isFirst == false;
|
|
|
|
if (shouldPopCurrentRoute && currentNavigator.canPop()) {
|
|
currentNavigator.pop();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!activeTabNavigator.mounted) return;
|
|
activeTabNavigator.push(MaterialPageRoute<void>(builder: builder));
|
|
});
|
|
return;
|
|
}
|
|
|
|
activeTabNavigator.push(MaterialPageRoute<void>(builder: builder));
|
|
}
|
|
|
|
void _showLoadingSnackBar(BuildContext context, String message) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(message),
|
|
],
|
|
),
|
|
duration: const Duration(seconds: 10),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showUnavailable(BuildContext context, String type) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.clickableInformationUnavailable(type))),
|
|
);
|
|
}
|
|
|
|
class ClickableArtistName extends StatefulWidget {
|
|
final String artistName;
|
|
final String? artistId;
|
|
final String? coverUrl;
|
|
final String? extensionId;
|
|
final TextStyle? style;
|
|
final int? maxLines;
|
|
final TextOverflow? overflow;
|
|
final TextAlign? textAlign;
|
|
|
|
const ClickableArtistName({
|
|
super.key,
|
|
required this.artistName,
|
|
this.artistId,
|
|
this.coverUrl,
|
|
this.extensionId,
|
|
this.style,
|
|
this.maxLines,
|
|
this.overflow,
|
|
this.textAlign,
|
|
});
|
|
|
|
@override
|
|
State<ClickableArtistName> createState() => _ClickableArtistNameState();
|
|
}
|
|
|
|
class _ClickableArtistNameState extends State<ClickableArtistName> {
|
|
List<_ArtistTapTarget> _artistTargets = const [];
|
|
final List<TapGestureRecognizer> _recognizers = [];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_rebuildArtistTargets();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant ClickableArtistName oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.artistName != widget.artistName ||
|
|
oldWidget.artistId != widget.artistId ||
|
|
oldWidget.coverUrl != widget.coverUrl ||
|
|
oldWidget.extensionId != widget.extensionId) {
|
|
_rebuildArtistTargets();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_disposeRecognizers();
|
|
super.dispose();
|
|
}
|
|
|
|
void _disposeRecognizers() {
|
|
for (final recognizer in _recognizers) {
|
|
recognizer.dispose();
|
|
}
|
|
_recognizers.clear();
|
|
}
|
|
|
|
void _rebuildArtistTargets() {
|
|
_disposeRecognizers();
|
|
_artistTargets = _buildArtistTapTargets(widget.artistName, widget.artistId);
|
|
if (_artistTargets.length <= 1) return;
|
|
|
|
for (final target in _artistTargets) {
|
|
final recognizer = TapGestureRecognizer()
|
|
..onTap = () => navigateToArtist(
|
|
context,
|
|
artistName: target.name,
|
|
artistId: target.artistId,
|
|
coverUrl: widget.coverUrl,
|
|
extensionId: _extensionIdForTarget(target),
|
|
);
|
|
_recognizers.add(recognizer);
|
|
}
|
|
}
|
|
|
|
String? _extensionIdForTarget(_ArtistTapTarget target) {
|
|
if (widget.extensionId == null) return null;
|
|
if (_artistTargets.length == 1) return widget.extensionId;
|
|
return target.artistId != null ? widget.extensionId : null;
|
|
}
|
|
|
|
List<InlineSpan> _buildMultiArtistSpans() {
|
|
final spans = <InlineSpan>[];
|
|
for (var i = 0; i < _artistTargets.length; i++) {
|
|
final target = _artistTargets[i];
|
|
spans.add(
|
|
TextSpan(
|
|
text: target.name,
|
|
style: widget.style,
|
|
recognizer: _recognizers[i],
|
|
),
|
|
);
|
|
if (i < _artistTargets.length - 1) {
|
|
spans.add(TextSpan(text: ', ', style: widget.style));
|
|
}
|
|
}
|
|
return spans;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_artistTargets.isEmpty) {
|
|
return Text(
|
|
widget.artistName,
|
|
style: widget.style,
|
|
maxLines: widget.maxLines,
|
|
overflow: widget.overflow,
|
|
textAlign: widget.textAlign,
|
|
);
|
|
}
|
|
|
|
if (_artistTargets.length == 1) {
|
|
final target = _artistTargets.first;
|
|
return GestureDetector(
|
|
onTap: () => navigateToArtist(
|
|
context,
|
|
artistName: target.name,
|
|
artistId: target.artistId,
|
|
coverUrl: widget.coverUrl,
|
|
extensionId: _extensionIdForTarget(target),
|
|
),
|
|
child: Text(
|
|
target.name,
|
|
style: widget.style,
|
|
maxLines: widget.maxLines,
|
|
overflow: widget.overflow,
|
|
textAlign: widget.textAlign,
|
|
),
|
|
);
|
|
}
|
|
|
|
return Text.rich(
|
|
TextSpan(style: widget.style, children: _buildMultiArtistSpans()),
|
|
maxLines: widget.maxLines,
|
|
overflow: widget.overflow ?? TextOverflow.clip,
|
|
textAlign: widget.textAlign ?? TextAlign.start,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ArtistTapTarget {
|
|
final String name;
|
|
final String? artistId;
|
|
|
|
const _ArtistTapTarget({required this.name, this.artistId});
|
|
}
|
|
|
|
List<_ArtistTapTarget> _buildArtistTapTargets(
|
|
String rawArtistNames,
|
|
String? rawArtistIds,
|
|
) {
|
|
final parsedNames = splitArtistNames(rawArtistNames);
|
|
if (parsedNames.isEmpty) return const [];
|
|
|
|
final uniqueNames = <String>[];
|
|
final seen = <String>{};
|
|
for (final parsed in parsedNames) {
|
|
final key = parsed.toLowerCase().replaceAll(RegExp(r'\s+'), ' ').trim();
|
|
if (key.isEmpty || !seen.add(key)) continue;
|
|
uniqueNames.add(parsed);
|
|
}
|
|
if (uniqueNames.isEmpty) return const [];
|
|
|
|
if (uniqueNames.length == 1) {
|
|
return [
|
|
_ArtistTapTarget(
|
|
name: uniqueNames.first,
|
|
artistId: _normalizeArtistId(rawArtistIds),
|
|
),
|
|
];
|
|
}
|
|
|
|
final parsedIds = _parseArtistIds(rawArtistIds);
|
|
if (parsedIds.isEmpty || !parsedIds.any((id) => id != null)) {
|
|
return uniqueNames
|
|
.map((name) => _ArtistTapTarget(name: name))
|
|
.toList(growable: false);
|
|
}
|
|
|
|
// Providers may return one id per artist (aligned with the names) or only
|
|
// the primary artist's id. Map ids to names positionally, preserving empty
|
|
// slots as null, so each tapped artist navigates by its own id when known.
|
|
return List<_ArtistTapTarget>.generate(
|
|
uniqueNames.length,
|
|
(index) => _ArtistTapTarget(
|
|
name: uniqueNames[index],
|
|
artistId: index < parsedIds.length ? parsedIds[index] : null,
|
|
),
|
|
growable: false,
|
|
);
|
|
}
|
|
|
|
List<String?> _parseArtistIds(String? rawArtistIds) {
|
|
final raw = rawArtistIds?.trim();
|
|
if (raw == null || raw.isEmpty) return const [];
|
|
|
|
return raw
|
|
.split(RegExp(r'\s*,\s*'))
|
|
.map(_normalizeArtistId)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
String? _normalizeArtistId(String? artistId) {
|
|
final id = artistId?.trim();
|
|
if (id == null || _isUnknownResourceId(id)) {
|
|
return null;
|
|
}
|
|
return id;
|
|
}
|
|
|
|
bool _isUnknownResourceId(String id) {
|
|
final normalized = id.trim().toLowerCase();
|
|
return normalized.isEmpty ||
|
|
normalized == 'unknown' ||
|
|
normalized.endsWith(':unknown');
|
|
}
|
|
|
|
String? _resolveResultProviderId(
|
|
Map<String, dynamic> result,
|
|
String? fallbackProviderId,
|
|
) {
|
|
final providerId = result['provider_id']?.toString().trim();
|
|
if (providerId != null && providerId.isNotEmpty) return providerId;
|
|
final source = result['source']?.toString().trim();
|
|
if (source != null && source.isNotEmpty) return source;
|
|
final fallback = fallbackProviderId?.trim();
|
|
return fallback != null && fallback.isNotEmpty ? fallback : null;
|
|
}
|
|
|
|
bool _canNavigateArtistDirectly({
|
|
required String artistId,
|
|
required String? extensionId,
|
|
}) {
|
|
if (extensionId != null) return true;
|
|
return _spotifyArtistIdPattern.hasMatch(artistId);
|
|
}
|
|
|
|
/// Selects the result whose name best matches [query] instead of blindly
|
|
/// trusting the provider's first result. This prevents tapping an artist like
|
|
/// "creo" from opening a completely unrelated artist (e.g. "Tyler, the
|
|
/// Creator") just because the provider ranked it first.
|
|
Map<String, dynamic>? _pickBestResultByName(
|
|
List<Map<String, dynamic>> results,
|
|
String query,
|
|
) {
|
|
if (results.isEmpty) return null;
|
|
|
|
final normalizedQuery = _normalizeForMatch(query);
|
|
if (normalizedQuery.isEmpty) return results.first;
|
|
|
|
Map<String, dynamic>? best;
|
|
double bestScore = -1;
|
|
for (final result in results) {
|
|
final name = result['name'] as String? ?? '';
|
|
final normalizedName = _normalizeForMatch(name);
|
|
if (normalizedName.isEmpty) continue;
|
|
|
|
if (normalizedName == normalizedQuery) {
|
|
return result;
|
|
}
|
|
|
|
final score = _nameMatchScore(normalizedQuery, normalizedName);
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = result;
|
|
}
|
|
}
|
|
|
|
// Accept a fuzzy match only when it is reasonably close. Otherwise fall back
|
|
// to the provider's own top result so we never crash on an empty match.
|
|
if (best != null && bestScore >= 0.5) {
|
|
if (bestScore < 0.85) {
|
|
_log.w(
|
|
'No exact match for "$query"; using closest result '
|
|
'"${best['name']}" (score ${bestScore.toStringAsFixed(2)})',
|
|
);
|
|
}
|
|
return best;
|
|
}
|
|
|
|
_log.w(
|
|
'No close match for "$query" among ${results.length} results; '
|
|
'falling back to first result "${results.first['name']}"',
|
|
);
|
|
return results.first;
|
|
}
|
|
|
|
String _normalizeForMatch(String value) {
|
|
return value
|
|
.toLowerCase()
|
|
.replaceAll(RegExp(r'[^\p{L}\p{N}\s]', unicode: true), ' ')
|
|
.replaceAll(RegExp(r'\s+'), ' ')
|
|
.trim();
|
|
}
|
|
|
|
/// Returns a similarity score in [0, 1] between two normalized names.
|
|
double _nameMatchScore(String query, String candidate) {
|
|
if (query == candidate) return 1.0;
|
|
|
|
final queryTokens = query.split(' ').where((t) => t.isNotEmpty).toSet();
|
|
final candidateTokens = candidate
|
|
.split(' ')
|
|
.where((t) => t.isNotEmpty)
|
|
.toSet();
|
|
if (queryTokens.isEmpty || candidateTokens.isEmpty) return 0;
|
|
|
|
final intersection = queryTokens.intersection(candidateTokens).length;
|
|
final union = queryTokens.union(candidateTokens).length;
|
|
final jaccard = union == 0 ? 0.0 : intersection / union;
|
|
|
|
// Reward full substring containment of the (shorter) query in the candidate.
|
|
double containment = 0;
|
|
if (candidate.contains(query) || query.contains(candidate)) {
|
|
final shorter = query.length < candidate.length ? query : candidate;
|
|
final longer = query.length < candidate.length ? candidate : query;
|
|
containment = longer.isEmpty ? 0 : shorter.length / longer.length;
|
|
}
|
|
|
|
return jaccard > containment ? jaccard : containment;
|
|
}
|
|
|
|
final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
|
|
|
|
class ClickableAlbumName extends StatelessWidget {
|
|
final String albumName;
|
|
final String? albumId;
|
|
final String? artistName;
|
|
final String? coverUrl;
|
|
final String? extensionId;
|
|
final TextStyle? style;
|
|
final int? maxLines;
|
|
final TextOverflow? overflow;
|
|
final TextAlign? textAlign;
|
|
|
|
const ClickableAlbumName({
|
|
super.key,
|
|
required this.albumName,
|
|
this.albumId,
|
|
this.artistName,
|
|
this.coverUrl,
|
|
this.extensionId,
|
|
this.style,
|
|
this.maxLines,
|
|
this.overflow,
|
|
this.textAlign,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: () => navigateToAlbum(
|
|
context,
|
|
albumName: albumName,
|
|
albumId: albumId,
|
|
artistName: artistName,
|
|
coverUrl: coverUrl,
|
|
extensionId: extensionId,
|
|
),
|
|
child: Text(
|
|
albumName,
|
|
style: style,
|
|
maxLines: maxLines,
|
|
overflow: overflow,
|
|
textAlign: textAlign,
|
|
),
|
|
);
|
|
}
|
|
}
|