v1.5.5: History tab, share intent, artist support, lyrics viewer, folder organization

This commit is contained in:
zarzet
2026-01-02 06:47:49 +07:00
parent 0edd616c3d
commit 0d8234ccd2
35 changed files with 1959 additions and 539 deletions
+49
View File
@@ -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
+4 -4
View File
@@ -26,10 +26,10 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
## Screenshots
<p align="center">
<img src="assets/images/photo_2026-01-02_02-35-09.jpg" width="200" />
<img src="assets/images/photo_2026-01-02_02-35-34.jpg" width="200" />
<img src="assets/images/photo_2026-01-02_02-35-37.jpg" width="200" />
<img src="assets/images/photo_2026-01-02_02-36-23.jpg" width="200" />
<img src="assets/images/1.jpg" width="200" />
<img src="assets/images/2.jpg" width="200" />
<img src="assets/images/3.jpg" width="200" />
<img src="assets/images/4.jpg" width="200" />
</p>
## Other project
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

+11
View File
@@ -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)
+7
View File
@@ -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,
+2
View File
@@ -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
}
+11
View File
@@ -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)
+112
View File
@@ -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"`
+11
View File
@@ -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)
+2 -2
View File
@@ -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';
+4
View File
@@ -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(
+16
View File
@@ -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,
);
}
+8
View File
@@ -17,6 +17,10 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -31,4 +35,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'isFirstLaunch': instance.isFirstLaunch,
'concurrentDownloads': instance.concurrentDownloads,
'checkForUpdates': instance.checkForUpdates,
'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization,
'convertLyricsToRomaji': instance.convertLyricsToRomaji,
'historyViewMode': instance.historyViewMode,
};
+58 -2
View File
@@ -386,6 +386,52 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(outputDir: dir);
}
/// Build output directory based on folder organization setting
Future<String> _buildOutputDir(Track track, String folderOrganization) async {
String baseDir = state.outputDir;
if (folderOrganization == 'none') {
return baseDir;
}
// Sanitize folder names (remove invalid characters)
String sanitize(String name) {
return name
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
.replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots
.trim();
}
String subPath = '';
switch (folderOrganization) {
case 'artist':
final artistName = sanitize(track.albumArtist ?? track.artistName);
subPath = artistName;
break;
case 'album':
final albumName = sanitize(track.albumName);
subPath = albumName;
break;
case 'artist_album':
final artistName = sanitize(track.albumArtist ?? track.artistName);
final albumName = sanitize(track.albumName);
subPath = '$artistName${Platform.pathSeparator}$albumName';
break;
}
if (subPath.isNotEmpty) {
final fullPath = '$baseDir${Platform.pathSeparator}$subPath';
final dir = Directory(fullPath);
if (!await dir.exists()) {
await dir.create(recursive: true);
print('[DownloadQueue] Created folder: $fullPath');
}
return fullPath;
}
return baseDir;
}
void updateSettings(AppSettings settings) {
state = state.copyWith(
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir,
@@ -760,11 +806,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
updateItemStatus(item.id, DownloadStatus.downloading);
try {
// Get folder organization setting and build output directory
final settings = ref.read(settingsProvider);
final outputDir = await _buildOutputDir(item.track, settings.folderOrganization);
Map<String, dynamic> result;
if (state.autoFallback) {
print('[DownloadQueue] Using auto-fallback mode');
print('[DownloadQueue] Quality: ${state.audioQuality}');
print('[DownloadQueue] Output dir: $outputDir');
result = await PlatformBridge.downloadWithFallback(
isrc: item.track.isrc ?? '',
spotifyId: item.track.id,
@@ -773,7 +824,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumName: item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
outputDir: state.outputDir,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: state.audioQuality,
trackNumber: item.track.trackNumber ?? 1,
@@ -781,6 +832,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
releaseDate: item.track.releaseDate,
preferredService: item.service,
itemId: item.id, // Pass item ID for progress tracking
convertLyricsToRomaji: settings.convertLyricsToRomaji,
);
} else {
result = await PlatformBridge.downloadTrack(
@@ -792,13 +844,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumName: item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
outputDir: state.outputDir,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: state.audioQuality,
trackNumber: item.track.trackNumber ?? 1,
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
itemId: item.id, // Pass item ID for progress tracking
convertLyricsToRomaji: settings.convertLyricsToRomaji,
);
}
@@ -873,6 +926,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
quality: state.audioQuality,
),
);
// Auto-remove completed item from queue (it's now in history)
removeItem(item.id);
}
} else {
final errorMsg = result['error'] as String? ?? 'Download failed';
+22
View File
@@ -76,6 +76,28 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(checkForUpdates: enabled);
_saveSettings();
}
void setHasSearchedBefore() {
if (!state.hasSearchedBefore) {
state = state.copyWith(hasSearchedBefore: true);
_saveSettings();
}
}
void setFolderOrganization(String organization) {
state = state.copyWith(folderOrganization: organization);
_saveSettings();
}
void setConvertLyricsToRomaji(bool enabled) {
state = state.copyWith(convertLyricsToRomaji: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+144 -14
View File
@@ -8,7 +8,10 @@ class TrackState {
final String? error;
final String? albumName;
final String? playlistName;
final String? artistName;
final String? coverUrl;
final List<ArtistAlbum>? artistAlbums; // For artist page
final TrackState? previousState; // For back navigation
const TrackState({
this.tracks = const [],
@@ -16,16 +19,27 @@ class TrackState {
this.error,
this.albumName,
this.playlistName,
this.artistName,
this.coverUrl,
this.artistAlbums,
this.previousState,
});
bool get canGoBack => previousState != null;
bool get hasContent => tracks.isNotEmpty || artistAlbums != null;
TrackState copyWith({
List<Track>? tracks,
bool? isLoading,
String? error,
String? albumName,
String? playlistName,
String? artistName,
String? coverUrl,
List<ArtistAlbum>? artistAlbums,
TrackState? previousState,
bool clearPreviousState = false,
}) {
return TrackState(
tracks: tracks ?? this.tracks,
@@ -33,11 +47,35 @@ class TrackState {
error: error,
albumName: albumName ?? this.albumName,
playlistName: playlistName ?? this.playlistName,
artistName: artistName ?? this.artistName,
coverUrl: coverUrl ?? this.coverUrl,
artistAlbums: artistAlbums ?? this.artistAlbums,
previousState: clearPreviousState ? null : (previousState ?? this.previousState),
);
}
}
/// Represents an album in artist discography
class ArtistAlbum {
final String id;
final String name;
final String releaseDate;
final int totalTracks;
final String? coverUrl;
final String albumType; // album, single, compilation
final String artists;
const ArtistAlbum({
required this.id,
required this.name,
required this.releaseDate,
required this.totalTracks,
this.coverUrl,
required this.albumType,
required this.artists,
});
}
class TrackNotifier extends Notifier<TrackState> {
@override
TrackState build() {
@@ -45,7 +83,18 @@ class TrackNotifier extends Notifier<TrackState> {
}
Future<void> fetchFromUrl(String url) async {
state = state.copyWith(isLoading: true, error: null);
// Save current state for back navigation (only if we have content or it's empty)
final savedState = state.hasContent ? TrackState(
tracks: state.tracks,
albumName: state.albumName,
playlistName: state.playlistName,
artistName: state.artistName,
coverUrl: state.coverUrl,
artistAlbums: state.artistAlbums,
previousState: state.previousState,
) : const TrackState(); // Empty state for back to home
state = TrackState(isLoading: true, previousState: savedState);
try {
final parsed = await PlatformBridge.parseSpotifyUrl(url);
@@ -56,57 +105,78 @@ class TrackNotifier extends Notifier<TrackState> {
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = state.copyWith(
state = TrackState(
tracks: [track],
isLoading: false,
albumName: null,
playlistName: null,
coverUrl: track.coverUrl,
previousState: savedState,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
state = state.copyWith(
state = TrackState(
tracks: tracks,
isLoading: false,
albumName: albumInfo['name'] as String?,
playlistName: null,
coverUrl: albumInfo['images'] as String?,
previousState: savedState,
);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
state = state.copyWith(
state = TrackState(
tracks: tracks,
isLoading: false,
albumName: null,
playlistName: owner?['name'] as String?,
coverUrl: owner?['images'] as String?,
previousState: savedState,
);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
state = TrackState(
tracks: [], // No tracks for artist view
isLoading: false,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
artistAlbums: albums,
previousState: savedState,
);
}
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
}
}
Future<void> search(String query) async {
state = state.copyWith(isLoading: true, error: null);
// Save current state for back navigation
final savedState = state.hasContent ? TrackState(
tracks: state.tracks,
albumName: state.albumName,
playlistName: state.playlistName,
artistName: state.artistName,
coverUrl: state.coverUrl,
artistAlbums: state.artistAlbums,
previousState: state.previousState,
) : const TrackState();
state = TrackState(isLoading: true, previousState: savedState);
try {
final results = await PlatformBridge.searchSpotify(query, limit: 20);
final trackList = results['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
state = state.copyWith(
state = TrackState(
tracks: tracks,
isLoading: false,
albumName: null,
playlistName: null,
previousState: savedState,
);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
}
}
@@ -152,6 +222,54 @@ class TrackNotifier extends Notifier<TrackState> {
state = const TrackState();
}
/// Go back to previous state (if available)
bool goBack() {
if (state.previousState != null) {
state = state.previousState!;
return true;
}
return false;
}
/// Fetch album from artist view - saves current artist state for back navigation
Future<void> fetchAlbumFromArtist(String albumId) async {
// Save current artist state before fetching album
final savedState = TrackState(
artistName: state.artistName,
coverUrl: state.coverUrl,
artistAlbums: state.artistAlbums,
previousState: state.previousState, // Keep the chain
);
state = TrackState(
isLoading: true,
previousState: savedState,
);
try {
final url = 'https://open.spotify.com/album/$albumId';
final metadata = await PlatformBridge.getSpotifyMetadata(url);
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
previousState: savedState,
);
} catch (e) {
state = TrackState(
isLoading: false,
error: e.toString(),
previousState: savedState,
);
}
}
Track _parseTrack(Map<String, dynamic> data) {
return Track(
id: data['spotify_id'] as String? ?? '',
@@ -183,6 +301,18 @@ class TrackNotifier extends Notifier<TrackState> {
releaseDate: data['release_date'] as String?,
);
}
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
return ArtistAlbum(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['images'] as String?,
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
);
}
}
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
+3 -3
View File
@@ -177,9 +177,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
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(
+418 -305
View File
@@ -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<HomeTab> createState() => _HomeTabState();
}
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin {
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
final _urlController = TextEditingController();
final Map<String, bool> _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<void> _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<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
}
Future<void> _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<DownloadHistoryItem> 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<HomeTab> 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<HomeTab> 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<ArtistAlbum> 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<HomeTab> 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)); },
);
},
)),
]),
),
);
}
}
+122 -38
View File
@@ -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<MainShell> {
int _currentIndex = 0;
late PageController _pageController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
@override
void initState() {
@@ -27,9 +32,42 @@ class _MainShellState extends ConsumerState<MainShell> {
// 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<void> _checkForUpdates() async {
if (_hasCheckedUpdate) return;
_hasCheckedUpdate = true;
@@ -51,6 +89,7 @@ class _MainShellState extends ConsumerState<MainShell> {
@override
void dispose() {
_shareSubscription?.cancel();
_pageController.dispose();
super.dispose();
}
@@ -72,50 +111,95 @@ class _MainShellState extends ConsumerState<MainShell> {
}
}
Future<bool> _showExitDialog() async {
return await showDialog<bool>(
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',
),
],
),
),
);
}
+451 -170
View File
@@ -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<QueueTab> createState() => _QueueTabState();
}
class _QueueTabState extends ConsumerState<QueueTab> {
final Map<String, bool> _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<void> _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),
],
),
],
),
),
),
);
}
}
@@ -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<String> 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)),
]),
),
),
),
);
}
}
@@ -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,
);
}
}
@@ -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 {
+159
View File
@@ -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<TrackMetadataScreen> {
bool _fileExists = false;
int? _fileSize;
String? _lyrics;
bool _lyricsLoading = false;
String? _lyricsError;
@override
void initState() {
@@ -113,6 +117,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// 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<TrackMetadataScreen> {
);
}
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<void> _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 = <String>[];
final timestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
for (final line in lines) {
final cleanLine = line.replaceAll(timestampPattern, '').trim();
if (cleanLine.isNotEmpty) {
cleanLines.add(cleanLine);
}
}
return cleanLines.join('\n');
}
Widget _buildActionButtons(BuildContext context, WidgetRef ref, ColorScheme colorScheme, bool fileExists) {
return Row(
children: [
+4
View File
@@ -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,
+96
View File
@@ -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<String>.broadcast();
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
bool _initialized = false;
String? _pendingUrl; // Store URL received before listener is ready
/// Stream of shared Spotify URLs
Stream<String> 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<void> 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<SharedMediaFile> 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();
}
}
+8
View File
@@ -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:
+2 -1
View File
@@ -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