mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-15 07:30:58 +02:00
feat: v3.2.1 - lyrics improvements, pause/resume, folder options
- Add instrumental track detection (shows 'Instrumental track' instead of 'not available') - Add embed lyrics button in Track Info (preserves synced timestamps) - Add pause/resume button next to 'Downloading' header in History - Add Artist/Album + Singles folder structure option - Fix multi-artist lyrics search (try primary artist first) - Fix lyrics display stripping metadata tags ([ti:], [ar:], [by:]) - Skip lyrics embedding for instrumental tracks during download
This commit is contained in:
@@ -2962,6 +2962,24 @@ abstract class AppLocalizations {
|
||||
/// **'Failed to load lyrics'**
|
||||
String get trackLyricsLoadFailed;
|
||||
|
||||
/// Action - embed lyrics into audio file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Embed Lyrics'**
|
||||
String get trackEmbedLyrics;
|
||||
|
||||
/// Snackbar - lyrics saved to file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lyrics embedded successfully'**
|
||||
String get trackLyricsEmbedded;
|
||||
|
||||
/// Message when track is instrumental (no lyrics)
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Instrumental track'**
|
||||
String get trackInstrumental;
|
||||
|
||||
/// Snackbar - content copied
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3688,6 +3706,18 @@ abstract class AppLocalizations {
|
||||
/// **'Albums/[2005] Album Name/'**
|
||||
String get albumFolderYearAlbumSubtitle;
|
||||
|
||||
/// Album folder option with singles inside artist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist / Album + Singles'**
|
||||
String get albumFolderArtistAlbumSingles;
|
||||
|
||||
/// Folder structure example
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist/Album/ and Artist/Singles/'**
|
||||
String get albumFolderArtistAlbumSinglesSubtitle;
|
||||
|
||||
/// Button - delete selected tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -1631,6 +1631,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -2019,6 +2028,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
|
||||
@@ -1618,6 +1618,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -2006,6 +2015,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
|
||||
@@ -1618,6 +1618,15 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -2006,6 +2015,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
|
||||
@@ -1618,6 +1618,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -2006,6 +2015,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
|
||||
@@ -1618,6 +1618,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -2006,6 +2015,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
|
||||
@@ -1628,6 +1628,15 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Disalin ke clipboard';
|
||||
|
||||
@@ -2019,6 +2028,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
||||
|
||||
|
||||
@@ -1618,6 +1618,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -2006,6 +2015,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
|
||||
@@ -1618,6 +1618,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -2006,6 +2015,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
|
||||
@@ -1618,6 +1618,15 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -2006,6 +2015,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
|
||||
@@ -1618,6 +1618,15 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -2006,6 +2015,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
|
||||
@@ -1652,6 +1652,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Скопировано в буфер обмена';
|
||||
|
||||
@@ -2047,6 +2056,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get albumFolderYearAlbumSubtitle =>
|
||||
'Альбомы/[2005] Название Альбома /';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
|
||||
|
||||
|
||||
@@ -1618,6 +1618,15 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -2006,6 +2015,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
|
||||
@@ -1618,6 +1618,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@@ -2006,6 +2015,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
|
||||
@@ -1188,6 +1188,12 @@
|
||||
"@trackLyricsTimeout": {"description": "Message when lyrics request times out"},
|
||||
"trackLyricsLoadFailed": "Failed to load lyrics",
|
||||
"@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"},
|
||||
"trackEmbedLyrics": "Embed Lyrics",
|
||||
"@trackEmbedLyrics": {"description": "Action - embed lyrics into audio file"},
|
||||
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||
"@trackLyricsEmbedded": {"description": "Snackbar - lyrics saved to file"},
|
||||
"trackInstrumental": "Instrumental track",
|
||||
"@trackInstrumental": {"description": "Message when track is instrumental (no lyrics)"},
|
||||
"trackCopiedToClipboard": "Copied to clipboard",
|
||||
"@trackCopiedToClipboard": {"description": "Snackbar - content copied"},
|
||||
"trackDeleteConfirmTitle": "Remove from device?",
|
||||
@@ -1477,6 +1483,10 @@
|
||||
"@albumFolderYearAlbum": {"description": "Album folder option with year"},
|
||||
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
|
||||
"@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"},
|
||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||
"@albumFolderArtistAlbumSingles": {"description": "Album folder option with singles inside artist"},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {"description": "Folder structure example"},
|
||||
|
||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||
"@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"},
|
||||
|
||||
@@ -712,14 +712,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
if (separateSingles) {
|
||||
final isSingle = track.isSingle;
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
|
||||
// New option: Singles folder inside Artist folder
|
||||
if (albumFolderStructure == 'artist_album_singles') {
|
||||
if (isSingle) {
|
||||
final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles';
|
||||
await _ensureDirExists(singlesPath, label: 'Artist Singles folder');
|
||||
return singlesPath;
|
||||
} else {
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
final albumPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
||||
await _ensureDirExists(albumPath, label: 'Artist Album folder');
|
||||
return albumPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Existing behavior: Separate Albums/ and Singles/ at root
|
||||
if (isSingle) {
|
||||
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
||||
await _ensureDirExists(singlesPath, label: 'Singles folder');
|
||||
return singlesPath;
|
||||
} else {
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
final year = _extractYear(track.releaseDate);
|
||||
String albumPath;
|
||||
|
||||
@@ -1169,10 +1184,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
durationMs: durationMs,
|
||||
);
|
||||
|
||||
if (lrcContent.isNotEmpty) {
|
||||
// Skip instrumental tracks (no lyrics to embed)
|
||||
if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
|
||||
} else if (lrcContent == '[instrumental:true]') {
|
||||
_log.d('Track is instrumental, skipping lyrics embedding');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to fetch lyrics for embedding: $e');
|
||||
|
||||
@@ -783,11 +783,17 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Text(
|
||||
'Downloading (${queueItems.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Downloading (${queueItems.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_buildPauseResumeButton(context, ref, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1146,6 +1152,31 @@ if (queueItems.isEmpty &&
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPauseResumeButton(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
|
||||
|
||||
return TextButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(downloadQueueProvider.notifier).togglePause();
|
||||
},
|
||||
icon: Icon(
|
||||
isPaused ? Icons.play_arrow : Icons.pause,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(
|
||||
isPaused ? context.l10n.actionResume : context.l10n.actionPause,
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
foregroundColor: isPaused ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
|
||||
@@ -276,6 +276,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
return 'Albums/Artist/[Year] Album/';
|
||||
case 'year_album':
|
||||
return 'Albums/[Year] Album/';
|
||||
case 'artist_album_singles':
|
||||
return 'Artist/Album/ + Artist/Singles/';
|
||||
default:
|
||||
return 'Albums/Artist/Album Name/';
|
||||
}
|
||||
@@ -328,6 +330,16 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_outlined),
|
||||
title: Text(context.l10n.albumFolderArtistAlbumSingles),
|
||||
subtitle: Text(context.l10n.albumFolderArtistAlbumSinglesSubtitle),
|
||||
trailing: current == 'artist_album_singles' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album_singles');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -25,11 +25,15 @@ class TrackMetadataScreen extends ConsumerStatefulWidget {
|
||||
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool _fileExists = false;
|
||||
int? _fileSize;
|
||||
String? _lyrics;
|
||||
String? _lyrics; // Cleaned lyrics for display (no timestamps)
|
||||
String? _rawLyrics; // Raw LRC with timestamps for embedding
|
||||
bool _lyricsLoading = false;
|
||||
String? _lyricsError;
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
|
||||
bool _isEmbedding = false; // Track embed operation in progress
|
||||
bool _isInstrumental = false; // Track if detected as instrumental
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
static final RegExp _lrcTimestampPattern =
|
||||
RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
|
||||
@@ -844,18 +848,62 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (_lyrics != null)
|
||||
else if (_isInstrumental)
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 300),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
_lyrics!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
height: 1.6,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.music_note, color: colorScheme.tertiary, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
context.l10n.trackInstrumental,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (_lyrics != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 300),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
_lyrics!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Show "Embed Lyrics" button if lyrics are from online (not already embedded)
|
||||
if (!_lyricsEmbedded && _fileExists) ...[
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: _isEmbedding ? null : _embedLyrics,
|
||||
icon: _isEmbedding
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save_alt),
|
||||
label: Text(context.l10n.trackEmbedLyrics),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
else
|
||||
Center(
|
||||
@@ -877,26 +925,57 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
setState(() {
|
||||
_lyricsLoading = true;
|
||||
_lyricsError = null;
|
||||
_isInstrumental = false;
|
||||
});
|
||||
|
||||
try {
|
||||
// Convert duration from seconds to milliseconds
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
|
||||
// Add timeout to prevent infinite loading
|
||||
// First, check if lyrics are embedded in the file
|
||||
if (_fileExists) {
|
||||
final embeddedResult = await PlatformBridge.getLyricsLRC(
|
||||
'',
|
||||
item.trackName,
|
||||
item.artistName,
|
||||
filePath: cleanFilePath,
|
||||
durationMs: 0,
|
||||
).timeout(const Duration(seconds: 5), onTimeout: () => '');
|
||||
|
||||
if (embeddedResult.isNotEmpty) {
|
||||
// Lyrics found in file
|
||||
if (mounted) {
|
||||
final cleanLyrics = _cleanLrcForDisplay(embeddedResult);
|
||||
setState(() {
|
||||
_lyrics = cleanLyrics;
|
||||
_lyricsEmbedded = true;
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No embedded lyrics, fetch from online
|
||||
final result = await PlatformBridge.getLyricsLRC(
|
||||
item.spotifyId ?? '',
|
||||
item.trackName,
|
||||
item.artistName,
|
||||
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first
|
||||
filePath: null, // Don't check file again
|
||||
durationMs: durationMs,
|
||||
).timeout(
|
||||
const Duration(seconds: 20),
|
||||
onTimeout: () => '', // Return empty string on timeout
|
||||
onTimeout: () => '',
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
if (result.isEmpty) {
|
||||
// Check for instrumental marker
|
||||
if (result == '[instrumental:true]') {
|
||||
setState(() {
|
||||
_isInstrumental = true;
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
} else if (result.isEmpty) {
|
||||
setState(() {
|
||||
_lyricsError = context.l10n.trackLyricsNotAvailable;
|
||||
_lyricsLoading = false;
|
||||
@@ -905,6 +984,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final cleanLyrics = _cleanLrcForDisplay(result);
|
||||
setState(() {
|
||||
_lyrics = cleanLyrics;
|
||||
_rawLyrics = result; // Keep raw LRC with timestamps for embedding
|
||||
_lyricsEmbedded = false; // Lyrics from online, not embedded
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -921,13 +1002,62 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _embedLyrics() async {
|
||||
if (_isEmbedding || _rawLyrics == null || !_fileExists) return;
|
||||
|
||||
setState(() => _isEmbedding = true);
|
||||
|
||||
try {
|
||||
// Use raw LRC content directly - it already has timestamps and metadata
|
||||
final result = await PlatformBridge.embedLyricsToFile(
|
||||
cleanFilePath,
|
||||
_rawLyrics!,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
if (result['success'] == true) {
|
||||
setState(() {
|
||||
_lyricsEmbedded = true;
|
||||
_isEmbedding = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackLyricsEmbedded)),
|
||||
);
|
||||
} else {
|
||||
setState(() => _isEmbedding = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(result['error'] ?? 'Failed to embed lyrics')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isEmbedding = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _cleanLrcForDisplay(String lrc) {
|
||||
final lines = lrc.split('\n');
|
||||
final cleanLines = <String>[];
|
||||
|
||||
// Pattern to match LRC metadata tags like [ti:...], [ar:...], [al:...], [by:...], etc.
|
||||
final metadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$');
|
||||
|
||||
for (final line in lines) {
|
||||
final cleanLine = line.replaceAll(_lrcTimestampPattern, '').trim();
|
||||
final trimmedLine = line.trim();
|
||||
|
||||
// Skip metadata tags
|
||||
if (metadataPattern.hasMatch(trimmedLine)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove timestamp and clean up
|
||||
final cleanLine = trimmedLine.replaceAll(_lrcTimestampPattern, '').trim();
|
||||
if (cleanLine.isNotEmpty) {
|
||||
cleanLines.add(cleanLine);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user