Files
SpotiFLAC-Mobile/lib/utils/clickable_metadata.dart
T
zarzet 16ce6089fb feat: remove Tidal built-in provider, add extension download dedup/ISRC/Lyrics APIs, and expand l10n/a11y
Remove Tidal from built-in provider registry (metadata, search, download,
URL parsing) and delete tidal.go. Introduce extension runtime APIs for
lyrics lookup (getLyricsLRC), ISRC existence check (checkISRCExists), and
ISRC index management (addToISRCIndex). Refactor extension download response
construction into normalizeExtensionDownloadResult/overlayExtensionDownloadMetadata
helpers with AlreadyExists support and ISRC indexing. Switch download mirrors
to DoRequestWithUserAgent for ISP blocking detection. Add 50+ new
localization keys and accessibility labels across all supported locales.
2026-04-18 22:12:14 +07:00

569 lines
15 KiB
Dart

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/extension_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');
const _deezerExtensionId = 'deezer';
Future<List<Map<String, dynamic>>> _searchDeezerExtension(
String query, {
required String filter,
int limit = 5,
}) {
return PlatformBridge.customSearchWithExtension(
_deezerExtensionId,
query,
options: {'filter': filter, 'limit': limit},
);
}
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 artistList = await _searchDeezerExtension(
artistName,
filter: 'artist',
limit: 3,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
if (artistList.isEmpty) {
_showUnavailable(context, context.l10n.trackArtist);
return;
}
Map<String, dynamic>? bestMatch;
final lowerName = artistName.toLowerCase().trim();
for (final a in artistList) {
final name = (a['name'] as String? ?? '').toLowerCase().trim();
if (name == lowerName) {
bestMatch = a;
break;
}
}
bestMatch ??= artistList.first;
final resolvedId = bestMatch['id'] as String? ?? '';
final resolvedName = bestMatch['name'] as String? ?? artistName;
final resolvedImage = bestMatch['images'] as String?;
if (resolvedId.isEmpty) {
_showUnavailable(context, context.l10n.trackArtist);
return;
}
if (!context.mounted) return;
_pushArtistScreen(
context,
artistId: resolvedId,
artistName: resolvedName,
coverUrl: resolvedImage ?? coverUrl,
extensionId: _deezerExtensionId,
);
} 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 &&
albumId != 'unknown' &&
albumId != 'deezer:unknown') {
_pushAlbumScreen(
context,
albumId: albumId,
albumName: albumName,
coverUrl: coverUrl,
extensionId: extensionId,
);
return;
}
if (extensionId != null) {
_showUnavailable(context, 'Album');
return;
}
_showLoadingSnackBar(context, 'Looking up album...');
try {
final query = artistName != null && artistName.isNotEmpty
? '$albumName $artistName'
: albumName;
final albumList = await _searchDeezerExtension(
query,
filter: 'album',
limit: 5,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
if (albumList.isEmpty) {
_showUnavailable(context, 'Album');
return;
}
Map<String, dynamic>? bestMatch;
final lowerName = albumName.toLowerCase().trim();
for (final a in albumList) {
final name = (a['name'] as String? ?? '').toLowerCase().trim();
if (name == lowerName) {
bestMatch = a;
break;
}
}
bestMatch ??= albumList.first;
final resolvedId = bestMatch['id'] as String? ?? '';
final resolvedName = bestMatch['name'] as String? ?? albumName;
final resolvedImage = bestMatch['images'] as String?;
if (resolvedId.isEmpty) {
_showUnavailable(context, 'Album');
return;
}
if (!context.mounted) return;
_pushAlbumScreen(
context,
albumId: resolvedId,
albumName: resolvedName,
coverUrl: resolvedImage ?? coverUrl,
extensionId: _deezerExtensionId,
);
} catch (e) {
_log.e('Failed to look up album "$albumName": $e', e);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
_showUnavailable(context, 'Album');
}
}
void _pushArtistScreen(
BuildContext context, {
required String artistId,
required String artistName,
String? coverUrl,
String? extensionId,
}) {
_pushViaPreferredNavigator(
context,
(context) => extensionId != null
? ExtensionArtistScreen(
extensionId: extensionId,
artistId: artistId,
artistName: artistName,
coverUrl: coverUrl,
)
: ArtistScreen(
artistId: artistId,
artistName: artistName,
coverUrl: coverUrl,
),
);
}
void _pushAlbumScreen(
BuildContext context, {
required String albumId,
required String albumName,
String? coverUrl,
String? extensionId,
}) {
final isExtension =
extensionId != null && !isBuiltInMetadataProvider(extensionId);
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,
tracks: const [],
),
);
}
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.length == uniqueNames.length) {
return List<_ArtistTapTarget>.generate(
uniqueNames.length,
(index) => _ArtistTapTarget(
name: uniqueNames[index],
artistId: parsedIds[index],
),
growable: false,
);
}
return uniqueNames
.map((name) => _ArtistTapTarget(name: name))
.toList(growable: false);
}
List<String> _parseArtistIds(String? rawArtistIds) {
final raw = rawArtistIds?.trim();
if (raw == null || raw.isEmpty) return const [];
final parsed = <String>[];
for (final part in raw.split(RegExp(r'\s*,\s*'))) {
final normalized = _normalizeArtistId(part);
if (normalized != null) {
parsed.add(normalized);
}
}
return parsed;
}
String? _normalizeArtistId(String? artistId) {
final id = artistId?.trim();
if (id == null || id.isEmpty || id == 'unknown' || id == 'deezer:unknown') {
return null;
}
return id;
}
bool _canNavigateArtistDirectly({
required String artistId,
required String? extensionId,
}) {
if (extensionId != null) return true;
if (artistId.startsWith('deezer:')) return true;
return _spotifyArtistIdPattern.hasMatch(artistId);
}
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,
),
);
}
}