mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-20 09:57:15 +02:00
v1.5.5: History tab, share intent, artist support, lyrics viewer, folder organization
This commit is contained in:
@@ -386,6 +386,52 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
state = state.copyWith(outputDir: dir);
|
||||
}
|
||||
|
||||
/// Build output directory based on folder organization setting
|
||||
Future<String> _buildOutputDir(Track track, String folderOrganization) async {
|
||||
String baseDir = state.outputDir;
|
||||
|
||||
if (folderOrganization == 'none') {
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
// Sanitize folder names (remove invalid characters)
|
||||
String sanitize(String name) {
|
||||
return name
|
||||
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
|
||||
.replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots
|
||||
.trim();
|
||||
}
|
||||
|
||||
String subPath = '';
|
||||
switch (folderOrganization) {
|
||||
case 'artist':
|
||||
final artistName = sanitize(track.albumArtist ?? track.artistName);
|
||||
subPath = artistName;
|
||||
break;
|
||||
case 'album':
|
||||
final albumName = sanitize(track.albumName);
|
||||
subPath = albumName;
|
||||
break;
|
||||
case 'artist_album':
|
||||
final artistName = sanitize(track.albumArtist ?? track.artistName);
|
||||
final albumName = sanitize(track.albumName);
|
||||
subPath = '$artistName${Platform.pathSeparator}$albumName';
|
||||
break;
|
||||
}
|
||||
|
||||
if (subPath.isNotEmpty) {
|
||||
final fullPath = '$baseDir${Platform.pathSeparator}$subPath';
|
||||
final dir = Directory(fullPath);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
print('[DownloadQueue] Created folder: $fullPath');
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
void updateSettings(AppSettings settings) {
|
||||
state = state.copyWith(
|
||||
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir,
|
||||
@@ -760,11 +806,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||
|
||||
try {
|
||||
// Get folder organization setting and build output directory
|
||||
final settings = ref.read(settingsProvider);
|
||||
final outputDir = await _buildOutputDir(item.track, settings.folderOrganization);
|
||||
|
||||
Map<String, dynamic> result;
|
||||
|
||||
if (state.autoFallback) {
|
||||
print('[DownloadQueue] Using auto-fallback mode');
|
||||
print('[DownloadQueue] Quality: ${state.audioQuality}');
|
||||
print('[DownloadQueue] Output dir: $outputDir');
|
||||
result = await PlatformBridge.downloadWithFallback(
|
||||
isrc: item.track.isrc ?? '',
|
||||
spotifyId: item.track.id,
|
||||
@@ -773,7 +824,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
albumName: item.track.albumName,
|
||||
albumArtist: item.track.albumArtist,
|
||||
coverUrl: item.track.coverUrl,
|
||||
outputDir: state.outputDir,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: state.audioQuality,
|
||||
trackNumber: item.track.trackNumber ?? 1,
|
||||
@@ -781,6 +832,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
releaseDate: item.track.releaseDate,
|
||||
preferredService: item.service,
|
||||
itemId: item.id, // Pass item ID for progress tracking
|
||||
convertLyricsToRomaji: settings.convertLyricsToRomaji,
|
||||
);
|
||||
} else {
|
||||
result = await PlatformBridge.downloadTrack(
|
||||
@@ -792,13 +844,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
albumName: item.track.albumName,
|
||||
albumArtist: item.track.albumArtist,
|
||||
coverUrl: item.track.coverUrl,
|
||||
outputDir: state.outputDir,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: state.audioQuality,
|
||||
trackNumber: item.track.trackNumber ?? 1,
|
||||
discNumber: item.track.discNumber ?? 1,
|
||||
releaseDate: item.track.releaseDate,
|
||||
itemId: item.id, // Pass item ID for progress tracking
|
||||
convertLyricsToRomaji: settings.convertLyricsToRomaji,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -873,6 +926,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
quality: state.audioQuality,
|
||||
),
|
||||
);
|
||||
|
||||
// Auto-remove completed item from queue (it's now in history)
|
||||
removeItem(item.id);
|
||||
}
|
||||
} else {
|
||||
final errorMsg = result['error'] as String? ?? 'Download failed';
|
||||
|
||||
@@ -76,6 +76,28 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = state.copyWith(checkForUpdates: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHasSearchedBefore() {
|
||||
if (!state.hasSearchedBefore) {
|
||||
state = state.copyWith(hasSearchedBefore: true);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
void setFolderOrganization(String organization) {
|
||||
state = state.copyWith(folderOrganization: organization);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setConvertLyricsToRomaji(bool enabled) {
|
||||
state = state.copyWith(convertLyricsToRomaji: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHistoryViewMode(String mode) {
|
||||
state = state.copyWith(historyViewMode: mode);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -8,7 +8,10 @@ class TrackState {
|
||||
final String? error;
|
||||
final String? albumName;
|
||||
final String? playlistName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||
final TrackState? previousState; // For back navigation
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -16,16 +19,27 @@ class TrackState {
|
||||
this.error,
|
||||
this.albumName,
|
||||
this.playlistName,
|
||||
this.artistName,
|
||||
this.coverUrl,
|
||||
this.artistAlbums,
|
||||
this.previousState,
|
||||
});
|
||||
|
||||
bool get canGoBack => previousState != null;
|
||||
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null;
|
||||
|
||||
TrackState copyWith({
|
||||
List<Track>? tracks,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
String? albumName,
|
||||
String? playlistName,
|
||||
String? artistName,
|
||||
String? coverUrl,
|
||||
List<ArtistAlbum>? artistAlbums,
|
||||
TrackState? previousState,
|
||||
bool clearPreviousState = false,
|
||||
}) {
|
||||
return TrackState(
|
||||
tracks: tracks ?? this.tracks,
|
||||
@@ -33,11 +47,35 @@ class TrackState {
|
||||
error: error,
|
||||
albumName: albumName ?? this.albumName,
|
||||
playlistName: playlistName ?? this.playlistName,
|
||||
artistName: artistName ?? this.artistName,
|
||||
coverUrl: coverUrl ?? this.coverUrl,
|
||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||
previousState: clearPreviousState ? null : (previousState ?? this.previousState),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an album in artist discography
|
||||
class ArtistAlbum {
|
||||
final String id;
|
||||
final String name;
|
||||
final String releaseDate;
|
||||
final int totalTracks;
|
||||
final String? coverUrl;
|
||||
final String albumType; // album, single, compilation
|
||||
final String artists;
|
||||
|
||||
const ArtistAlbum({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.releaseDate,
|
||||
required this.totalTracks,
|
||||
this.coverUrl,
|
||||
required this.albumType,
|
||||
required this.artists,
|
||||
});
|
||||
}
|
||||
|
||||
class TrackNotifier extends Notifier<TrackState> {
|
||||
@override
|
||||
TrackState build() {
|
||||
@@ -45,7 +83,18 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
|
||||
Future<void> fetchFromUrl(String url) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
// Save current state for back navigation (only if we have content or it's empty)
|
||||
final savedState = state.hasContent ? TrackState(
|
||||
tracks: state.tracks,
|
||||
albumName: state.albumName,
|
||||
playlistName: state.playlistName,
|
||||
artistName: state.artistName,
|
||||
coverUrl: state.coverUrl,
|
||||
artistAlbums: state.artistAlbums,
|
||||
previousState: state.previousState,
|
||||
) : const TrackState(); // Empty state for back to home
|
||||
|
||||
state = TrackState(isLoading: true, previousState: savedState);
|
||||
|
||||
try {
|
||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||
@@ -56,57 +105,78 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
if (type == 'track') {
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = state.copyWith(
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
albumName: null,
|
||||
playlistName: null,
|
||||
coverUrl: track.coverUrl,
|
||||
previousState: savedState,
|
||||
);
|
||||
} else if (type == 'album') {
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
state = state.copyWith(
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
playlistName: null,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
previousState: savedState,
|
||||
);
|
||||
} else if (type == 'playlist') {
|
||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||
state = state.copyWith(
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumName: null,
|
||||
playlistName: owner?['name'] as String?,
|
||||
coverUrl: owner?['images'] as String?,
|
||||
previousState: savedState,
|
||||
);
|
||||
} else if (type == 'artist') {
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
state = TrackState(
|
||||
tracks: [], // No tracks for artist view
|
||||
isLoading: false,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
artistAlbums: albums,
|
||||
previousState: savedState,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(String query) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
// Save current state for back navigation
|
||||
final savedState = state.hasContent ? TrackState(
|
||||
tracks: state.tracks,
|
||||
albumName: state.albumName,
|
||||
playlistName: state.playlistName,
|
||||
artistName: state.artistName,
|
||||
coverUrl: state.coverUrl,
|
||||
artistAlbums: state.artistAlbums,
|
||||
previousState: state.previousState,
|
||||
) : const TrackState();
|
||||
|
||||
state = TrackState(isLoading: true, previousState: savedState);
|
||||
|
||||
try {
|
||||
final results = await PlatformBridge.searchSpotify(query, limit: 20);
|
||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
||||
state = state.copyWith(
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumName: null,
|
||||
playlistName: null,
|
||||
previousState: savedState,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +222,54 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = const TrackState();
|
||||
}
|
||||
|
||||
/// Go back to previous state (if available)
|
||||
bool goBack() {
|
||||
if (state.previousState != null) {
|
||||
state = state.previousState!;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Fetch album from artist view - saves current artist state for back navigation
|
||||
Future<void> fetchAlbumFromArtist(String albumId) async {
|
||||
// Save current artist state before fetching album
|
||||
final savedState = TrackState(
|
||||
artistName: state.artistName,
|
||||
coverUrl: state.coverUrl,
|
||||
artistAlbums: state.artistAlbums,
|
||||
previousState: state.previousState, // Keep the chain
|
||||
);
|
||||
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
previousState: savedState,
|
||||
);
|
||||
|
||||
try {
|
||||
final url = 'https://open.spotify.com/album/$albumId';
|
||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
previousState: savedState,
|
||||
);
|
||||
} catch (e) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
previousState: savedState,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
return Track(
|
||||
id: data['spotify_id'] as String? ?? '',
|
||||
@@ -183,6 +301,18 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
releaseDate: data['release_date'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||
return ArtistAlbum(
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
releaseDate: data['release_date'] as String? ?? '',
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
coverUrl: data['images'] as String?,
|
||||
albumType: data['album_type'] as String? ?? 'album',
|
||||
artists: data['artists'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
||||
|
||||
Reference in New Issue
Block a user