mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-05-31 19:05:05 +07:00
- Add flutter_localizations and intl dependencies - Create l10n.yaml configuration and ARB files (app_en.arb, app_id.arb) - Add L10n extension for easy context.l10n access - Localize all active screens: - setup_screen, track_metadata_screen, log_screen - download_settings_page, options_settings_page, appearance_settings_page - extensions_page, extension_detail_page, extension_details_screen - about_page, provider_priority_page, metadata_provider_priority_page - home_tab, queue_tab, store_tab, main_shell - album_screen, artist_screen, playlist_screen - downloaded_album_screen, queue_screen - Localize widgets: update_dialog, download_service_picker - Technical terms (FLAC, API, Spotify, Tidal, Qobuz, etc.) are NOT translated - ~900+ localized strings in English, ~660+ in Indonesian
1073 lines
36 KiB
Dart
1073 lines
36 KiB
Dart
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:open_filex/open_filex.dart';
|
|
import 'package:spotiflac_android/utils/mime_utils.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
|
|
/// Screen to display detailed metadata for a downloaded track
|
|
/// Designed with Material Expressive 3 style
|
|
class TrackMetadataScreen extends ConsumerStatefulWidget {
|
|
final DownloadHistoryItem item;
|
|
|
|
const TrackMetadataScreen({super.key, required this.item});
|
|
|
|
@override
|
|
ConsumerState<TrackMetadataScreen> createState() => _TrackMetadataScreenState();
|
|
}
|
|
|
|
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|
bool _fileExists = false;
|
|
int? _fileSize;
|
|
String? _lyrics;
|
|
bool _lyricsLoading = false;
|
|
String? _lyricsError;
|
|
|
|
String? _normalizeOptionalString(String? value) {
|
|
if (value == null) return null;
|
|
final trimmed = value.trim();
|
|
if (trimmed.isEmpty) return null;
|
|
if (trimmed.toLowerCase() == 'null') return null;
|
|
return trimmed;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_checkFile();
|
|
}
|
|
|
|
Future<void> _checkFile() async {
|
|
// Strip EXISTS: prefix from legacy history items
|
|
var filePath = widget.item.filePath;
|
|
if (filePath.startsWith('EXISTS:')) {
|
|
filePath = filePath.substring(7);
|
|
}
|
|
|
|
final file = File(filePath);
|
|
final exists = await file.exists();
|
|
int? size;
|
|
|
|
if (exists) {
|
|
try {
|
|
size = await file.length();
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_fileExists = exists;
|
|
_fileSize = size;
|
|
});
|
|
|
|
// Auto-load lyrics if file exists (embedded lyrics are instant)
|
|
if (exists) {
|
|
_fetchLyrics();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use data directly from history item (cached from download)
|
|
DownloadHistoryItem get item => widget.item;
|
|
String get trackName => item.trackName;
|
|
String get artistName => item.artistName;
|
|
String get albumName => item.albumName;
|
|
String? get albumArtist => _normalizeOptionalString(item.albumArtist);
|
|
int? get trackNumber => item.trackNumber;
|
|
int? get discNumber => item.discNumber;
|
|
String? get releaseDate => item.releaseDate;
|
|
String? get isrc => item.isrc;
|
|
|
|
// Clean filePath - strip EXISTS: prefix from legacy history items
|
|
String get cleanFilePath {
|
|
final path = item.filePath;
|
|
return path.startsWith('EXISTS:') ? path.substring(7) : path;
|
|
}
|
|
int? get bitDepth => item.bitDepth;
|
|
int? get sampleRate => item.sampleRate;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Scaffold(
|
|
body: CustomScrollView(
|
|
slivers: [
|
|
// App Bar with cover art background
|
|
SliverAppBar(
|
|
expandedHeight: 280,
|
|
pinned: true,
|
|
stretch: true,
|
|
backgroundColor: colorScheme.surface,
|
|
flexibleSpace: FlexibleSpaceBar(
|
|
background: _buildHeaderBackground(context, colorScheme),
|
|
stretchModes: const [
|
|
StretchMode.zoomBackground,
|
|
StretchMode.blurBackground,
|
|
],
|
|
),
|
|
leading: IconButton(
|
|
icon: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surface.withValues(alpha: 0.8),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
|
),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surface.withValues(alpha: 0.8),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(Icons.more_vert, color: colorScheme.onSurface),
|
|
),
|
|
onPressed: () => _showOptionsMenu(context, ref, colorScheme),
|
|
),
|
|
],
|
|
),
|
|
|
|
// Content
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Track info card
|
|
_buildTrackInfoCard(context, colorScheme, _fileExists),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Metadata card
|
|
_buildMetadataCard(context, colorScheme, _fileSize),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// File info card
|
|
_buildFileInfoCard(context, colorScheme, _fileExists, _fileSize),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Lyrics card
|
|
_buildLyricsCard(context, colorScheme),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
// Action buttons
|
|
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
|
|
|
const SizedBox(height: 32),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme) {
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
// Blurred background
|
|
if (item.coverUrl != null)
|
|
CachedNetworkImage(
|
|
imageUrl: item.coverUrl!,
|
|
fit: BoxFit.cover,
|
|
color: Colors.black.withValues(alpha: 0.5),
|
|
colorBlendMode: BlendMode.darken,
|
|
),
|
|
|
|
// Gradient overlay
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.transparent,
|
|
colorScheme.surface.withValues(alpha: 0.8),
|
|
colorScheme.surface,
|
|
],
|
|
stops: const [0.0, 0.7, 1.0],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Cover art centered
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 60),
|
|
child: Hero(
|
|
tag: 'cover_${item.id}',
|
|
child: Container(
|
|
width: 140,
|
|
height: 140,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 10),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: item.coverUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: item.coverUrl!,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, _) => Container(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: Icon(
|
|
Icons.music_note,
|
|
size: 48,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
)
|
|
: Container(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: Icon(
|
|
Icons.music_note,
|
|
size: 48,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildTrackInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists) {
|
|
return Card(
|
|
elevation: 0,
|
|
color: colorScheme.surfaceContainerLow,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Track name (from file metadata)
|
|
Text(
|
|
trackName,
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
|
|
// Artist name (from file metadata)
|
|
Text(
|
|
artistName,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: colorScheme.primary,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
|
|
// Album name (from file metadata)
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.album,
|
|
size: 16,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
albumName,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
// File status
|
|
if (!fileExists) ...[
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.errorContainer,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.warning_rounded,
|
|
size: 16,
|
|
color: colorScheme.onErrorContainer,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
context.l10n.trackFileNotFound,
|
|
style: TextStyle(
|
|
color: colorScheme.onErrorContainer,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMetadataCard(BuildContext context, ColorScheme colorScheme, int? fileSize) {
|
|
return Card(
|
|
elevation: 0,
|
|
color: colorScheme.surfaceContainerLow,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
size: 20,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
context.l10n.trackMetadata,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Metadata grid
|
|
_buildMetadataGrid(context, colorScheme),
|
|
|
|
// Streaming service link button
|
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Builder(
|
|
builder: (context) {
|
|
final isDeezer = item.spotifyId!.contains('deezer');
|
|
return OutlinedButton.icon(
|
|
onPressed: () => _openServiceUrl(context),
|
|
icon: const Icon(Icons.open_in_new, size: 18),
|
|
label: Text(isDeezer ? context.l10n.trackOpenInDeezer : context.l10n.trackOpenInSpotify),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openServiceUrl(BuildContext context) async {
|
|
if (item.spotifyId == null) return;
|
|
|
|
final isDeezer = item.spotifyId!.contains('deezer');
|
|
final rawId = item.spotifyId!.replaceAll('deezer:', '');
|
|
|
|
final webUrl = isDeezer
|
|
? 'https://www.deezer.com/track/$rawId'
|
|
: 'https://open.spotify.com/track/$rawId';
|
|
|
|
final appUri = isDeezer
|
|
? Uri.parse('deezer://www.deezer.com/track/$rawId')
|
|
: Uri.parse('spotify:track:$rawId');
|
|
|
|
try {
|
|
// Try to open in App first using URI scheme
|
|
final launched = await launchUrl(
|
|
appUri,
|
|
mode: LaunchMode.externalApplication,
|
|
);
|
|
|
|
if (!launched) {
|
|
// Fallback to web URL which will redirect to app if installed
|
|
await launchUrl(
|
|
Uri.parse(webUrl),
|
|
mode: LaunchMode.externalApplication,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
// If URI scheme fails, try web URL
|
|
try {
|
|
await launchUrl(
|
|
Uri.parse(webUrl),
|
|
mode: LaunchMode.externalApplication,
|
|
);
|
|
} catch (_) {
|
|
// Last resort: copy to clipboard
|
|
if (context.mounted) {
|
|
_copyToClipboard(context, webUrl);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarUrlCopied(isDeezer ? 'Deezer' : 'Spotify'))),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
|
// Build audio quality string from file metadata
|
|
String? audioQualityStr;
|
|
if (bitDepth != null && sampleRate != null) {
|
|
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
|
|
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
|
|
}
|
|
|
|
final items = <_MetadataItem>[
|
|
_MetadataItem(context.l10n.trackTrackName, trackName),
|
|
_MetadataItem(context.l10n.trackArtist, artistName),
|
|
if (albumArtist != null && albumArtist != artistName)
|
|
_MetadataItem(context.l10n.trackAlbumArtist, albumArtist!),
|
|
_MetadataItem(context.l10n.trackAlbum, albumName),
|
|
if (trackNumber != null && trackNumber! > 0)
|
|
_MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()),
|
|
if (discNumber != null && discNumber! > 0)
|
|
_MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()),
|
|
if (item.duration != null)
|
|
_MetadataItem(context.l10n.trackDuration, _formatDuration(item.duration!)),
|
|
if (audioQualityStr != null)
|
|
_MetadataItem(context.l10n.trackAudioQuality, audioQualityStr),
|
|
if (releaseDate != null && releaseDate!.isNotEmpty)
|
|
_MetadataItem(context.l10n.trackReleaseDate, releaseDate!),
|
|
if (isrc != null && isrc!.isNotEmpty)
|
|
_MetadataItem('ISRC', isrc!),
|
|
];
|
|
|
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
|
final isDeezer = item.spotifyId!.contains('deezer');
|
|
final cleanId = item.spotifyId!.replaceAll('deezer:', '');
|
|
items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId));
|
|
}
|
|
|
|
items.addAll([
|
|
_MetadataItem(context.l10n.trackMetadataService, item.service.toUpperCase()),
|
|
_MetadataItem(context.l10n.trackDownloaded, _formatFullDate(item.downloadedAt)),
|
|
]);
|
|
|
|
return Column(
|
|
children: items.map((metadata) {
|
|
final isCopyable = metadata.label == 'ISRC' ||
|
|
metadata.label == 'Spotify ID';
|
|
return InkWell(
|
|
onTap: isCopyable ? () => _copyToClipboard(context, metadata.value) : null,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 100,
|
|
child: Text(
|
|
metadata.label,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
metadata.value,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
if (isCopyable)
|
|
Icon(
|
|
Icons.copy,
|
|
size: 14,
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
String _formatDuration(int seconds) {
|
|
final minutes = seconds ~/ 60;
|
|
final secs = seconds % 60;
|
|
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
|
|
final fileName = cleanFilePath.split(Platform.pathSeparator).last;
|
|
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
|
|
|
|
return Card(
|
|
elevation: 0,
|
|
color: colorScheme.surfaceContainerLow,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.folder_outlined,
|
|
size: 20,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
context.l10n.trackFileInfo,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Format chip
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Text(
|
|
fileExtension,
|
|
style: TextStyle(
|
|
color: colorScheme.onPrimaryContainer,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
if (fileSize != null)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.secondaryContainer,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Text(
|
|
_formatFileSize(fileSize),
|
|
style: TextStyle(
|
|
color: colorScheme.onSecondaryContainer,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
if (bitDepth != null && sampleRate != null)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.tertiaryContainer,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Text(
|
|
'$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz',
|
|
style: TextStyle(
|
|
color: colorScheme.onTertiaryContainer,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: _getServiceColor(item.service, colorScheme),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
_getServiceIcon(item.service),
|
|
size: 14,
|
|
color: Colors.white,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
item.service.toUpperCase(),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// File path
|
|
InkWell(
|
|
onTap: () => _copyToClipboard(context, cleanFilePath),
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
cleanFilePath,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
fontFamily: 'monospace',
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Icon(
|
|
Icons.copy,
|
|
size: 18,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLyricsCard(BuildContext context, ColorScheme colorScheme) {
|
|
return Card(
|
|
elevation: 0,
|
|
color: colorScheme.surfaceContainerLow,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.lyrics_outlined,
|
|
size: 20,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
context.l10n.trackLyrics,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
if (_lyrics != null)
|
|
IconButton(
|
|
icon: const Icon(Icons.copy, size: 20),
|
|
onPressed: () => _copyToClipboard(context, _lyrics!),
|
|
tooltip: context.l10n.trackCopyLyrics,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
if (_lyricsLoading)
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(20),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
)
|
|
else if (_lyricsError != null)
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.errorContainer.withValues(alpha: 0.3),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: colorScheme.error, size: 20),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
_lyricsError!,
|
|
style: TextStyle(color: colorScheme.onErrorContainer),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: _fetchLyrics,
|
|
child: Text(context.l10n.dialogRetry),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else if (_lyrics != null)
|
|
Container(
|
|
constraints: const BoxConstraints(maxHeight: 300),
|
|
child: SingleChildScrollView(
|
|
child: Text(
|
|
_lyrics!,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurface,
|
|
height: 1.6,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
Center(
|
|
child: FilledButton.tonalIcon(
|
|
onPressed: _fetchLyrics,
|
|
icon: const Icon(Icons.download),
|
|
label: Text(context.l10n.trackLoadLyrics),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _fetchLyrics() async {
|
|
if (_lyricsLoading) return;
|
|
|
|
setState(() {
|
|
_lyricsLoading = true;
|
|
_lyricsError = null;
|
|
});
|
|
|
|
try {
|
|
// Add timeout to prevent infinite loading
|
|
final result = await PlatformBridge.getLyricsLRC(
|
|
item.spotifyId ?? '',
|
|
item.trackName,
|
|
item.artistName,
|
|
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first
|
|
).timeout(
|
|
const Duration(seconds: 20),
|
|
onTimeout: () => '', // Return empty string on timeout
|
|
);
|
|
|
|
if (mounted) {
|
|
if (result.isEmpty) {
|
|
setState(() {
|
|
_lyricsError = context.l10n.trackLyricsNotAvailable;
|
|
_lyricsLoading = false;
|
|
});
|
|
} else {
|
|
// Clean up LRC timestamps for display
|
|
final cleanLyrics = _cleanLrcForDisplay(result);
|
|
setState(() {
|
|
_lyrics = cleanLyrics;
|
|
_lyricsLoading = false;
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
final errorMsg = e.toString().contains('TimeoutException')
|
|
? context.l10n.trackLyricsTimeout
|
|
: context.l10n.trackLyricsLoadFailed;
|
|
setState(() {
|
|
_lyricsError = errorMsg;
|
|
_lyricsLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
String _cleanLrcForDisplay(String lrc) {
|
|
// Remove LRC timestamps [mm:ss.xx] for cleaner display
|
|
final lines = lrc.split('\n');
|
|
final cleanLines = <String>[];
|
|
final timestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
|
|
|
|
for (final line in lines) {
|
|
final cleanLine = line.replaceAll(timestampPattern, '').trim();
|
|
if (cleanLine.isNotEmpty) {
|
|
cleanLines.add(cleanLine);
|
|
}
|
|
}
|
|
|
|
return cleanLines.join('\n');
|
|
}
|
|
|
|
Widget _buildActionButtons(BuildContext context, WidgetRef ref, ColorScheme colorScheme, bool fileExists) {
|
|
return Row(
|
|
children: [
|
|
// Play button
|
|
Expanded(
|
|
flex: 2,
|
|
child: FilledButton.icon(
|
|
onPressed: fileExists ? () => _openFile(context, cleanFilePath) : null,
|
|
icon: const Icon(Icons.play_arrow),
|
|
label: Text(context.l10n.trackMetadataPlay),
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
|
|
// Delete button
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: () => _confirmDelete(context, ref, colorScheme),
|
|
icon: Icon(Icons.delete_outline, color: colorScheme.error),
|
|
label: Text(context.l10n.trackMetadataDelete, style: TextStyle(color: colorScheme.error)),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
side: BorderSide(color: colorScheme.error.withValues(alpha: 0.5)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _showOptionsMenu(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (context) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ListTile(
|
|
leading: const Icon(Icons.copy),
|
|
title: Text(context.l10n.trackCopyFilePath),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_copyToClipboard(context, cleanFilePath);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.share),
|
|
title: Text(context.l10n.trackMetadataShare),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_shareFile(context);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Icon(Icons.delete, color: colorScheme.error),
|
|
title: Text(context.l10n.trackRemoveFromDevice, style: TextStyle(color: colorScheme.error)),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_confirmDelete(context, ref, colorScheme);
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmDelete(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(context.l10n.trackDeleteConfirmTitle),
|
|
content: Text(context.l10n.trackDeleteConfirmMessage),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
// Delete the file first
|
|
try {
|
|
final file = File(cleanFilePath);
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to delete file: $e');
|
|
}
|
|
|
|
// Remove from history
|
|
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
|
|
|
if (context.mounted) {
|
|
Navigator.pop(context); // Close dialog
|
|
Navigator.pop(context); // Go back to history
|
|
}
|
|
},
|
|
child: Text(context.l10n.dialogDelete, style: TextStyle(color: colorScheme.error)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openFile(BuildContext context, String filePath) async {
|
|
try {
|
|
final mimeType = audioMimeTypeForPath(filePath);
|
|
final result = await OpenFilex.open(filePath, type: mimeType);
|
|
if (result.type != ResultType.done && context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackCannotOpen(result.message))),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _copyToClipboard(BuildContext context, String text) {
|
|
Clipboard.setData(ClipboardData(text: text));
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.trackCopiedToClipboard),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _shareFile(BuildContext context) async {
|
|
final file = File(cleanFilePath);
|
|
if (!await file.exists()) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarFileNotFound)),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await SharePlus.instance.share(
|
|
ShareParams(
|
|
files: [XFile(cleanFilePath)],
|
|
text: '${item.trackName} - ${item.artistName}',
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatFullDate(DateTime date) {
|
|
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
return '${date.day} ${months[date.month - 1]} ${date.year}, '
|
|
'${date.hour.toString().padLeft(2, '0')}:'
|
|
'${date.minute.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
String _formatFileSize(int bytes) {
|
|
if (bytes < 1024) return '$bytes B';
|
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
|
}
|
|
|
|
IconData _getServiceIcon(String service) {
|
|
switch (service.toLowerCase()) {
|
|
case 'tidal':
|
|
return Icons.waves;
|
|
case 'qobuz':
|
|
return Icons.album;
|
|
case 'amazon':
|
|
return Icons.shopping_cart;
|
|
default:
|
|
return Icons.cloud_download;
|
|
}
|
|
}
|
|
|
|
Color _getServiceColor(String service, ColorScheme colorScheme) {
|
|
switch (service.toLowerCase()) {
|
|
case 'tidal':
|
|
return const Color(0xFF0077B5); // Tidal blue (darker, more readable)
|
|
case 'qobuz':
|
|
return const Color(0xFF0052CC); // Qobuz blue
|
|
case 'amazon':
|
|
return const Color(0xFFFF9900); // Amazon orange
|
|
default:
|
|
return colorScheme.primary;
|
|
}
|
|
}
|
|
}
|
|
|
|
class _MetadataItem {
|
|
final String label;
|
|
final String value;
|
|
|
|
_MetadataItem(this.label, this.value);
|
|
}
|