mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d98960d053 | |||
| d417743654 | |||
| efbf5d4c5b | |||
| c673581c32 | |||
| a6d488696b |
@@ -16,7 +16,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: I have searched existing issues and this bug hasn't been reported yet
|
- label: I have searched existing issues and this bug hasn't been reported yet
|
||||||
required: true
|
required: true
|
||||||
- label: I am using the latest version of SpotiFLAC
|
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ contact_links:
|
|||||||
- name: README
|
- name: README
|
||||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||||
about: Check the README for setup instructions and FAQ
|
about: Check the README for setup instructions and FAQ
|
||||||
|
- name: Extension Development Guide
|
||||||
|
url: https://zarz.moe/docs
|
||||||
|
about: Documentation for building SpotiFLAC extensions
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
||||||
required: true
|
required: true
|
||||||
- label: I am using the latest version of SpotiFLAC
|
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
name: Extension API Feature Request (Alpha)
|
||||||
|
description: Request new API features or capabilities for extension development (Extension system is in alpha)
|
||||||
|
title: "[Extension API]: "
|
||||||
|
labels: ["enhancement", "extension-api"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for helping improve the SpotiFLAC Extension API!
|
||||||
|
This form is for extension developers who need new features or capabilities that don't exist yet.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- label: I have read the [Extension Development Guide](https://zarz.moe/docs)
|
||||||
|
required: true
|
||||||
|
- label: I have searched existing issues and this API feature hasn't been requested yet
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extension_goal
|
||||||
|
attributes:
|
||||||
|
label: What are you trying to build?
|
||||||
|
description: Describe the extension or feature you're developing
|
||||||
|
placeholder: "I'm building an extension that downloads from [service name] / provides metadata from [source]..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: current_limitation
|
||||||
|
attributes:
|
||||||
|
label: Current API Limitation
|
||||||
|
description: What's missing or limiting in the current extension API?
|
||||||
|
placeholder: |
|
||||||
|
The current API doesn't support:
|
||||||
|
- [missing feature 1]
|
||||||
|
- [missing feature 2]
|
||||||
|
|
||||||
|
This prevents me from...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposed_api
|
||||||
|
attributes:
|
||||||
|
label: Proposed API / Feature
|
||||||
|
description: Describe the API or feature you'd like to see added
|
||||||
|
placeholder: |
|
||||||
|
I would like to have:
|
||||||
|
- A new function `api.newFeature()` that does X
|
||||||
|
- A new manifest field `newOption` that enables Y
|
||||||
|
- Access to Z capability...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: use_case
|
||||||
|
attributes:
|
||||||
|
label: Use Case Example
|
||||||
|
description: Provide a code example of how you would use this feature
|
||||||
|
placeholder: |
|
||||||
|
```javascript
|
||||||
|
// Example usage in extension code
|
||||||
|
function download(request, progressCallback) {
|
||||||
|
const result = api.proposedFeature(params);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: api_category
|
||||||
|
attributes:
|
||||||
|
label: API Category
|
||||||
|
description: What category does this feature fall under?
|
||||||
|
options:
|
||||||
|
- HTTP/Network API
|
||||||
|
- File System API
|
||||||
|
- Storage API
|
||||||
|
- FFmpeg/Audio Processing
|
||||||
|
- Manifest Options
|
||||||
|
- Runtime Functions
|
||||||
|
- UI Integration
|
||||||
|
- Authentication
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: How critical is this for your extension?
|
||||||
|
options:
|
||||||
|
- Blocker - Cannot build my extension without this
|
||||||
|
- High - Major functionality depends on this
|
||||||
|
- Medium - Would significantly improve my extension
|
||||||
|
- Low - Nice to have
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: workaround
|
||||||
|
attributes:
|
||||||
|
label: Current Workaround
|
||||||
|
description: Are you using any workaround currently? If so, describe it.
|
||||||
|
placeholder: "Currently I'm working around this by..."
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context, links to similar APIs, or examples from other platforms
|
||||||
|
placeholder: "Similar feature in other platforms: ..."
|
||||||
@@ -13,6 +13,9 @@ Thumbs.db
|
|||||||
# Reference folder (development only)
|
# Reference folder (development only)
|
||||||
referensi/
|
referensi/
|
||||||
|
|
||||||
|
# Documentation (hosted separately)
|
||||||
|
docs/
|
||||||
|
|
||||||
# Old spotiflac_android folder (moved to root)
|
# Old spotiflac_android folder (moved to root)
|
||||||
spotiflac_android/
|
spotiflac_android/
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,48 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.2.9] - 2026-01-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
|
||||||
|
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
|
||||||
|
- Replaces existing entry and moves to top of list
|
||||||
|
- Auto-deduplicates existing history on app load
|
||||||
|
- **Permission Error Message**: Fixed download showing "Song not found" when actually a permission error
|
||||||
|
- Now shows proper message: "Cannot write to folder, check storage permission"
|
||||||
|
- Added `permission` error type detection in backend
|
||||||
|
- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+
|
||||||
|
- Android 13+ now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO`
|
||||||
|
- `MANAGE_EXTERNAL_STORAGE` opens Settings (system-level, persists across app data clear)
|
||||||
|
- `READ_MEDIA_AUDIO` shows dialog (app-level, resets on app data clear)
|
||||||
|
- Proper permission check before showing "granted" status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.8] - 2026-01-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Multi-Select Batch Delete**: Long-press tracks in History to enter selection mode
|
||||||
|
- Select multiple tracks at once
|
||||||
|
- "Select All" and "Delete Selected" actions
|
||||||
|
- Modern Material 3 bottom action bar (slides up from bottom)
|
||||||
|
- Works in both grid and list view modes
|
||||||
|
- **History Filter Tabs**: Filter history by All/Albums/Singles
|
||||||
|
- Album = tracks where album has >1 track in history
|
||||||
|
- Single = tracks where album has only 1 track in history
|
||||||
|
- Filter chips show counts for each category
|
||||||
|
- **Album Grouping View**: When "Albums" filter is selected, tracks are grouped by album
|
||||||
|
- Album cards displayed in 2-column grid with cover art and track count badge
|
||||||
|
- Tap album to open dedicated album detail screen
|
||||||
|
- Album detail shows all downloaded tracks from that album
|
||||||
|
- Multi-select delete support within album view
|
||||||
|
- Auto-navigates back when album has <2 tracks remaining
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Issue Templates**: Updated version confirmation checkbox to specify "(Stable Version)"
|
||||||
|
|
||||||
## [2.2.7] - 2026-01-11
|
## [2.2.7] - 2026-01-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -991,6 +991,12 @@ func errorResponse(msg string) (string, error) {
|
|||||||
strings.Contains(lowerMsg, "try using vpn") ||
|
strings.Contains(lowerMsg, "try using vpn") ||
|
||||||
strings.Contains(lowerMsg, "change dns") {
|
strings.Contains(lowerMsg, "change dns") {
|
||||||
errorType = "isp_blocked"
|
errorType = "isp_blocked"
|
||||||
|
} else if strings.Contains(lowerMsg, "permission") ||
|
||||||
|
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||||
|
strings.Contains(lowerMsg, "access denied") ||
|
||||||
|
strings.Contains(lowerMsg, "failed to create file") ||
|
||||||
|
strings.Contains(lowerMsg, "failed to create directory") {
|
||||||
|
errorType = "permission"
|
||||||
} else if strings.Contains(lowerMsg, "not found") ||
|
} else if strings.Contains(lowerMsg, "not found") ||
|
||||||
strings.Contains(lowerMsg, "not available") ||
|
strings.Contains(lowerMsg, "not available") ||
|
||||||
strings.Contains(lowerMsg, "no results") ||
|
strings.Contains(lowerMsg, "no results") ||
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.2.7';
|
static const String version = '2.2.9';
|
||||||
static const String buildNumber = '49';
|
static const String buildNumber = '51';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ enum DownloadErrorType {
|
|||||||
notFound, // Track not found on any service
|
notFound, // Track not found on any service
|
||||||
rateLimit, // Rate limited by service
|
rateLimit, // Rate limited by service
|
||||||
network, // Network/connection error
|
network, // Network/connection error
|
||||||
|
permission, // File/folder permission error
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
@@ -88,6 +89,8 @@ class DownloadItem {
|
|||||||
return 'Rate limit reached, try again later';
|
return 'Rate limit reached, try again later';
|
||||||
case DownloadErrorType.network:
|
case DownloadErrorType.network:
|
||||||
return 'Connection failed, check your internet';
|
return 'Connection failed, check your internet';
|
||||||
|
case DownloadErrorType.permission:
|
||||||
|
return 'Cannot write to folder, check storage permission';
|
||||||
default:
|
default:
|
||||||
return error ?? 'An error occurred';
|
return error ?? 'An error occurred';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,4 +51,5 @@ const _$DownloadErrorTypeEnumMap = {
|
|||||||
DownloadErrorType.notFound: 'notFound',
|
DownloadErrorType.notFound: 'notFound',
|
||||||
DownloadErrorType.rateLimit: 'rateLimit',
|
DownloadErrorType.rateLimit: 'rateLimit',
|
||||||
DownloadErrorType.network: 'network',
|
DownloadErrorType.network: 'network',
|
||||||
|
DownloadErrorType.permission: 'permission',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class AppSettings {
|
|||||||
final bool hasSearchedBefore; // Hide helper text after first search
|
final bool hasSearchedBefore; // Hide helper text after first search
|
||||||
final String folderOrganization; // none, artist, album, artist_album
|
final String folderOrganization; // none, artist, album, artist_album
|
||||||
final String historyViewMode; // list, grid
|
final String historyViewMode; // list, grid
|
||||||
|
final String historyFilterMode; // all, albums, singles
|
||||||
final bool askQualityBeforeDownload; // Show quality picker before each download
|
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||||
@@ -40,6 +41,7 @@ class AppSettings {
|
|||||||
this.hasSearchedBefore = false, // Default: show helper text
|
this.hasSearchedBefore = false, // Default: show helper text
|
||||||
this.folderOrganization = 'none', // Default: no folder organization
|
this.folderOrganization = 'none', // Default: no folder organization
|
||||||
this.historyViewMode = 'grid', // Default: grid view
|
this.historyViewMode = 'grid', // Default: grid view
|
||||||
|
this.historyFilterMode = 'all', // Default: show all
|
||||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||||
this.spotifyClientId = '', // Default: use built-in credentials
|
this.spotifyClientId = '', // Default: use built-in credentials
|
||||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||||
@@ -63,6 +65,7 @@ class AppSettings {
|
|||||||
bool? hasSearchedBefore,
|
bool? hasSearchedBefore,
|
||||||
String? folderOrganization,
|
String? folderOrganization,
|
||||||
String? historyViewMode,
|
String? historyViewMode,
|
||||||
|
String? historyFilterMode,
|
||||||
bool? askQualityBeforeDownload,
|
bool? askQualityBeforeDownload,
|
||||||
String? spotifyClientId,
|
String? spotifyClientId,
|
||||||
String? spotifyClientSecret,
|
String? spotifyClientSecret,
|
||||||
@@ -85,6 +88,7 @@ class AppSettings {
|
|||||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
|
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
|
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||||
@@ -46,6 +47,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'folderOrganization': instance.folderOrganization,
|
||||||
'historyViewMode': instance.historyViewMode,
|
'historyViewMode': instance.historyViewMode,
|
||||||
|
'historyFilterMode': instance.historyFilterMode,
|
||||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||||
'spotifyClientId': instance.spotifyClientId,
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
|
|||||||
@@ -155,8 +155,18 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
final items = jsonList
|
final items = jsonList
|
||||||
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
|
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
state = state.copyWith(items: items);
|
|
||||||
_historyLog.i('Loaded ${items.length} items from storage');
|
// Deduplicate existing history on load
|
||||||
|
final deduplicatedItems = _deduplicateHistory(items);
|
||||||
|
|
||||||
|
state = state.copyWith(items: deduplicatedItems);
|
||||||
|
_historyLog.i('Loaded ${deduplicatedItems.length} items from storage (original: ${items.length})');
|
||||||
|
|
||||||
|
// Save if duplicates were removed
|
||||||
|
if (deduplicatedItems.length < items.length) {
|
||||||
|
_historyLog.i('Removed ${items.length - deduplicatedItems.length} duplicate entries');
|
||||||
|
await _saveToStorage();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_historyLog.d('No history found in storage');
|
_historyLog.d('No history found in storage');
|
||||||
}
|
}
|
||||||
@@ -165,6 +175,46 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deduplicate history items by spotifyId, deezerId, or ISRC
|
||||||
|
/// Keeps the most recent entry (first occurrence since list is sorted by date desc)
|
||||||
|
List<DownloadHistoryItem> _deduplicateHistory(List<DownloadHistoryItem> items) {
|
||||||
|
final seen = <String, int>{}; // key -> index of first occurrence
|
||||||
|
final result = <DownloadHistoryItem>[];
|
||||||
|
|
||||||
|
for (int i = 0; i < items.length; i++) {
|
||||||
|
final item = items[i];
|
||||||
|
String? key;
|
||||||
|
|
||||||
|
// Generate unique key based on available identifiers
|
||||||
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
||||||
|
// Extract numeric ID for deezer: prefixed IDs
|
||||||
|
if (item.spotifyId!.startsWith('deezer:')) {
|
||||||
|
key = 'deezer:${item.spotifyId!.substring(7)}';
|
||||||
|
} else {
|
||||||
|
key = 'spotify:${item.spotifyId}';
|
||||||
|
}
|
||||||
|
} else if (item.isrc != null && item.isrc!.isNotEmpty) {
|
||||||
|
key = 'isrc:${item.isrc}';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key != null) {
|
||||||
|
if (!seen.containsKey(key)) {
|
||||||
|
// First occurrence - keep it (most recent since list is sorted by date desc)
|
||||||
|
seen[key] = result.length;
|
||||||
|
result.add(item);
|
||||||
|
} else {
|
||||||
|
// Duplicate found - skip (keep the first/most recent one)
|
||||||
|
_historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No identifier - keep it (can't deduplicate)
|
||||||
|
result.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveToStorage() async {
|
Future<void> _saveToStorage() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -182,7 +232,48 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void addToHistory(DownloadHistoryItem item) {
|
void addToHistory(DownloadHistoryItem item) {
|
||||||
state = state.copyWith(items: [item, ...state.items]);
|
// Check if track already exists in history (by spotifyId, deezerId, or ISRC)
|
||||||
|
final existingIndex = state.items.indexWhere((existing) {
|
||||||
|
// Match by spotifyId (primary identifier - includes deezer:xxx format)
|
||||||
|
if (item.spotifyId != null &&
|
||||||
|
item.spotifyId!.isNotEmpty &&
|
||||||
|
existing.spotifyId == item.spotifyId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match Deezer tracks: extract numeric ID from "deezer:123456" format
|
||||||
|
if (item.spotifyId != null && item.spotifyId!.startsWith('deezer:') &&
|
||||||
|
existing.spotifyId != null && existing.spotifyId!.startsWith('deezer:')) {
|
||||||
|
final itemDeezerId = item.spotifyId!.substring(7); // Remove "deezer:" prefix
|
||||||
|
final existingDeezerId = existing.spotifyId!.substring(7);
|
||||||
|
if (itemDeezerId == existingDeezerId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: match by ISRC if spotifyId not available
|
||||||
|
if (item.isrc != null &&
|
||||||
|
item.isrc!.isNotEmpty &&
|
||||||
|
existing.isrc == item.isrc) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Replace existing entry (update with new download info)
|
||||||
|
final updatedItems = [...state.items];
|
||||||
|
updatedItems[existingIndex] = item;
|
||||||
|
// Move to top of list (most recent)
|
||||||
|
updatedItems.removeAt(existingIndex);
|
||||||
|
updatedItems.insert(0, item);
|
||||||
|
state = state.copyWith(items: updatedItems);
|
||||||
|
_historyLog.d('Updated existing history entry: ${item.trackName}');
|
||||||
|
} else {
|
||||||
|
// Add new entry
|
||||||
|
state = state.copyWith(items: [item, ...state.items]);
|
||||||
|
_historyLog.d('Added new history entry: ${item.trackName}');
|
||||||
|
}
|
||||||
_saveToStorage();
|
_saveToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1586,6 +1677,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
case 'network':
|
case 'network':
|
||||||
errorType = DownloadErrorType.network;
|
errorType = DownloadErrorType.network;
|
||||||
break;
|
break;
|
||||||
|
case 'permission':
|
||||||
|
errorType = DownloadErrorType.permission;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
errorType = DownloadErrorType.unknown;
|
errorType = DownloadErrorType.unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setHistoryFilterMode(String mode) {
|
||||||
|
state = state.copyWith(historyFilterMode: mode);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setAskQualityBeforeDownload(bool enabled) {
|
void setAskQualityBeforeDownload(bool enabled) {
|
||||||
state = state.copyWith(askQualityBeforeDownload: enabled);
|
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|||||||
@@ -0,0 +1,573 @@
|
|||||||
|
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/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
|
|
||||||
|
/// Screen to display downloaded tracks from a specific album
|
||||||
|
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||||
|
final String albumName;
|
||||||
|
final String artistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
|
||||||
|
const DownloadedAlbumScreen({
|
||||||
|
super.key,
|
||||||
|
required this.albumName,
|
||||||
|
required this.artistName,
|
||||||
|
this.coverUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||||
|
// Multi-select state
|
||||||
|
bool _isSelectionMode = false;
|
||||||
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
|
/// Get tracks for this album from history provider (reactive)
|
||||||
|
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
||||||
|
return allItems.where((item) {
|
||||||
|
final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||||
|
final albumKey = '${widget.albumName}|${widget.artistName}';
|
||||||
|
return itemKey == albumKey;
|
||||||
|
}).toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final aNum = a.trackNumber ?? 999;
|
||||||
|
final bNum = b.trackNumber ?? 999;
|
||||||
|
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||||
|
return a.trackName.compareTo(b.trackName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enterSelectionMode(String itemId) {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = true;
|
||||||
|
_selectedIds.add(itemId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _exitSelectionMode() {
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = false;
|
||||||
|
_selectedIds.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSelection(String itemId) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedIds.contains(itemId)) {
|
||||||
|
_selectedIds.remove(itemId);
|
||||||
|
if (_selectedIds.isEmpty) {
|
||||||
|
_isSelectionMode = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_selectedIds.add(itemId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectAll(List<DownloadHistoryItem> tracks) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIds.addAll(tracks.map((e) => e.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteSelected(List<DownloadHistoryItem> currentTracks) async {
|
||||||
|
final count = _selectedIds.length;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Delete Selected'),
|
||||||
|
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true && mounted) {
|
||||||
|
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||||
|
final idsToDelete = _selectedIds.toList();
|
||||||
|
|
||||||
|
int deletedCount = 0;
|
||||||
|
for (final id in idsToDelete) {
|
||||||
|
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
||||||
|
if (item != null) {
|
||||||
|
try {
|
||||||
|
final file = File(item.filePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
historyNotifier.removeFromHistory(id);
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_exitSelectionMode();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(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),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
|
||||||
|
// Watch history and get tracks for this album (reactive!)
|
||||||
|
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
|
final tracks = _getAlbumTracks(allHistoryItems);
|
||||||
|
|
||||||
|
// Auto-pop if album has less than 2 tracks (no longer an "album")
|
||||||
|
if (tracks.length < 2) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
});
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up selected IDs that no longer exist
|
||||||
|
final validIds = tracks.map((t) => t.id).toSet();
|
||||||
|
_selectedIds.removeWhere((id) => !validIds.contains(id));
|
||||||
|
if (_selectedIds.isEmpty && _isSelectionMode) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) setState(() => _isSelectionMode = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_isSelectionMode,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (!didPop && _isSelectionMode) {
|
||||||
|
_exitSelectionMode();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context, colorScheme),
|
||||||
|
_buildInfoCard(context, colorScheme, tracks),
|
||||||
|
_buildTrackListHeader(context, colorScheme, tracks),
|
||||||
|
_buildTrackList(context, colorScheme, tracks),
|
||||||
|
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom Selection Action Bar
|
||||||
|
AnimatedPositioned(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
||||||
|
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 280,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (widget.coverUrl != null)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
colorBlendMode: BlendMode.darken,
|
||||||
|
memCacheWidth: 600,
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
colorScheme.surface,
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
||||||
|
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: 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: [
|
||||||
|
Text(
|
||||||
|
widget.albumName,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
widget.artistName,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${tracks.length} downloaded', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (_getCommonQuality(tracks) != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||||
|
? colorScheme.tertiaryContainer
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_getCommonQuality(tracks)!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||||
|
? colorScheme.onTertiaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
|
||||||
|
if (tracks.isEmpty) return null;
|
||||||
|
final firstQuality = tracks.first.quality;
|
||||||
|
if (firstQuality == null) return null;
|
||||||
|
for (final track in tracks) {
|
||||||
|
if (track.quality != firstQuality) return null;
|
||||||
|
}
|
||||||
|
return firstQuality;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||||
|
const Spacer(),
|
||||||
|
if (!_isSelectionMode)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
||||||
|
icon: const Icon(Icons.checklist, size: 18),
|
||||||
|
label: const Text('Select'),
|
||||||
|
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final track = tracks[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(track.id),
|
||||||
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: tracks.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) {
|
||||||
|
final isSelected = _selectedIds.contains(track.id);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
onTap: _isSelectionMode
|
||||||
|
? () => _toggleSelection(track.id)
|
||||||
|
: () => _navigateToMetadataScreen(track),
|
||||||
|
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
|
||||||
|
leading: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (_isSelectionMode) ...[
|
||||||
|
Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? colorScheme.primary : Colors.transparent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
|
||||||
|
),
|
||||||
|
child: isSelected
|
||||||
|
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
child: Text(
|
||||||
|
track.trackNumber?.toString() ?? '-',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
track.trackName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
track.artistName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
trailing: _isSelectionMode ? null : IconButton(
|
||||||
|
onPressed: () => _openFile(track.filePath),
|
||||||
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks, double bottomPadding) {
|
||||||
|
final selectedCount = _selectedIds.length;
|
||||||
|
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.15),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, -4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: _exitSelectionMode,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$selectedCount selected',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
allSelected ? 'All tracks selected' : 'Tap tracks to select',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
if (allSelected) {
|
||||||
|
_exitSelectionMode();
|
||||||
|
} else {
|
||||||
|
_selectAll(tracks);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
|
||||||
|
label: Text(allSelected ? 'Deselect' : 'Select All'),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: Text(
|
||||||
|
selectedCount > 0
|
||||||
|
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
|
||||||
|
: 'Select tracks to delete',
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
|
||||||
|
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+977
-397
File diff suppressed because it is too large
Load Diff
@@ -359,8 +359,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
} else {
|
} else {
|
||||||
// Android: Use file picker
|
// Android: Use file picker
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
if (result != null)
|
if (result != null) {
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,9 +513,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
example: 'SpotiFLAC/Track.flac',
|
example: 'SpotiFLAC/Track.flac',
|
||||||
isSelected: current == 'none',
|
isSelected: current == 'none',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref
|
ref.read(settingsProvider.notifier).setFolderOrganization('none');
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setFolderOrganization('none');
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -524,9 +523,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
example: 'SpotiFLAC/Artist Name/Track.flac',
|
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||||
isSelected: current == 'artist',
|
isSelected: current == 'artist',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref
|
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setFolderOrganization('artist');
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -536,9 +533,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
example: 'SpotiFLAC/Album Name/Track.flac',
|
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||||
isSelected: current == 'album',
|
isSelected: current == 'album',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref
|
ref.read(settingsProvider.notifier).setFolderOrganization('album');
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setFolderOrganization('album');
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -548,9 +543,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||||
isSelected: current == 'artist_album',
|
isSelected: current == 'artist_album',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref
|
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setFolderOrganization('artist_album');
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -66,24 +66,38 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
}
|
}
|
||||||
} else if (Platform.isAndroid) {
|
} else if (Platform.isAndroid) {
|
||||||
// Check storage permission
|
// Check storage permission
|
||||||
PermissionStatus storageStatus;
|
bool storageGranted = false;
|
||||||
|
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
storageStatus = await Permission.audio.status;
|
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
|
||||||
|
final manageStatus = await Permission.manageExternalStorage.status;
|
||||||
|
final audioStatus = await Permission.audio.status;
|
||||||
|
debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus');
|
||||||
|
storageGranted = manageStatus.isGranted && audioStatus.isGranted;
|
||||||
} else if (_androidSdkVersion >= 30) {
|
} else if (_androidSdkVersion >= 30) {
|
||||||
storageStatus = await Permission.manageExternalStorage.status;
|
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
|
||||||
|
final manageStatus = await Permission.manageExternalStorage.status;
|
||||||
|
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
|
||||||
|
storageGranted = manageStatus.isGranted;
|
||||||
} else {
|
} else {
|
||||||
storageStatus = await Permission.storage.status;
|
// Android 10 and below: Use legacy storage permission
|
||||||
|
final storageStatus = await Permission.storage.status;
|
||||||
|
debugPrint('[Permission] Android 10- check: STORAGE=$storageStatus');
|
||||||
|
storageGranted = storageStatus.isGranted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugPrint('[Permission] Final storageGranted=$storageGranted');
|
||||||
|
|
||||||
// Check notification permission (Android 13+)
|
// Check notification permission (Android 13+)
|
||||||
PermissionStatus notificationStatus = PermissionStatus.granted;
|
PermissionStatus notificationStatus = PermissionStatus.granted;
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
notificationStatus = await Permission.notification.status;
|
notificationStatus = await Permission.notification.status;
|
||||||
|
debugPrint('[Permission] Notification=$notificationStatus');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_storagePermissionGranted = storageStatus.isGranted;
|
_storagePermissionGranted = storageGranted;
|
||||||
_notificationPermissionGranted = notificationStatus.isGranted;
|
_notificationPermissionGranted = notificationStatus.isGranted;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -97,17 +111,57 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
setState(() => _storagePermissionGranted = true);
|
setState(() => _storagePermissionGranted = true);
|
||||||
} else if (Platform.isAndroid) {
|
} else if (Platform.isAndroid) {
|
||||||
PermissionStatus status;
|
bool allGranted = false;
|
||||||
|
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
// Android 13+: Use audio permission
|
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
|
||||||
status = await Permission.audio.request();
|
|
||||||
|
// First check/request MANAGE_EXTERNAL_STORAGE
|
||||||
|
var manageStatus = await Permission.manageExternalStorage.status;
|
||||||
|
if (!manageStatus.isGranted) {
|
||||||
|
if (mounted) {
|
||||||
|
final shouldOpen = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Storage Access Required'),
|
||||||
|
content: const Text(
|
||||||
|
'SpotiFLAC needs "All files access" permission to save music files to your chosen folder.\n\n'
|
||||||
|
'Please enable "Allow access to manage all files" in the next screen.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Open Settings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldOpen == true) {
|
||||||
|
await Permission.manageExternalStorage.request();
|
||||||
|
// Re-check after returning from settings
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
manageStatus = await Permission.manageExternalStorage.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then request READ_MEDIA_AUDIO (this shows a dialog)
|
||||||
|
var audioStatus = await Permission.audio.status;
|
||||||
|
if (!audioStatus.isGranted && manageStatus.isGranted) {
|
||||||
|
audioStatus = await Permission.audio.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
allGranted = manageStatus.isGranted && audioStatus.isGranted;
|
||||||
|
|
||||||
} else if (_androidSdkVersion >= 30) {
|
} else if (_androidSdkVersion >= 30) {
|
||||||
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE
|
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
|
||||||
// This opens system settings, not a dialog
|
var manageStatus = await Permission.manageExternalStorage.status;
|
||||||
status = await Permission.manageExternalStorage.status;
|
if (!manageStatus.isGranted) {
|
||||||
if (!status.isGranted) {
|
|
||||||
// Show explanation dialog first
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final shouldOpen = await showDialog<bool>(
|
final shouldOpen = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -131,23 +185,33 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (shouldOpen == true) {
|
if (shouldOpen == true) {
|
||||||
status = await Permission.manageExternalStorage.request();
|
await Permission.manageExternalStorage.request();
|
||||||
|
// Re-check after returning from settings
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
manageStatus = await Permission.manageExternalStorage.status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
allGranted = manageStatus.isGranted;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Android 10 and below: Use legacy storage permission
|
// Android 10 and below: Use legacy storage permission
|
||||||
status = await Permission.storage.request();
|
final status = await Permission.storage.request();
|
||||||
|
allGranted = status.isGranted;
|
||||||
|
|
||||||
|
if (status.isPermanentlyDenied) {
|
||||||
|
_showPermissionDeniedDialog('Storage');
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.isGranted) {
|
if (allGranted) {
|
||||||
setState(() => _storagePermissionGranted = true);
|
setState(() => _storagePermissionGranted = true);
|
||||||
} else if (status.isPermanentlyDenied) {
|
|
||||||
_showPermissionDeniedDialog('Storage');
|
|
||||||
} else {
|
} else {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Permission denied. Please grant permission to continue.')),
|
const SnackBar(content: Text('Permission denied. Please grant all permissions to continue.')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.2.7+49
|
version: 2.2.9+51
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.2.7+49
|
version: 2.2.9+51
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user