mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-19 09:27:14 +02:00
Merge dev into main: v3.0.0 stable release
This commit is contained in:
@@ -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 = '2.2.9';
|
||||
static const String buildNumber = '51';
|
||||
static const String version = '3.0.0';
|
||||
static const String buildNumber = '57';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
+34
-3
@@ -1,7 +1,10 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/app.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
|
||||
@@ -24,14 +27,42 @@ void main() async {
|
||||
}
|
||||
|
||||
/// Widget to eagerly initialize providers that need to load data on startup
|
||||
class _EagerInitialization extends ConsumerWidget {
|
||||
class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
const _EagerInitialization({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState();
|
||||
}
|
||||
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeExtensions();
|
||||
}
|
||||
|
||||
Future<void> _initializeExtensions() async {
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
// Create directories if needed
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
|
||||
// Initialize extension system
|
||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to initialize extensions: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Eagerly initialize download history provider to load from storage
|
||||
ref.watch(downloadHistoryProvider);
|
||||
return child;
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ class AppSettings {
|
||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||
final bool enableLogging; // Enable detailed logging for debugging
|
||||
final bool useExtensionProviders; // Use extension providers for downloads when available
|
||||
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
||||
final bool separateSingles; // Separate singles/EPs into their own folder
|
||||
final String albumFolderStructure; // artist_album or album_only
|
||||
final bool showExtensionStore; // Show Extension Store tab in navigation
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -48,6 +53,11 @@ class AppSettings {
|
||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||
this.enableLogging = false, // Default: disabled for performance
|
||||
this.useExtensionProviders = true, // Default: use extensions when available
|
||||
this.searchProvider, // Default: null (use Deezer/Spotify)
|
||||
this.separateSingles = false, // Default: disabled
|
||||
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
|
||||
this.showExtensionStore = true, // Default: show store
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -72,6 +82,12 @@ class AppSettings {
|
||||
bool? useCustomSpotifyCredentials,
|
||||
String? metadataSource,
|
||||
bool? enableLogging,
|
||||
bool? useExtensionProviders,
|
||||
String? searchProvider,
|
||||
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
|
||||
bool? separateSingles,
|
||||
String? albumFolderStructure,
|
||||
bool? showExtensionStore,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -95,6 +111,11 @@ class AppSettings {
|
||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||
metadataSource: metadataSource ?? this.metadataSource,
|
||||
enableLogging: enableLogging ?? this.enableLogging,
|
||||
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
|
||||
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
|
||||
separateSingles: separateSingles ?? this.separateSingles,
|
||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
searchProvider: json['searchProvider'] as String?,
|
||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||
albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -54,4 +59,9 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||
'metadataSource': instance.metadataSource,
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ class Track {
|
||||
final String? releaseDate;
|
||||
final String? deezerId;
|
||||
final ServiceAvailability? availability;
|
||||
final String? source; // Extension ID that provided this track (null for built-in sources)
|
||||
final String? albumType; // album, single, ep, compilation (from metadata API)
|
||||
|
||||
const Track({
|
||||
required this.id,
|
||||
@@ -33,10 +35,18 @@ class Track {
|
||||
this.releaseDate,
|
||||
this.deezerId,
|
||||
this.availability,
|
||||
this.source,
|
||||
this.albumType,
|
||||
});
|
||||
|
||||
/// Check if this track is a single (based on album_type metadata)
|
||||
bool get isSingle => albumType == 'single' || albumType == 'ep';
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||
|
||||
/// Check if this track is from an extension
|
||||
bool get isFromExtension => source != null && source!.isNotEmpty;
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
|
||||
@@ -24,6 +24,8 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
: ServiceAvailability.fromJson(
|
||||
json['availability'] as Map<String, dynamic>,
|
||||
),
|
||||
source: json['source'] as String?,
|
||||
albumType: json['albumType'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
@@ -40,6 +42,8 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'releaseDate': instance.releaseDate,
|
||||
'deezerId': instance.deezerId,
|
||||
'availability': instance.availability,
|
||||
'source': instance.source,
|
||||
'albumType': instance.albumType,
|
||||
};
|
||||
|
||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
@@ -667,35 +668,64 @@ 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 {
|
||||
/// Build output directory based on folder organization setting and separateSingles
|
||||
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async {
|
||||
String baseDir = state.outputDir;
|
||||
|
||||
if (folderOrganization == 'none') {
|
||||
return baseDir;
|
||||
// If separateSingles is enabled, use Albums/Singles structure
|
||||
if (separateSingles) {
|
||||
final isSingle = track.isSingle;
|
||||
|
||||
if (isSingle) {
|
||||
// Singles go to Singles folder (flat structure)
|
||||
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
||||
final dir = Directory(singlesPath);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
_log.d('Created Singles folder: $singlesPath');
|
||||
}
|
||||
return singlesPath;
|
||||
} else {
|
||||
// Albums folder structure based on setting
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
String albumPath;
|
||||
|
||||
if (albumFolderStructure == 'album_only') {
|
||||
// Albums/Album structure (no artist folder)
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
|
||||
} else {
|
||||
// Albums/Artist/Album structure (default)
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
||||
}
|
||||
|
||||
final dir = Directory(albumPath);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
_log.d('Created Album folder: $albumPath');
|
||||
}
|
||||
return albumPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize folder names (remove invalid characters)
|
||||
String sanitize(String name) {
|
||||
return name
|
||||
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
|
||||
.replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots
|
||||
.trim();
|
||||
// Original folder organization logic (when separateSingles is disabled)
|
||||
if (folderOrganization == 'none') {
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
String subPath = '';
|
||||
switch (folderOrganization) {
|
||||
case 'artist':
|
||||
final artistName = sanitize(track.albumArtist ?? track.artistName);
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
subPath = artistName;
|
||||
break;
|
||||
case 'album':
|
||||
final albumName = sanitize(track.albumName);
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
subPath = albumName;
|
||||
break;
|
||||
case 'artist_album':
|
||||
final artistName = sanitize(track.albumArtist ?? track.artistName);
|
||||
final albumName = sanitize(track.albumName);
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
subPath = '$artistName${Platform.pathSeparator}$albumName';
|
||||
break;
|
||||
}
|
||||
@@ -713,6 +743,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
/// Sanitize folder names (remove invalid characters)
|
||||
String _sanitizeFolderName(String name) {
|
||||
return name
|
||||
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
|
||||
.replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots
|
||||
.trim();
|
||||
}
|
||||
|
||||
void updateSettings(AppSettings settings) {
|
||||
state = state.copyWith(
|
||||
outputDir: settings.downloadDirectory.isNotEmpty
|
||||
@@ -922,13 +960,92 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
}
|
||||
|
||||
/// Run post-processing hooks on a downloaded file
|
||||
Future<void> _runPostProcessingHooks(String filePath, Track track) async {
|
||||
try {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
|
||||
// Check if post-processing is enabled and there are extensions with hooks
|
||||
if (!settings.useExtensionProviders) return;
|
||||
|
||||
final hasPostProcessing = extensionState.extensions.any(
|
||||
(e) => e.enabled && e.hasPostProcessing,
|
||||
);
|
||||
if (!hasPostProcessing) return;
|
||||
|
||||
_log.d('Running post-processing hooks on: $filePath');
|
||||
|
||||
// Build metadata map for post-processing
|
||||
final metadata = <String, dynamic>{
|
||||
'title': track.name,
|
||||
'artist': track.artistName,
|
||||
'album': track.albumName,
|
||||
'album_artist': track.albumArtist ?? track.artistName,
|
||||
'track_number': track.trackNumber ?? 1,
|
||||
'disc_number': track.discNumber ?? 1,
|
||||
'isrc': track.isrc ?? '',
|
||||
'release_date': track.releaseDate ?? '',
|
||||
'duration_ms': track.duration * 1000,
|
||||
'cover_url': track.coverUrl ?? '',
|
||||
};
|
||||
|
||||
final result = await PlatformBridge.runPostProcessing(filePath, metadata: metadata);
|
||||
|
||||
if (result['success'] == true) {
|
||||
final hooksRun = result['hooks_run'] as int? ?? 0;
|
||||
final newPath = result['file_path'] as String?;
|
||||
_log.i('Post-processing completed: $hooksRun hook(s) executed');
|
||||
|
||||
if (newPath != null && newPath != filePath) {
|
||||
_log.d('File path changed by post-processing: $newPath');
|
||||
}
|
||||
} else {
|
||||
final error = result['error'] as String? ?? 'Unknown error';
|
||||
_log.w('Post-processing failed: $error');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Post-processing error: $e');
|
||||
// Don't fail the download if post-processing fails
|
||||
}
|
||||
}
|
||||
|
||||
/// Upgrade Spotify cover URL to max quality (~2000x2000)
|
||||
/// Same logic as Go backend cover.go
|
||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
|
||||
const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium)
|
||||
const spotifySizeMax = 'ab67616d000082c1'; // Max resolution (~2000x2000)
|
||||
|
||||
// First upgrade small (300) to medium (640)
|
||||
var result = coverUrl;
|
||||
if (result.contains(spotifySize300)) {
|
||||
result = result.replaceFirst(spotifySize300, spotifySize640);
|
||||
}
|
||||
|
||||
// Then upgrade medium (640) to max
|
||||
if (result.contains(spotifySize640)) {
|
||||
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Embed metadata and cover to a FLAC file after M4A conversion
|
||||
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
// Download cover first
|
||||
String? coverPath;
|
||||
final coverUrl = track.coverUrl;
|
||||
var coverUrl = track.coverUrl;
|
||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||
try {
|
||||
// Upgrade cover URL to max quality if setting is enabled
|
||||
if (settings.maxQualityCover) {
|
||||
coverUrl = _upgradeToMaxQualityCover(coverUrl);
|
||||
_log.d('Cover URL upgraded to max quality: $coverUrl');
|
||||
}
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId =
|
||||
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
||||
@@ -1366,6 +1483,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final outputDir = await _buildOutputDir(
|
||||
trackToDownload,
|
||||
settings.folderOrganization,
|
||||
separateSingles: settings.separateSingles,
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
);
|
||||
|
||||
// Use quality override if set, otherwise use default from settings
|
||||
@@ -1373,7 +1492,37 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
Map<String, dynamic> result;
|
||||
|
||||
if (state.autoFallback) {
|
||||
// Check if extension providers should be used
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final hasActiveExtensions = extensionState.extensions.any((e) => e.enabled);
|
||||
final useExtensions = settings.useExtensionProviders && hasActiveExtensions;
|
||||
|
||||
if (useExtensions) {
|
||||
// Use extension providers (includes fallback to built-in services)
|
||||
_log.d('Using extension providers for download');
|
||||
_log.d(
|
||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||
);
|
||||
_log.d('Output dir: $outputDir');
|
||||
result = await PlatformBridge.downloadWithExtensions(
|
||||
isrc: trackToDownload.isrc ?? '',
|
||||
spotifyId: trackToDownload.id,
|
||||
trackName: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: quality,
|
||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||
discNumber: trackToDownload.discNumber ?? 1,
|
||||
releaseDate: trackToDownload.releaseDate,
|
||||
itemId: item.id,
|
||||
durationMs: trackToDownload.duration,
|
||||
source: trackToDownload.source, // Pass extension ID that provided this track
|
||||
);
|
||||
} else if (state.autoFallback) {
|
||||
_log.d('Using auto-fallback mode');
|
||||
_log.d(
|
||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||
@@ -1447,6 +1596,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
if (result['success'] == true) {
|
||||
var filePath = result['file_path'] as String?;
|
||||
|
||||
// Strip EXISTS: prefix from duplicate detection
|
||||
if (filePath != null && filePath.startsWith('EXISTS:')) {
|
||||
filePath = filePath.substring(7); // Remove "EXISTS:" prefix
|
||||
}
|
||||
|
||||
_log.i('Download success, file: $filePath');
|
||||
|
||||
// Get actual quality from response (if available)
|
||||
@@ -1593,6 +1748,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
filePath: filePath,
|
||||
);
|
||||
|
||||
// Run post-processing hooks if enabled
|
||||
if (filePath != null) {
|
||||
await _runPostProcessingHooks(filePath, trackToDownload);
|
||||
}
|
||||
|
||||
// Increment completed counter
|
||||
_completedInSession++;
|
||||
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExtensionProvider');
|
||||
|
||||
/// Represents an installed extension
|
||||
class Extension {
|
||||
final String id;
|
||||
final String name;
|
||||
final String displayName;
|
||||
final String version;
|
||||
final String author;
|
||||
final String description;
|
||||
final bool enabled;
|
||||
final String status; // 'loaded', 'error', 'disabled'
|
||||
final String? errorMessage;
|
||||
final String? iconPath; // Path to extension icon
|
||||
final List<String> permissions;
|
||||
final List<ExtensionSetting> settings;
|
||||
final List<QualityOption> qualityOptions; // Custom quality options for download providers
|
||||
final bool hasMetadataProvider;
|
||||
final bool hasDownloadProvider;
|
||||
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||
final SearchBehavior? searchBehavior; // Custom search behavior
|
||||
final URLHandler? urlHandler; // Custom URL handling
|
||||
final TrackMatching? trackMatching; // Custom track matching
|
||||
final PostProcessing? postProcessing; // Post-processing hooks
|
||||
|
||||
const Extension({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
required this.version,
|
||||
required this.author,
|
||||
required this.description,
|
||||
required this.enabled,
|
||||
required this.status,
|
||||
this.errorMessage,
|
||||
this.iconPath,
|
||||
this.permissions = const [],
|
||||
this.settings = const [],
|
||||
this.qualityOptions = const [],
|
||||
this.hasMetadataProvider = false,
|
||||
this.hasDownloadProvider = false,
|
||||
this.skipMetadataEnrichment = false,
|
||||
this.searchBehavior,
|
||||
this.urlHandler,
|
||||
this.trackMatching,
|
||||
this.postProcessing,
|
||||
});
|
||||
|
||||
factory Extension.fromJson(Map<String, dynamic> json) {
|
||||
return Extension(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
version: json['version'] as String? ?? '0.0.0',
|
||||
author: json['author'] as String? ?? 'Unknown',
|
||||
description: json['description'] as String? ?? '',
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
status: json['status'] as String? ?? 'loaded',
|
||||
errorMessage: json['error_message'] as String?,
|
||||
iconPath: json['icon_path'] as String?,
|
||||
permissions: (json['permissions'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
settings: (json['settings'] as List<dynamic>?)
|
||||
?.map((s) => ExtensionSetting.fromJson(s as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
qualityOptions: (json['quality_options'] as List<dynamic>?)
|
||||
?.map((q) => QualityOption.fromJson(q as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false,
|
||||
hasDownloadProvider: json['has_download_provider'] as bool? ?? false,
|
||||
skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false,
|
||||
searchBehavior: json['search_behavior'] != null
|
||||
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
|
||||
: null,
|
||||
urlHandler: json['url_handler'] != null
|
||||
? URLHandler.fromJson(json['url_handler'] as Map<String, dynamic>)
|
||||
: null,
|
||||
trackMatching: json['track_matching'] != null
|
||||
? TrackMatching.fromJson(json['track_matching'] as Map<String, dynamic>)
|
||||
: null,
|
||||
postProcessing: json['post_processing'] != null
|
||||
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Extension copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? displayName,
|
||||
String? version,
|
||||
String? author,
|
||||
String? description,
|
||||
bool? enabled,
|
||||
String? status,
|
||||
String? errorMessage,
|
||||
String? iconPath,
|
||||
List<String>? permissions,
|
||||
List<ExtensionSetting>? settings,
|
||||
List<QualityOption>? qualityOptions,
|
||||
bool? hasMetadataProvider,
|
||||
bool? hasDownloadProvider,
|
||||
bool? skipMetadataEnrichment,
|
||||
SearchBehavior? searchBehavior,
|
||||
URLHandler? urlHandler,
|
||||
TrackMatching? trackMatching,
|
||||
PostProcessing? postProcessing,
|
||||
}) {
|
||||
return Extension(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
displayName: displayName ?? this.displayName,
|
||||
version: version ?? this.version,
|
||||
author: author ?? this.author,
|
||||
description: description ?? this.description,
|
||||
enabled: enabled ?? this.enabled,
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
iconPath: iconPath ?? this.iconPath,
|
||||
permissions: permissions ?? this.permissions,
|
||||
settings: settings ?? this.settings,
|
||||
qualityOptions: qualityOptions ?? this.qualityOptions,
|
||||
hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider,
|
||||
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
|
||||
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
||||
searchBehavior: searchBehavior ?? this.searchBehavior,
|
||||
urlHandler: urlHandler ?? this.urlHandler,
|
||||
trackMatching: trackMatching ?? this.trackMatching,
|
||||
postProcessing: postProcessing ?? this.postProcessing,
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasCustomSearch => searchBehavior?.enabled ?? false;
|
||||
bool get hasURLHandler => urlHandler?.enabled ?? false;
|
||||
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
|
||||
bool get hasPostProcessing => postProcessing?.enabled ?? false;
|
||||
}
|
||||
|
||||
/// Custom search behavior configuration
|
||||
class SearchBehavior {
|
||||
final bool enabled;
|
||||
final String? placeholder;
|
||||
final bool primary;
|
||||
final String? icon;
|
||||
final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||
final int? thumbnailWidth;
|
||||
final int? thumbnailHeight;
|
||||
|
||||
const SearchBehavior({
|
||||
required this.enabled,
|
||||
this.placeholder,
|
||||
this.primary = false,
|
||||
this.icon,
|
||||
this.thumbnailRatio,
|
||||
this.thumbnailWidth,
|
||||
this.thumbnailHeight,
|
||||
});
|
||||
|
||||
factory SearchBehavior.fromJson(Map<String, dynamic> json) {
|
||||
return SearchBehavior(
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
placeholder: json['placeholder'] as String?,
|
||||
primary: json['primary'] as bool? ?? false,
|
||||
icon: json['icon'] as String?,
|
||||
thumbnailRatio: json['thumbnailRatio'] as String?,
|
||||
thumbnailWidth: json['thumbnailWidth'] as int?,
|
||||
thumbnailHeight: json['thumbnailHeight'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get thumbnail size based on configuration
|
||||
/// Returns (width, height) tuple
|
||||
(double, double) getThumbnailSize({double defaultSize = 56}) {
|
||||
// If custom dimensions specified, use them
|
||||
if (thumbnailWidth != null && thumbnailHeight != null) {
|
||||
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
|
||||
}
|
||||
|
||||
// Otherwise use ratio presets
|
||||
switch (thumbnailRatio) {
|
||||
case 'wide': // 16:9 - YouTube style
|
||||
return (defaultSize * 16 / 9, defaultSize);
|
||||
case 'portrait': // 2:3 - Poster style
|
||||
return (defaultSize * 2 / 3, defaultSize);
|
||||
case 'square': // 1:1 - Album art style
|
||||
default:
|
||||
return (defaultSize, defaultSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom track matching configuration
|
||||
class TrackMatching {
|
||||
final bool customMatching;
|
||||
final String? strategy; // "isrc", "name", "duration", "custom"
|
||||
final int durationTolerance; // in seconds
|
||||
|
||||
const TrackMatching({
|
||||
required this.customMatching,
|
||||
this.strategy,
|
||||
this.durationTolerance = 3,
|
||||
});
|
||||
|
||||
factory TrackMatching.fromJson(Map<String, dynamic> json) {
|
||||
return TrackMatching(
|
||||
customMatching: json['customMatching'] as bool? ?? false,
|
||||
strategy: json['strategy'] as String?,
|
||||
durationTolerance: json['durationTolerance'] as int? ?? 3,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-processing configuration
|
||||
class PostProcessing {
|
||||
final bool enabled;
|
||||
final List<PostProcessingHook> hooks;
|
||||
|
||||
const PostProcessing({
|
||||
required this.enabled,
|
||||
this.hooks = const [],
|
||||
});
|
||||
|
||||
factory PostProcessing.fromJson(Map<String, dynamic> json) {
|
||||
return PostProcessing(
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
hooks: (json['hooks'] as List<dynamic>?)
|
||||
?.map((h) => PostProcessingHook.fromJson(h as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// URL handler configuration for custom URL patterns
|
||||
class URLHandler {
|
||||
final bool enabled;
|
||||
final List<String> patterns;
|
||||
|
||||
const URLHandler({
|
||||
required this.enabled,
|
||||
this.patterns = const [],
|
||||
});
|
||||
|
||||
factory URLHandler.fromJson(Map<String, dynamic> json) {
|
||||
return URLHandler(
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
patterns: (json['patterns'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if a URL matches any of the patterns
|
||||
bool matchesURL(String url) {
|
||||
if (!enabled || patterns.isEmpty) return false;
|
||||
final lowerUrl = url.toLowerCase();
|
||||
for (final pattern in patterns) {
|
||||
if (lowerUrl.contains(pattern.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// A post-processing hook
|
||||
class PostProcessingHook {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? description;
|
||||
final bool defaultEnabled;
|
||||
final List<String> supportedFormats;
|
||||
|
||||
const PostProcessingHook({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.defaultEnabled = false,
|
||||
this.supportedFormats = const [],
|
||||
});
|
||||
|
||||
factory PostProcessingHook.fromJson(Map<String, dynamic> json) {
|
||||
return PostProcessingHook(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
defaultEnabled: json['defaultEnabled'] as bool? ?? false,
|
||||
supportedFormats: (json['supportedFormats'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a quality option for download providers
|
||||
class QualityOption {
|
||||
final String id;
|
||||
final String label;
|
||||
final String? description;
|
||||
final List<QualitySpecificSetting> settings; // Quality-specific settings
|
||||
|
||||
const QualityOption({
|
||||
required this.id,
|
||||
required this.label,
|
||||
this.description,
|
||||
this.settings = const [],
|
||||
});
|
||||
|
||||
factory QualityOption.fromJson(Map<String, dynamic> json) {
|
||||
return QualityOption(
|
||||
id: json['id'] as String? ?? '',
|
||||
label: json['label'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
settings: (json['settings'] as List<dynamic>?)
|
||||
?.map((s) => QualitySpecificSetting.fromJson(s as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a setting that's specific to a quality option
|
||||
class QualitySpecificSetting {
|
||||
final String key;
|
||||
final String label;
|
||||
final String type; // 'string', 'number', 'boolean', 'select'
|
||||
final dynamic defaultValue;
|
||||
final String? description;
|
||||
final List<String>? options; // For select type
|
||||
final bool required;
|
||||
final bool secret;
|
||||
|
||||
const QualitySpecificSetting({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.type,
|
||||
this.defaultValue,
|
||||
this.description,
|
||||
this.options,
|
||||
this.required = false,
|
||||
this.secret = false,
|
||||
});
|
||||
|
||||
factory QualitySpecificSetting.fromJson(Map<String, dynamic> json) {
|
||||
return QualitySpecificSetting(
|
||||
key: json['key'] as String? ?? '',
|
||||
label: json['label'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'string',
|
||||
defaultValue: json['default'],
|
||||
description: json['description'] as String?,
|
||||
options: (json['options'] as List<dynamic>?)?.cast<String>(),
|
||||
required: json['required'] as bool? ?? false,
|
||||
secret: json['secret'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a setting field for an extension
|
||||
class ExtensionSetting {
|
||||
final String key;
|
||||
final String label;
|
||||
final String type; // 'string', 'number', 'boolean', 'select'
|
||||
final dynamic defaultValue;
|
||||
final String? description;
|
||||
final List<String>? options; // For select type
|
||||
final bool required;
|
||||
|
||||
const ExtensionSetting({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.type,
|
||||
this.defaultValue,
|
||||
this.description,
|
||||
this.options,
|
||||
this.required = false,
|
||||
});
|
||||
|
||||
factory ExtensionSetting.fromJson(Map<String, dynamic> json) {
|
||||
return ExtensionSetting(
|
||||
key: json['key'] as String? ?? '',
|
||||
label: json['label'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'string',
|
||||
defaultValue: json['default'],
|
||||
description: json['description'] as String?,
|
||||
options: (json['options'] as List<dynamic>?)?.cast<String>(),
|
||||
required: json['required'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// State for extension management
|
||||
class ExtensionState {
|
||||
final List<Extension> extensions;
|
||||
final List<String> providerPriority;
|
||||
final List<String> metadataProviderPriority;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final bool isInitialized;
|
||||
|
||||
const ExtensionState({
|
||||
this.extensions = const [],
|
||||
this.providerPriority = const [],
|
||||
this.metadataProviderPriority = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.isInitialized = false,
|
||||
});
|
||||
|
||||
ExtensionState copyWith({
|
||||
List<Extension>? extensions,
|
||||
List<String>? providerPriority,
|
||||
List<String>? metadataProviderPriority,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
bool? isInitialized,
|
||||
}) {
|
||||
return ExtensionState(
|
||||
extensions: extensions ?? this.extensions,
|
||||
providerPriority: providerPriority ?? this.providerPriority,
|
||||
metadataProviderPriority: metadataProviderPriority ?? this.metadataProviderPriority,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Provider for managing extensions
|
||||
class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
@override
|
||||
ExtensionState build() {
|
||||
return const ExtensionState();
|
||||
}
|
||||
|
||||
/// Initialize the extension system
|
||||
Future<void> initialize(String extensionsDir, String dataDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
|
||||
await loadExtensions(extensionsDir);
|
||||
await loadProviderPriority();
|
||||
await loadMetadataProviderPriority();
|
||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
||||
_log.i('Extension system initialized');
|
||||
} catch (e) {
|
||||
_log.e('Failed to initialize extension system: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all extensions from directory
|
||||
Future<void> loadExtensions(String dirPath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.loadExtensionsFromDir(dirPath);
|
||||
_log.d('Load extensions result: $result');
|
||||
await refreshExtensions();
|
||||
state = state.copyWith(isLoading: false);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load extensions: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the list of installed extensions
|
||||
Future<void> refreshExtensions() async {
|
||||
try {
|
||||
final list = await PlatformBridge.getInstalledExtensions();
|
||||
final extensions = list.map((e) => Extension.fromJson(e)).toList();
|
||||
state = state.copyWith(extensions: extensions);
|
||||
_log.d('Loaded ${extensions.length} extensions');
|
||||
|
||||
// Log search behavior for extensions that have it
|
||||
for (final ext in extensions) {
|
||||
if (ext.searchBehavior != null) {
|
||||
_log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to refresh extensions: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear any error state
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
|
||||
/// Install extension from file (auto-upgrades if already installed with newer version)
|
||||
Future<bool> installExtension(String filePath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.loadExtensionFromPath(filePath);
|
||||
_log.i('Installed extension: ${result['name']}');
|
||||
await refreshExtensions();
|
||||
state = state.copyWith(isLoading: false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.e('Failed to install extension: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a package file is an upgrade for an existing extension
|
||||
/// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed}
|
||||
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
|
||||
try {
|
||||
return await PlatformBridge.checkExtensionUpgrade(filePath);
|
||||
} catch (e) {
|
||||
_log.e('Failed to check extension upgrade: $e');
|
||||
return {'error': e.toString()};
|
||||
}
|
||||
}
|
||||
|
||||
/// Upgrade an existing extension from a new package file
|
||||
Future<bool> upgradeExtension(String filePath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.upgradeExtension(filePath);
|
||||
_log.i('Upgraded extension: ${result['display_name']} to v${result['version']}');
|
||||
await refreshExtensions();
|
||||
state = state.copyWith(isLoading: false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.e('Failed to upgrade extension: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Uninstall/remove an extension
|
||||
Future<bool> removeExtension(String extensionId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
await PlatformBridge.removeExtension(extensionId);
|
||||
_log.i('Removed extension: $extensionId');
|
||||
await refreshExtensions();
|
||||
state = state.copyWith(isLoading: false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.e('Failed to remove extension: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable an extension
|
||||
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||
_log.d('Set extension $extensionId enabled: $enabled');
|
||||
|
||||
// Get extension info before updating state
|
||||
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||
|
||||
// Update local state
|
||||
final extensions = state.extensions.map((e) {
|
||||
if (e.id == extensionId) {
|
||||
return e.copyWith(enabled: enabled);
|
||||
}
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(extensions: extensions);
|
||||
|
||||
// If disabling an extension, reset related settings
|
||||
if (!enabled && ext != null) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
// If this extension was the search provider, clear it and reset to Deezer
|
||||
if (settings.searchProvider == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
|
||||
}
|
||||
|
||||
// If this extension was the default download service, reset to Tidal
|
||||
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
||||
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to set extension enabled: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Get settings for an extension
|
||||
Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
|
||||
try {
|
||||
return await PlatformBridge.getExtensionSettings(extensionId);
|
||||
} catch (e) {
|
||||
_log.e('Failed to get extension settings: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Update settings for an extension
|
||||
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionSettings(extensionId, settings);
|
||||
_log.d('Updated settings for extension: $extensionId');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set extension settings: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load provider priority order
|
||||
Future<void> loadProviderPriority() async {
|
||||
try {
|
||||
final priority = await PlatformBridge.getProviderPriority();
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load provider priority: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Set provider priority order
|
||||
Future<void> setProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
_log.d('Updated provider priority: $priority');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set provider priority: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load metadata provider priority order
|
||||
Future<void> loadMetadataProviderPriority() async {
|
||||
try {
|
||||
final priority = await PlatformBridge.getMetadataProviderPriority();
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load metadata provider priority: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Set metadata provider priority order
|
||||
Future<void> setMetadataProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
_log.d('Updated metadata provider priority: $priority');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set metadata provider priority: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleanup all extensions (call on app close)
|
||||
Future<void> cleanup() async {
|
||||
try {
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
_log.d('Extensions cleaned up');
|
||||
} catch (e) {
|
||||
_log.e('Failed to cleanup extensions: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get extension by ID
|
||||
Extension? getExtension(String extensionId) {
|
||||
try {
|
||||
return state.extensions.firstWhere((ext) => ext.id == extensionId);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all enabled extensions
|
||||
List<Extension> get enabledExtensions {
|
||||
return state.extensions.where((ext) => ext.enabled).toList();
|
||||
}
|
||||
|
||||
/// Get all download providers (built-in + extensions)
|
||||
List<String> getAllDownloadProviders() {
|
||||
final providers = ['tidal', 'qobuz', 'amazon'];
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasDownloadProvider) {
|
||||
providers.add(ext.id);
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
/// Get all metadata providers (built-in + extensions)
|
||||
List<String> getAllMetadataProviders() {
|
||||
final providers = ['deezer', 'spotify'];
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasMetadataProvider) {
|
||||
providers.add(ext.id);
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
/// Get all extensions that provide custom search
|
||||
List<Extension> get searchProviders {
|
||||
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
|
||||
}
|
||||
}
|
||||
|
||||
final extensionProvider = NotifierProvider<ExtensionNotifier, ExtensionState>(
|
||||
ExtensionNotifier.new,
|
||||
);
|
||||
@@ -60,18 +60,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
|
||||
/// Apply current Spotify credentials to Go backend
|
||||
Future<void> _applySpotifyCredentials() async {
|
||||
// Only apply custom credentials if enabled and both fields are set
|
||||
if (state.useCustomSpotifyCredentials &&
|
||||
state.spotifyClientId.isNotEmpty &&
|
||||
// Only apply if both fields are set
|
||||
if (state.spotifyClientId.isNotEmpty &&
|
||||
state.spotifyClientSecret.isNotEmpty) {
|
||||
await PlatformBridge.setSpotifyCredentials(
|
||||
state.spotifyClientId,
|
||||
state.spotifyClientSecret,
|
||||
);
|
||||
} else {
|
||||
// Clear to use default
|
||||
await PlatformBridge.setSpotifyCredentials('', '');
|
||||
}
|
||||
// Note: If credentials are empty, Spotify API will return error
|
||||
// User should use Deezer as metadata source instead
|
||||
}
|
||||
|
||||
void setDefaultService(String service) {
|
||||
@@ -197,12 +195,41 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSearchProvider(String? provider) {
|
||||
if (provider == null || provider.isEmpty) {
|
||||
state = state.copyWith(clearSearchProvider: true);
|
||||
} else {
|
||||
state = state.copyWith(searchProvider: provider);
|
||||
}
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEnableLogging(bool enabled) {
|
||||
state = state.copyWith(enableLogging: enabled);
|
||||
_saveSettings();
|
||||
// Sync logging state to LogBuffer
|
||||
LogBuffer.loggingEnabled = enabled;
|
||||
}
|
||||
|
||||
void setUseExtensionProviders(bool enabled) {
|
||||
state = state.copyWith(useExtensionProviders: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSeparateSingles(bool enabled) {
|
||||
state = state.copyWith(separateSingles: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAlbumFolderStructure(String structure) {
|
||||
state = state.copyWith(albumFolderStructure: structure);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setShowExtensionStore(bool enabled) {
|
||||
state = state.copyWith(showExtensionStore: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('StoreProvider');
|
||||
|
||||
/// Extension categories
|
||||
class StoreCategory {
|
||||
static const String metadata = 'metadata';
|
||||
static const String download = 'download';
|
||||
static const String utility = 'utility';
|
||||
static const String lyrics = 'lyrics';
|
||||
static const String integration = 'integration';
|
||||
|
||||
static const List<String> all = [metadata, download, utility, lyrics, integration];
|
||||
|
||||
static String getDisplayName(String category) {
|
||||
switch (category) {
|
||||
case metadata:
|
||||
return 'Metadata';
|
||||
case download:
|
||||
return 'Download';
|
||||
case utility:
|
||||
return 'Utility';
|
||||
case lyrics:
|
||||
return 'Lyrics';
|
||||
case integration:
|
||||
return 'Integration';
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an extension in the store
|
||||
class StoreExtension {
|
||||
final String id;
|
||||
final String name;
|
||||
final String displayName;
|
||||
final String version;
|
||||
final String author;
|
||||
final String description;
|
||||
final String downloadUrl;
|
||||
final String? iconUrl;
|
||||
final String category;
|
||||
final List<String> tags;
|
||||
final int downloads;
|
||||
final String updatedAt;
|
||||
final String? minAppVersion;
|
||||
final bool isInstalled;
|
||||
final String? installedVersion;
|
||||
final bool hasUpdate;
|
||||
|
||||
const StoreExtension({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
required this.version,
|
||||
required this.author,
|
||||
required this.description,
|
||||
required this.downloadUrl,
|
||||
this.iconUrl,
|
||||
required this.category,
|
||||
this.tags = const [],
|
||||
this.downloads = 0,
|
||||
required this.updatedAt,
|
||||
this.minAppVersion,
|
||||
this.isInstalled = false,
|
||||
this.installedVersion,
|
||||
this.hasUpdate = false,
|
||||
});
|
||||
|
||||
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
||||
return StoreExtension(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
version: json['version'] as String? ?? '0.0.0',
|
||||
author: json['author'] as String? ?? 'Unknown',
|
||||
description: json['description'] as String? ?? '',
|
||||
downloadUrl: json['download_url'] as String? ?? '',
|
||||
iconUrl: json['icon_url'] as String?,
|
||||
category: json['category'] as String? ?? 'utility',
|
||||
tags: (json['tags'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
downloads: json['downloads'] as int? ?? 0,
|
||||
updatedAt: json['updated_at'] as String? ?? '',
|
||||
minAppVersion: json['min_app_version'] as String?,
|
||||
isInstalled: json['is_installed'] as bool? ?? false,
|
||||
installedVersion: json['installed_version'] as String?,
|
||||
hasUpdate: json['has_update'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// State for extension store
|
||||
class StoreState {
|
||||
final List<StoreExtension> extensions;
|
||||
final String? selectedCategory;
|
||||
final String searchQuery;
|
||||
final bool isLoading;
|
||||
final bool isDownloading;
|
||||
final String? downloadingId;
|
||||
final String? error;
|
||||
final bool isInitialized;
|
||||
|
||||
const StoreState({
|
||||
this.extensions = const [],
|
||||
this.selectedCategory,
|
||||
this.searchQuery = '',
|
||||
this.isLoading = false,
|
||||
this.isDownloading = false,
|
||||
this.downloadingId,
|
||||
this.error,
|
||||
this.isInitialized = false,
|
||||
});
|
||||
|
||||
StoreState copyWith({
|
||||
List<StoreExtension>? extensions,
|
||||
String? selectedCategory,
|
||||
bool clearCategory = false,
|
||||
String? searchQuery,
|
||||
bool? isLoading,
|
||||
bool? isDownloading,
|
||||
String? downloadingId,
|
||||
bool clearDownloadingId = false,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
bool? isInitialized,
|
||||
}) {
|
||||
return StoreState(
|
||||
extensions: extensions ?? this.extensions,
|
||||
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory),
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isDownloading: isDownloading ?? this.isDownloading,
|
||||
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get filtered extensions based on category and search
|
||||
List<StoreExtension> get filteredExtensions {
|
||||
var result = extensions;
|
||||
|
||||
if (selectedCategory != null) {
|
||||
result = result.where((e) => e.category == selectedCategory).toList();
|
||||
}
|
||||
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final query = searchQuery.toLowerCase();
|
||||
result = result.where((e) =>
|
||||
e.name.toLowerCase().contains(query) ||
|
||||
e.displayName.toLowerCase().contains(query) ||
|
||||
e.description.toLowerCase().contains(query) ||
|
||||
e.author.toLowerCase().contains(query) ||
|
||||
e.tags.any((t) => t.toLowerCase().contains(query))
|
||||
).toList();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for managing extension store
|
||||
class StoreNotifier extends Notifier<StoreState> {
|
||||
@override
|
||||
StoreState build() {
|
||||
return const StoreState();
|
||||
}
|
||||
|
||||
/// Initialize the store
|
||||
Future<void> initialize(String cacheDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
try {
|
||||
await PlatformBridge.initExtensionStore(cacheDir);
|
||||
await refresh();
|
||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
||||
_log.i('Extension store initialized');
|
||||
} catch (e) {
|
||||
_log.e('Failed to initialize store: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh extensions from store
|
||||
Future<void> refresh({bool forceRefresh = false}) async {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
try {
|
||||
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh);
|
||||
state = state.copyWith(
|
||||
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
|
||||
isLoading: false,
|
||||
);
|
||||
_log.d('Loaded ${state.extensions.length} extensions from store');
|
||||
} catch (e) {
|
||||
_log.e('Failed to refresh store: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Set category filter
|
||||
void setCategory(String? category) {
|
||||
if (category == null) {
|
||||
state = state.copyWith(clearCategory: true);
|
||||
} else {
|
||||
state = state.copyWith(selectedCategory: category);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set search query
|
||||
void setSearchQuery(String query) {
|
||||
state = state.copyWith(searchQuery: query);
|
||||
}
|
||||
|
||||
/// Clear search
|
||||
void clearSearch() {
|
||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||
}
|
||||
|
||||
/// Download and install extension
|
||||
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
|
||||
try {
|
||||
_log.i('Downloading extension: $extensionId');
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
||||
|
||||
_log.i('Installing extension from: $downloadPath');
|
||||
final extNotifier = ref.read(extensionProvider.notifier);
|
||||
final success = await extNotifier.installExtension(downloadPath);
|
||||
|
||||
if (success) {
|
||||
_log.i('Extension installed: $extensionId');
|
||||
await refresh();
|
||||
}
|
||||
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true);
|
||||
return success;
|
||||
} catch (e) {
|
||||
_log.e('Failed to install extension: $e');
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an installed extension
|
||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
|
||||
try {
|
||||
_log.i('Downloading update for: $extensionId');
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
||||
|
||||
_log.i('Upgrading extension from: $downloadPath');
|
||||
final extNotifier = ref.read(extensionProvider.notifier);
|
||||
final success = await extNotifier.upgradeExtension(downloadPath);
|
||||
|
||||
if (success) {
|
||||
_log.i('Extension updated: $extensionId');
|
||||
await refresh();
|
||||
}
|
||||
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true);
|
||||
return success;
|
||||
} catch (e) {
|
||||
_log.e('Failed to update extension: $e');
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear error
|
||||
void clearError() {
|
||||
state = state.copyWith(clearError: true);
|
||||
}
|
||||
}
|
||||
|
||||
final storeProvider = NotifierProvider<StoreNotifier, StoreState>(
|
||||
StoreNotifier.new,
|
||||
);
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('TrackProvider');
|
||||
|
||||
@@ -18,6 +20,7 @@ class TrackState {
|
||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||
final List<SearchArtist>? searchArtists; // For search results
|
||||
final bool hasSearchText; // For back button handling
|
||||
final String? searchExtensionId; // Extension ID used for current search results
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -32,6 +35,7 @@ class TrackState {
|
||||
this.artistAlbums,
|
||||
this.searchArtists,
|
||||
this.hasSearchText = false,
|
||||
this.searchExtensionId,
|
||||
});
|
||||
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
||||
@@ -49,6 +53,7 @@ class TrackState {
|
||||
List<ArtistAlbum>? artistAlbums,
|
||||
List<SearchArtist>? searchArtists,
|
||||
bool? hasSearchText,
|
||||
String? searchExtensionId,
|
||||
}) {
|
||||
return TrackState(
|
||||
tracks: tracks ?? this.tracks,
|
||||
@@ -63,6 +68,7 @@ class TrackState {
|
||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||
searchArtists: searchArtists ?? this.searchArtists,
|
||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||
searchExtensionId: searchExtensionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +131,59 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
// First, check if any extension can handle this URL
|
||||
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||
if (extensionHandler != null) {
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (result != null) {
|
||||
final type = result['type'] as String?;
|
||||
final extensionId = result['extension_id'] as String?;
|
||||
|
||||
if (type == 'track' && result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final track = _parseSearchTrack(trackData, source: extensionId);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) {
|
||||
final trackList = result['tracks'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId: result['album']?['id'] as String?,
|
||||
albumName: result['name'] as String? ?? result['album']?['name'] as String?,
|
||||
playlistName: type == 'playlist' ? result['name'] as String? : null,
|
||||
coverUrl: result['cover_url'] as String?,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if (type == 'artist' && result['artist'] != null) {
|
||||
final artistData = result['artist'] as Map<String, dynamic>;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
|
||||
artistAlbums: albums,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No extension handler found, try Spotify URL parsing
|
||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
|
||||
@@ -210,12 +269,43 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
// Check if extension providers should be used for search
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
||||
(e) => e.enabled && e.hasMetadataProvider,
|
||||
);
|
||||
final useExtensions = settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||
|
||||
// Use Deezer or Spotify based on settings
|
||||
final source = metadataSource ?? 'deezer';
|
||||
|
||||
_log.i('Search started: source=$source, query="$query"');
|
||||
_log.i('Search started: source=$source, query="$query", useExtensions=$useExtensions');
|
||||
|
||||
Map<String, dynamic> results;
|
||||
List<Track> extensionTracks = [];
|
||||
|
||||
// Try extension providers first if enabled
|
||||
if (useExtensions) {
|
||||
try {
|
||||
_log.d('Calling extension search API...');
|
||||
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
|
||||
_log.i('Extensions returned ${extResults.length} tracks');
|
||||
|
||||
// Parse extension results
|
||||
for (final t in extResults) {
|
||||
try {
|
||||
extensionTracks.add(_parseSearchTrack(t));
|
||||
} catch (e) {
|
||||
_log.e('Failed to parse extension track: $e', e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Extension search failed, falling back to built-in: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Also search with built-in providers
|
||||
if (source == 'deezer') {
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||
@@ -238,11 +328,26 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
// Parse tracks with error handling per item
|
||||
final tracks = <Track>[];
|
||||
|
||||
// Add extension tracks first (they have priority)
|
||||
tracks.addAll(extensionTracks);
|
||||
|
||||
// Add built-in provider tracks, avoiding duplicates by ISRC
|
||||
final existingIsrcs = extensionTracks
|
||||
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
||||
.map((t) => t.isrc!)
|
||||
.toSet();
|
||||
|
||||
for (int i = 0; i < trackList.length; i++) {
|
||||
final t = trackList[i];
|
||||
try {
|
||||
if (t is Map<String, dynamic>) {
|
||||
tracks.add(_parseSearchTrack(t));
|
||||
final track = _parseSearchTrack(t);
|
||||
// Skip if we already have this track from extensions
|
||||
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
|
||||
continue;
|
||||
}
|
||||
tracks.add(track);
|
||||
} else {
|
||||
_log.w('Track[$i] is not a Map: ${t.runtimeType}');
|
||||
}
|
||||
@@ -266,7 +371,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Search complete: ${tracks.length} tracks, ${artists.length} artists parsed successfully');
|
||||
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists parsed successfully');
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
@@ -281,6 +386,53 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform custom search using a specific extension
|
||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
||||
// Increment request ID to cancel any pending requests
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
// Preserve hasSearchText during search
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
_log.i('Custom search started: extension=$extensionId, query="$query"');
|
||||
|
||||
final results = await PlatformBridge.customSearchWithExtension(extensionId, query, options: options);
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
_log.w('Custom search request cancelled (requestId=$requestId)');
|
||||
return;
|
||||
}
|
||||
|
||||
_log.i('Custom search returned ${results.length} tracks');
|
||||
|
||||
// Parse tracks with error handling per item, setting source to extension ID
|
||||
final tracks = <Track>[];
|
||||
for (int i = 0; i < results.length; i++) {
|
||||
final t = results[i];
|
||||
try {
|
||||
tracks.add(_parseSearchTrack(t, source: extensionId));
|
||||
} catch (e) {
|
||||
_log.e('Failed to parse custom search track[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)');
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
searchArtists: [], // Custom search doesn't return artists
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
searchExtensionId: extensionId, // Store which extension was used
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.e('Custom search failed: $e', e, stackTrace);
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> checkAvailability(int index) async {
|
||||
if (index < 0 || index >= state.tracks.length) return;
|
||||
|
||||
@@ -344,7 +496,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
Track _parseSearchTrack(Map<String, dynamic> data) {
|
||||
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
|
||||
// Handle duration_ms which might be int or double
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
@@ -366,6 +518,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||
albumType: data['album_type']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+20
-218
@@ -7,6 +7,7 @@ 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/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
/// Simple in-memory cache for album tracks
|
||||
class _AlbumCache {
|
||||
@@ -316,10 +317,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
void _downloadTrack(BuildContext context, Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
@@ -331,84 +338,21 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
if (tracks == null || tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
artistName: widget.albumName,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}
|
||||
}
|
||||
|
||||
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.read(settingsProvider);
|
||||
String selectedService = settings.defaultService;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
],
|
||||
// Service selector
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build error widget with special handling for rate limit (429)
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit = error.contains('429') ||
|
||||
@@ -473,148 +417,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfoHeader extends StatefulWidget {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||
|
||||
@override
|
||||
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||
}
|
||||
|
||||
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
bool _expanded = false;
|
||||
bool _isOverflowing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: widget.coverUrl != null
|
||||
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isOverflowing != titleOverflows) {
|
||||
setState(() => _isOverflowing = titleOverflows);
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.trackName,
|
||||
style: titleStyle,
|
||||
maxLines: _expanded ? 10 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.artistName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.artistName!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: _expanded ? 3 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_isOverflowing || _expanded)
|
||||
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
class _AlbumTrackItem extends ConsumerWidget {
|
||||
final Track track;
|
||||
|
||||
+109
-253
@@ -8,12 +8,14 @@ import 'package:spotiflac_android/models/track.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';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
class HomeTab extends ConsumerStatefulWidget {
|
||||
const HomeTab({super.key});
|
||||
@@ -78,12 +80,31 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
// Skip if same query already searched
|
||||
if (_lastSearchQuery == query) return;
|
||||
_lastSearchQuery = query;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||
final extState = ref.read(extensionProvider);
|
||||
final searchProvider = settings.searchProvider;
|
||||
|
||||
// Skip if same query already searched with same provider
|
||||
final searchKey = '${searchProvider ?? 'default'}:$query';
|
||||
if (_lastSearchQuery == searchKey) return;
|
||||
_lastSearchQuery = searchKey;
|
||||
|
||||
// Check if extension search provider is set AND still enabled
|
||||
final isExtensionEnabled = searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||
|
||||
if (isExtensionEnabled) {
|
||||
// Use custom search from extension
|
||||
await ref.read(trackProvider.notifier).customSearch(searchProvider, query);
|
||||
} else {
|
||||
// Use default search (Deezer/Spotify)
|
||||
// Also clear searchProvider if it was set but extension is disabled
|
||||
if (searchProvider != null && searchProvider.isNotEmpty && !isExtensionEnabled) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||
}
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
}
|
||||
|
||||
@@ -173,10 +194,16 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
@@ -184,107 +211,23 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
}
|
||||
|
||||
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.read(settingsProvider);
|
||||
String selectedService = settings.defaultService;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
],
|
||||
// Service selector
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_QualityPickerOption(
|
||||
title: 'FLAC Lossless',
|
||||
subtitle: '16-bit / 44.1kHz',
|
||||
icon: Icons.music_note,
|
||||
onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); },
|
||||
),
|
||||
_QualityPickerOption(
|
||||
title: 'Hi-Res FLAC',
|
||||
subtitle: '24-bit / up to 96kHz',
|
||||
icon: Icons.high_quality,
|
||||
onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); },
|
||||
),
|
||||
_QualityPickerOption(
|
||||
title: 'Hi-Res FLAC Max',
|
||||
subtitle: '24-bit / up to 192kHz',
|
||||
icon: Icons.four_k,
|
||||
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); },
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _importCsv(BuildContext context, WidgetRef ref) async {
|
||||
// Show loading dialog with progress
|
||||
int currentProgress = 0;
|
||||
int totalTracks = 0;
|
||||
|
||||
// Use StatefulBuilder to update dialog content
|
||||
final dialogContext = context;
|
||||
bool dialogShown = false;
|
||||
StateSetter? setDialogState;
|
||||
|
||||
void showProgressDialog() {
|
||||
if (dialogShown) return;
|
||||
if (dialogShown || !mounted) return;
|
||||
dialogShown = true;
|
||||
showDialog(
|
||||
context: dialogContext,
|
||||
context: this.context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
builder: (dialogCtx) => StatefulBuilder(
|
||||
builder: (dialogCtx, setState) {
|
||||
setDialogState = setState;
|
||||
return AlertDialog(
|
||||
content: Column(
|
||||
@@ -318,25 +261,27 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
|
||||
// Close progress dialog
|
||||
if (dialogShown && mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
Navigator.of(this.context).pop();
|
||||
}
|
||||
|
||||
if (tracks.isNotEmpty) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Optionally show confirmation dialog
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
context: this.context,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
title: const Text('Import Playlist'),
|
||||
content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
onPressed: () => Navigator.pop(dialogCtx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
onPressed: () => Navigator.pop(dialogCtx, true),
|
||||
child: const Text('Import'),
|
||||
),
|
||||
],
|
||||
@@ -346,7 +291,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
if (confirmed == true) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Added ${tracks.length} tracks to queue'),
|
||||
action: SnackBarAction(
|
||||
@@ -385,6 +330,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
||||
|
||||
// Watch extension state to update search hint when extensions load/change
|
||||
ref.watch(extensionProvider.select((s) => s.isInitialized));
|
||||
ref.watch(extensionProvider.select((s) => s.extensions));
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
@@ -836,6 +785,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
));
|
||||
}
|
||||
|
||||
/// Get search hint based on selected provider
|
||||
String _getSearchHint() {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final searchProvider = settings.searchProvider;
|
||||
final extState = ref.read(extensionProvider);
|
||||
|
||||
// If extension system not initialized yet, show default hint
|
||||
if (!extState.isInitialized) {
|
||||
return 'Paste Spotify URL or search...';
|
||||
}
|
||||
|
||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||
final ext = extState.extensions.where((e) => e.id == searchProvider).firstOrNull;
|
||||
// Only show extension placeholder if extension exists AND is enabled
|
||||
if (ext != null && ext.enabled) {
|
||||
if (ext.searchBehavior?.placeholder != null) {
|
||||
return ext.searchBehavior!.placeholder!;
|
||||
}
|
||||
return 'Search with ${ext.displayName}...';
|
||||
}
|
||||
// Extension not found or disabled - clear the search provider setting
|
||||
// and return default hint
|
||||
}
|
||||
return 'Paste Spotify URL or search...';
|
||||
}
|
||||
|
||||
Widget _buildSearchBar(ColorScheme colorScheme) {
|
||||
final hasText = _urlController.text.isNotEmpty;
|
||||
|
||||
@@ -844,7 +819,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
focusNode: _searchFocusNode,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Paste Spotify URL or search...',
|
||||
hintText: _getSearchHint(),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
@@ -910,147 +885,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
|
||||
}
|
||||
|
||||
class _QualityPickerOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
const _QualityPickerOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfoHeader extends StatefulWidget {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||
|
||||
@override
|
||||
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||
}
|
||||
|
||||
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
bool _expanded = false;
|
||||
bool _isOverflowing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: widget.coverUrl != null
|
||||
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isOverflowing != titleOverflows) {
|
||||
setState(() => _isOverflowing = titleOverflows);
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.trackName,
|
||||
style: titleStyle,
|
||||
maxLines: _expanded ? 10 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.artistName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.artistName!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: _expanded ? 3 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_isOverflowing || _expanded)
|
||||
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes
|
||||
class _TrackItemWithStatus extends ConsumerWidget {
|
||||
final Track track;
|
||||
@@ -1080,6 +914,28 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
// Get thumbnail size from extension if track is from extension
|
||||
double thumbWidth = 56;
|
||||
double thumbHeight = 56;
|
||||
|
||||
// Get extension ID from track.source or from TrackState.searchExtensionId
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final extensionId = track.source ?? trackState.searchExtensionId;
|
||||
|
||||
if (extensionId != null && extensionId.isNotEmpty) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final extension = extState.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||
if (extension?.searchBehavior != null) {
|
||||
final size = extension!.searchBehavior!.getThumbnailSize(defaultSize: 56);
|
||||
thumbWidth = size.$1;
|
||||
thumbHeight = size.$2;
|
||||
// Debug: log only when using custom size
|
||||
if (thumbWidth != 56 || thumbHeight != 56) {
|
||||
debugPrint('[Thumbnail] ${track.name}: using ${thumbWidth.toInt()}x${thumbHeight.toInt()} from ${extension.id}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final isQueued = queueItem != null;
|
||||
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||
@@ -1100,21 +956,21 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
// Album art
|
||||
// Album art with dynamic size based on extension config
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: track.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
width: thumbWidth,
|
||||
height: thumbHeight,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
memCacheWidth: (thumbWidth * 2).toInt(),
|
||||
memCacheHeight: (thumbHeight * 2).toInt(),
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
width: thumbWidth,
|
||||
height: thumbHeight,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
@@ -1151,7 +1007,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 80,
|
||||
indent: thumbWidth + 24, // Adjust divider indent based on thumbnail width
|
||||
endIndent: 12,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
|
||||
+63
-32
@@ -6,6 +6,7 @@ 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/store_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';
|
||||
@@ -121,6 +122,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
void _onPageChanged(int index) {
|
||||
if (_currentIndex != index) {
|
||||
setState(() => _currentIndex = index);
|
||||
// Unfocus any text field when switching tabs to prevent keyboard from appearing
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +175,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
Widget build(BuildContext context) {
|
||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
|
||||
|
||||
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
@@ -185,6 +189,61 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
!trackState.isLoading &&
|
||||
!isKeyboardVisible;
|
||||
|
||||
// Build tabs and destinations based on settings
|
||||
final tabs = <Widget>[
|
||||
const HomeTab(),
|
||||
QueueTab(
|
||||
parentPageController: _pageController,
|
||||
parentPageIndex: 1,
|
||||
nextPageIndex: showStore ? 2 : 3,
|
||||
),
|
||||
if (showStore) const StoreTab(),
|
||||
const SettingsTab(),
|
||||
];
|
||||
|
||||
final destinations = <NavigationDestination>[
|
||||
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.history_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history),
|
||||
),
|
||||
label: 'History',
|
||||
),
|
||||
if (showStore)
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.store_outlined),
|
||||
selectedIcon: Icon(Icons.store),
|
||||
label: 'Store',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
];
|
||||
|
||||
// Clamp current index if tabs changed
|
||||
final maxIndex = tabs.length - 1;
|
||||
if (_currentIndex > maxIndex) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() => _currentIndex = maxIndex);
|
||||
_pageController.jumpToPage(maxIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: canPop,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
@@ -201,45 +260,17 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
children: const [
|
||||
HomeTab(),
|
||||
QueueTab(),
|
||||
SettingsTab(),
|
||||
],
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: tabs,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 200),
|
||||
backgroundColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface),
|
||||
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.history_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history),
|
||||
),
|
||||
label: 'History',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
destinations: destinations,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/models/track.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/widgets/download_service_picker.dart';
|
||||
|
||||
/// Playlist detail screen with Material Expressive 3 design
|
||||
class PlaylistScreen extends ConsumerWidget {
|
||||
@@ -168,10 +169,16 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, ref, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
@@ -182,222 +189,20 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
if (tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, ref, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}, trackName: '${tracks.length} tracks', artistName: playlistName);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
artistName: playlistName,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}
|
||||
}
|
||||
|
||||
void _showQualityPicker(BuildContext context, WidgetRef ref, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.read(settingsProvider);
|
||||
String selectedService = settings.defaultService;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
],
|
||||
// Service selector
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold))),
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfoHeader extends StatefulWidget {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||
|
||||
@override
|
||||
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||
}
|
||||
|
||||
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
bool _expanded = false;
|
||||
bool _isOverflowing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: widget.coverUrl != null
|
||||
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isOverflowing != titleOverflows) {
|
||||
setState(() => _isOverflowing = titleOverflows);
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.trackName,
|
||||
style: titleStyle,
|
||||
maxLines: _expanded ? 10 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.artistName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.artistName!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: _expanded ? 3 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_isOverflowing || _expanded)
|
||||
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
|
||||
+943
-422
File diff suppressed because it is too large
Load Diff
@@ -13,45 +13,45 @@ class AboutPage extends StatelessWidget {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
// When collapsed (expandRatio=0): left=56 to avoid back button
|
||||
// When expanded (expandRatio=1): left=24 for normal padding
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'About',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
// When collapsed (expandRatio=0): left=56 to avoid back button
|
||||
// When expanded (expandRatio=1): left=24 for normal padding
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'About',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// App header card with logo and description
|
||||
SliverToBoxAdapter(
|
||||
@@ -220,7 +220,7 @@ class AboutPage extends StatelessWidget {
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,27 +15,27 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: _AppBarTitle(
|
||||
title: 'Appearance',
|
||||
topPadding: topPadding,
|
||||
),
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: _AppBarTitle(
|
||||
title: 'Appearance',
|
||||
topPadding: topPadding,
|
||||
),
|
||||
),
|
||||
|
||||
// Preview Section
|
||||
SliverToBoxAdapter(
|
||||
|
||||
@@ -4,61 +4,68 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class DownloadSettingsPage extends ConsumerWidget {
|
||||
const DownloadSettingsPage({super.key});
|
||||
|
||||
// Built-in services that support quality options
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
// Check if current service is built-in (supports quality options)
|
||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
'Download',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
'Download',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Service section
|
||||
const SliverToBoxAdapter(
|
||||
@@ -87,13 +94,17 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.tune,
|
||||
title: 'Ask Before Download',
|
||||
subtitle: 'Choose quality for each download',
|
||||
subtitle: isBuiltInService
|
||||
? 'Choose quality for each download'
|
||||
: 'Select a built-in service to enable',
|
||||
value: settings.askQualityBeforeDownload,
|
||||
// Not selected visually if extension is active
|
||||
enabled: isBuiltInService,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAskQualityBeforeDownload(value),
|
||||
),
|
||||
if (!settings.askQualityBeforeDownload) ...[
|
||||
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
||||
_QualityOption(
|
||||
title: 'FLAC Lossless',
|
||||
subtitle: '16-bit / 44.1kHz',
|
||||
@@ -120,6 +131,29 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
if (!isBuiltInService) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Select Tidal, Qobuz, or Amazon above to configure quality',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -151,19 +185,44 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
: settings.downloadDirectory,
|
||||
onTap: () => _pickDirectory(context, ref),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.create_new_folder_outlined,
|
||||
title: 'Folder Organization',
|
||||
subtitle: _getFolderOrganizationLabel(
|
||||
settings.folderOrganization,
|
||||
),
|
||||
onTap: () => _showFolderOrganizationPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.folderOrganization,
|
||||
),
|
||||
showDivider: false,
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.library_music_outlined,
|
||||
title: 'Separate Singles Folder',
|
||||
subtitle: settings.separateSingles
|
||||
? 'Albums/ and Singles/ folders'
|
||||
: 'All files in same structure',
|
||||
value: settings.separateSingles,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSeparateSingles(value),
|
||||
),
|
||||
if (settings.separateSingles)
|
||||
SettingsItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: 'Album Folder Structure',
|
||||
subtitle: settings.albumFolderStructure == 'album_only'
|
||||
? 'Albums/Album Name/'
|
||||
: 'Albums/Artist/Album Name/',
|
||||
onTap: () => _showAlbumFolderStructurePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.albumFolderStructure,
|
||||
),
|
||||
),
|
||||
if (!settings.separateSingles)
|
||||
SettingsItem(
|
||||
icon: Icons.create_new_folder_outlined,
|
||||
title: 'Folder Organization',
|
||||
subtitle: _getFolderOrganizationLabel(
|
||||
settings.folderOrganization,
|
||||
),
|
||||
onTap: () => _showFolderOrganizationPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.folderOrganization,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -175,6 +234,39 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: const Text('Artist / Album'),
|
||||
subtitle: const Text('Albums/Artist Name/Album Name/'),
|
||||
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.album_outlined),
|
||||
title: const Text('Album Only'),
|
||||
subtitle: const Text('Albums/Album Name/'),
|
||||
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
||||
final controller = TextEditingController(text: current);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -481,81 +573,87 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.7,
|
||||
),
|
||||
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,
|
||||
child: SingleChildScrollView(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
_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),
|
||||
],
|
||||
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 _ServiceSelector extends StatelessWidget {
|
||||
class _ServiceSelector extends ConsumerWidget {
|
||||
final String currentService;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _ServiceSelector({
|
||||
@@ -564,31 +662,75 @@ class _ServiceSelector extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
|
||||
// Get enabled extension download providers
|
||||
final extensionProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||
.toList();
|
||||
|
||||
// Check if current service is an extension that's now disabled
|
||||
final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService);
|
||||
final isCurrentExtensionEnabled = isExtensionService
|
||||
? extensionProviders.any((e) => e.id == currentService)
|
||||
: true;
|
||||
|
||||
// If current extension is disabled, show it as not selected
|
||||
final effectiveService = isCurrentExtensionEnabled ? currentService : '';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
_ServiceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Tidal',
|
||||
isSelected: currentService == 'tidal',
|
||||
onTap: () => onChanged('tidal'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: currentService == 'qobuz',
|
||||
onTap: () => onChanged('qobuz'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.shopping_bag,
|
||||
label: 'Amazon',
|
||||
isSelected: currentService == 'amazon',
|
||||
onTap: () => onChanged('amazon'),
|
||||
Row(
|
||||
children: [
|
||||
_ServiceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Tidal',
|
||||
isSelected: effectiveService == 'tidal',
|
||||
onTap: () => onChanged('tidal'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: effectiveService == 'qobuz',
|
||||
onTap: () => onChanged('qobuz'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.shopping_bag,
|
||||
label: 'Amazon',
|
||||
isSelected: effectiveService == 'amazon',
|
||||
onTap: () => onChanged('amazon'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Show extension download providers if any
|
||||
if (extensionProviders.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
for (int i = 0; i < extensionProviders.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.extension,
|
||||
label: extensionProviders[i].displayName,
|
||||
isSelected: effectiveService == extensionProviders[i].id,
|
||||
onTap: () => onChanged(extensionProviders[i].id),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Fill remaining space if less than 3 extensions
|
||||
for (int i = extensionProviders.length; i < 3; i++) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,724 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class ExtensionsPage extends ConsumerStatefulWidget {
|
||||
const ExtensionsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionsPage> createState() => _ExtensionsPageState();
|
||||
}
|
||||
|
||||
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeExtensions();
|
||||
}
|
||||
|
||||
Future<void> _initializeExtensions() async {
|
||||
final extState = ref.read(extensionProvider);
|
||||
if (!extState.isInitialized) {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
// Create directories if they don't exist
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
|
||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'Extensions',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Loading indicator
|
||||
if (extState.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
|
||||
// Error message
|
||||
if (extState.error != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
extState.error!,
|
||||
style: TextStyle(color: colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Provider Priority
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Provider Priority'),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_DownloadPriorityItem(),
|
||||
_MetadataPriorityItem(),
|
||||
_SearchProviderSelector(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Installed Extensions
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Installed Extensions'),
|
||||
),
|
||||
|
||||
if (extState.extensions.isEmpty && !extState.isLoading)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.extension_outlined,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No extensions installed',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Install .spotiflac-ext files to add new providers',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.extensions.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: extState.extensions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ext = entry.value;
|
||||
return _ExtensionItem(
|
||||
extension: ext,
|
||||
showDivider: index < extState.extensions.length - 1,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ExtensionDetailPage(extensionId: ext.id),
|
||||
),
|
||||
),
|
||||
onToggle: (enabled) => ref
|
||||
.read(extensionProvider.notifier)
|
||||
.setExtensionEnabled(ext.id, enabled),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
// Install button
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _installExtension,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Install Extension'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Info section
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Extensions can add new metadata and download providers. '
|
||||
'Only install extensions from trusted sources.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _installExtension() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.any,
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
final file = result.files.first;
|
||||
if (file.path != null) {
|
||||
if (!file.path!.endsWith('.spotiflac-ext')) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please select a .spotiflac-ext file'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final success = await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.installExtension(file.path!);
|
||||
|
||||
if (mounted) {
|
||||
final extState = ref.read(extensionProvider);
|
||||
String message;
|
||||
if (success) {
|
||||
message = 'Extension installed successfully';
|
||||
} else {
|
||||
// Parse friendly error message
|
||||
message = _getFriendlyErrorMessage(extState.error);
|
||||
}
|
||||
|
||||
// Clear the error from state to avoid showing it twice (in error container)
|
||||
ref.read(extensionProvider.notifier).clearError();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse error message to be more user-friendly
|
||||
String _getFriendlyErrorMessage(String? error) {
|
||||
if (error == null) return 'Failed to install extension';
|
||||
|
||||
String message = error;
|
||||
|
||||
// Remove PlatformException wrapper if present
|
||||
// Format: PlatformException(ERROR, actual message, null, null)
|
||||
if (message.contains('PlatformException')) {
|
||||
// Try to extract the actual error message
|
||||
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message);
|
||||
if (match != null) {
|
||||
message = match.group(1)?.trim() ?? message;
|
||||
} else {
|
||||
// Fallback: try simpler extraction
|
||||
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message);
|
||||
if (simpleMatch != null) {
|
||||
message = simpleMatch.group(1)?.trim() ?? message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any remaining artifacts
|
||||
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), '');
|
||||
message = message.replaceAll(RegExp(r'^\s*,\s*'), '');
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
class _ExtensionItem extends StatelessWidget {
|
||||
final Extension extension;
|
||||
final bool showDivider;
|
||||
final VoidCallback onTap;
|
||||
final ValueChanged<bool> onToggle;
|
||||
|
||||
const _ExtensionItem({
|
||||
required this.extension,
|
||||
required this.showDivider,
|
||||
required this.onTap,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasError = extension.status == 'error';
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Extension icon
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: hasError
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: extension.iconPath != null && extension.iconPath!.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(
|
||||
File(extension.iconPath!),
|
||||
width: 44,
|
||||
height: 44,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
hasError ? Icons.error_outline : Icons.extension,
|
||||
color: hasError
|
||||
? colorScheme.error
|
||||
: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
hasError ? Icons.error_outline : Icons.extension,
|
||||
color: hasError
|
||||
? colorScheme.error
|
||||
: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Extension info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
extension.displayName,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasError
|
||||
? extension.errorMessage ?? 'Error loading extension'
|
||||
: 'v${extension.version} by ${extension.author}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: hasError
|
||||
? colorScheme.error
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Toggle switch
|
||||
Switch(
|
||||
value: extension.enabled,
|
||||
onChanged: hasError ? null : onToggle,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 76,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DownloadPriorityItem extends ConsumerWidget {
|
||||
const _DownloadPriorityItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Check if any extension has download provider
|
||||
final hasDownloadExtensions = extState.extensions
|
||||
.any((e) => e.enabled && e.hasDownloadProvider);
|
||||
|
||||
return InkWell(
|
||||
onTap: hasDownloadExtensions
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ProviderPriorityPage(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.download,
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Download Priority',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: hasDownloadExtensions
|
||||
? null
|
||||
: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasDownloadExtensions
|
||||
? 'Set download service order'
|
||||
: 'No extensions with download provider',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataPriorityItem extends ConsumerWidget {
|
||||
const _MetadataPriorityItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Check if any extension has metadata provider
|
||||
final hasMetadataExtensions = extState.extensions
|
||||
.any((e) => e.enabled && e.hasMetadataProvider);
|
||||
|
||||
return InkWell(
|
||||
onTap: hasMetadataExtensions
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const MetadataProviderPriorityPage(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Metadata Priority',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: hasMetadataExtensions
|
||||
? null
|
||||
: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasMetadataExtensions
|
||||
? 'Set search & metadata source order'
|
||||
: 'No extensions with metadata provider',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchProviderSelector extends ConsumerWidget {
|
||||
const _SearchProviderSelector();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Get extensions with custom search
|
||||
final searchProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasCustomSearch)
|
||||
.toList();
|
||||
|
||||
// Get current provider name
|
||||
String currentProviderName = 'Default (Deezer/Spotify)';
|
||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
|
||||
currentProviderName = ext?.displayName ?? settings.searchProvider!;
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: searchProviders.isEmpty
|
||||
? null
|
||||
: () => _showSearchProviderPicker(context, ref, settings, searchProviders),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.manage_search,
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Search Provider',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
searchProviders.isEmpty
|
||||
? 'No extensions with custom search'
|
||||
: currentProviderName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showSearchProviderPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
dynamic settings,
|
||||
List<Extension> searchProviders,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
'Search Provider',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'Choose which service to use for searching tracks',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Default option
|
||||
ListTile(
|
||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||
title: const Text('Default (Deezer/Spotify)'),
|
||||
subtitle: const Text('Use built-in search'),
|
||||
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
// Extension options
|
||||
...searchProviders.map((ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
title: Text(ext.displayName),
|
||||
subtitle: Text(ext.searchBehavior?.placeholder ?? 'Custom search'),
|
||||
trailing: settings.searchProvider == ext.id
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(ext.id);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
)),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -125,59 +125,59 @@ class _LogScreenState extends State<LogScreen> {
|
||||
final logs = _filteredLogs;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button - same as other settings pages
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
|
||||
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
|
||||
onPressed: () => setState(() => _autoScroll = !_autoScroll),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
|
||||
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
|
||||
onPressed: () => setState(() => _autoScroll = !_autoScroll),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: 'Copy logs',
|
||||
onPressed: _copyLogs,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'share':
|
||||
_shareLogs();
|
||||
break;
|
||||
case 'clear':
|
||||
_clearLogs();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'share',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.share),
|
||||
title: Text('Share logs'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: 'Copy logs',
|
||||
onPressed: _copyLogs,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'share':
|
||||
_shareLogs();
|
||||
break;
|
||||
case 'clear':
|
||||
_clearLogs();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'share',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.share),
|
||||
title: Text('Share logs'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'clear',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete_outline),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'clear',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete_outline),
|
||||
title: Text('Clear logs'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const MetadataProviderPriorityPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MetadataProviderPriorityPage> createState() => _MetadataProviderPriorityPageState();
|
||||
}
|
||||
|
||||
class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderPriorityPage> {
|
||||
late List<String> _providers;
|
||||
bool _hasChanges = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProviders();
|
||||
}
|
||||
|
||||
void _loadProviders() {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final allProviders = ref.read(extensionProvider.notifier).getAllMetadataProviders();
|
||||
|
||||
// Use saved priority if available, otherwise use default order
|
||||
if (extState.metadataProviderPriority.isNotEmpty) {
|
||||
_providers = List.from(extState.metadataProviderPriority);
|
||||
// Add any new providers not in saved priority
|
||||
for (final provider in allProviders) {
|
||||
if (!_providers.contains(provider)) {
|
||||
_providers.add(provider);
|
||||
}
|
||||
}
|
||||
// Remove providers that no longer exist
|
||||
_providers.removeWhere((p) => !allProviders.contains(p));
|
||||
} else {
|
||||
_providers = allProviders;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: !_hasChanges,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () async {
|
||||
if (_hasChanges) {
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (_hasChanges)
|
||||
TextButton(
|
||||
onPressed: _saveChanges,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'Metadata Priority',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Description
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Drag to reorder metadata providers. The app will try providers '
|
||||
'from top to bottom when searching for tracks and fetching metadata.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Provider list
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: _providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = _providers[index];
|
||||
return _MetadataProviderItem(
|
||||
key: ValueKey(provider),
|
||||
provider: provider,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
isLast: index == _providers.length - 1,
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = _providers.removeAt(oldIndex);
|
||||
_providers.insert(newIndex, item);
|
||||
_hasChanges = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Info section
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Deezer has no rate limits and is recommended as primary. '
|
||||
'Spotify may rate limit after many requests.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Discard Changes?'),
|
||||
content: const Text('You have unsaved changes. Do you want to discard them?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Discard'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
await ref.read(extensionProvider.notifier).setMetadataProviderPriority(_providers);
|
||||
setState(() {
|
||||
_hasChanges = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Metadata provider priority saved')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataProviderItem extends StatelessWidget {
|
||||
final String provider;
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
|
||||
const _MetadataProviderItem({
|
||||
super.key,
|
||||
required this.provider,
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final backgroundColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
final info = _getProviderInfo(provider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Priority number
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isFirst
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFirst
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Provider icon
|
||||
Icon(
|
||||
info.icon,
|
||||
color: info.isBuiltIn
|
||||
? colorScheme.primary
|
||||
: colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Provider name
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Drag handle
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_MetadataProviderInfo _getProviderInfo(String provider) {
|
||||
switch (provider) {
|
||||
case 'deezer':
|
||||
return _MetadataProviderInfo(
|
||||
name: 'Deezer',
|
||||
icon: Icons.album,
|
||||
description: 'No rate limits',
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'spotify':
|
||||
return _MetadataProviderInfo(
|
||||
name: 'Spotify',
|
||||
icon: Icons.music_note,
|
||||
description: 'May rate limit',
|
||||
isBuiltIn: true,
|
||||
);
|
||||
default:
|
||||
// Extension provider
|
||||
return _MetadataProviderInfo(
|
||||
name: provider,
|
||||
icon: Icons.extension,
|
||||
description: 'Extension',
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataProviderInfo {
|
||||
final String name;
|
||||
final IconData icon;
|
||||
final String description;
|
||||
final bool isBuiltIn;
|
||||
|
||||
_MetadataProviderInfo({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.description,
|
||||
required this.isBuiltIn,
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class OptionsSettingsPage extends ConsumerWidget {
|
||||
@@ -11,53 +12,55 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extensionState = ref.watch(extensionProvider);
|
||||
final hasExtensions = extensionState.extensions.isNotEmpty;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
'Options',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
'Options',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Search Source section
|
||||
const SliverToBoxAdapter(
|
||||
@@ -73,38 +76,50 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
.setMetadataSource(v),
|
||||
),
|
||||
if (settings.metadataSource == 'spotify') ...[
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.toggle_on,
|
||||
title: 'Use Custom Credentials',
|
||||
subtitle: settings.useCustomSpotifyCredentials
|
||||
? 'Using your credentials'
|
||||
: 'Using default credentials',
|
||||
value: settings.useCustomSpotifyCredentials,
|
||||
onChanged: (v) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUseCustomSpotifyCredentials(v);
|
||||
if (v && settings.spotifyClientId.isEmpty) {
|
||||
_showSpotifyCredentialsDialog(context, ref, settings);
|
||||
}
|
||||
},
|
||||
showDivider: true,
|
||||
),
|
||||
// Info card about Spotify credentials requirement
|
||||
if (settings.spotifyClientId.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.key,
|
||||
title: 'Set Credentials',
|
||||
title: 'Spotify Credentials',
|
||||
subtitle: settings.spotifyClientId.isNotEmpty
|
||||
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||
: 'Not configured',
|
||||
: 'Required - tap to configure',
|
||||
onTap: () =>
|
||||
_showSpotifyCredentialsDialog(context, ref, settings),
|
||||
trailing: Icon(
|
||||
settings.spotifyClientId.isNotEmpty
|
||||
? Icons.edit
|
||||
: Icons.add,
|
||||
? Icons.check_circle
|
||||
: Icons.error_outline,
|
||||
color: settings.spotifyClientId.isNotEmpty
|
||||
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
showDivider: false,
|
||||
@@ -129,6 +144,18 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
onChanged: (v) =>
|
||||
ref.read(settingsProvider.notifier).setAutoFallback(v),
|
||||
),
|
||||
if (hasExtensions)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.extension,
|
||||
title: 'Use Extension Providers',
|
||||
subtitle: settings.useExtensionProviders
|
||||
? 'Extensions will be tried first'
|
||||
: 'Using built-in providers only',
|
||||
value: settings.useExtensionProviders,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUseExtensionProviders(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.lyrics,
|
||||
title: 'Embed Lyrics',
|
||||
@@ -175,6 +202,15 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.store,
|
||||
title: 'Extension Store',
|
||||
subtitle: 'Show Store tab in navigation',
|
||||
value: settings.showExtensionStore,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setShowExtensionStore(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.system_update,
|
||||
title: 'Check for Updates',
|
||||
@@ -345,11 +381,15 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -380,11 +420,15 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -745,7 +789,7 @@ class _ChannelChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataSourceSelector extends StatelessWidget {
|
||||
class _MetadataSourceSelector extends ConsumerWidget {
|
||||
final String currentSource;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _MetadataSourceSelector({
|
||||
@@ -754,8 +798,25 @@ class _MetadataSourceSelector extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
|
||||
// Check if extension search provider is active AND enabled
|
||||
Extension? activeExtension;
|
||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||
activeExtension = extState.extensions
|
||||
.where((e) => e.id == settings.searchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
}
|
||||
final hasExtensionSearch = activeExtension != null;
|
||||
|
||||
String? extensionName;
|
||||
if (hasExtensionSearch) {
|
||||
extensionName = activeExtension.displayName;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -769,9 +830,13 @@ class _MetadataSourceSelector extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Service used when searching by track name.',
|
||||
hasExtensionSearch
|
||||
? 'Using extension: $extensionName'
|
||||
: 'Service used when searching by track name.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
color: hasExtensionSearch
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -780,18 +845,53 @@ class _MetadataSourceSelector extends StatelessWidget {
|
||||
_SourceChip(
|
||||
icon: Icons.graphic_eq,
|
||||
label: 'Deezer',
|
||||
isSelected: currentSource == 'deezer',
|
||||
onTap: () => onChanged('deezer'),
|
||||
// Not selected if extension is active
|
||||
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
|
||||
onTap: () {
|
||||
// If extension was active, reset it to default
|
||||
if (hasExtensionSearch) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
onChanged('deezer');
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_SourceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Spotify',
|
||||
isSelected: currentSource == 'spotify',
|
||||
onTap: () => onChanged('spotify'),
|
||||
// Not selected if extension is active
|
||||
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
|
||||
onTap: () {
|
||||
// If extension was active, reset it to default
|
||||
if (hasExtensionSearch) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
onChanged('spotify');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasExtensionSearch) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tap Deezer or Spotify to switch back from extension',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -802,13 +902,17 @@ class _SourceChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onTap;
|
||||
final String? badge;
|
||||
final Color? badgeColor;
|
||||
|
||||
const _SourceChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.onTap,
|
||||
this.badge,
|
||||
this.badgeColor,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -854,6 +958,24 @@ class _SourceChip extends StatelessWidget {
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (badge != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (badgeColor ?? colorScheme.tertiary).withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
badge!,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: badgeColor ?? colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
class ProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const ProviderPriorityPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProviderPriorityPage> createState() => _ProviderPriorityPageState();
|
||||
}
|
||||
|
||||
class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
late List<String> _providers;
|
||||
bool _hasChanges = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProviders();
|
||||
}
|
||||
|
||||
void _loadProviders() {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
|
||||
|
||||
// Use saved priority if available, otherwise use default order
|
||||
if (extState.providerPriority.isNotEmpty) {
|
||||
// Start with saved priority
|
||||
_providers = List.from(extState.providerPriority);
|
||||
// Add any new providers not in saved priority
|
||||
for (final provider in allProviders) {
|
||||
if (!_providers.contains(provider)) {
|
||||
_providers.add(provider);
|
||||
}
|
||||
}
|
||||
// Remove providers that no longer exist
|
||||
_providers.removeWhere((p) => !allProviders.contains(p));
|
||||
} else {
|
||||
_providers = allProviders;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: !_hasChanges,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () async {
|
||||
if (_hasChanges) {
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (_hasChanges)
|
||||
TextButton(
|
||||
onPressed: _saveChanges,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'Provider Priority',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Description
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Drag to reorder download providers. The app will try providers '
|
||||
'from top to bottom when downloading tracks.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Provider list
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: _providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = _providers[index];
|
||||
return _ProviderItem(
|
||||
key: ValueKey(provider),
|
||||
provider: provider,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
isLast: index == _providers.length - 1,
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = _providers.removeAt(oldIndex);
|
||||
_providers.insert(newIndex, item);
|
||||
_hasChanges = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Info section
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'If a track is not available on the first provider, '
|
||||
'the app will automatically try the next one.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Discard Changes?'),
|
||||
content: const Text('You have unsaved changes. Do you want to discard them?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Discard'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
await ref.read(extensionProvider.notifier).setProviderPriority(_providers);
|
||||
setState(() {
|
||||
_hasChanges = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Provider priority saved')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ProviderItem extends StatelessWidget {
|
||||
final String provider;
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
|
||||
const _ProviderItem({
|
||||
super.key,
|
||||
required this.provider,
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final backgroundColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
// Get provider info
|
||||
final info = _getProviderInfo(provider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Priority number
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isFirst
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFirst
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Provider icon
|
||||
Icon(
|
||||
info.icon,
|
||||
color: info.isBuiltIn
|
||||
? colorScheme.primary
|
||||
: colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Provider name
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.isBuiltIn ? 'Built-in' : 'Extension',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Drag handle
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_ProviderInfo _getProviderInfo(String provider) {
|
||||
switch (provider) {
|
||||
case 'tidal':
|
||||
return _ProviderInfo(
|
||||
name: 'Tidal',
|
||||
icon: Icons.music_note,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'qobuz':
|
||||
return _ProviderInfo(
|
||||
name: 'Qobuz',
|
||||
icon: Icons.album,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'amazon':
|
||||
return _ProviderInfo(
|
||||
name: 'Amazon Music',
|
||||
icon: Icons.shopping_bag,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
default:
|
||||
// Extension provider
|
||||
return _ProviderInfo(
|
||||
name: provider,
|
||||
icon: Icons.extension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ProviderInfo {
|
||||
final String name;
|
||||
final IconData icon;
|
||||
final bool isBuiltIn;
|
||||
|
||||
_ProviderInfo({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.isBuiltIn,
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
||||
@@ -31,8 +32,11 @@ class SettingsTab extends ConsumerWidget {
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
@@ -58,7 +62,8 @@ class SettingsTab extends ConsumerWidget {
|
||||
icon: Icons.palette_outlined,
|
||||
title: 'Appearance',
|
||||
subtitle: 'Theme, colors, display',
|
||||
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
|
||||
onTap: () =>
|
||||
_navigateTo(context, const AppearanceSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.download_outlined,
|
||||
@@ -71,6 +76,12 @@ class SettingsTab extends ConsumerWidget {
|
||||
title: 'Options',
|
||||
subtitle: 'Fallback, lyrics, cover art, updates',
|
||||
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.extension_outlined,
|
||||
title: 'Extensions',
|
||||
subtitle: 'Manage download providers',
|
||||
onTap: () => _navigateTo(context, const ExtensionsPage()),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
@@ -97,7 +108,7 @@ class SettingsTab extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Fill remaining space
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
@@ -105,6 +116,27 @@ class SettingsTab extends ConsumerWidget {
|
||||
}
|
||||
|
||||
void _navigateTo(BuildContext context, Widget page) {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
||||
Navigator.of(context).push(
|
||||
// Use PageRouteBuilder for better predictive back gesture support
|
||||
// MaterialPageRoute can cause freeze on some devices with gesture navigation
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
// Use slide transition similar to MaterialPageRoute
|
||||
const begin = Offset(1.0, 0.0);
|
||||
const end = Offset.zero;
|
||||
const curve = Curves.easeInOut;
|
||||
var tween = Tween(begin: begin, end: end).chain(
|
||||
CurveTween(curve: curve),
|
||||
);
|
||||
return SlideTransition(
|
||||
position: animation.drive(tween),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,11 +444,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
_clientIdController.text.trim(),
|
||||
_clientSecretController.text.trim(),
|
||||
);
|
||||
ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(true);
|
||||
// Set search source to Spotify when using custom credentials
|
||||
// Set search source to Spotify when credentials are provided
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
||||
} else {
|
||||
// Use Deezer as default search source
|
||||
// Use Deezer as default search source (free, no credentials required)
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
class ExtensionDetailsScreen extends ConsumerStatefulWidget {
|
||||
final StoreExtension extension;
|
||||
|
||||
const ExtensionDetailsScreen({super.key, required this.extension});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionDetailsScreen> createState() =>
|
||||
_ExtensionDetailsScreenState();
|
||||
}
|
||||
|
||||
class _ExtensionDetailsScreenState
|
||||
extends ConsumerState<ExtensionDetailsScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Watch store provider to get latest state of this extension (e.g. if updated/installed)
|
||||
final storeState = ref.watch(storeProvider);
|
||||
|
||||
// Find our extension in the store state to get the latest status
|
||||
// If not found in current store state (rare), fallback to widget.extension
|
||||
final liveExtension =
|
||||
storeState.extensions
|
||||
.where((e) => e.id == widget.extension.id)
|
||||
.firstOrNull ??
|
||||
widget.extension;
|
||||
|
||||
final isDownloading = storeState.downloadingId == liveExtension.id;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(context, liveExtension, colorScheme),
|
||||
_buildInfoCard(context, liveExtension, colorScheme, isDownloading),
|
||||
_buildSectionHeader(
|
||||
context,
|
||||
'About',
|
||||
Icons.info_outline,
|
||||
colorScheme,
|
||||
),
|
||||
_buildDescription(context, liveExtension, colorScheme),
|
||||
|
||||
if (liveExtension.tags.isNotEmpty) ...[
|
||||
_buildSectionHeader(context, 'Tags', Icons.tag, colorScheme),
|
||||
_buildTags(context, liveExtension, colorScheme),
|
||||
],
|
||||
|
||||
_buildSectionHeader(
|
||||
context,
|
||||
'Information',
|
||||
Icons.table_chart_outlined,
|
||||
colorScheme,
|
||||
),
|
||||
_buildMetadataTable(context, liveExtension, colorScheme),
|
||||
|
||||
_buildSectionHeader(
|
||||
context,
|
||||
'Capabilities',
|
||||
Icons.extension_outlined,
|
||||
colorScheme,
|
||||
),
|
||||
_buildCapabilities(context, liveExtension, colorScheme),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: kToolbarHeight),
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: ext.iconUrl != null && ext.iconUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
ext.iconUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildFallbackIcon(ext, colorScheme, 50),
|
||||
)
|
||||
: _buildFallbackIcon(ext, colorScheme, 50),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFallbackIcon(
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
double size,
|
||||
) {
|
||||
return Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
_getCategoryIcon(ext.category),
|
||||
size: size,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
bool isDownloading,
|
||||
) {
|
||||
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: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
ext.displayName,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'by ${ext.author}',
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Badges row
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_Badge(
|
||||
label: 'v${ext.version}',
|
||||
color: colorScheme.secondaryContainer,
|
||||
textColor: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
_Badge(
|
||||
label: _getCategoryName(ext.category),
|
||||
color: colorScheme.tertiaryContainer,
|
||||
textColor: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
if (ext.isInstalled)
|
||||
_Badge(
|
||||
label: 'Installed',
|
||||
color: colorScheme.primaryContainer,
|
||||
textColor: colorScheme.onPrimaryContainer,
|
||||
icon: Icons.check,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action Buttons
|
||||
if (isDownloading)
|
||||
Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
if (ext.hasUpdate)
|
||||
FilledButton.icon(
|
||||
onPressed: () => _updateExtension(ext),
|
||||
icon: const Icon(Icons.update),
|
||||
label: Text('Update to v${ext.version}'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (ext.isInstalled)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: null,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Installed'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton.filled(
|
||||
onPressed: () => _uninstallExtension(ext),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer,
|
||||
foregroundColor: colorScheme.onErrorContainer,
|
||||
minimumSize: const Size(52, 52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
tooltip: 'Uninstall',
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: () => _installExtension(ext),
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Install Extension'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(
|
||||
BuildContext context,
|
||||
String title,
|
||||
IconData icon,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescription(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Text(
|
||||
ext.description,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
height: 1.5,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTags(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: ext.tags
|
||||
.map(
|
||||
(tag) => Chip(
|
||||
label: Text(tag),
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
labelStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetadataTable(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_MetadataRow(
|
||||
label: 'Updated',
|
||||
value: ext.updatedAt.isNotEmpty
|
||||
? _formatDate(ext.updatedAt)
|
||||
: '-',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_MetadataRow(
|
||||
label: 'ID',
|
||||
value: ext.id,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_MetadataRow(
|
||||
label: 'Min App Version',
|
||||
value: ext.minAppVersion ?? 'Any',
|
||||
colorScheme: colorScheme,
|
||||
isLast: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCapabilities(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
// Determine capabilities based on category
|
||||
final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration';
|
||||
final isDownloadProvider = ext.category == 'download';
|
||||
final isLyricsProvider = ext.category == 'lyrics';
|
||||
final isUtility = ext.category == 'utility';
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_CapabilityRow(
|
||||
icon: Icons.search,
|
||||
label: 'Metadata Provider',
|
||||
enabled: isMetadataProvider,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_CapabilityRow(
|
||||
icon: Icons.download,
|
||||
label: 'Download Provider',
|
||||
enabled: isDownloadProvider,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_CapabilityRow(
|
||||
icon: Icons.lyrics,
|
||||
label: 'Lyrics Provider',
|
||||
enabled: isLyricsProvider,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_CapabilityRow(
|
||||
icon: Icons.build,
|
||||
label: 'Utility Functions',
|
||||
enabled: isUtility,
|
||||
colorScheme: colorScheme,
|
||||
isLast: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(String dateStr) {
|
||||
try {
|
||||
final date = DateTime.parse(dateStr);
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inDays == 0) {
|
||||
return 'Today';
|
||||
} else if (diff.inDays == 1) {
|
||||
return 'Yesterday';
|
||||
} else if (diff.inDays < 7) {
|
||||
return '${diff.inDays} days ago';
|
||||
} else if (diff.inDays < 30) {
|
||||
return '${(diff.inDays / 7).floor()} weeks ago';
|
||||
} else if (diff.inDays < 365) {
|
||||
return '${(diff.inDays / 30).floor()} months ago';
|
||||
} else {
|
||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
} catch (_) {
|
||||
return dateStr.split('T').first;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getCategoryIcon(String category) {
|
||||
switch (category) {
|
||||
case 'metadata':
|
||||
return Icons.label_outline;
|
||||
case 'download':
|
||||
return Icons.download_outlined;
|
||||
case 'utility':
|
||||
return Icons.build_outlined;
|
||||
case 'lyrics':
|
||||
return Icons.lyrics_outlined;
|
||||
case 'integration':
|
||||
return Icons.link;
|
||||
default:
|
||||
return Icons.extension;
|
||||
}
|
||||
}
|
||||
|
||||
String _getCategoryName(String category) {
|
||||
switch (category) {
|
||||
case 'metadata':
|
||||
return 'Metadata';
|
||||
case 'download':
|
||||
return 'Download';
|
||||
case 'utility':
|
||||
return 'Utility';
|
||||
case 'lyrics':
|
||||
return 'Lyrics';
|
||||
case 'integration':
|
||||
return 'Integration';
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _installExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
|
||||
final success = await ref
|
||||
.read(storeProvider.notifier)
|
||||
.installExtension(ext.id, tempDir.path, extensionsDir);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success
|
||||
? '${ext.displayName} installed.'
|
||||
: 'Failed to install ${ext.displayName}',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
|
||||
final success = await ref
|
||||
.read(storeProvider.notifier)
|
||||
.updateExtension(ext.id, tempDir.path);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success
|
||||
? '${ext.displayName} updated.'
|
||||
: 'Failed to update ${ext.displayName}',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uninstallExtension(StoreExtension ext) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Uninstall Extension?'),
|
||||
content: Text('Are you sure you want to remove ${ext.displayName}?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(
|
||||
'Uninstall',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true) {
|
||||
await ref.read(extensionProvider.notifier).removeExtension(ext.id);
|
||||
await ref.read(storeProvider.notifier).refresh();
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color textColor;
|
||||
final IconData? icon;
|
||||
|
||||
const _Badge({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.textColor,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 14, color: textColor),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final ColorScheme colorScheme;
|
||||
final bool isLast;
|
||||
|
||||
const _MetadataRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.colorScheme,
|
||||
this.isLast = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CapabilityRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool enabled;
|
||||
final ColorScheme colorScheme;
|
||||
final bool isLast;
|
||||
|
||||
const _CapabilityRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.enabled,
|
||||
required this.colorScheme,
|
||||
this.isLast = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: enabled ? colorScheme.primary : colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
enabled ? Icons.check_circle : Icons.cancel_outlined,
|
||||
size: 20,
|
||||
color: enabled ? colorScheme.primary : colorScheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
|
||||
|
||||
class StoreTab extends ConsumerStatefulWidget {
|
||||
const StoreTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StoreTab> createState() => _StoreTabState();
|
||||
}
|
||||
|
||||
class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
final _searchController = TextEditingController();
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initialize());
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
|
||||
// Check if widget is still mounted after async operation
|
||||
if (!mounted) return;
|
||||
|
||||
await ref.read(storeProvider.notifier).initialize(cacheDir.path);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(storeProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar - consistent with other tabs
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Store',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (14 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Search Bar
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search extensions...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
ref
|
||||
.read(storeProvider.notifier)
|
||||
.setSearchQuery('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(storeProvider.notifier).setSearchQuery(value);
|
||||
setState(() {}); // Update suffix icon
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Category Chips
|
||||
SliverToBoxAdapter(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_CategoryChip(
|
||||
label: 'All',
|
||||
icon: Icons.apps,
|
||||
isSelected: state.selectedCategory == null,
|
||||
onTap: () =>
|
||||
ref.read(storeProvider.notifier).setCategory(null),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Metadata',
|
||||
icon: Icons.label_outline,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.metadata,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.metadata),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Download',
|
||||
icon: Icons.download_outlined,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.download,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.download),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Utility',
|
||||
icon: Icons.build_outlined,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.utility,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.utility),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Lyrics',
|
||||
icon: Icons.lyrics_outlined,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.lyrics,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.lyrics),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Integration',
|
||||
icon: Icons.link,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.integration,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.integration),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
if (state.isLoading && state.extensions.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (state.error != null && state.extensions.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: _buildErrorState(state.error!, colorScheme),
|
||||
)
|
||||
else if (state.filteredExtensions.isEmpty)
|
||||
SliverFillRemaining(child: _buildEmptyState(state, colorScheme))
|
||||
else ...[
|
||||
// Extensions count
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'${state.filteredExtensions.length} ${state.filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Extensions list in grouped card (like queue_tab)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SettingsGroup(
|
||||
children: state.filteredExtensions.asMap().entries.map((
|
||||
entry,
|
||||
) {
|
||||
final index = entry.key;
|
||||
final ext = entry.value;
|
||||
return _ExtensionItem(
|
||||
extension: ext,
|
||||
showDivider:
|
||||
index < state.filteredExtensions.length - 1,
|
||||
isDownloading: state.downloadingId == ext.id,
|
||||
onInstall: () => _installExtension(ext),
|
||||
onUpdate: () => _updateExtension(ext),
|
||||
onTap: () => _showExtensionDetails(ext),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom padding
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(String error, ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64, color: colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load store',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) {
|
||||
final hasFilters =
|
||||
state.searchQuery.isNotEmpty || state.selectedCategory != null;
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
hasFilters ? Icons.search_off : Icons.extension_off,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
hasFilters ? 'No extensions found' : 'No extensions available',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (hasFilters) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
ref.read(storeProvider.notifier).clearSearch();
|
||||
},
|
||||
child: const Text('Clear filters'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showExtensionDetails(StoreExtension ext) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ExtensionDetailsScreen(extension: ext),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _installExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
|
||||
final success = await ref
|
||||
.read(storeProvider.notifier)
|
||||
.installExtension(ext.id, tempDir.path, extensionsDir);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success
|
||||
? '${ext.displayName} installed. Enable it in Settings > Extensions'
|
||||
: 'Failed to install ${ext.displayName}',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
|
||||
final success = await ref
|
||||
.read(storeProvider.notifier)
|
||||
.updateExtension(ext.id, tempDir.path);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success
|
||||
? '${ext.displayName} updated to v${ext.version}'
|
||||
: 'Failed to update ${ext.displayName}',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryChip extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CategoryChip({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilterChip(
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Icon(icon, size: 16), const SizedBox(width: 6), Text(label)],
|
||||
),
|
||||
selected: isSelected,
|
||||
onSelected: (_) => onTap(),
|
||||
showCheckmark: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExtensionItem extends StatelessWidget {
|
||||
final StoreExtension extension;
|
||||
final bool showDivider;
|
||||
final bool isDownloading;
|
||||
final VoidCallback onInstall;
|
||||
final VoidCallback onUpdate;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _ExtensionItem({
|
||||
required this.extension,
|
||||
required this.showDivider,
|
||||
required this.isDownloading,
|
||||
required this.onInstall,
|
||||
required this.onUpdate,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
IconData _getCategoryIcon(String category) {
|
||||
switch (category) {
|
||||
case StoreCategory.metadata:
|
||||
return Icons.label_outline;
|
||||
case StoreCategory.download:
|
||||
return Icons.download_outlined;
|
||||
case StoreCategory.utility:
|
||||
return Icons.build_outlined;
|
||||
case StoreCategory.lyrics:
|
||||
return Icons.lyrics_outlined;
|
||||
case StoreCategory.integration:
|
||||
return Icons.link;
|
||||
default:
|
||||
return Icons.extension;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Extension icon - custom or category-based
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: extension.isInstalled
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child:
|
||||
extension.iconUrl != null && extension.iconUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
extension.iconUrl!,
|
||||
width: 44,
|
||||
height: 44,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
_getCategoryIcon(extension.category),
|
||||
color: extension.isInstalled
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value:
|
||||
loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Icon(
|
||||
_getCategoryIcon(extension.category),
|
||||
color: extension.isInstalled
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Extension info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
extension.displayName,
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
// Version badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'v${extension.version}',
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'by ${extension.author}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
extension.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Action button
|
||||
if (isDownloading)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else if (extension.hasUpdate)
|
||||
FilledButton.tonal(
|
||||
onPressed: onUpdate,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: const Text('Update'),
|
||||
)
|
||||
else if (extension.isInstalled)
|
||||
OutlinedButton(
|
||||
onPressed: null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check, size: 16, color: colorScheme.outline),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Installed',
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: onInstall,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: const Text('Install'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 76,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Future<void> _checkFile() async {
|
||||
final file = File(widget.item.filePath);
|
||||
// Strip EXISTS: prefix from legacy history items
|
||||
var filePath = widget.item.filePath;
|
||||
if (filePath.startsWith('EXISTS:')) {
|
||||
filePath = filePath.substring(7);
|
||||
}
|
||||
|
||||
final file = File(filePath);
|
||||
final exists = await file.exists();
|
||||
int? size;
|
||||
|
||||
@@ -67,6 +73,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
int? get discNumber => item.discNumber;
|
||||
String? get releaseDate => item.releaseDate;
|
||||
String? get isrc => item.isrc;
|
||||
|
||||
// Clean filePath - strip EXISTS: prefix from legacy history items
|
||||
String get cleanFilePath {
|
||||
final path = item.filePath;
|
||||
return path.startsWith('EXISTS:') ? path.substring(7) : path;
|
||||
}
|
||||
int? get bitDepth => item.bitDepth;
|
||||
int? get sampleRate => item.sampleRate;
|
||||
|
||||
@@ -515,7 +527,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
|
||||
final fileName = item.filePath.split(Platform.pathSeparator).last;
|
||||
final fileName = cleanFilePath.split(Platform.pathSeparator).last;
|
||||
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
|
||||
|
||||
return Card(
|
||||
@@ -631,7 +643,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
// File path
|
||||
InkWell(
|
||||
onTap: () => _copyToClipboard(context, item.filePath),
|
||||
onTap: () => _copyToClipboard(context, cleanFilePath),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -643,7 +655,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.filePath,
|
||||
cleanFilePath,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
@@ -776,7 +788,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
item.spotifyId ?? '',
|
||||
item.trackName,
|
||||
item.artistName,
|
||||
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
|
||||
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first
|
||||
).timeout(
|
||||
const Duration(seconds: 20),
|
||||
onTimeout: () => '', // Return empty string on timeout
|
||||
@@ -833,7 +845,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton.icon(
|
||||
onPressed: fileExists ? () => _openFile(context, item.filePath) : null,
|
||||
onPressed: fileExists ? () => _openFile(context, cleanFilePath) : null,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('Play'),
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -890,7 +902,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
title: const Text('Copy file path'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_copyToClipboard(context, item.filePath);
|
||||
_copyToClipboard(context, cleanFilePath);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
@@ -933,7 +945,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
onPressed: () async {
|
||||
// Delete the file first
|
||||
try {
|
||||
final file = File(item.filePath);
|
||||
final file = File(cleanFilePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
@@ -984,7 +996,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Future<void> _shareFile(BuildContext context) async {
|
||||
final file = File(item.filePath);
|
||||
final file = File(cleanFilePath);
|
||||
if (!await file.exists()) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -996,7 +1008,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(item.filePath)],
|
||||
files: [XFile(cleanFilePath)],
|
||||
text: '${item.trackName} - ${item.artistName}',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -331,7 +331,6 @@ class PlatformBridge {
|
||||
}
|
||||
|
||||
/// Set custom Spotify API credentials
|
||||
/// Pass empty strings to use default credentials
|
||||
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
||||
await _channel.invokeMethod('setSpotifyCredentials', {
|
||||
'client_id': clientId,
|
||||
@@ -339,6 +338,13 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if Spotify credentials are configured
|
||||
/// Returns true if credentials are available (custom or env vars)
|
||||
static Future<bool> hasSpotifyCredentials() async {
|
||||
final result = await _channel.invokeMethod('hasSpotifyCredentials');
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
/// Pre-warm track ID cache for album/playlist tracks
|
||||
/// This runs in background and returns immediately
|
||||
/// Speeds up subsequent downloads by caching ISRC → Track ID mappings
|
||||
@@ -439,4 +445,418 @@ class PlatformBridge {
|
||||
static Future<void> setGoLoggingEnabled(bool enabled) async {
|
||||
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
|
||||
}
|
||||
|
||||
// ==================== EXTENSION SYSTEM ====================
|
||||
|
||||
/// Initialize the extension system
|
||||
static Future<void> initExtensionSystem(String extensionsDir, String dataDir) async {
|
||||
_log.d('initExtensionSystem: $extensionsDir, $dataDir');
|
||||
await _channel.invokeMethod('initExtensionSystem', {
|
||||
'extensions_dir': extensionsDir,
|
||||
'data_dir': dataDir,
|
||||
});
|
||||
}
|
||||
|
||||
/// Load all extensions from directory
|
||||
static Future<Map<String, dynamic>> loadExtensionsFromDir(String dirPath) async {
|
||||
_log.d('loadExtensionsFromDir: $dirPath');
|
||||
final result = await _channel.invokeMethod('loadExtensionsFromDir', {
|
||||
'dir_path': dirPath,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Load a single extension from file
|
||||
static Future<Map<String, dynamic>> loadExtensionFromPath(String filePath) async {
|
||||
_log.d('loadExtensionFromPath: $filePath');
|
||||
final result = await _channel.invokeMethod('loadExtensionFromPath', {
|
||||
'file_path': filePath,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Unload an extension
|
||||
static Future<void> unloadExtension(String extensionId) async {
|
||||
_log.d('unloadExtension: $extensionId');
|
||||
await _channel.invokeMethod('unloadExtension', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove an extension completely (unload + delete files)
|
||||
static Future<void> removeExtension(String extensionId) async {
|
||||
_log.d('removeExtension: $extensionId');
|
||||
await _channel.invokeMethod('removeExtension', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Upgrade an existing extension from a new package file
|
||||
static Future<Map<String, dynamic>> upgradeExtension(String filePath) async {
|
||||
_log.d('upgradeExtension: $filePath');
|
||||
final result = await _channel.invokeMethod('upgradeExtension', {
|
||||
'file_path': filePath,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Check if a package file is an upgrade for an existing extension
|
||||
static Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
|
||||
_log.d('checkExtensionUpgrade: $filePath');
|
||||
final result = await _channel.invokeMethod('checkExtensionUpgrade', {
|
||||
'file_path': filePath,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get all installed extensions
|
||||
static Future<List<Map<String, dynamic>>> getInstalledExtensions() async {
|
||||
final result = await _channel.invokeMethod('getInstalledExtensions');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Enable or disable an extension
|
||||
static Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
||||
_log.d('setExtensionEnabled: $extensionId = $enabled');
|
||||
await _channel.invokeMethod('setExtensionEnabled', {
|
||||
'extension_id': extensionId,
|
||||
'enabled': enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/// Set provider priority order
|
||||
static Future<void> setProviderPriority(List<String> providerIds) async {
|
||||
_log.d('setProviderPriority: $providerIds');
|
||||
await _channel.invokeMethod('setProviderPriority', {
|
||||
'priority': jsonEncode(providerIds),
|
||||
});
|
||||
}
|
||||
|
||||
/// Get provider priority order
|
||||
static Future<List<String>> getProviderPriority() async {
|
||||
final result = await _channel.invokeMethod('getProviderPriority');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as String).toList();
|
||||
}
|
||||
|
||||
/// Set metadata provider priority order
|
||||
static Future<void> setMetadataProviderPriority(List<String> providerIds) async {
|
||||
_log.d('setMetadataProviderPriority: $providerIds');
|
||||
await _channel.invokeMethod('setMetadataProviderPriority', {
|
||||
'priority': jsonEncode(providerIds),
|
||||
});
|
||||
}
|
||||
|
||||
/// Get metadata provider priority order
|
||||
static Future<List<String>> getMetadataProviderPriority() async {
|
||||
final result = await _channel.invokeMethod('getMetadataProviderPriority');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as String).toList();
|
||||
}
|
||||
|
||||
/// Get extension settings
|
||||
static Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
|
||||
final result = await _channel.invokeMethod('getExtensionSettings', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Set extension settings
|
||||
static Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
|
||||
_log.d('setExtensionSettings: $extensionId');
|
||||
await _channel.invokeMethod('setExtensionSettings', {
|
||||
'extension_id': extensionId,
|
||||
'settings': jsonEncode(settings),
|
||||
});
|
||||
}
|
||||
|
||||
/// Search tracks using extension providers
|
||||
static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(String query, {int limit = 20}) async {
|
||||
_log.d('searchTracksWithExtensions: "$query"');
|
||||
final result = await _channel.invokeMethod('searchTracksWithExtensions', {
|
||||
'query': query,
|
||||
'limit': limit,
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Download with extension providers (includes fallback)
|
||||
static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||
required String isrc,
|
||||
required String spotifyId,
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
required String albumName,
|
||||
String? albumArtist,
|
||||
String? coverUrl,
|
||||
required String outputDir,
|
||||
required String filenameFormat,
|
||||
String quality = 'LOSSLESS',
|
||||
bool embedLyrics = true,
|
||||
bool embedMaxQualityCover = true,
|
||||
int trackNumber = 1,
|
||||
int discNumber = 1,
|
||||
int totalTracks = 1,
|
||||
String? releaseDate,
|
||||
String? itemId,
|
||||
int durationMs = 0,
|
||||
String? source, // Extension ID that provided this track (prioritize this extension)
|
||||
}) async {
|
||||
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}');
|
||||
final request = jsonEncode({
|
||||
'isrc': isrc,
|
||||
'spotify_id': spotifyId,
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
'album_name': albumName,
|
||||
'album_artist': albumArtist ?? artistName,
|
||||
'cover_url': coverUrl,
|
||||
'output_dir': outputDir,
|
||||
'filename_format': filenameFormat,
|
||||
'quality': quality,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'track_number': trackNumber,
|
||||
'disc_number': discNumber,
|
||||
'total_tracks': totalTracks,
|
||||
'release_date': releaseDate ?? '',
|
||||
'item_id': itemId ?? '',
|
||||
'duration_ms': durationMs,
|
||||
'source': source ?? '', // Extension ID that provided this track
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadWithExtensions', request);
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Cleanup all extensions (call on app close)
|
||||
static Future<void> cleanupExtensions() async {
|
||||
_log.d('cleanupExtensions');
|
||||
await _channel.invokeMethod('cleanupExtensions');
|
||||
}
|
||||
|
||||
// ==================== EXTENSION AUTH API ====================
|
||||
|
||||
/// Get pending auth request for an extension (if any)
|
||||
static Future<Map<String, dynamic>?> getExtensionPendingAuth(String extensionId) async {
|
||||
final result = await _channel.invokeMethod('getExtensionPendingAuth', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
if (result == null) return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Set auth code for an extension (after OAuth callback)
|
||||
static Future<void> setExtensionAuthCode(String extensionId, String authCode) async {
|
||||
_log.d('setExtensionAuthCode: $extensionId');
|
||||
await _channel.invokeMethod('setExtensionAuthCode', {
|
||||
'extension_id': extensionId,
|
||||
'auth_code': authCode,
|
||||
});
|
||||
}
|
||||
|
||||
/// Set tokens for an extension (after token exchange)
|
||||
static Future<void> setExtensionTokens(
|
||||
String extensionId, {
|
||||
required String accessToken,
|
||||
String? refreshToken,
|
||||
int? expiresIn,
|
||||
}) async {
|
||||
_log.d('setExtensionTokens: $extensionId');
|
||||
await _channel.invokeMethod('setExtensionTokens', {
|
||||
'extension_id': extensionId,
|
||||
'access_token': accessToken,
|
||||
'refresh_token': refreshToken ?? '',
|
||||
'expires_in': expiresIn ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear pending auth request for an extension
|
||||
static Future<void> clearExtensionPendingAuth(String extensionId) async {
|
||||
await _channel.invokeMethod('clearExtensionPendingAuth', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if extension is authenticated
|
||||
static Future<bool> isExtensionAuthenticated(String extensionId) async {
|
||||
final result = await _channel.invokeMethod('isExtensionAuthenticated', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
/// Get all pending auth requests (for polling)
|
||||
static Future<List<Map<String, dynamic>>> getAllPendingAuthRequests() async {
|
||||
final result = await _channel.invokeMethod('getAllPendingAuthRequests');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION FFMPEG API ====================
|
||||
|
||||
/// Get pending FFmpeg command for execution
|
||||
static Future<Map<String, dynamic>?> getPendingFFmpegCommand(String commandId) async {
|
||||
final result = await _channel.invokeMethod('getPendingFFmpegCommand', {
|
||||
'command_id': commandId,
|
||||
});
|
||||
if (result == null) return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Set FFmpeg command result
|
||||
static Future<void> setFFmpegCommandResult(
|
||||
String commandId, {
|
||||
required bool success,
|
||||
String output = '',
|
||||
String error = '',
|
||||
}) async {
|
||||
await _channel.invokeMethod('setFFmpegCommandResult', {
|
||||
'command_id': commandId,
|
||||
'success': success,
|
||||
'output': output,
|
||||
'error': error,
|
||||
});
|
||||
}
|
||||
|
||||
/// Get all pending FFmpeg commands
|
||||
static Future<List<Map<String, dynamic>>> getAllPendingFFmpegCommands() async {
|
||||
final result = await _channel.invokeMethod('getAllPendingFFmpegCommands');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION CUSTOM SEARCH ====================
|
||||
|
||||
/// Perform custom search using an extension
|
||||
static Future<List<Map<String, dynamic>>> customSearchWithExtension(
|
||||
String extensionId,
|
||||
String query, {
|
||||
Map<String, dynamic>? options,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('customSearchWithExtension', {
|
||||
'extension_id': extensionId,
|
||||
'query': query,
|
||||
'options': options != null ? jsonEncode(options) : '',
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get all extensions that provide custom search
|
||||
static Future<List<Map<String, dynamic>>> getSearchProviders() async {
|
||||
final result = await _channel.invokeMethod('getSearchProviders');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION URL HANDLER ====================
|
||||
|
||||
/// Handle a URL with any matching extension
|
||||
/// Returns null if no extension can handle the URL
|
||||
static Future<Map<String, dynamic>?> handleURLWithExtension(String url) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('handleURLWithExtension', {
|
||||
'url': url,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
// No extension found or error handling URL
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Find an extension that can handle the given URL
|
||||
/// Returns extension ID or null if none found
|
||||
static Future<String?> findURLHandler(String url) async {
|
||||
final result = await _channel.invokeMethod('findURLHandler', {
|
||||
'url': url,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return result as String;
|
||||
}
|
||||
|
||||
/// Get all extensions that handle custom URLs
|
||||
static Future<List<Map<String, dynamic>>> getURLHandlers() async {
|
||||
final result = await _channel.invokeMethod('getURLHandlers');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION POST-PROCESSING ====================
|
||||
|
||||
/// Run post-processing hooks on a file
|
||||
static Future<Map<String, dynamic>> runPostProcessing(
|
||||
String filePath, {
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('runPostProcessing', {
|
||||
'file_path': filePath,
|
||||
'metadata': metadata != null ? jsonEncode(metadata) : '',
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get all extensions that provide post-processing
|
||||
static Future<List<Map<String, dynamic>>> getPostProcessingProviders() async {
|
||||
final result = await _channel.invokeMethod('getPostProcessingProviders');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION STORE ====================
|
||||
|
||||
/// Initialize extension store
|
||||
static Future<void> initExtensionStore(String cacheDir) async {
|
||||
_log.d('initExtensionStore: $cacheDir');
|
||||
await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir});
|
||||
}
|
||||
|
||||
/// Get all extensions from store with installation status
|
||||
static Future<List<Map<String, dynamic>>> getStoreExtensions({bool forceRefresh = false}) async {
|
||||
_log.d('getStoreExtensions (forceRefresh: $forceRefresh)');
|
||||
final result = await _channel.invokeMethod('getStoreExtensions', {
|
||||
'force_refresh': forceRefresh,
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Search extensions in store
|
||||
static Future<List<Map<String, dynamic>>> searchStoreExtensions(String query, {String? category}) async {
|
||||
_log.d('searchStoreExtensions: "$query" (category: $category)');
|
||||
final result = await _channel.invokeMethod('searchStoreExtensions', {
|
||||
'query': query,
|
||||
'category': category ?? '',
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get store categories
|
||||
static Future<List<String>> getStoreCategories() async {
|
||||
final result = await _channel.invokeMethod('getStoreCategories');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.cast<String>();
|
||||
}
|
||||
|
||||
/// Download extension from store
|
||||
static Future<String> downloadStoreExtension(String extensionId, String destDir) async {
|
||||
_log.i('downloadStoreExtension: $extensionId to $destDir');
|
||||
final result = await _channel.invokeMethod('downloadStoreExtension', {
|
||||
'extension_id': extensionId,
|
||||
'dest_dir': destDir,
|
||||
});
|
||||
return result as String;
|
||||
}
|
||||
|
||||
/// Clear store cache
|
||||
static Future<void> clearStoreCache() async {
|
||||
_log.d('clearStoreCache');
|
||||
await _channel.invokeMethod('clearStoreCache');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
/// Built-in service info with quality options
|
||||
class BuiltInService {
|
||||
final String id;
|
||||
final String label;
|
||||
final List<QualityOption> qualityOptions;
|
||||
|
||||
const BuiltInService({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.qualityOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/// Default quality options for built-in services (Tidal, Qobuz, Amazon)
|
||||
const _builtInServices = [
|
||||
BuiltInService(
|
||||
id: 'tidal',
|
||||
label: 'Tidal',
|
||||
qualityOptions: [
|
||||
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
|
||||
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
||||
],
|
||||
),
|
||||
BuiltInService(
|
||||
id: 'qobuz',
|
||||
label: 'Qobuz',
|
||||
qualityOptions: [
|
||||
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
|
||||
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
||||
],
|
||||
),
|
||||
BuiltInService(
|
||||
id: 'amazon',
|
||||
label: 'Amazon',
|
||||
qualityOptions: [
|
||||
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
|
||||
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// A reusable widget for selecting download service (built-in + extensions)
|
||||
class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
final String? trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
final void Function(String quality, String service) onSelect;
|
||||
|
||||
const DownloadServicePicker({
|
||||
super.key,
|
||||
this.trackName,
|
||||
this.artistName,
|
||||
this.coverUrl,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<DownloadServicePicker> createState() => _DownloadServicePickerState();
|
||||
|
||||
/// Show the download service picker as a modal bottom sheet
|
||||
static void show(
|
||||
BuildContext context, {
|
||||
String? trackName,
|
||||
String? artistName,
|
||||
String? coverUrl,
|
||||
required void Function(String quality, String service) onSelect,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => DownloadServicePicker(
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
onSelect: onSelect,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
late String _selectedService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedService = ref.read(settingsProvider).defaultService;
|
||||
}
|
||||
|
||||
/// Get quality options for the selected service
|
||||
List<QualityOption> _getQualityOptions() {
|
||||
// Check if it's a built-in service
|
||||
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
|
||||
if (builtIn != null) {
|
||||
return builtIn.qualityOptions;
|
||||
}
|
||||
|
||||
// Check if it's an extension
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
|
||||
if (ext != null && ext.qualityOptions.isNotEmpty) {
|
||||
return ext.qualityOptions;
|
||||
}
|
||||
|
||||
// Default quality options if extension doesn't specify any
|
||||
return const [
|
||||
QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final extensionState = ref.watch(extensionProvider);
|
||||
|
||||
// Get enabled download provider extensions
|
||||
final downloadExtensions = extensionState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
||||
.toList();
|
||||
|
||||
final qualityOptions = _getQualityOptions();
|
||||
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Track info header (if provided)
|
||||
if (widget.trackName != null) ...[
|
||||
_TrackInfoHeader(
|
||||
trackName: widget.trackName!,
|
||||
artistName: widget.artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Service selector section
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text(
|
||||
'Download From',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
// Built-in services
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
// Built-in services
|
||||
for (final service in _builtInServices)
|
||||
_ServiceChip(
|
||||
label: service.label,
|
||||
isSelected: _selectedService == service.id,
|
||||
onTap: () => setState(() => _selectedService = service.id),
|
||||
),
|
||||
// Extension services
|
||||
for (final ext in downloadExtensions)
|
||||
_ServiceChip(
|
||||
label: ext.displayName,
|
||||
isSelected: _selectedService == ext.id,
|
||||
onTap: () => setState(() => _selectedService = ext.id),
|
||||
iconPath: ext.iconPath,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Quality selector section
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text(
|
||||
'Select Quality',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
// Disclaimer for built-in services
|
||||
if (_builtInServices.any((s) => s.id == _selectedService))
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Quality options
|
||||
for (final quality in qualityOptions)
|
||||
_QualityOption(
|
||||
title: quality.label,
|
||||
subtitle: quality.description ?? '',
|
||||
icon: _getQualityIcon(quality.id),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onSelect(quality.id, _selectedService);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getQualityIcon(String qualityId) {
|
||||
switch (qualityId.toUpperCase()) {
|
||||
case 'HI_RES_LOSSLESS':
|
||||
return Icons.four_k;
|
||||
case 'HI_RES':
|
||||
return Icons.high_quality;
|
||||
case 'LOSSLESS':
|
||||
return Icons.music_note;
|
||||
case 'MP3_320':
|
||||
case 'MP3':
|
||||
return Icons.audiotrack;
|
||||
case 'OPUS':
|
||||
case 'OPUS_128':
|
||||
return Icons.graphic_eq;
|
||||
default:
|
||||
return Icons.music_note;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QualityOption({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: subtitle.isNotEmpty
|
||||
? Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant))
|
||||
: null,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final String? iconPath;
|
||||
|
||||
const _ServiceChip({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.iconPath,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (iconPath != null) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image.file(
|
||||
File(iconPath!),
|
||||
width: 18,
|
||||
height: 18,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
Icons.extension,
|
||||
size: 18,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfoHeader extends StatefulWidget {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
|
||||
const _TrackInfoHeader({
|
||||
required this.trackName,
|
||||
this.artistName,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||
}
|
||||
|
||||
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
bool _expanded = false;
|
||||
bool _isOverflowing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: widget.coverUrl != null
|
||||
? Image.network(
|
||||
widget.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||
final titlePainter = TextPainter(
|
||||
text: titleSpan,
|
||||
maxLines: 1,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(maxWidth: constraints.maxWidth);
|
||||
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isOverflowing != titleOverflows) {
|
||||
setState(() => _isOverflowing = titleOverflows);
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.trackName,
|
||||
style: titleStyle,
|
||||
maxLines: _expanded ? 10 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.artistName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.artistName!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: _expanded ? 3 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_isOverflowing || _expanded)
|
||||
Icon(
|
||||
_expanded ? Icons.expand_less : Icons.expand_more,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ class SettingsItem extends StatelessWidget {
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
highlightColor: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
@@ -133,6 +133,7 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
final bool value;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
final bool showDivider;
|
||||
final bool enabled;
|
||||
|
||||
const SettingsSwitchItem({
|
||||
super.key,
|
||||
@@ -142,53 +143,60 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
required this.value,
|
||||
this.onChanged,
|
||||
this.showDivider = true,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDisabled = !enabled || onChanged == null;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onChanged != null ? () => onChanged!(!value) : null,
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Opacity(
|
||||
opacity: isDisabled ? 0.5 : 1.0,
|
||||
child: InkWell(
|
||||
onTap: isDisabled ? null : () => onChanged!(!value),
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: isDisabled ? colorScheme.outline : null,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: isDisabled ? null : onChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user