mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-13 20:42:10 +02:00
16ce6089fb
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.
569 lines
15 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|