From 497ba342c05de06e1d1beb7d7c31272c2124b5c1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 22 Mar 2026 22:43:03 +0700 Subject: [PATCH] feat: add createPlaylistFolder setting for playlist source folder prefix When enabled, playlist downloads are placed inside a subfolder named after the playlist before the normal folder organization structure (e.g. Playlist///). The setting is a no-op when folder organization is already set to 'By Playlist'. Includes model field, JSON serialization, settings notifier, download queue path logic, UI toggle in download settings, and localizations for all 13 languages. --- lib/l10n/app_localizations.dart | 24 ++++++ lib/l10n/app_localizations_de.dart | 16 ++++ lib/l10n/app_localizations_en.dart | 16 ++++ lib/l10n/app_localizations_es.dart | 16 ++++ lib/l10n/app_localizations_fr.dart | 16 ++++ lib/l10n/app_localizations_hi.dart | 16 ++++ lib/l10n/app_localizations_id.dart | 16 ++++ lib/l10n/app_localizations_ja.dart | 16 ++++ lib/l10n/app_localizations_ko.dart | 16 ++++ lib/l10n/app_localizations_nl.dart | 16 ++++ lib/l10n/app_localizations_pt.dart | 16 ++++ lib/l10n/app_localizations_ru.dart | 16 ++++ lib/l10n/app_localizations_tr.dart | 16 ++++ lib/l10n/app_localizations_zh.dart | 16 ++++ lib/l10n/arb/app_en.arb | 16 ++++ lib/l10n/arb/app_id.arb | 16 ++++ lib/models/settings.dart | 7 +- lib/models/settings.g.dart | 2 + lib/providers/download_queue_provider.dart | 65 ++++++++++++++--- lib/providers/settings_provider.dart | 5 ++ .../settings/download_settings_page.dart | 73 +++++++++++++------ 21 files changed, 380 insertions(+), 36 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0ea69f2b..d8e291ca 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4659,6 +4659,30 @@ abstract class AppLocalizations { /// **'Artist Name Filters'** String get downloadArtistNameFilters; + /// Setting title for adding a playlist folder prefix before the normal organization structure + /// + /// In en, this message translates to: + /// **'Create playlist source folder'** + String get downloadCreatePlaylistSourceFolder; + + /// Subtitle when playlist source folder prefix is enabled + /// + /// In en, this message translates to: + /// **'Playlist downloads use Playlist/ plus your normal folder structure.'** + String get downloadCreatePlaylistSourceFolderEnabled; + + /// Subtitle when playlist source folder prefix is disabled + /// + /// In en, this message translates to: + /// **'Playlist downloads use the normal folder structure only.'** + String get downloadCreatePlaylistSourceFolderDisabled; + + /// Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist + /// + /// In en, this message translates to: + /// **'By Playlist already places downloads inside a playlist folder.'** + String get downloadCreatePlaylistSourceFolderRedundant; + /// Setting title for SongLink country region /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 7ad45e21..b58fd78c 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2712,6 +2712,22 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadArtistNameFilters => 'Artist Name Filters'; + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + @override String get downloadSongLinkRegion => 'SongLink Region'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 13208d5b..328a4a9d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2685,6 +2685,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadArtistNameFilters => 'Artist Name Filters'; + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + @override String get downloadSongLinkRegion => 'SongLink Region'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 113c08f1..6308f8f4 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2685,6 +2685,22 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadArtistNameFilters => 'Artist Name Filters'; + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + @override String get downloadSongLinkRegion => 'SongLink Region'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 6521f755..f16c40f8 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2686,6 +2686,22 @@ class AppLocalizationsFr extends AppLocalizations { @override String get downloadArtistNameFilters => 'Artist Name Filters'; + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + @override String get downloadSongLinkRegion => 'SongLink Region'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 04227142..2022128f 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2684,6 +2684,22 @@ class AppLocalizationsHi extends AppLocalizations { @override String get downloadArtistNameFilters => 'Artist Name Filters'; + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + @override String get downloadSongLinkRegion => 'SongLink Region'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index b23768e3..dce7f058 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2692,6 +2692,22 @@ class AppLocalizationsId extends AppLocalizations { @override String get downloadArtistNameFilters => 'Artist Name Filters'; + @override + String get downloadCreatePlaylistSourceFolder => + 'Buat folder sumber playlist'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Unduhan dari playlist hanya memakai struktur folder normal.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.'; + @override String get downloadSongLinkRegion => 'SongLink Region'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index caf7d16e..5f1fc709 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2671,6 +2671,22 @@ class AppLocalizationsJa extends AppLocalizations { @override String get downloadArtistNameFilters => 'Artist Name Filters'; + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + @override String get downloadSongLinkRegion => 'SongLink Region'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 1cac05e7..5d4f256f 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2664,6 +2664,22 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadArtistNameFilters => 'Artist Name Filters'; + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + @override String get downloadSongLinkRegion => 'SongLink Region'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index ac2c8b57..ef14b913 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2684,6 +2684,22 @@ class AppLocalizationsNl extends AppLocalizations { @override String get downloadArtistNameFilters => 'Artist Name Filters'; + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + @override String get downloadSongLinkRegion => 'SongLink Region'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 3c5c8896..d1fb718a 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2685,6 +2685,22 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadArtistNameFilters => 'Artist Name Filters'; + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + @override String get downloadSongLinkRegion => 'SongLink Region'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 31a1a1dc..47398466 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2743,6 +2743,22 @@ class AppLocalizationsRu extends AppLocalizations { @override String get downloadArtistNameFilters => 'Artist Name Filters'; + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + @override String get downloadSongLinkRegion => 'SongLink Region'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 518bcf77..7c77c93e 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2696,6 +2696,22 @@ class AppLocalizationsTr extends AppLocalizations { @override String get downloadArtistNameFilters => 'Artist Name Filters'; + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + @override String get downloadSongLinkRegion => 'SongLink Region'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d36cf154..5ce4df91 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2685,6 +2685,22 @@ class AppLocalizationsZh extends AppLocalizations { @override String get downloadArtistNameFilters => 'Artist Name Filters'; + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + @override String get downloadSongLinkRegion => 'SongLink Region'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7055840d..69aa2068 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3586,6 +3586,22 @@ "@downloadArtistNameFilters": { "description": "Setting title for artist folder filter options" }, + "downloadCreatePlaylistSourceFolder": "Create playlist source folder", + "@downloadCreatePlaylistSourceFolder": { + "description": "Setting title for adding a playlist folder prefix before the normal organization structure" + }, + "downloadCreatePlaylistSourceFolderEnabled": "Playlist downloads use Playlist/ plus your normal folder structure.", + "@downloadCreatePlaylistSourceFolderEnabled": { + "description": "Subtitle when playlist source folder prefix is enabled" + }, + "downloadCreatePlaylistSourceFolderDisabled": "Playlist downloads use the normal folder structure only.", + "@downloadCreatePlaylistSourceFolderDisabled": { + "description": "Subtitle when playlist source folder prefix is disabled" + }, + "downloadCreatePlaylistSourceFolderRedundant": "By Playlist already places downloads inside a playlist folder.", + "@downloadCreatePlaylistSourceFolderRedundant": { + "description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist" + }, "downloadSongLinkRegion": "SongLink Region", "@downloadSongLinkRegion": { "description": "Setting title for SongLink country region" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index ff764ec8..bb02bc50 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1797,6 +1797,22 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, + "downloadCreatePlaylistSourceFolder": "Buat folder sumber playlist", + "@downloadCreatePlaylistSourceFolder": { + "description": "Setting title for adding a playlist folder prefix before the normal organization structure" + }, + "downloadCreatePlaylistSourceFolderEnabled": "Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.", + "@downloadCreatePlaylistSourceFolderEnabled": { + "description": "Subtitle when playlist source folder prefix is enabled" + }, + "downloadCreatePlaylistSourceFolderDisabled": "Unduhan dari playlist hanya memakai struktur folder normal.", + "@downloadCreatePlaylistSourceFolderDisabled": { + "description": "Subtitle when playlist source folder prefix is disabled" + }, + "downloadCreatePlaylistSourceFolderRedundant": "Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.", + "@downloadCreatePlaylistSourceFolderRedundant": { + "description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist" + }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 87c31e1b..c8848ab7 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -20,6 +20,7 @@ class AppSettings { final String updateChannel; final bool hasSearchedBefore; final String folderOrganization; + final bool createPlaylistFolder; final bool useAlbumArtistForFolders; final bool usePrimaryArtistOnly; // Strip featured artists from folder name final bool filterContributingArtistsInAlbumArtist; @@ -96,6 +97,7 @@ class AppSettings { this.updateChannel = 'stable', this.hasSearchedBefore = false, this.folderOrganization = 'none', + this.createPlaylistFolder = false, this.useAlbumArtistForFolders = true, this.usePrimaryArtistOnly = false, this.filterContributingArtistsInAlbumArtist = false, @@ -159,6 +161,7 @@ class AppSettings { String? updateChannel, bool? hasSearchedBefore, String? folderOrganization, + bool? createPlaylistFolder, bool? useAlbumArtistForFolders, bool? usePrimaryArtistOnly, bool? filterContributingArtistsInAlbumArtist, @@ -215,6 +218,7 @@ class AppSettings { updateChannel: updateChannel ?? this.updateChannel, hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore, folderOrganization: folderOrganization ?? this.folderOrganization, + createPlaylistFolder: createPlaylistFolder ?? this.createPlaylistFolder, useAlbumArtistForFolders: useAlbumArtistForFolders ?? this.useAlbumArtistForFolders, usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly, @@ -255,8 +259,7 @@ class AppSettings { localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark, localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates, - localLibraryAutoScan: - localLibraryAutoScan ?? this.localLibraryAutoScan, + localLibraryAutoScan: localLibraryAutoScan ?? this.localLibraryAutoScan, hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial, lyricsProviders: lyricsProviders ?? this.lyricsProviders, lyricsIncludeTranslationNetease: diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 2b6a35cf..026569dd 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( updateChannel: json['updateChannel'] as String? ?? 'stable', hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false, folderOrganization: json['folderOrganization'] as String? ?? 'none', + createPlaylistFolder: json['createPlaylistFolder'] as bool? ?? false, useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true, usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false, filterContributingArtistsInAlbumArtist: @@ -100,6 +101,7 @@ Map _$AppSettingsToJson( 'updateChannel': instance.updateChannel, 'hasSearchedBefore': instance.hasSearchedBefore, 'folderOrganization': instance.folderOrganization, + 'createPlaylistFolder': instance.createPlaylistFolder, 'useAlbumArtistForFolders': instance.useAlbumArtistForFolders, 'usePrimaryArtistOnly': instance.usePrimaryArtistOnly, 'filterContributingArtistsInAlbumArtist': diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 8901c647..4a308850 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1651,12 +1651,23 @@ class DownloadQueueNotifier extends Notifier { String folderOrganization, { bool separateSingles = false, String albumFolderStructure = 'artist_album', + bool createPlaylistFolder = false, bool useAlbumArtistForFolders = true, bool usePrimaryArtistOnly = false, bool filterContributingArtistsInAlbumArtist = false, String? playlistName, }) async { String baseDir = state.outputDir; + if (createPlaylistFolder && + folderOrganization != 'playlist' && + playlistName != null && + playlistName.isNotEmpty) { + final playlistFolder = _sanitizeFolderName(playlistName); + if (playlistFolder.isNotEmpty) { + baseDir = '$baseDir${Platform.pathSeparator}$playlistFolder'; + await _ensureDirExists(baseDir, label: 'Playlist folder'); + } + } final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); var folderArtist = useAlbumArtistForFolders ? normalizedAlbumArtist ?? track.artistName @@ -1809,11 +1820,19 @@ class DownloadQueueNotifier extends Notifier { String folderOrganization, { bool separateSingles = false, String albumFolderStructure = 'artist_album', + bool createPlaylistFolder = false, bool useAlbumArtistForFolders = true, bool usePrimaryArtistOnly = false, bool filterContributingArtistsInAlbumArtist = false, String? playlistName, }) async { + final playlistPrefix = + createPlaylistFolder && + folderOrganization != 'playlist' && + playlistName != null && + playlistName.isNotEmpty + ? _sanitizeFolderName(playlistName) + : ''; final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); var folderArtist = useAlbumArtistForFolders ? normalizedAlbumArtist ?? track.artistName @@ -1833,34 +1852,40 @@ class DownloadQueueNotifier extends Notifier { if (albumFolderStructure == 'artist_album_singles') { if (isSingle) { - return '$artistName/Singles'; + return _joinRelativePath(playlistPrefix, '$artistName/Singles'); } final albumName = _sanitizeFolderName(track.albumName); - return '$artistName/$albumName'; + return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); } if (isSingle) { - return 'Singles'; + return _joinRelativePath(playlistPrefix, 'Singles'); } final albumName = _sanitizeFolderName(track.albumName); final year = _extractYear(track.releaseDate); switch (albumFolderStructure) { case 'album_only': - return 'Albums/$albumName'; + return _joinRelativePath(playlistPrefix, 'Albums/$albumName'); case 'artist_year_album': final yearAlbum = year != null ? '[$year] $albumName' : albumName; - return 'Albums/$artistName/$yearAlbum'; + return _joinRelativePath( + playlistPrefix, + 'Albums/$artistName/$yearAlbum', + ); case 'year_album': final yearAlbum = year != null ? '[$year] $albumName' : albumName; - return 'Albums/$yearAlbum'; + return _joinRelativePath(playlistPrefix, 'Albums/$yearAlbum'); default: - return 'Albums/$artistName/$albumName'; + return _joinRelativePath( + playlistPrefix, + 'Albums/$artistName/$albumName', + ); } } if (folderOrganization == 'none') { - return ''; + return playlistPrefix; } switch (folderOrganization) { @@ -1870,18 +1895,30 @@ class DownloadQueueNotifier extends Notifier { } return ''; case 'artist': - return _sanitizeFolderName(folderArtist); + return _joinRelativePath( + playlistPrefix, + _sanitizeFolderName(folderArtist), + ); case 'album': - return _sanitizeFolderName(track.albumName); + return _joinRelativePath( + playlistPrefix, + _sanitizeFolderName(track.albumName), + ); case 'artist_album': final artistName = _sanitizeFolderName(folderArtist); final albumName = _sanitizeFolderName(track.albumName); - return '$artistName/$albumName'; + return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); default: - return ''; + return playlistPrefix; } } + String _joinRelativePath(String prefix, String suffix) { + if (prefix.isEmpty) return suffix; + if (suffix.isEmpty) return prefix; + return '$prefix/$suffix'; + } + String _determineOutputExt(String quality, String service) { if (service.toLowerCase() == 'youtube') { if (quality.toLowerCase().contains('mp3')) { @@ -3547,6 +3584,7 @@ class DownloadQueueNotifier extends Notifier { settings.folderOrganization, separateSingles: settings.separateSingles, albumFolderStructure: settings.albumFolderStructure, + createPlaylistFolder: settings.createPlaylistFolder, useAlbumArtistForFolders: settings.useAlbumArtistForFolders, usePrimaryArtistOnly: settings.usePrimaryArtistOnly, filterContributingArtistsInAlbumArtist: @@ -3562,6 +3600,7 @@ class DownloadQueueNotifier extends Notifier { settings.folderOrganization, separateSingles: settings.separateSingles, albumFolderStructure: settings.albumFolderStructure, + createPlaylistFolder: settings.createPlaylistFolder, useAlbumArtistForFolders: settings.useAlbumArtistForFolders, usePrimaryArtistOnly: settings.usePrimaryArtistOnly, filterContributingArtistsInAlbumArtist: @@ -3905,10 +3944,12 @@ class DownloadQueueNotifier extends Notifier { settings.folderOrganization, separateSingles: settings.separateSingles, albumFolderStructure: settings.albumFolderStructure, + createPlaylistFolder: settings.createPlaylistFolder, useAlbumArtistForFolders: settings.useAlbumArtistForFolders, usePrimaryArtistOnly: settings.usePrimaryArtistOnly, filterContributingArtistsInAlbumArtist: settings.filterContributingArtistsInAlbumArtist, + playlistName: item.playlistName, ); final fallbackResult = await runDownload( useSaf: false, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index fcab3631..44251a03 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -375,6 +375,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setCreatePlaylistFolder(bool enabled) { + state = state.copyWith(createPlaylistFolder: enabled); + _saveSettings(); + } + void setUseAlbumArtistForFolders(bool enabled) { state = state.copyWith(useAlbumArtistForFolders: enabled); _saveSettings(); diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index fb583df7..218871f0 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -436,7 +437,8 @@ class _DownloadSettingsPageState extends ConsumerState { ], SettingsItem( title: context.l10n.youtubeOpusBitrateTitle, - subtitle: '${settings.youtubeOpusBitrate}kbps (128/256/320)', + subtitle: + '${settings.youtubeOpusBitrate}kbps (128/256/320)', onTap: () => _showYoutubeBitratePicker( context: context, title: context.l10n.youtubeOpusBitrateTitle, @@ -515,8 +517,12 @@ class _DownloadSettingsPageState extends ConsumerState { icon: Icons.translate_outlined, title: context.l10n.downloadNeteaseIncludeTranslation, subtitle: settings.lyricsIncludeTranslationNetease - ? context.l10n.downloadNeteaseIncludeTranslationEnabled - : context.l10n.downloadNeteaseIncludeTranslationDisabled, + ? context + .l10n + .downloadNeteaseIncludeTranslationEnabled + : context + .l10n + .downloadNeteaseIncludeTranslationDisabled, value: settings.lyricsIncludeTranslationNetease, onChanged: (value) => ref .read(settingsProvider.notifier) @@ -526,8 +532,12 @@ class _DownloadSettingsPageState extends ConsumerState { icon: Icons.text_fields_outlined, title: context.l10n.downloadNeteaseIncludeRomanization, subtitle: settings.lyricsIncludeRomanizationNetease - ? context.l10n.downloadNeteaseIncludeRomanizationEnabled - : context.l10n.downloadNeteaseIncludeRomanizationDisabled, + ? context + .l10n + .downloadNeteaseIncludeRomanizationEnabled + : context + .l10n + .downloadNeteaseIncludeRomanizationDisabled, value: settings.lyricsIncludeRomanizationNetease, onChanged: (value) => ref .read(settingsProvider.notifier) @@ -627,6 +637,15 @@ class _DownloadSettingsPageState extends ConsumerState { settings.folderOrganization, ), ), + SettingsSwitchItem( + icon: Icons.playlist_play_outlined, + title: context.l10n.downloadCreatePlaylistSourceFolder, + subtitle: _getPlaylistFolderSubtitle(settings), + value: settings.createPlaylistFolder, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setCreatePlaylistFolder(value), + ), SettingsSwitchItem( icon: Icons.person_search_outlined, title: context.l10n.downloadUseAlbumArtistForFolders, @@ -642,7 +661,7 @@ class _DownloadSettingsPageState extends ConsumerState { .read(settingsProvider.notifier) .setUseAlbumArtistForFolders(value), ), - SettingsItem( + SettingsItem( icon: Icons.filter_alt_outlined, title: context.l10n.downloadArtistNameFilters, subtitle: _getArtistFolderFilterSubtitle( @@ -1407,6 +1426,16 @@ class _DownloadSettingsPageState extends ConsumerState { } } + String _getPlaylistFolderSubtitle(AppSettings settings) { + if (settings.folderOrganization == 'playlist') { + return context.l10n.downloadCreatePlaylistSourceFolderRedundant; + } + if (settings.createPlaylistFolder) { + return context.l10n.downloadCreatePlaylistSourceFolderEnabled; + } + return context.l10n.downloadCreatePlaylistSourceFolderDisabled; + } + String _getArtistFolderFilterSubtitle( BuildContext context, { required bool usePrimaryArtistOnly, @@ -1776,17 +1805,17 @@ class _DownloadSettingsPageState extends ConsumerState { children: [ Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - context.l10n.downloadSongLinkRegion, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + child: Text( + context.l10n.downloadSongLinkRegion, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - context.l10n.downloadSongLinkRegionDesc, + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.downloadSongLinkRegionDesc, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -1847,12 +1876,12 @@ class _DownloadSettingsPageState extends ConsumerState { children: [ Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - context.l10n.downloadFolderOrganization, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), + child: Text( + context.l10n.downloadFolderOrganization, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), ), Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),