diff --git a/CHANGELOG.md b/CHANGELOG.md index c5fc4b2f..75f1eaed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # Changelog +## [1.5.5] - 2026-01-02 + +### Added +- **Share to App**: Share Spotify links directly from Spotify app or browser to SpotiFLAC + - Supports track, album, playlist, and artist URLs + - Auto-fetches metadata when link is shared + - Works with both `open.spotify.com` URLs and `spotify:` URIs +- **Lyrics Viewer**: View lyrics for downloaded tracks in Track Metadata screen + - Fetches lyrics from LRCLIB on-demand + - Clean display without timestamps + - Copy lyrics to clipboard +- **Artist URL Support**: Paste artist URL to browse their discography + - Shows all albums, singles, and compilations + - Horizontal scrollable album cards grouped by type + - Tap any album to view and download its tracks +- **Folder Organization**: Organize downloads into folders by artist or album + - Options: None, By Artist, By Album, By Artist & Album + - Configurable in Settings > Download +- **Japanese Lyrics to Romaji**: Auto-convert Hiragana/Katakana lyrics to romaji + - Useful for non-Japanese speakers who want to sing along + - Toggle in Settings > Options > Lyrics + - Kanji characters are preserved (requires dictionary lookup) +- **History View Mode**: Choose between grid or list view for download history + - Grid view shows album art in a 3-column layout (default) + - List view shows detailed track info with date + - Configurable in Settings > Appearance > Layout +- **Exit Confirmation**: Dialog prompt when pressing back to exit app (only at root) + +### Changed +- **Downloads Tab Renamed to History**: Better reflects the tab's purpose + - Shows download queue at top when active + - Completed downloads auto-move to history section + - Cleaner separation between active downloads and history +- **Smarter Back Navigation**: Back button now navigates properly + - Goes back through search history (album → artist → empty) + - Returns to Search tab from other tabs + - Only shows exit dialog when truly at root + +### Fixed +- **Download Progress**: Fixed progress stuck at 0% when using item-based progress tracking (affected sequential downloads after multi-download feature was added) +- **Artist View State**: Fixed UI state not clearing properly when switching between artist and album views +- **Share Intent Timing**: Fixed shared URLs not being processed when app was cold-started from share intent + +### Improved +- **Cleaner UI for Returning Users**: Helper text "Supports: Track, Album, Playlist URLs" now only shows for new users and hides after first search +- **Cleaner Home Tab**: Removed redundant "Recent Downloads" section, renamed to "Search" tab +- **Centered Search Bar**: Search bar now appears centered on screen when empty, moves to top when results are shown - easier to reach on large phones +- **Back Navigation**: Android back button now works as expected - returns to previous view (album → artist → empty search) + ## [1.5.0-hotfix6] - 2026-01-02 ### Fixed diff --git a/README.md b/README.md index 1ecb3348..91333ad3 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account ## Screenshots

- - - - + + + +

## Other project diff --git a/assets/images/1.jpg b/assets/images/1.jpg new file mode 100644 index 00000000..64749dcb Binary files /dev/null and b/assets/images/1.jpg differ diff --git a/assets/images/2.jpg b/assets/images/2.jpg new file mode 100644 index 00000000..c509b212 Binary files /dev/null and b/assets/images/2.jpg differ diff --git a/assets/images/3.jpg b/assets/images/3.jpg new file mode 100644 index 00000000..09d59ab2 Binary files /dev/null and b/assets/images/3.jpg differ diff --git a/assets/images/4.jpg b/assets/images/4.jpg new file mode 100644 index 00000000..3afce344 Binary files /dev/null and b/assets/images/4.jpg differ diff --git a/assets/images/photo_2026-01-02_02-35-09.jpg b/assets/images/photo_2026-01-02_02-35-09.jpg deleted file mode 100644 index bb4001bd..00000000 Binary files a/assets/images/photo_2026-01-02_02-35-09.jpg and /dev/null differ diff --git a/assets/images/photo_2026-01-02_02-35-34.jpg b/assets/images/photo_2026-01-02_02-35-34.jpg deleted file mode 100644 index b2816252..00000000 Binary files a/assets/images/photo_2026-01-02_02-35-34.jpg and /dev/null differ diff --git a/assets/images/photo_2026-01-02_02-35-37.jpg b/assets/images/photo_2026-01-02_02-35-37.jpg deleted file mode 100644 index 852e2777..00000000 Binary files a/assets/images/photo_2026-01-02_02-35-37.jpg and /dev/null differ diff --git a/assets/images/photo_2026-01-02_02-36-23.jpg b/assets/images/photo_2026-01-02_02-36-23.jpg deleted file mode 100644 index 59a75d94..00000000 Binary files a/assets/images/photo_2026-01-02_02-36-23.jpg and /dev/null differ diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 9148e2f9..3270b71b 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -364,6 +364,17 @@ func downloadFromAmazon(req DownloadRequest) (string, error) { fmt.Println("[Amazon] No lyrics found for this track") } else { fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines)) + + // Convert Japanese lyrics to romaji if enabled + if req.ConvertLyricsToRomaji { + for i := range lyrics.Lines { + if ContainsKana(lyrics.Lines[i].Words) { + lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words) + } + } + fmt.Println("[Amazon] Converted Japanese lyrics to romaji") + } + lrcContent := convertToLRC(lyrics) if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil { fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) diff --git a/go_backend/exports.go b/go_backend/exports.go index 1e81b92f..a472fbb8 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -102,6 +102,7 @@ type DownloadRequest struct { Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS EmbedLyrics bool `json:"embed_lyrics"` EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` + ConvertLyricsToRomaji bool `json:"convert_lyrics_to_romaji"` TrackNumber int `json:"track_number"` DiscNumber int `json:"disc_number"` TotalTracks int `json:"total_tracks"` @@ -373,6 +374,12 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) { return string(jsonBytes), nil } +// ConvertToRomaji converts Japanese kana (Hiragana/Katakana) to romaji +// Kanji characters are preserved as-is +func ConvertToRomaji(text string) string { + return ToRomaji(text) +} + func errorResponse(msg string) (string, error) { resp := DownloadResponse{ Success: false, diff --git a/go_backend/progress.go b/go_backend/progress.go index 0cf546ce..67975fbc 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -267,5 +267,7 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) { } pw.current += int64(n) SetItemBytesReceived(pw.itemID, pw.current) + // Also update legacy progress for backward compatibility + SetBytesReceived(pw.current) return n, nil } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index b8b4ddb7..be1e9750 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -426,6 +426,17 @@ func downloadFromQobuz(req DownloadRequest) (string, error) { fmt.Println("[Qobuz] No lyrics found for this track") } else { fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines)) + + // Convert Japanese lyrics to romaji if enabled + if req.ConvertLyricsToRomaji { + for i := range lyrics.Lines { + if ContainsKana(lyrics.Lines[i].Words) { + lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words) + } + } + fmt.Println("[Qobuz] Converted Japanese lyrics to romaji") + } + lrcContent := convertToLRC(lyrics) if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil { fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) diff --git a/go_backend/spotify.go b/go_backend/spotify.go index 745a21ac..ed5b49e6 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -20,6 +20,8 @@ const ( playlistBaseURL = "https://api.spotify.com/v1/playlists/%s" albumBaseURL = "https://api.spotify.com/v1/albums/%s" trackBaseURL = "https://api.spotify.com/v1/tracks/%s" + artistBaseURL = "https://api.spotify.com/v1/artists/%s" + artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" searchBaseURL = "https://api.spotify.com/v1/search" ) @@ -131,6 +133,32 @@ type PlaylistResponsePayload struct { TrackList []AlbumTrackMetadata `json:"track_list"` } +// ArtistInfoMetadata holds artist information +type ArtistInfoMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + Images string `json:"images"` + Followers int `json:"followers"` + Popularity int `json:"popularity"` +} + +// ArtistAlbumMetadata holds album info for artist discography +type ArtistAlbumMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + ReleaseDate string `json:"release_date"` + TotalTracks int `json:"total_tracks"` + Images string `json:"images"` + AlbumType string `json:"album_type"` // album, single, compilation + Artists string `json:"artists"` +} + +// ArtistResponsePayload is the response for artist requests +type ArtistResponsePayload struct { + ArtistInfo ArtistInfoMetadata `json:"artist_info"` + Albums []ArtistAlbumMetadata `json:"albums"` +} + // TrackResponse is the response for single track requests type TrackResponse struct { Track TrackMetadata `json:"track"` @@ -212,6 +240,8 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL return c.fetchAlbum(ctx, parsed.ID, token) case "playlist": return c.fetchPlaylist(ctx, parsed.ID, token) + case "artist": + return c.fetchArtist(ctx, parsed.ID, token) default: return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type) } @@ -405,6 +435,88 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t }, nil } +func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) { + // Fetch artist info + var artistData struct { + ID string `json:"id"` + Name string `json:"name"` + Images []image `json:"images"` + Followers struct { + Total int `json:"total"` + } `json:"followers"` + Popularity int `json:"popularity"` + } + + if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, artistID), token, &artistData); err != nil { + return nil, err + } + + artistInfo := ArtistInfoMetadata{ + ID: artistData.ID, + Name: artistData.Name, + Images: firstImageURL(artistData.Images), + Followers: artistData.Followers.Total, + Popularity: artistData.Popularity, + } + + // Fetch artist albums (all types: album, single, compilation) + albums := make([]ArtistAlbumMetadata, 0) + offset := 0 + limit := 50 + + for { + albumsURL := fmt.Sprintf("%s?include_groups=album,single,compilation&limit=%d&offset=%d", + fmt.Sprintf(artistAlbumsURL, artistID), limit, offset) + + var albumsData struct { + Items []struct { + ID string `json:"id"` + Name string `json:"name"` + ReleaseDate string `json:"release_date"` + TotalTracks int `json:"total_tracks"` + Images []image `json:"images"` + AlbumType string `json:"album_type"` + Artists []artist `json:"artists"` + ExternalURL externalURL `json:"external_urls"` + } `json:"items"` + Next string `json:"next"` + Total int `json:"total"` + } + + if err := c.getJSON(ctx, albumsURL, token, &albumsData); err != nil { + return nil, err + } + + for _, album := range albumsData.Items { + albums = append(albums, ArtistAlbumMetadata{ + ID: album.ID, + Name: album.Name, + ReleaseDate: album.ReleaseDate, + TotalTracks: album.TotalTracks, + Images: firstImageURL(album.Images), + AlbumType: album.AlbumType, + Artists: joinArtists(album.Artists), + }) + } + + // Check if there are more albums + if albumsData.Next == "" || len(albumsData.Items) < limit { + break + } + offset += limit + + // Safety limit to prevent infinite loops + if offset > 500 { + break + } + } + + return &ArtistResponsePayload{ + ArtistInfo: artistInfo, + Albums: albums, + }, nil +} + func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string { var data struct { ExternalID externalID `json:"external_ids"` diff --git a/go_backend/tidal.go b/go_backend/tidal.go index c6b216f5..c28cf9c2 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -961,6 +961,17 @@ func downloadFromTidal(req DownloadRequest) (string, error) { fmt.Println("[Tidal] No lyrics found for this track") } else { fmt.Printf("[Tidal] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines)) + + // Convert Japanese lyrics to romaji if enabled + if req.ConvertLyricsToRomaji { + for i := range lyrics.Lines { + if ContainsKana(lyrics.Lines[i].Words) { + lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words) + } + } + fmt.Println("[Tidal] Converted Japanese lyrics to romaji") + } + lrcContent := convertToLRC(lyrics) if embedErr := EmbedLyrics(actualOutputPath, lrcContent); embedErr != nil { fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr) diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 973f36b4..10b74e8d 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '1.5.0-hotfix6'; - static const String buildNumber = '20'; + static const String version = '1.5.5'; + static const String buildNumber = '22'; static const String fullVersion = '$version+$buildNumber'; static const String appName = 'SpotiFLAC'; diff --git a/lib/main.dart b/lib/main.dart index 4e8fe0d6..dbdb3dfd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/app.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/services/notification_service.dart'; +import 'package:spotiflac_android/services/share_intent_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -10,6 +11,9 @@ void main() async { // Initialize notification service await NotificationService().initialize(); + // Initialize share intent service + await ShareIntentService().initialize(); + runApp( ProviderScope( child: const _EagerInitialization( diff --git a/lib/models/settings.dart b/lib/models/settings.dart index af120301..6c8d8c66 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -14,6 +14,10 @@ class AppSettings { final bool isFirstLaunch; final int concurrentDownloads; // 1 = sequential (default), max 3 final bool checkForUpdates; // Check for updates on app start + final bool hasSearchedBefore; // Hide helper text after first search + final String folderOrganization; // none, artist, album, artist_album + final bool convertLyricsToRomaji; // Convert Japanese lyrics to romaji + final String historyViewMode; // list, grid const AppSettings({ this.defaultService = 'tidal', @@ -26,6 +30,10 @@ class AppSettings { this.isFirstLaunch = true, this.concurrentDownloads = 1, // Default: sequential (off) this.checkForUpdates = true, // Default: enabled + this.hasSearchedBefore = false, // Default: show helper text + this.folderOrganization = 'none', // Default: no folder organization + this.convertLyricsToRomaji = false, // Default: keep original Japanese + this.historyViewMode = 'grid', // Default: grid view }); AppSettings copyWith({ @@ -39,6 +47,10 @@ class AppSettings { bool? isFirstLaunch, int? concurrentDownloads, bool? checkForUpdates, + bool? hasSearchedBefore, + String? folderOrganization, + bool? convertLyricsToRomaji, + String? historyViewMode, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -51,6 +63,10 @@ class AppSettings { isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads, checkForUpdates: checkForUpdates ?? this.checkForUpdates, + hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore, + folderOrganization: folderOrganization ?? this.folderOrganization, + convertLyricsToRomaji: convertLyricsToRomaji ?? this.convertLyricsToRomaji, + historyViewMode: historyViewMode ?? this.historyViewMode, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 564baae3..7821fbf0 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -17,6 +17,10 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1, checkForUpdates: json['checkForUpdates'] as bool? ?? true, + hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false, + folderOrganization: json['folderOrganization'] as String? ?? 'none', + convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false, + historyViewMode: json['historyViewMode'] as String? ?? 'list', ); Map _$AppSettingsToJson(AppSettings instance) => @@ -31,4 +35,8 @@ Map _$AppSettingsToJson(AppSettings instance) => 'isFirstLaunch': instance.isFirstLaunch, 'concurrentDownloads': instance.concurrentDownloads, 'checkForUpdates': instance.checkForUpdates, + 'hasSearchedBefore': instance.hasSearchedBefore, + 'folderOrganization': instance.folderOrganization, + 'convertLyricsToRomaji': instance.convertLyricsToRomaji, + 'historyViewMode': instance.historyViewMode, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 4f8b3ac2..443f5dec 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -386,6 +386,52 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(outputDir: dir); } + /// Build output directory based on folder organization setting + Future _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 { 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 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 { 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 { 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 { 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 { 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'; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 403b3d8e..31e3adc7 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -76,6 +76,28 @@ class SettingsNotifier extends Notifier { 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( diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 2a75c7cd..8ff1eba0 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -8,7 +8,10 @@ class TrackState { final String? error; final String? albumName; final String? playlistName; + final String? artistName; final String? coverUrl; + final List? 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? tracks, bool? isLoading, String? error, String? albumName, String? playlistName, + String? artistName, String? coverUrl, + List? 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 { @override TrackState build() { @@ -45,7 +83,18 @@ class TrackNotifier extends Notifier { } Future 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 { if (type == 'track') { final trackData = metadata['track'] as Map; 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; final trackList = metadata['track_list'] as List; final tracks = trackList.map((t) => _parseTrack(t as Map)).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; final trackList = metadata['track_list'] as List; final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); final owner = playlistInfo['owner'] as Map?; - 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; + final albumsList = metadata['albums'] as List; + final albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).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 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? ?? []; final tracks = trackList.map((t) => _parseSearchTrack(t as Map)).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 { 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 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; + final trackList = metadata['track_list'] as List; + final tracks = trackList.map((t) => _parseTrack(t as Map)).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 data) { return Track( id: data['spotify_id'] as String? ?? '', @@ -183,6 +301,18 @@ class TrackNotifier extends Notifier { releaseDate: data['release_date'] as String?, ); } + + ArtistAlbum _parseArtistAlbum(Map 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( diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index ea51b161..36a64519 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -177,9 +177,9 @@ class _HomeScreenState extends ConsumerState { onDestinationSelected: _onNavTap, destinations: [ const NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Home', + icon: Icon(Icons.search_outlined), + selectedIcon: Icon(Icons.search), + label: 'Search', ), NavigationDestination( icon: Badge( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 1ca44057..62f2258f 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1,9 +1,7 @@ -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/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -15,31 +13,14 @@ class HomeTab extends ConsumerStatefulWidget { ConsumerState createState() => _HomeTabState(); } -class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin { +class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { final _urlController = TextEditingController(); - final Map _fileExistsCache = {}; // Cache file existence @override bool get wantKeepAlive => true; @override void dispose() { _urlController.dispose(); super.dispose(); } - /// Check if file exists with caching to avoid blocking main thread - bool _checkFileExists(String filePath) { - if (_fileExistsCache.containsKey(filePath)) { - return _fileExistsCache[filePath]!; - } - // Schedule async check and return false for now - Future.microtask(() async { - final exists = await File(filePath).exists(); - if (mounted && _fileExistsCache[filePath] != exists) { - setState(() => _fileExistsCache[filePath] = exists); - } - }); - _fileExistsCache[filePath] = false; // Assume false until checked - return false; - } - Future _pasteFromClipboard() async { final data = await Clipboard.getData(Clipboard.kTextPlain); if (data?.text != null) _urlController.text = data!.text!; @@ -59,6 +40,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } else { await ref.read(trackProvider.notifier).search(url); } + ref.read(settingsProvider.notifier).setHasSearchedBefore(); } void _downloadTrack(int index) { @@ -79,189 +61,300 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue'))); } - Future _openFile(String filePath) async { - try { await OpenFilex.open(filePath); } catch (e) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Cannot open file: $e'))); - } + bool get _hasResults { + final trackState = ref.watch(trackProvider); + return trackState.tracks.isNotEmpty || trackState.artistAlbums != null || trackState.isLoading; } @override Widget build(BuildContext context) { super.build(context); final trackState = ref.watch(trackProvider); - final historyState = ref.watch(downloadHistoryProvider); final colorScheme = Theme.of(context).colorScheme; + final hasResults = _hasResults; + return Scaffold( + body: SafeArea( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: hasResults + ? _buildResultsView(trackState, colorScheme) + : _buildCenteredSearch(colorScheme), + ), + ), + ); + } + + // Centered search view when no results + Widget _buildCenteredSearch(ColorScheme colorScheme) { + final historyItems = ref.watch(downloadHistoryProvider).items; + + return Center( + key: const ValueKey('centered'), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // App icon/logo + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: Icon(Icons.music_note, size: 48, color: colorScheme.primary), + ), + const SizedBox(height: 24), + Text( + 'Search Music', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Paste a Spotify link or search by name', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + // Search bar + _buildSearchBar(colorScheme), + const SizedBox(height: 12), + // Helper text + if (!ref.watch(settingsProvider).hasSearchedBefore) + Text( + 'Supports: Track, Album, Playlist, Artist URLs', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + // Recent downloads - compact horizontal scroll + if (historyItems.isNotEmpty) ...[ + const SizedBox(height: 32), + _buildRecentDownloads(historyItems, colorScheme), + ], + ], + ), + ), + ); + } + + Widget _buildRecentDownloads(List items, ColorScheme colorScheme) { + final displayItems = items.take(10).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + 'Recent', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + SizedBox( + height: 80, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: displayItems.length, + itemBuilder: (context, index) { + final item = displayItems[index]; + return GestureDetector( + onTap: () => _navigateToMetadataScreen(item), + child: Container( + width: 60, + margin: const EdgeInsets.only(right: 12), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: item.coverUrl != null + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + memCacheHeight: 112, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24), + ), + ), + const SizedBox(height: 4), + Text( + item.trackName, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + }, + ), + ), + ], + ); + } + + void _navigateToMetadataScreen(DownloadHistoryItem item) { + Navigator.push(context, PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item), + transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), + )); + } + + // Results view with search bar at top + Widget _buildResultsView(TrackState trackState, ColorScheme colorScheme) { return RefreshIndicator( + key: const ValueKey('results'), onRefresh: _clearAndRefresh, displacement: 100, child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ - // Collapsing App Bar - Simplified for performance - SliverAppBar( - expandedHeight: 100, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - automaticallyImplyLeading: false, - flexibleSpace: FlexibleSpaceBar( - expandedTitleScale: 1.4, - titlePadding: const EdgeInsets.only(left: 24, bottom: 16), - title: Text( - 'Home', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + // Collapsing App Bar + SliverAppBar( + expandedHeight: 100, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + automaticallyImplyLeading: false, + flexibleSpace: FlexibleSpaceBar( + expandedTitleScale: 1.4, + titlePadding: const EdgeInsets.only(left: 24, bottom: 16), + title: Text( + 'Search', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), ), ), ), - ), - // Search bar - Simple TextField with border - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), - child: TextField( - controller: _urlController, - decoration: InputDecoration( - hintText: 'Paste Spotify URL or search...', - filled: true, - fillColor: colorScheme.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.5)), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.5)), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide(color: colorScheme.primary, width: 2), - ), - prefixIcon: const Icon(Icons.link), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.paste), - onPressed: _pasteFromClipboard, - tooltip: 'Paste', - ), - Padding( - padding: const EdgeInsets.only(right: 8), - child: IconButton( - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.primary, - borderRadius: BorderRadius.circular(12), - ), - child: Icon(Icons.search, color: colorScheme.onPrimary, size: 20), - ), - onPressed: _fetchMetadata, - tooltip: 'Search', - ), - ), - ], - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - ), - onSubmitted: (_) => _fetchMetadata(), + // Search bar at top + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: _buildSearchBar(colorScheme), ), ), - ), - - // Helper text - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Text( - 'Supports: Track, Album, Playlist URLs', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ), - ), - // Error message - if (trackState.error != null) - SliverToBoxAdapter(child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text(trackState.error!, style: TextStyle(color: colorScheme.error)), + // Error message + if (trackState.error != null) + SliverToBoxAdapter(child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(trackState.error!, style: TextStyle(color: colorScheme.error)), + )), + + // Loading indicator + if (trackState.isLoading) + const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())), + + // Album/Playlist header + if (trackState.albumName != null || trackState.playlistName != null) + SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)), + + // Artist header and discography + if (trackState.artistName != null && trackState.artistAlbums != null) + SliverToBoxAdapter(child: _buildArtistHeader(trackState, colorScheme)), + + if (trackState.artistAlbums != null && trackState.artistAlbums!.isNotEmpty) + SliverToBoxAdapter(child: _buildArtistDiscography(trackState, colorScheme)), + + // Download All button + if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null && trackState.artistAlbums == null) + SliverToBoxAdapter(child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download), + label: Text('Download All (${trackState.tracks.length})'), + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))), + )), + + // Track list + SliverList(delegate: SliverChildBuilderDelegate( + (context, index) => _buildTrackTile(index, colorScheme), + childCount: trackState.tracks.length, )), - // Loading indicator - if (trackState.isLoading) - const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())), - - // Album/Playlist header - if (trackState.albumName != null || trackState.playlistName != null) - SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)), - - // Download All button - if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null) - SliverToBoxAdapter(child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download), - label: Text('Download All (${trackState.tracks.length})'), - style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))), - )), - - // Track list - SliverList(delegate: SliverChildBuilderDelegate( - (context, index) => _buildTrackTile(index, colorScheme), - childCount: trackState.tracks.length, - )), - - // Divider - if (trackState.tracks.isNotEmpty && historyState.items.isNotEmpty) - const SliverToBoxAdapter(child: Divider(height: 32)), - - // Recent Downloads header - if (historyState.items.isNotEmpty) - SliverToBoxAdapter(child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Recent Downloads', style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.primary, fontWeight: FontWeight.w600)), - TextButton(onPressed: () => _showClearHistoryDialog(colorScheme), child: const Text('Clear')), - ], - ), - )), - - // Recent Downloads list - SliverList(delegate: SliverChildBuilderDelegate( - (context, index) => _buildHistoryTile(historyState.items[index], colorScheme), - childCount: historyState.items.length > 10 ? 10 : historyState.items.length, - )), - - // Show more button - if (historyState.items.length > 10) - SliverToBoxAdapter(child: Padding( - padding: const EdgeInsets.all(16), - child: OutlinedButton(onPressed: () => _showAllHistory(colorScheme), - child: Text('Show all ${historyState.items.length} downloads')), - )), - - // Empty state or fill remaining for scroll - if (trackState.tracks.isEmpty && historyState.items.isEmpty) - SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(colorScheme)) - else - const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), - ], + // Bottom padding + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ], ), ); } + Widget _buildSearchBar(ColorScheme colorScheme) { + return TextField( + controller: _urlController, + decoration: InputDecoration( + hintText: 'Paste Spotify URL or search...', + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.5)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.5)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + prefixIcon: const Icon(Icons.link), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.paste), + onPressed: _pasteFromClipboard, + tooltip: 'Paste', + ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.search, color: colorScheme.onPrimary, size: 20), + ), + onPressed: _fetchMetadata, + tooltip: 'Search', + ), + ), + ], + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + ), + onSubmitted: (_) => _fetchMetadata(), + ); + } + Widget _buildHeader(TrackState state, ColorScheme colorScheme) { return Card( margin: const EdgeInsets.all(16), @@ -272,7 +365,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (state.coverUrl != null) ClipRRect(borderRadius: BorderRadius.circular(12), child: CachedNetworkImage(imageUrl: state.coverUrl!, width: 80, height: 80, fit: BoxFit.cover, - placeholder: (_, __) => Container(width: 80, height: 80, color: colorScheme.surfaceContainerHighest))), + placeholder: (_, _) => Container(width: 80, height: 80, color: colorScheme.surfaceContainerHighest))), const SizedBox(width: 16), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(state.albumName ?? state.playlistName ?? '', @@ -291,6 +384,154 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } + Widget _buildArtistHeader(TrackState state, ColorScheme colorScheme) { + final albumCount = state.artistAlbums?.length ?? 0; + return Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (state.coverUrl != null) + ClipRRect( + borderRadius: BorderRadius.circular(40), + child: CachedNetworkImage( + imageUrl: state.coverUrl!, + width: 80, + height: 80, + fit: BoxFit.cover, + placeholder: (_, _) => Container( + width: 80, + height: 80, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.person, color: colorScheme.onSurfaceVariant), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.artistName ?? '', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + '$albumCount releases', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildArtistDiscography(TrackState state, ColorScheme colorScheme) { + final albums = state.artistAlbums ?? []; + + final albumsOnly = albums.where((a) => a.albumType == 'album').toList(); + final singles = albums.where((a) => a.albumType == 'single').toList(); + final compilations = albums.where((a) => a.albumType == 'compilation').toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (albumsOnly.isNotEmpty) _buildAlbumSection('Albums', albumsOnly, colorScheme), + if (singles.isNotEmpty) _buildAlbumSection('Singles & EPs', singles, colorScheme), + if (compilations.isNotEmpty) _buildAlbumSection('Compilations', compilations, colorScheme), + ], + ); + } + + Widget _buildAlbumSection(String title, List albums, ColorScheme colorScheme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + '$title (${albums.length})', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ), + ), + ), + SizedBox( + height: 180, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: albums.length, + itemBuilder: (context, index) => _buildAlbumCard(albums[index], colorScheme), + ), + ), + ], + ); + } + + Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) { + return GestureDetector( + onTap: () => _fetchAlbum(album.id), + child: Container( + width: 130, + margin: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: album.coverUrl != null + ? CachedNetworkImage( + imageUrl: album.coverUrl!, + width: 130, + height: 130, + fit: BoxFit.cover, + memCacheWidth: 260, + memCacheHeight: 260, + ) + : Container( + width: 130, + height: 130, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.album, color: colorScheme.onSurfaceVariant), + ), + ), + const SizedBox(height: 8), + Text( + album.name, + style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} • ${album.totalTracks} tracks', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + void _fetchAlbum(String albumId) { + // Use fetchAlbumFromArtist to save artist state for back navigation + ref.read(trackProvider.notifier).fetchAlbumFromArtist(albumId); + ref.read(settingsProvider.notifier).setHasSearchedBefore(); + } + Widget _buildTrackTile(int index, ColorScheme colorScheme) { final track = ref.watch(trackProvider).tracks[index]; return ListTile( @@ -313,132 +554,4 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient onTap: () => _downloadTrack(index), ); } - - Widget _buildHistoryTile(DownloadHistoryItem item, ColorScheme colorScheme) { - final fileExists = _checkFileExists(item.filePath); - return ListTile( - leading: Hero(tag: 'cover_${item.id}', - child: item.coverUrl != null - ? ClipRRect(borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: item.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - memCacheWidth: 96, - memCacheHeight: 96, - )) - : Container(width: 48, height: 48, - decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant))), - title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text(item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)), - trailing: fileExists - ? IconButton(icon: Icon(Icons.play_arrow, color: colorScheme.primary), onPressed: () => _openFile(item.filePath)) - : Icon(Icons.error_outline, color: colorScheme.error, size: 20), - onTap: () => _navigateToMetadataScreen(item), - ); - } - - Widget _buildEmptyState(ColorScheme colorScheme) => Center(child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withOpacity(0.3), - shape: BoxShape.circle, - ), - child: Icon(Icons.music_note, size: 48, color: colorScheme.primary), - ), - const SizedBox(height: 24), - Text( - 'Ready to Download', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Paste a Spotify link in the search bar above to get started', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - )); - - void _navigateToMetadataScreen(DownloadHistoryItem item) { - Navigator.push(context, PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), - )); - } - - void _showClearHistoryDialog(ColorScheme colorScheme) { - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Clear History'), - content: const Text('Clear all download history?'), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), - TextButton(onPressed: () { ref.read(downloadHistoryProvider.notifier).clearHistory(); Navigator.pop(context); }, - child: Text('Clear', style: TextStyle(color: colorScheme.error))), - ], - )); - } - - void _showAllHistory(ColorScheme colorScheme) { - final historyState = ref.read(downloadHistoryProvider); - showModalBottomSheet(context: context, isScrollControlled: true, - builder: (context) => DraggableScrollableSheet( - initialChildSize: 0.7, minChildSize: 0.5, maxChildSize: 0.95, expand: false, - builder: (context, scrollController) => Column(children: [ - Padding(padding: const EdgeInsets.all(16), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('All Downloads (${historyState.items.length})', - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), - ], - )), - const Divider(height: 1), - Expanded(child: ListView.builder( - controller: scrollController, - itemCount: historyState.items.length, - itemBuilder: (context, index) { - final item = historyState.items[index]; - final fileExists = _checkFileExists(item.filePath); - return ListTile( - leading: item.coverUrl != null - ? ClipRRect(borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: item.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - memCacheWidth: 96, - memCacheHeight: 96, - )) - : Container(width: 48, height: 48, - decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), - title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text(item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)), - trailing: fileExists - ? IconButton(icon: Icon(Icons.play_arrow, color: colorScheme.primary), onPressed: () => _openFile(item.filePath)) - : Icon(Icons.error_outline, color: colorScheme.error, size: 20), - onTap: () { Navigator.pop(context); Future.delayed(const Duration(milliseconds: 100), () => _navigateToMetadataScreen(item)); }, - ); - }, - )), - ]), - ), - ); - } } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 23a4cb4c..2b9511bc 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -1,10 +1,14 @@ +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/screens/home_tab.dart'; import 'package:spotiflac_android/screens/queue_tab.dart'; import 'package:spotiflac_android/screens/settings/settings_tab.dart'; +import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart'; @@ -19,6 +23,7 @@ class _MainShellState extends ConsumerState { int _currentIndex = 0; late PageController _pageController; bool _hasCheckedUpdate = false; + StreamSubscription? _shareSubscription; @override void initState() { @@ -27,9 +32,42 @@ class _MainShellState extends ConsumerState { // Check for updates after first frame WidgetsBinding.instance.addPostFrameCallback((_) { _checkForUpdates(); + _setupShareListener(); }); } + void _setupShareListener() { + // Check for pending URL that was received before listener was ready + final pendingUrl = ShareIntentService().consumePendingUrl(); + if (pendingUrl != null) { + print('[MainShell] Processing pending shared URL: $pendingUrl'); + _handleSharedUrl(pendingUrl); + } + + // Listen for future shared URLs + _shareSubscription = ShareIntentService().sharedUrlStream.listen((url) { + print('[MainShell] Received shared URL from stream: $url'); + _handleSharedUrl(url); + }); + } + + void _handleSharedUrl(String url) { + // Navigate to Home tab + if (_currentIndex != 0) { + _onNavTap(0); + } + // Fetch metadata for shared URL + ref.read(trackProvider.notifier).fetchFromUrl(url); + // Mark that user has searched (hide helper text) + ref.read(settingsProvider.notifier).setHasSearchedBefore(); + // Show snackbar + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Loading shared link...')), + ); + } + } + Future _checkForUpdates() async { if (_hasCheckedUpdate) return; _hasCheckedUpdate = true; @@ -51,6 +89,7 @@ class _MainShellState extends ConsumerState { @override void dispose() { + _shareSubscription?.cancel(); _pageController.dispose(); super.dispose(); } @@ -72,50 +111,95 @@ class _MainShellState extends ConsumerState { } } + Future _showExitDialog() async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Exit App'), + content: const Text('Are you sure you want to exit?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('No'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Yes'), + ), + ], + ), + ) ?? false; + } + @override Widget build(BuildContext context) { final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); + final trackState = ref.watch(trackProvider); - return Scaffold( - body: PageView( - controller: _pageController, - onPageChanged: _onPageChanged, - physics: const BouncingScrollPhysics(), - children: const [ - HomeTab(), - QueueTab(), - SettingsTab(), - ], - ), - bottomNavigationBar: NavigationBar( - selectedIndex: _currentIndex, - onDestinationSelected: _onNavTap, - animationDuration: const Duration(milliseconds: 200), - destinations: [ - const NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Home', - ), - NavigationDestination( - icon: Badge( - isLabelVisible: queueState > 0, - label: Text('$queueState'), - child: const Icon(Icons.download_outlined), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + + // If on Search tab and can go back in track history, go back + if (_currentIndex == 0 && trackState.canGoBack) { + ref.read(trackProvider.notifier).goBack(); + return; + } + + // If not on Search tab, go to Search tab first + if (_currentIndex != 0) { + _onNavTap(0); + return; + } + + // Already at root, show exit dialog + final shouldPop = await _showExitDialog(); + if (shouldPop && context.mounted) { + SystemNavigator.pop(); + } + }, + child: Scaffold( + body: PageView( + controller: _pageController, + onPageChanged: _onPageChanged, + physics: const BouncingScrollPhysics(), + children: const [ + HomeTab(), + QueueTab(), + SettingsTab(), + ], + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: _onNavTap, + animationDuration: const Duration(milliseconds: 200), + destinations: [ + const NavigationDestination( + icon: Icon(Icons.search_outlined), + selectedIcon: Icon(Icons.search), + label: 'Search', ), - selectedIcon: Badge( - isLabelVisible: queueState > 0, - label: Text('$queueState'), - child: const Icon(Icons.download), + NavigationDestination( + icon: Badge( + isLabelVisible: queueState > 0, + label: Text('$queueState'), + child: const Icon(Icons.history_outlined), + ), + selectedIcon: Badge( + isLabelVisible: queueState > 0, + label: Text('$queueState'), + child: const Icon(Icons.history), + ), + label: 'History', ), - label: 'Downloads', - ), - const NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings', - ), - ], + const NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), + ], + ), ), ); } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 08c56160..69b434f4 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -1,15 +1,77 @@ +import 'dart:io'; import 'package:flutter/material.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/models/download_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; -class QueueTab extends ConsumerWidget { +class QueueTab extends ConsumerStatefulWidget { const QueueTab({super.key}); + @override + ConsumerState createState() => _QueueTabState(); +} + +class _QueueTabState extends ConsumerState { + final Map _fileExistsCache = {}; + + bool _checkFileExists(String? filePath) { + if (filePath == null) return false; + if (_fileExistsCache.containsKey(filePath)) { + return _fileExistsCache[filePath]!; + } + Future.microtask(() async { + final exists = await File(filePath).exists(); + if (mounted && _fileExistsCache[filePath] != exists) { + setState(() => _fileExistsCache[filePath] = exists); + } + }); + _fileExistsCache[filePath] = false; + return false; + } + + Future _openFile(String filePath) async { + try { + await OpenFilex.open(filePath); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Cannot open file: $e')), + ); + } + } + } + + void _navigateToMetadataScreen(DownloadItem item) { + final historyItem = ref.read(downloadHistoryProvider).items.firstWhere( + (h) => h.filePath == item.filePath, + orElse: () => DownloadHistoryItem( + id: item.id, + trackName: item.track.name, + artistName: item.track.artistName, + albumName: item.track.albumName, + coverUrl: item.track.coverUrl, + filePath: item.filePath ?? '', + downloadedAt: DateTime.now(), + service: item.service, + ), + ); + + Navigator.push(context, PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: historyItem), + transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), + )); + } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final queueState = ref.watch(downloadQueueProvider); + final historyState = ref.watch(downloadHistoryProvider); + final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode)); final colorScheme = Theme.of(context).colorScheme; return CustomScrollView( @@ -27,7 +89,7 @@ class QueueTab extends ConsumerWidget { expandedTitleScale: 1.4, titlePadding: const EdgeInsets.only(left: 24, bottom: 16), title: Text( - 'Downloads', + 'History', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, @@ -37,8 +99,8 @@ class QueueTab extends ConsumerWidget { ), ), - // Pause/Resume controls when downloading - if (queueState.isProcessing || queueState.queuedCount > 0) + // Pause/Resume controls - only show when multiple items or paused + if ((queueState.isProcessing || queueState.queuedCount > 0) && (queueState.items.length > 1 || queueState.isPaused)) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), @@ -64,37 +126,21 @@ class QueueTab extends ConsumerWidget { ), ), const SizedBox(width: 12), - // Status text + // Status text - simplified Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - queueState.isPaused ? 'Queue Paused' : 'Downloading...', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - '${queueState.completedCount}/${queueState.items.length} completed', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], + child: Text( + queueState.isPaused + ? 'Paused' + : '${queueState.completedCount}/${queueState.items.length}', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), ), ), // Pause/Resume button FilledButton.tonal( onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(queueState.isPaused ? Icons.play_arrow : Icons.pause, size: 20), - const SizedBox(width: 4), - Text(queueState.isPaused ? 'Resume' : 'Pause'), - ], - ), + child: Text(queueState.isPaused ? 'Resume' : 'Pause'), ), ], ), @@ -103,170 +149,210 @@ class QueueTab extends ConsumerWidget { ), ), - // Header with actions + // Queue header if (queueState.items.isNotEmpty) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('${queueState.items.length} items', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - Row(children: [ - TextButton.icon( - onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(), - icon: const Icon(Icons.done_all, size: 18), - label: const Text('Clear done'), - ), - TextButton.icon( - onPressed: () => _showClearAllDialog(context, ref), - icon: Icon(Icons.clear_all, size: 18, color: colorScheme.error), - label: Text('Clear all', style: TextStyle(color: colorScheme.error)), - ), - ]), - ], - ), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text('Downloading (${queueState.items.length})', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), ), ), // Queue list if (queueState.items.isNotEmpty) SliverList(delegate: SliverChildBuilderDelegate( - (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme), + (context, index) => _buildQueueItem(context, queueState.items[index], colorScheme), childCount: queueState.items.length, )), - // Empty state or fill remaining for scroll - if (queueState.items.isEmpty) + // History section header - show count only + if (historyState.items.isNotEmpty && queueState.items.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text('${historyState.items.length} ${historyState.items.length == 1 ? 'track' : 'tracks'}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ), + ), + + // History section header when queue has items (show "Downloaded" label) + if (historyState.items.isNotEmpty && queueState.items.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Downloaded', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + ), + ), + + // History - Grid or List based on setting + if (historyState.items.isNotEmpty) + historyViewMode == 'grid' + ? SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => _buildHistoryGridItem(context, historyState.items[index], colorScheme), + childCount: historyState.items.length, + ), + ), + ) + : SliverList(delegate: SliverChildBuilderDelegate( + (context, index) => _buildHistoryItem(context, historyState.items[index], colorScheme), + childCount: historyState.items.length, + )), + + // Empty state when both queue and history are empty + if (queueState.items.isEmpty && historyState.items.isEmpty) SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme)) else - const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), + const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ); } Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) => Center( child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.queue_music, size: 64, color: colorScheme.onSurfaceVariant), + Icon(Icons.history, size: 64, color: colorScheme.onSurfaceVariant), const SizedBox(height: 16), - Text('No downloads in queue', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)), + Text('No download history', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)), const SizedBox(height: 8), - Text('Add tracks from the Home tab', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7))), + Text('Downloaded tracks will appear here', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7))), ]), ); - Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) { + Widget _buildQueueItem(BuildContext context, DownloadItem item, ColorScheme colorScheme) { + final isCompleted = item.status == DownloadStatus.completed; + return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - // Cover art - item.track.coverUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: item.track.coverUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, - ), - ) - : Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ), - const SizedBox(width: 12), - - // Track info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.track.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - item.track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (item.status == DownloadStatus.downloading) ...[ - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: item.progress > 0 ? item.progress : null, - backgroundColor: colorScheme.surfaceContainerHighest, - color: colorScheme.primary, - minHeight: 6, - ), - ), - ), - const SizedBox(width: 8), - Text( - '${(item.progress * 100).toStringAsFixed(0)}%', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ], - if (item.status == DownloadStatus.failed) ...[ - const SizedBox(height: 4), + child: InkWell( + onTap: isCompleted ? () => _navigateToMetadataScreen(item) : null, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Cover art with Hero for completed items + isCompleted + ? Hero( + tag: 'cover_${item.id}', + child: _buildCoverArt(item, colorScheme), + ) + : _buildCoverArt(item, colorScheme), + const SizedBox(width: 12), + + // Track info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - item.error ?? 'Download failed', + item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.error, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, ), ), + const SizedBox(height: 2), + Text( + item.track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (item.status == DownloadStatus.downloading) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: item.progress > 0 ? item.progress : null, + backgroundColor: colorScheme.surfaceContainerHighest, + color: colorScheme.primary, + minHeight: 6, + ), + ), + ), + const SizedBox(width: 8), + Text( + '${(item.progress * 100).toStringAsFixed(0)}%', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + if (item.status == DownloadStatus.failed) ...[ + const SizedBox(height: 4), + Text( + item.error ?? 'Download failed', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.error, + ), + ), + ], ], - ], + ), ), - ), - const SizedBox(width: 8), - - // Action buttons based on status - _buildActionButtons(context, ref, item, colorScheme), - ], + const SizedBox(width: 8), + + // Action buttons based on status + _buildActionButtons(context, item, colorScheme), + ], + ), ), ), ); } - Widget _buildActionButtons(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) { + Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) { + return item.track.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: item.track.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + memCacheHeight: 112, + ), + ) + : Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ); + } + + Widget _buildActionButtons(BuildContext context, DownloadItem item, ColorScheme colorScheme) { switch (item.status) { case DownloadStatus.queued: - // Queued: Show play (start) and cancel buttons + // Queued: Show cancel button return Row( mainAxisSize: MainAxisSize.min, children: [ - // Cancel button IconButton( onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id), icon: Icon(Icons.close, color: colorScheme.error), @@ -279,11 +365,10 @@ class QueueTab extends ConsumerWidget { ); case DownloadStatus.downloading: - // Downloading: Show progress indicator and cancel button + // Downloading: Show stop button return Row( mainAxisSize: MainAxisSize.min, children: [ - // Cancel button (skip this download) IconButton( onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id), icon: Icon(Icons.stop, color: colorScheme.error), @@ -296,14 +381,32 @@ class QueueTab extends ConsumerWidget { ); case DownloadStatus.completed: - // Completed: Show check icon - return Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: 20), + // Completed: Show play button and check icon + final fileExists = _checkFileExists(item.filePath); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (fileExists) + IconButton( + onPressed: () => _openFile(item.filePath!), + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + tooltip: 'Play', + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), + ), + ) + else + Icon(Icons.error_outline, color: colorScheme.error, size: 20), + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: 20), + ), + ], ); case DownloadStatus.failed: @@ -355,16 +458,194 @@ class QueueTab extends ConsumerWidget { } } - void _showClearAllDialog(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Clear All'), - content: const Text('Are you sure you want to clear all downloads?'), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), - TextButton(onPressed: () { ref.read(downloadQueueProvider.notifier).clearAll(); Navigator.pop(context); }, - child: Text('Clear', style: TextStyle(color: colorScheme.error))), - ], + void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) { + Navigator.push(context, PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item), + transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), )); } + + Widget _buildHistoryGridItem(BuildContext context, DownloadHistoryItem item, ColorScheme colorScheme) { + final fileExists = _checkFileExists(item.filePath); + + return GestureDetector( + onTap: () => _navigateToHistoryMetadataScreen(item), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Cover art with play button overlay + Stack( + children: [ + AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: item.coverUrl != null + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + fit: BoxFit.cover, + memCacheWidth: 200, + memCacheHeight: 200, + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32), + ), + ), + ), + // Play button overlay + if (fileExists) + Positioned( + right: 4, + bottom: 4, + child: GestureDetector( + onTap: () => _openFile(item.filePath), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + child: Icon(Icons.play_arrow, color: colorScheme.onPrimary, size: 16), + ), + ), + ), + // Error indicator if file missing + if (!fileExists) + Positioned( + right: 4, + bottom: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + shape: BoxShape.circle, + ), + child: Icon(Icons.error_outline, color: colorScheme.error, size: 14), + ), + ), + ], + ), + const SizedBox(height: 6), + // Track name + Text( + item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + // Artist name + Text( + item.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + Widget _buildHistoryItem(BuildContext context, DownloadHistoryItem item, ColorScheme colorScheme) { + final fileExists = _checkFileExists(item.filePath); + final date = item.downloadedAt; + final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + final dateStr = '${months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: InkWell( + onTap: () => _navigateToHistoryMetadataScreen(item), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Cover art + item.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + memCacheHeight: 112, + ), + ) + : Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + const SizedBox(width: 12), + + // Track info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + item.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text( + dateStr, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + + // Action buttons + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (fileExists) + IconButton( + onPressed: () => _openFile(item.filePath), + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + tooltip: 'Play', + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), + ), + ) + else + Icon(Icons.error_outline, color: colorScheme.error, size: 20), + ], + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 1a0325de..6922f03b 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/theme_provider.dart'; class AppearanceSettingsPage extends ConsumerWidget { @@ -8,6 +9,7 @@ class AppearanceSettingsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final themeSettings = ref.watch(themeProvider); + final settings = ref.watch(settingsProvider); final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; @@ -85,6 +87,18 @@ class AppearanceSettingsPage extends ConsumerWidget { ), ), + // Layout section + SliverToBoxAdapter(child: _SectionHeader(title: 'Layout')), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _HistoryViewSelector( + currentMode: settings.historyViewMode, + onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode), + ), + ), + ), + // Fill remaining for scroll const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), ], @@ -201,3 +215,69 @@ class _ColorPicker extends StatelessWidget { ); } } + +class _HistoryViewSelector extends StatelessWidget { + final String currentMode; + final ValueChanged onChanged; + const _HistoryViewSelector({required this.currentMode, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Card( + elevation: 0, + color: colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8, bottom: 8), + child: Text('History View', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + ), + Row(children: [ + _ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')), + const SizedBox(width: 8), + _ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')), + ]), + ], + ), + ), + ); + } +} + +class _ViewModeChip extends StatelessWidget { + final IconData icon; + final String label; + final bool isSelected; + final VoidCallback onTap; + const _ViewModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Expanded( + child: Material( + color: isSelected ? colorScheme.primaryContainer : Colors.transparent, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column(children: [ + Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant), + const SizedBox(height: 6), + Text(label, style: TextStyle(fontSize: 12, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)), + ]), + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index fe4ed5fb..8f24420b 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -99,6 +99,14 @@ class DownloadSettingsPage extends ConsumerWidget { trailing: const Icon(Icons.chevron_right), onTap: () => _pickDirectory(ref), ), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: Icon(Icons.create_new_folder_outlined, color: colorScheme.onSurfaceVariant), + title: const Text('Folder Organization'), + subtitle: Text(_getFolderOrganizationLabel(settings.folderOrganization)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showFolderOrganizationPicker(context, ref, settings.folderOrganization), + ), ])), const SliverToBoxAdapter(child: SizedBox(height: 32)), @@ -138,6 +146,73 @@ class DownloadSettingsPage extends ConsumerWidget { final result = await FilePicker.platform.getDirectoryPath(); if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result); } + + String _getFolderOrganizationLabel(String value) { + switch (value) { + case 'artist': + return 'By Artist (Artist/Track.flac)'; + case 'album': + return 'By Album (Album/Track.flac)'; + case 'artist_album': + return 'By Artist & Album (Artist/Album/Track.flac)'; + default: + return 'None (all in one folder)'; + } + } + + void _showFolderOrganizationPicker(BuildContext context, WidgetRef ref, String current) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text('Folder Organization', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text('Organize downloaded files into folders', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ), + _FolderOption( + title: 'None', + subtitle: 'All files in download folder', + example: 'SpotiFLAC/Track.flac', + isSelected: current == 'none', + onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('none'); Navigator.pop(context); }, + ), + _FolderOption( + title: 'By Artist', + subtitle: 'Separate folder for each artist', + example: 'SpotiFLAC/Artist Name/Track.flac', + isSelected: current == 'artist', + onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist'); Navigator.pop(context); }, + ), + _FolderOption( + title: 'By Album', + subtitle: 'Separate folder for each album', + example: 'SpotiFLAC/Album Name/Track.flac', + isSelected: current == 'album', + onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('album'); Navigator.pop(context); }, + ), + _FolderOption( + title: 'By Artist & Album', + subtitle: 'Nested folders for artist and album', + example: 'SpotiFLAC/Artist/Album/Track.flac', + isSelected: current == 'artist_album', + onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist_album'); Navigator.pop(context); }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } } class _SectionHeader extends StatelessWidget { @@ -230,3 +305,31 @@ class _QualityOption extends StatelessWidget { ); } } + +class _FolderOption extends StatelessWidget { + final String title; + final String subtitle; + final String example; + final bool isSelected; + final VoidCallback onTap; + const _FolderOption({required this.title, required this.subtitle, required this.example, required this.isSelected, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + title: Text(title), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(subtitle), + const SizedBox(height: 4), + Text(example, style: TextStyle(fontFamily: 'monospace', fontSize: 11, color: colorScheme.primary)), + ], + ), + trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: onTap, + ); + } +} diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 37a2a8b5..fb91085d 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; class OptionsSettingsPage extends ConsumerWidget { @@ -91,6 +92,19 @@ class OptionsSettingsPage extends ConsumerWidget { ), ), + // Lyrics section + SliverToBoxAdapter(child: _SectionHeader(title: 'Lyrics')), + SliverList(delegate: SliverChildListDelegate([ + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + secondary: Icon(Icons.translate, color: colorScheme.onSurfaceVariant), + title: const Text('Convert Japanese to Romaji'), + subtitle: const Text('Auto-convert Hiragana/Katakana lyrics'), + value: settings.convertLyricsToRomaji, + onChanged: (v) => ref.read(settingsProvider.notifier).setConvertLyricsToRomaji(v), + ), + ])), + // App section SliverToBoxAdapter(child: _SectionHeader(title: 'App')), SliverToBoxAdapter( @@ -104,11 +118,49 @@ class OptionsSettingsPage extends ConsumerWidget { ), ), + // Data section + SliverToBoxAdapter(child: _SectionHeader(title: 'Data')), + SliverToBoxAdapter( + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: Icon(Icons.delete_forever, color: colorScheme.error), + title: const Text('Clear Download History'), + subtitle: const Text('Remove all downloaded tracks from history'), + onTap: () => _showClearHistoryDialog(context, ref, colorScheme), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), ); } + + void _showClearHistoryDialog(BuildContext context, WidgetRef ref, ColorScheme colorScheme) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear History'), + content: const Text('Are you sure you want to clear all download history? This cannot be undone.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + ref.read(downloadHistoryProvider.notifier).clearHistory(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('History cleared')), + ); + }, + child: Text('Clear', style: TextStyle(color: colorScheme.error)), + ), + ], + ), + ); + } } class _SectionHeader extends StatelessWidget { diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 9c6320d7..ab61236f 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -6,6 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:open_filex/open_filex.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; /// Screen to display detailed metadata for a downloaded track /// Designed with Material Expressive 3 style @@ -21,6 +22,9 @@ class TrackMetadataScreen extends ConsumerStatefulWidget { class _TrackMetadataScreenState extends ConsumerState { bool _fileExists = false; int? _fileSize; + String? _lyrics; + bool _lyricsLoading = false; + String? _lyricsError; @override void initState() { @@ -113,6 +117,11 @@ class _TrackMetadataScreenState extends ConsumerState { // File info card _buildFileInfoCard(context, colorScheme, _fileExists, _fileSize), + const SizedBox(height: 16), + + // Lyrics card + _buildLyricsCard(context, colorScheme), + const SizedBox(height: 24), // Action buttons @@ -623,6 +632,156 @@ class _TrackMetadataScreenState extends ConsumerState { ); } + 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( + 'Lyrics', + 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: 'Copy lyrics', + ), + ], + ), + 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: const Text('Retry'), + ), + ], + ), + ) + 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: const Text('Load Lyrics'), + ), + ), + ], + ), + ), + ); + } + + Future _fetchLyrics() async { + if (_lyricsLoading) return; + + setState(() { + _lyricsLoading = true; + _lyricsError = null; + }); + + try { + final result = await PlatformBridge.getLyricsLRC( + item.spotifyId ?? '', + item.trackName, + item.artistName, + ); + + if (mounted) { + if (result.isEmpty) { + setState(() { + _lyricsError = 'Lyrics not found'; + _lyricsLoading = false; + }); + } else { + // Clean up LRC timestamps for display + final cleanLyrics = _cleanLrcForDisplay(result); + setState(() { + _lyrics = cleanLyrics; + _lyricsLoading = false; + }); + } + } + } catch (e) { + if (mounted) { + setState(() { + _lyricsError = 'Failed to load lyrics'; + _lyricsLoading = false; + }); + } + } + } + + String _cleanLrcForDisplay(String lrc) { + // Remove LRC timestamps [mm:ss.xx] for cleaner display + final lines = lrc.split('\n'); + final cleanLines = []; + 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: [ diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 911cf8c1..c1c41b1c 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -50,6 +50,7 @@ class PlatformBridge { String quality = 'LOSSLESS', bool embedLyrics = true, bool embedMaxQualityCover = true, + bool convertLyricsToRomaji = false, int trackNumber = 1, int discNumber = 1, int totalTracks = 1, @@ -70,6 +71,7 @@ class PlatformBridge { 'quality': quality, 'embed_lyrics': embedLyrics, 'embed_max_quality_cover': embedMaxQualityCover, + 'convert_lyrics_to_romaji': convertLyricsToRomaji, 'track_number': trackNumber, 'disc_number': discNumber, 'total_tracks': totalTracks, @@ -95,6 +97,7 @@ class PlatformBridge { String quality = 'LOSSLESS', bool embedLyrics = true, bool embedMaxQualityCover = true, + bool convertLyricsToRomaji = false, int trackNumber = 1, int discNumber = 1, int totalTracks = 1, @@ -116,6 +119,7 @@ class PlatformBridge { 'quality': quality, 'embed_lyrics': embedLyrics, 'embed_max_quality_cover': embedMaxQualityCover, + 'convert_lyrics_to_romaji': convertLyricsToRomaji, 'track_number': trackNumber, 'disc_number': discNumber, 'total_tracks': totalTracks, diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart new file mode 100644 index 00000000..79c7acbe --- /dev/null +++ b/lib/services/share_intent_service.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; + +/// Service to handle incoming share intents from other apps (e.g., Spotify) +class ShareIntentService { + static final ShareIntentService _instance = ShareIntentService._internal(); + factory ShareIntentService() => _instance; + ShareIntentService._internal(); + + final _sharedUrlController = StreamController.broadcast(); + StreamSubscription>? _mediaSubscription; + bool _initialized = false; + String? _pendingUrl; // Store URL received before listener is ready + + /// Stream of shared Spotify URLs + Stream get sharedUrlStream => _sharedUrlController.stream; + + /// Get pending URL that was received before listener was ready + String? consumePendingUrl() { + final url = _pendingUrl; + _pendingUrl = null; + return url; + } + + /// Initialize the service and start listening for share intents + Future initialize() async { + if (_initialized) return; + _initialized = true; + + // Listen to media sharing coming from outside the app while the app is in memory + _mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen( + _handleSharedMedia, + onError: (err) => print('[ShareIntent] Error: $err'), + ); + + // Get the media sharing coming from outside the app while the app is closed + final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia(); + if (initialMedia.isNotEmpty) { + _handleSharedMedia(initialMedia, isInitial: true); + // Tell the library that we are done processing the intent + ReceiveSharingIntent.instance.reset(); + } + } + + void _handleSharedMedia(List files, {bool isInitial = false}) { + for (final file in files) { + // Check the path - for text shares, the path contains the shared text + final textToCheck = file.path; + + final url = _extractSpotifyUrl(textToCheck); + if (url != null) { + print('[ShareIntent] Received Spotify URL: $url (initial: $isInitial)'); + if (isInitial) { + // Store for later - listener might not be ready yet + _pendingUrl = url; + } + _sharedUrlController.add(url); + return; // Only process first valid URL + } + } + } + + /// Extract Spotify URL from shared text + /// Handles various formats: + /// - Direct URL: https://open.spotify.com/track/xxx + /// - With text: "Check out this song! https://open.spotify.com/track/xxx" + /// - Spotify URI: spotify:track:xxx + String? _extractSpotifyUrl(String text) { + if (text.isEmpty) return null; + + // Check for spotify: URI format + final uriMatch = RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+').firstMatch(text); + if (uriMatch != null) { + return uriMatch.group(0); + } + + // Check for open.spotify.com URL + final urlMatch = RegExp( + r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?', + ).firstMatch(text); + if (urlMatch != null) { + // Return URL without query params for cleaner handling + final fullUrl = urlMatch.group(0)!; + final queryIndex = fullUrl.indexOf('?'); + return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl; + } + + return null; + } + + /// Dispose resources + void dispose() { + _mediaSubscription?.cancel(); + _sharedUrlController.close(); + } +} diff --git a/pubspec.lock b/pubspec.lock index 1f25ea5a..f3e18361 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -824,6 +824,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + receive_sharing_intent: + dependency: "direct main" + description: + name: receive_sharing_intent + sha256: ec76056e4d258ad708e76d85591d933678625318e411564dcb9059048ca3a593 + url: "https://pub.dev" + source: hosted + version: "1.8.1" riverpod: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7db3f306..b85990fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 1.5.0+20 +version: 1.5.5+22 environment: sdk: ^3.10.0 @@ -47,6 +47,7 @@ dependencies: url_launcher: ^6.3.1 device_info_plus: ^12.3.0 share_plus: ^10.1.4 + receive_sharing_intent: ^1.8.1 # FFmpeg for audio conversion (audio-only version - much smaller) ffmpeg_kit_flutter_new_audio: ^2.0.0