mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 04:37:59 +02:00
Merge branch 'dev'
# Conflicts: # README.md # lib/constants/app_info.dart # lib/l10n/app_localizations_ru.dart # pubspec.yaml
This commit is contained in:
+28
-2
@@ -37,6 +37,9 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
||||
builder: (context, state) => const TutorialScreen(),
|
||||
),
|
||||
],
|
||||
// Safety net: if a deep link URL (e.g. Spotify/Deezer) somehow reaches
|
||||
// GoRouter, redirect to home instead of showing "Page Not Found".
|
||||
errorBuilder: (context, state) => const MainShell(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -54,10 +57,14 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
: null;
|
||||
|
||||
Locale? locale;
|
||||
if (localeString != 'system') {
|
||||
if (localeString != 'system' && localeString.isNotEmpty) {
|
||||
if (localeString.contains('_')) {
|
||||
final parts = localeString.split('_');
|
||||
locale = Locale(parts[0], parts[1]);
|
||||
if (parts.length == 2) {
|
||||
locale = Locale(parts[0], parts[1]);
|
||||
} else {
|
||||
locale = Locale(parts[0]);
|
||||
}
|
||||
} else {
|
||||
locale = Locale(localeString);
|
||||
}
|
||||
@@ -76,6 +83,25 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
themeAnimationCurve: Curves.easeInOut,
|
||||
routerConfig: router,
|
||||
locale: locale,
|
||||
localeResolutionCallback: (deviceLocale, supportedLocales) {
|
||||
if (locale != null) return locale;
|
||||
if (deviceLocale == null) return supportedLocales.first;
|
||||
|
||||
for (var supportedLocale in supportedLocales) {
|
||||
if (supportedLocale.languageCode == deviceLocale.languageCode &&
|
||||
supportedLocale.countryCode == deviceLocale.countryCode) {
|
||||
return supportedLocale;
|
||||
}
|
||||
}
|
||||
|
||||
for (var supportedLocale in supportedLocales) {
|
||||
if (supportedLocale.languageCode == deviceLocale.languageCode) {
|
||||
return supportedLocale;
|
||||
}
|
||||
}
|
||||
|
||||
return supportedLocales.first;
|
||||
},
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
|
||||
@@ -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 = '3.6.8';
|
||||
static const String buildNumber = '82';
|
||||
static const String version = '3.7.1';
|
||||
static const String buildNumber = '104';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
+326
-1444
File diff suppressed because it is too large
Load Diff
+232
-836
File diff suppressed because it is too large
Load Diff
+232
-832
File diff suppressed because it is too large
Load Diff
+246
-1675
File diff suppressed because it is too large
Load Diff
+232
-836
File diff suppressed because it is too large
Load Diff
+232
-832
File diff suppressed because it is too large
Load Diff
+233
-838
File diff suppressed because it is too large
Load Diff
+232
-831
File diff suppressed because it is too large
Load Diff
+232
-832
File diff suppressed because it is too large
Load Diff
+232
-832
File diff suppressed because it is too large
Load Diff
+246
-1672
File diff suppressed because it is too large
Load Diff
+228
-890
File diff suppressed because it is too large
Load Diff
+232
-835
File diff suppressed because it is too large
Load Diff
+260
-2502
File diff suppressed because it is too large
Load Diff
+19
-1088
File diff suppressed because it is too large
Load Diff
+2273
-1399
File diff suppressed because it is too large
Load Diff
+9
-850
File diff suppressed because it is too large
Load Diff
+19
-1088
File diff suppressed because it is too large
Load Diff
+19
-1088
File diff suppressed because it is too large
Load Diff
+19
-1088
File diff suppressed because it is too large
Load Diff
+3094
-3869
File diff suppressed because it is too large
Load Diff
+19
-1088
File diff suppressed because it is too large
Load Diff
+19
-1088
File diff suppressed because it is too large
Load Diff
+19
-1088
File diff suppressed because it is too large
Load Diff
+9
-850
File diff suppressed because it is too large
Load Diff
+19
-1088
File diff suppressed because it is too large
Load Diff
+19
-1088
File diff suppressed because it is too large
Load Diff
+19
-1088
File diff suppressed because it is too large
Load Diff
+9
-850
File diff suppressed because it is too large
Load Diff
+19
-1088
File diff suppressed because it is too large
Load Diff
+19
-1088
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ 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/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
@@ -93,6 +95,8 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
_initializeAppServices();
|
||||
_initializeExtensions();
|
||||
ref.read(downloadHistoryProvider);
|
||||
ref.read(localLibraryProvider);
|
||||
ref.read(libraryCollectionsProvider);
|
||||
}
|
||||
|
||||
Future<void> _initializeAppServices() async {
|
||||
|
||||
@@ -11,6 +11,7 @@ class AppSettings {
|
||||
final String storageMode; // 'app' or 'saf'
|
||||
final String downloadTreeUri; // SAF persistable tree URI
|
||||
final bool autoFallback;
|
||||
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
|
||||
final bool embedLyrics;
|
||||
final bool maxQualityCover;
|
||||
final bool isFirstLaunch;
|
||||
@@ -39,12 +40,20 @@ class AppSettings {
|
||||
final String lyricsMode;
|
||||
final String
|
||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||
final int
|
||||
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps)
|
||||
final int
|
||||
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
|
||||
final bool
|
||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||
final bool
|
||||
autoExportFailedDownloads; // Auto export failed downloads to TXT file
|
||||
final String
|
||||
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
||||
final bool
|
||||
networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests
|
||||
final String
|
||||
songLinkRegion; // SongLink userCountry region code used for platform lookup
|
||||
|
||||
// Local Library Settings
|
||||
final bool localLibraryEnabled; // Enable local library scanning
|
||||
@@ -68,6 +77,10 @@ class AppSettings {
|
||||
final String
|
||||
musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics
|
||||
|
||||
// Version upgrade tracking
|
||||
final String
|
||||
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
this.audioQuality = 'LOSSLESS',
|
||||
@@ -76,6 +89,7 @@ class AppSettings {
|
||||
this.storageMode = 'app',
|
||||
this.downloadTreeUri = '',
|
||||
this.autoFallback = true,
|
||||
this.embedMetadata = true,
|
||||
this.embedLyrics = true,
|
||||
this.maxQualityCover = true,
|
||||
this.isFirstLaunch = true,
|
||||
@@ -103,9 +117,13 @@ class AppSettings {
|
||||
this.locale = 'system',
|
||||
this.lyricsMode = 'embed',
|
||||
this.tidalHighFormat = 'mp3_320',
|
||||
this.youtubeOpusBitrate = 256,
|
||||
this.youtubeMp3Bitrate = 320,
|
||||
this.useAllFilesAccess = false,
|
||||
this.autoExportFailedDownloads = false,
|
||||
this.downloadNetworkMode = 'any',
|
||||
this.networkCompatibilityMode = false,
|
||||
this.songLinkRegion = 'US',
|
||||
// Local Library defaults
|
||||
this.localLibraryEnabled = false,
|
||||
this.localLibraryPath = '',
|
||||
@@ -113,11 +131,20 @@ class AppSettings {
|
||||
// Tutorial default
|
||||
this.hasCompletedTutorial = false,
|
||||
// Lyrics providers default order
|
||||
this.lyricsProviders = const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'],
|
||||
this.lyricsProviders = const [
|
||||
'lrclib',
|
||||
'spotify_api',
|
||||
'musixmatch',
|
||||
'netease',
|
||||
'apple_music',
|
||||
'qqmusic',
|
||||
],
|
||||
this.lyricsIncludeTranslationNetease = false,
|
||||
this.lyricsIncludeRomanizationNetease = false,
|
||||
this.lyricsMultiPersonWordByWord = true,
|
||||
this.lyricsMultiPersonWordByWord = false,
|
||||
this.musixmatchLanguage = '',
|
||||
// Version upgrade tracking
|
||||
this.lastSeenVersion = '',
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -127,7 +154,8 @@ class AppSettings {
|
||||
String? downloadDirectory,
|
||||
String? storageMode,
|
||||
String? downloadTreeUri,
|
||||
bool? autoFallback,
|
||||
bool? autoFallback,
|
||||
bool? embedMetadata,
|
||||
bool? embedLyrics,
|
||||
bool? maxQualityCover,
|
||||
bool? isFirstLaunch,
|
||||
@@ -156,9 +184,13 @@ class AppSettings {
|
||||
String? locale,
|
||||
String? lyricsMode,
|
||||
String? tidalHighFormat,
|
||||
int? youtubeOpusBitrate,
|
||||
int? youtubeMp3Bitrate,
|
||||
bool? useAllFilesAccess,
|
||||
bool? autoExportFailedDownloads,
|
||||
String? downloadNetworkMode,
|
||||
bool? networkCompatibilityMode,
|
||||
String? songLinkRegion,
|
||||
// Local Library
|
||||
bool? localLibraryEnabled,
|
||||
String? localLibraryPath,
|
||||
@@ -171,6 +203,8 @@ class AppSettings {
|
||||
bool? lyricsIncludeRomanizationNetease,
|
||||
bool? lyricsMultiPersonWordByWord,
|
||||
String? musixmatchLanguage,
|
||||
// Version upgrade tracking
|
||||
String? lastSeenVersion,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -180,6 +214,7 @@ class AppSettings {
|
||||
storageMode: storageMode ?? this.storageMode,
|
||||
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
||||
autoFallback: autoFallback ?? this.autoFallback,
|
||||
embedMetadata: embedMetadata ?? this.embedMetadata,
|
||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
@@ -215,10 +250,15 @@ class AppSettings {
|
||||
locale: locale ?? this.locale,
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
|
||||
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
|
||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||
autoExportFailedDownloads:
|
||||
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
|
||||
networkCompatibilityMode:
|
||||
networkCompatibilityMode ?? this.networkCompatibilityMode,
|
||||
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
|
||||
// Local Library
|
||||
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
||||
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
||||
@@ -229,12 +269,16 @@ class AppSettings {
|
||||
// Lyrics providers
|
||||
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
||||
lyricsIncludeTranslationNetease:
|
||||
lyricsIncludeTranslationNetease ?? this.lyricsIncludeTranslationNetease,
|
||||
lyricsIncludeTranslationNetease ??
|
||||
this.lyricsIncludeTranslationNetease,
|
||||
lyricsIncludeRomanizationNetease:
|
||||
lyricsIncludeRomanizationNetease ?? this.lyricsIncludeRomanizationNetease,
|
||||
lyricsIncludeRomanizationNetease ??
|
||||
this.lyricsIncludeRomanizationNetease,
|
||||
lyricsMultiPersonWordByWord:
|
||||
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
|
||||
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
|
||||
// Version upgrade tracking
|
||||
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
storageMode: json['storageMode'] as String? ?? 'app',
|
||||
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
|
||||
autoFallback: json['autoFallback'] as bool? ?? true,
|
||||
embedMetadata: json['embedMetadata'] as bool? ?? true,
|
||||
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||
@@ -44,10 +45,14 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
|
||||
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
|
||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||
autoExportFailedDownloads:
|
||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
|
||||
networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false,
|
||||
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
|
||||
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
|
||||
localLibraryPath: json['localLibraryPath'] as String? ?? '',
|
||||
localLibraryShowDuplicates:
|
||||
@@ -57,14 +62,22 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
(json['lyricsProviders'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'],
|
||||
const [
|
||||
'lrclib',
|
||||
'spotify_api',
|
||||
'musixmatch',
|
||||
'netease',
|
||||
'apple_music',
|
||||
'qqmusic',
|
||||
],
|
||||
lyricsIncludeTranslationNetease:
|
||||
json['lyricsIncludeTranslationNetease'] as bool? ?? false,
|
||||
lyricsIncludeRomanizationNetease:
|
||||
json['lyricsIncludeRomanizationNetease'] as bool? ?? false,
|
||||
lyricsMultiPersonWordByWord:
|
||||
json['lyricsMultiPersonWordByWord'] as bool? ?? true,
|
||||
json['lyricsMultiPersonWordByWord'] as bool? ?? false,
|
||||
musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '',
|
||||
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(
|
||||
@@ -77,6 +90,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'storageMode': instance.storageMode,
|
||||
'downloadTreeUri': instance.downloadTreeUri,
|
||||
'autoFallback': instance.autoFallback,
|
||||
'embedMetadata': instance.embedMetadata,
|
||||
'embedLyrics': instance.embedLyrics,
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
@@ -105,9 +119,13 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'locale': instance.locale,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'tidalHighFormat': instance.tidalHighFormat,
|
||||
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
|
||||
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
|
||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||
'networkCompatibilityMode': instance.networkCompatibilityMode,
|
||||
'songLinkRegion': instance.songLinkRegion,
|
||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||
'localLibraryPath': instance.localLibraryPath,
|
||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||
@@ -117,4 +135,5 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'lyricsIncludeRomanizationNetease': instance.lyricsIncludeRomanizationNetease,
|
||||
'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord,
|
||||
'musixmatchLanguage': instance.musixmatchLanguage,
|
||||
'lastSeenVersion': instance.lastSeenVersion,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ class Track {
|
||||
final String artistName;
|
||||
final String albumName;
|
||||
final String? albumArtist;
|
||||
final String? artistId;
|
||||
final String? albumId;
|
||||
final String? coverUrl;
|
||||
final String? isrc;
|
||||
final int duration;
|
||||
@@ -27,6 +29,8 @@ class Track {
|
||||
required this.artistName,
|
||||
required this.albumName,
|
||||
this.albumArtist,
|
||||
this.artistId,
|
||||
this.albumId,
|
||||
this.coverUrl,
|
||||
this.isrc,
|
||||
required this.duration,
|
||||
|
||||
@@ -12,6 +12,8 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
artistName: json['artistName'] as String,
|
||||
albumName: json['albumName'] as String,
|
||||
albumArtist: json['albumArtist'] as String?,
|
||||
artistId: json['artistId'] as String?,
|
||||
albumId: json['albumId'] as String?,
|
||||
coverUrl: json['coverUrl'] as String?,
|
||||
isrc: json['isrc'] as String?,
|
||||
duration: (json['duration'] as num).toInt(),
|
||||
@@ -35,6 +37,8 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'artistName': instance.artistName,
|
||||
'albumName': instance.albumName,
|
||||
'albumArtist': instance.albumArtist,
|
||||
'artistId': instance.artistId,
|
||||
'albumId': instance.albumId,
|
||||
'coverUrl': instance.coverUrl,
|
||||
'isrc': instance.isrc,
|
||||
'duration': instance.duration,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -9,6 +13,7 @@ final _log = AppLogger('ExtensionProvider');
|
||||
|
||||
const _metadataProviderPriorityKey = 'metadata_provider_priority';
|
||||
const _providerPriorityKey = 'provider_priority';
|
||||
const _spotifyWebExtensionId = 'spotify-web';
|
||||
|
||||
class Extension {
|
||||
final String id;
|
||||
@@ -27,12 +32,14 @@ class Extension {
|
||||
final bool hasMetadataProvider;
|
||||
final bool hasDownloadProvider;
|
||||
final bool hasLyricsProvider;
|
||||
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||
final bool
|
||||
skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||
final SearchBehavior? searchBehavior;
|
||||
final URLHandler? urlHandler;
|
||||
final TrackMatching? trackMatching;
|
||||
final PostProcessing? postProcessing;
|
||||
final Map<String, dynamic> capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||
final Map<String, dynamic>
|
||||
capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||
|
||||
const Extension({
|
||||
required this.id,
|
||||
@@ -63,7 +70,8 @@ class Extension {
|
||||
return Extension(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
displayName: json['display_name'] as String? ?? 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? ?? '',
|
||||
@@ -71,28 +79,40 @@ class Extension {
|
||||
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() ?? [],
|
||||
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,
|
||||
hasLyricsProvider: json['has_lyrics_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>)
|
||||
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>)
|
||||
? TrackMatching.fromJson(
|
||||
json['track_matching'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
postProcessing: json['post_processing'] != null
|
||||
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
|
||||
? PostProcessing.fromJson(
|
||||
json['post_processing'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
capabilities: (json['capabilities'] as Map<String, dynamic>?) ?? const {},
|
||||
);
|
||||
@@ -139,7 +159,8 @@ class Extension {
|
||||
hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider,
|
||||
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
|
||||
hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider,
|
||||
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
||||
skipMetadataEnrichment:
|
||||
skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
||||
searchBehavior: searchBehavior ?? this.searchBehavior,
|
||||
urlHandler: urlHandler ?? this.urlHandler,
|
||||
trackMatching: trackMatching ?? this.trackMatching,
|
||||
@@ -161,11 +182,7 @@ class SearchFilter {
|
||||
final String? label;
|
||||
final String? icon;
|
||||
|
||||
const SearchFilter({
|
||||
required this.id,
|
||||
this.label,
|
||||
this.icon,
|
||||
});
|
||||
const SearchFilter({required this.id, this.label, this.icon});
|
||||
|
||||
factory SearchFilter.fromJson(Map<String, dynamic> json) {
|
||||
return SearchFilter(
|
||||
@@ -181,10 +198,12 @@ class SearchBehavior {
|
||||
final String? placeholder;
|
||||
final bool primary;
|
||||
final String? icon;
|
||||
final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||
final String?
|
||||
thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||
final int? thumbnailWidth;
|
||||
final int? thumbnailHeight;
|
||||
final List<SearchFilter> filters; // Available search filters (e.g., track, album, artist, playlist)
|
||||
final List<SearchFilter>
|
||||
filters; // Available search filters (e.g., track, album, artist, playlist)
|
||||
|
||||
const SearchBehavior({
|
||||
required this.enabled,
|
||||
@@ -206,9 +225,11 @@ class SearchBehavior {
|
||||
thumbnailRatio: json['thumbnailRatio'] as String?,
|
||||
thumbnailWidth: json['thumbnailWidth'] as int?,
|
||||
thumbnailHeight: json['thumbnailHeight'] as int?,
|
||||
filters: (json['filters'] as List<dynamic>?)
|
||||
?.map((f) => SearchFilter.fromJson(f as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
filters:
|
||||
(json['filters'] as List<dynamic>?)
|
||||
?.map((f) => SearchFilter.fromJson(f as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -216,7 +237,7 @@ class SearchBehavior {
|
||||
if (thumbnailWidth != null && thumbnailHeight != null) {
|
||||
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
|
||||
}
|
||||
|
||||
|
||||
switch (thumbnailRatio) {
|
||||
case 'wide': // 16:9 - YouTube style
|
||||
return (defaultSize * 16 / 9, defaultSize);
|
||||
@@ -253,17 +274,18 @@ class PostProcessing {
|
||||
final bool enabled;
|
||||
final List<PostProcessingHook> hooks;
|
||||
|
||||
const PostProcessing({
|
||||
required this.enabled,
|
||||
this.hooks = const [],
|
||||
});
|
||||
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() ?? [],
|
||||
hooks:
|
||||
(json['hooks'] as List<dynamic>?)
|
||||
?.map(
|
||||
(h) => PostProcessingHook.fromJson(h as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -273,10 +295,7 @@ class URLHandler {
|
||||
final bool enabled;
|
||||
final List<String> patterns;
|
||||
|
||||
const URLHandler({
|
||||
required this.enabled,
|
||||
this.patterns = const [],
|
||||
});
|
||||
const URLHandler({required this.enabled, this.patterns = const []});
|
||||
|
||||
factory URLHandler.fromJson(Map<String, dynamic> json) {
|
||||
return URLHandler(
|
||||
@@ -319,7 +338,8 @@ class PostProcessingHook {
|
||||
name: json['name'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
defaultEnabled: json['defaultEnabled'] as bool? ?? false,
|
||||
supportedFormats: (json['supportedFormats'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
supportedFormats:
|
||||
(json['supportedFormats'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -342,9 +362,14 @@ class 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() ?? [],
|
||||
settings:
|
||||
(json['settings'] as List<dynamic>?)
|
||||
?.map(
|
||||
(s) =>
|
||||
QualitySpecificSetting.fromJson(s as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -447,7 +472,8 @@ class ExtensionState {
|
||||
return ExtensionState(
|
||||
extensions: extensions ?? this.extensions,
|
||||
providerPriority: providerPriority ?? this.providerPriority,
|
||||
metadataProviderPriority: metadataProviderPriority ?? this.metadataProviderPriority,
|
||||
metadataProviderPriority:
|
||||
metadataProviderPriority ?? this.metadataProviderPriority,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
@@ -455,18 +481,44 @@ class ExtensionState {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
AppLifecycleListener? _appLifecycleListener;
|
||||
bool _cleanupInFlight = false;
|
||||
|
||||
@override
|
||||
ExtensionState build() {
|
||||
_appLifecycleListener ??= AppLifecycleListener(
|
||||
onDetach: _scheduleLifecycleCleanup,
|
||||
);
|
||||
ref.onDispose(() {
|
||||
_appLifecycleListener?.dispose();
|
||||
_appLifecycleListener = null;
|
||||
});
|
||||
return const ExtensionState();
|
||||
}
|
||||
|
||||
void _scheduleLifecycleCleanup() {
|
||||
if (_cleanupInFlight) return;
|
||||
_cleanupInFlight = true;
|
||||
unawaited(_cleanupExtensions(reason: 'lifecycle detach'));
|
||||
}
|
||||
|
||||
Future<void> _cleanupExtensions({required String reason}) async {
|
||||
try {
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
_log.d('Extensions cleaned up ($reason)');
|
||||
} catch (e) {
|
||||
_log.w('Extension cleanup failed ($reason): $e');
|
||||
} finally {
|
||||
_cleanupInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -482,7 +534,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
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');
|
||||
@@ -500,10 +552,12 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
final extensions = list.map((e) => Extension.fromJson(e)).toList();
|
||||
state = state.copyWith(extensions: extensions);
|
||||
_log.d('Loaded ${extensions.length} extensions');
|
||||
|
||||
|
||||
for (final ext in extensions) {
|
||||
if (ext.searchBehavior != null) {
|
||||
_log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}');
|
||||
_log.d(
|
||||
'Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -512,14 +566,13 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
|
||||
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']}');
|
||||
@@ -544,10 +597,12 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
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']}');
|
||||
_log.i(
|
||||
'Upgraded extension: ${result['display_name']} to v${result['version']}',
|
||||
);
|
||||
await refreshExtensions();
|
||||
state = state.copyWith(isLoading: false);
|
||||
return true;
|
||||
@@ -560,7 +615,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<bool> removeExtension(String extensionId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
|
||||
try {
|
||||
await PlatformBridge.removeExtension(extensionId);
|
||||
_log.i('Removed extension: $extensionId');
|
||||
@@ -574,35 +629,40 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||
_log.d('Set extension $extensionId enabled: $enabled');
|
||||
|
||||
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||
|
||||
|
||||
final ext = state.extensions
|
||||
.where((e) => e.id == extensionId)
|
||||
.firstOrNull;
|
||||
|
||||
final extensions = state.extensions.map((e) {
|
||||
if (e.id == extensionId) {
|
||||
return e.copyWith(enabled: enabled);
|
||||
}
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
|
||||
state = state.copyWith(extensions: extensions);
|
||||
|
||||
|
||||
if (!enabled && ext != null) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
|
||||
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');
|
||||
_log.d(
|
||||
'Cleared search provider and reset to Deezer because extension $extensionId was disabled',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
||||
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
|
||||
_log.d(
|
||||
'Reset default service to Tidal because extension $extensionId was disabled',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -611,6 +671,68 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> ensureSpotifyWebExtensionReady({
|
||||
bool setAsSearchProvider = true,
|
||||
}) async {
|
||||
try {
|
||||
await refreshExtensions();
|
||||
|
||||
var ext = state.extensions
|
||||
.where((e) => e.id == _spotifyWebExtensionId)
|
||||
.firstOrNull;
|
||||
|
||||
if (ext == null) {
|
||||
final cacheDir = await getTemporaryDirectory();
|
||||
await PlatformBridge.initExtensionStore(cacheDir.path);
|
||||
|
||||
final tempRoot = await getTemporaryDirectory();
|
||||
final installDir = await Directory(
|
||||
'${tempRoot.path}/spotiflac_bootstrap_spotify_web',
|
||||
).create(recursive: true);
|
||||
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(
|
||||
_spotifyWebExtensionId,
|
||||
installDir.path,
|
||||
);
|
||||
|
||||
final installed = await installExtension(downloadPath);
|
||||
if (!installed) {
|
||||
_log.w('Failed to install spotify-web extension from store');
|
||||
return false;
|
||||
}
|
||||
|
||||
await refreshExtensions();
|
||||
ext = state.extensions
|
||||
.where((e) => e.id == _spotifyWebExtensionId)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
if (ext == null) {
|
||||
_log.w('spotify-web extension is still not available after install');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ext.enabled) {
|
||||
await setExtensionEnabled(_spotifyWebExtensionId, true);
|
||||
}
|
||||
|
||||
if (setAsSearchProvider) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.searchProvider != _spotifyWebExtensionId) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider(_spotifyWebExtensionId);
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('spotify-web extension is ready');
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.w('Failed to ensure spotify-web extension is ready: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
|
||||
try {
|
||||
return await PlatformBridge.getExtensionSettings(extensionId);
|
||||
@@ -620,7 +742,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
|
||||
Future<void> setExtensionSettings(
|
||||
String extensionId,
|
||||
Map<String, dynamic> settings,
|
||||
) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionSettings(extensionId, settings);
|
||||
_log.d('Updated settings for extension: $extensionId');
|
||||
@@ -635,49 +760,72 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
// Load from SharedPreferences first (persisted)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedJson = prefs.getString(_providerPriorityKey);
|
||||
|
||||
|
||||
List<String> priority;
|
||||
if (savedJson != null) {
|
||||
final saved = jsonDecode(savedJson) as List<dynamic>;
|
||||
priority = saved.map((e) => e as String).toList();
|
||||
priority = _sanitizeDownloadProviderPriority(priority);
|
||||
_log.d('Loaded provider priority from prefs: $priority');
|
||||
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
} else {
|
||||
// Fallback to Go backend default
|
||||
priority = await PlatformBridge.getProviderPriority();
|
||||
priority = _sanitizeDownloadProviderPriority(priority);
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
_log.d('Using default provider priority: $priority');
|
||||
}
|
||||
|
||||
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load provider priority: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> setProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
final sanitized = _sanitizeDownloadProviderPriority(priority);
|
||||
// Save to SharedPreferences for persistence
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
|
||||
|
||||
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
|
||||
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
_log.d('Saved provider priority: $priority');
|
||||
await PlatformBridge.setProviderPriority(sanitized);
|
||||
state = state.copyWith(providerPriority: sanitized);
|
||||
_log.d('Saved provider priority: $sanitized');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set provider priority: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _sanitizeDownloadProviderPriority(List<String> input) {
|
||||
final allowed = getAllDownloadProviders().toSet();
|
||||
final result = <String>[];
|
||||
|
||||
for (final provider in input) {
|
||||
if (allowed.contains(provider) && !result.contains(provider)) {
|
||||
result.add(provider);
|
||||
}
|
||||
}
|
||||
|
||||
for (final provider in const ['tidal', 'qobuz', 'amazon', 'deezer']) {
|
||||
if (!result.contains(provider)) {
|
||||
result.add(provider);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> loadMetadataProviderPriority() async {
|
||||
try {
|
||||
// Load from SharedPreferences first (persisted)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedJson = prefs.getString(_metadataProviderPriorityKey);
|
||||
|
||||
|
||||
List<String> priority;
|
||||
if (savedJson != null) {
|
||||
final saved = jsonDecode(savedJson) as List<dynamic>;
|
||||
@@ -690,7 +838,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
priority = await PlatformBridge.getMetadataProviderPriority();
|
||||
_log.d('Using default metadata provider priority: $priority');
|
||||
}
|
||||
|
||||
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load metadata provider priority: $e');
|
||||
@@ -702,7 +850,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
// Save to SharedPreferences for persistence
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority));
|
||||
|
||||
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
@@ -714,12 +862,9 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
Future<void> cleanup() async {
|
||||
try {
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
_log.d('Extensions cleaned up');
|
||||
} catch (e) {
|
||||
_log.e('Failed to cleanup extensions: $e');
|
||||
}
|
||||
if (_cleanupInFlight) return;
|
||||
_cleanupInFlight = true;
|
||||
await _cleanupExtensions(reason: 'manual');
|
||||
}
|
||||
|
||||
Extension? getExtension(String extensionId) {
|
||||
@@ -735,7 +880,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> getAllDownloadProviders() {
|
||||
final providers = ['tidal', 'qobuz', 'amazon'];
|
||||
final providers = ['tidal', 'qobuz', 'amazon', 'deezer'];
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasDownloadProvider) {
|
||||
providers.add(ext.id);
|
||||
@@ -755,7 +900,9 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
List<Extension> get searchProviders {
|
||||
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
|
||||
return state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_database.dart';
|
||||
|
||||
String trackCollectionKey(Track track) {
|
||||
final isrc = track.isrc?.trim();
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
return 'isrc:${isrc.toUpperCase()}';
|
||||
}
|
||||
final source = (track.source?.trim().isNotEmpty ?? false)
|
||||
? track.source!.trim()
|
||||
: 'builtin';
|
||||
return '$source:${track.id}';
|
||||
}
|
||||
|
||||
class CollectionTrackEntry {
|
||||
final String key;
|
||||
final Track track;
|
||||
final DateTime addedAt;
|
||||
|
||||
const CollectionTrackEntry({
|
||||
required this.key,
|
||||
required this.track,
|
||||
required this.addedAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'key': key,
|
||||
'track': track.toJson(),
|
||||
'addedAt': addedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory CollectionTrackEntry.fromJson(Map<String, dynamic> json) {
|
||||
final addedAtRaw = json['addedAt'] as String?;
|
||||
return CollectionTrackEntry(
|
||||
key: json['key'] as String,
|
||||
track: Track.fromJson(Map<String, dynamic>.from(json['track'] as Map)),
|
||||
addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserPlaylistCollection {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? coverImagePath;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final List<CollectionTrackEntry> tracks;
|
||||
final Set<String> _trackKeys;
|
||||
|
||||
UserPlaylistCollection({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.coverImagePath,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.tracks,
|
||||
Set<String>? trackKeys,
|
||||
}) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet();
|
||||
|
||||
UserPlaylistCollection copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? Function()? coverImagePath,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
List<CollectionTrackEntry>? tracks,
|
||||
}) {
|
||||
final nextTracks = tracks ?? this.tracks;
|
||||
final keepTrackIndex = identical(nextTracks, this.tracks);
|
||||
return UserPlaylistCollection(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
coverImagePath: coverImagePath != null
|
||||
? coverImagePath()
|
||||
: this.coverImagePath,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
tracks: nextTracks,
|
||||
trackKeys: keepTrackIndex ? _trackKeys : null,
|
||||
);
|
||||
}
|
||||
|
||||
bool containsTrack(Track track) {
|
||||
final key = trackCollectionKey(track);
|
||||
return _trackKeys.contains(key);
|
||||
}
|
||||
|
||||
bool containsTrackKey(String trackKey) {
|
||||
return _trackKeys.contains(trackKey);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
if (coverImagePath != null) 'coverImagePath': coverImagePath,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
'tracks': tracks.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory UserPlaylistCollection.fromJson(Map<String, dynamic> json) {
|
||||
final createdAtRaw = json['createdAt'] as String?;
|
||||
final updatedAtRaw = json['updatedAt'] as String?;
|
||||
final createdAt = DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now();
|
||||
final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt;
|
||||
final tracksRaw = (json['tracks'] as List?) ?? const [];
|
||||
return UserPlaylistCollection(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String? ?? '',
|
||||
coverImagePath: json['coverImagePath'] as String?,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
tracks: tracksRaw
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LibraryCollectionsState {
|
||||
final List<CollectionTrackEntry> wishlist;
|
||||
final List<CollectionTrackEntry> loved;
|
||||
final List<UserPlaylistCollection> playlists;
|
||||
final bool isLoaded;
|
||||
final Set<String> _wishlistKeys;
|
||||
final Set<String> _lovedKeys;
|
||||
final Map<String, UserPlaylistCollection> _playlistsById;
|
||||
final Set<String> _allPlaylistTrackKeys;
|
||||
|
||||
LibraryCollectionsState({
|
||||
this.wishlist = const [],
|
||||
this.loved = const [],
|
||||
this.playlists = const [],
|
||||
this.isLoaded = false,
|
||||
Set<String>? wishlistKeys,
|
||||
Set<String>? lovedKeys,
|
||||
Map<String, UserPlaylistCollection>? playlistsById,
|
||||
Set<String>? allPlaylistTrackKeys,
|
||||
}) : _wishlistKeys =
|
||||
wishlistKeys ?? wishlist.map((entry) => entry.key).toSet(),
|
||||
_lovedKeys = lovedKeys ?? loved.map((entry) => entry.key).toSet(),
|
||||
_playlistsById =
|
||||
playlistsById ??
|
||||
Map.fromEntries(
|
||||
playlists.map((playlist) => MapEntry(playlist.id, playlist)),
|
||||
),
|
||||
_allPlaylistTrackKeys =
|
||||
allPlaylistTrackKeys ?? _buildPlaylistTrackKeys(playlists);
|
||||
|
||||
int get wishlistCount => wishlist.length;
|
||||
int get lovedCount => loved.length;
|
||||
int get playlistCount => playlists.length;
|
||||
|
||||
bool isInWishlist(Track track) {
|
||||
final key = trackCollectionKey(track);
|
||||
return _wishlistKeys.contains(key);
|
||||
}
|
||||
|
||||
bool isLoved(Track track) {
|
||||
final key = trackCollectionKey(track);
|
||||
return _lovedKeys.contains(key);
|
||||
}
|
||||
|
||||
bool containsWishlistKey(String trackKey) {
|
||||
return _wishlistKeys.contains(trackKey);
|
||||
}
|
||||
|
||||
bool containsLovedKey(String trackKey) {
|
||||
return _lovedKeys.contains(trackKey);
|
||||
}
|
||||
|
||||
UserPlaylistCollection? playlistById(String playlistId) {
|
||||
return _playlistsById[playlistId];
|
||||
}
|
||||
|
||||
bool playlistContainsTrack(String playlistId, String trackKey) {
|
||||
final playlist = _playlistsById[playlistId];
|
||||
if (playlist == null) return false;
|
||||
return playlist.containsTrackKey(trackKey);
|
||||
}
|
||||
|
||||
bool isTrackInAnyPlaylist(String trackKey) {
|
||||
return _allPlaylistTrackKeys.contains(trackKey);
|
||||
}
|
||||
|
||||
bool get hasPlaylistTracks => _allPlaylistTrackKeys.isNotEmpty;
|
||||
|
||||
LibraryCollectionsState copyWith({
|
||||
List<CollectionTrackEntry>? wishlist,
|
||||
List<CollectionTrackEntry>? loved,
|
||||
List<UserPlaylistCollection>? playlists,
|
||||
bool? isLoaded,
|
||||
}) {
|
||||
final nextWishlist = wishlist ?? this.wishlist;
|
||||
final nextLoved = loved ?? this.loved;
|
||||
final nextPlaylists = playlists ?? this.playlists;
|
||||
final keepWishlistIndex = identical(nextWishlist, this.wishlist);
|
||||
final keepLovedIndex = identical(nextLoved, this.loved);
|
||||
final keepPlaylistIndex = identical(nextPlaylists, this.playlists);
|
||||
|
||||
return LibraryCollectionsState(
|
||||
wishlist: nextWishlist,
|
||||
loved: nextLoved,
|
||||
playlists: nextPlaylists,
|
||||
isLoaded: isLoaded ?? this.isLoaded,
|
||||
wishlistKeys: keepWishlistIndex ? _wishlistKeys : null,
|
||||
lovedKeys: keepLovedIndex ? _lovedKeys : null,
|
||||
playlistsById: keepPlaylistIndex ? _playlistsById : null,
|
||||
allPlaylistTrackKeys: keepPlaylistIndex ? _allPlaylistTrackKeys : null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'wishlist': wishlist.map((e) => e.toJson()).toList(),
|
||||
'loved': loved.map((e) => e.toJson()).toList(),
|
||||
'playlists': playlists.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory LibraryCollectionsState.fromJson(Map<String, dynamic> json) {
|
||||
final wishlistRaw = (json['wishlist'] as List?) ?? const [];
|
||||
final lovedRaw = (json['loved'] as List?) ?? const [];
|
||||
final playlistsRaw = (json['playlists'] as List?) ?? const [];
|
||||
|
||||
return LibraryCollectionsState(
|
||||
wishlist: wishlistRaw
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
||||
)
|
||||
.toList(growable: false),
|
||||
loved: lovedRaw
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
||||
)
|
||||
.toList(growable: false),
|
||||
playlists: playlistsRaw
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(e) =>
|
||||
UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)),
|
||||
)
|
||||
.toList(growable: false),
|
||||
isLoaded: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> _buildPlaylistTrackKeys(List<UserPlaylistCollection> playlists) {
|
||||
final keys = <String>{};
|
||||
for (final playlist in playlists) {
|
||||
for (final entry in playlist.tracks) {
|
||||
keys.add(entry.key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
class PlaylistAddBatchResult {
|
||||
final int addedCount;
|
||||
final int alreadyInPlaylistCount;
|
||||
|
||||
const PlaylistAddBatchResult({
|
||||
required this.addedCount,
|
||||
required this.alreadyInPlaylistCount,
|
||||
});
|
||||
}
|
||||
|
||||
class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance;
|
||||
Future<void>? _loadFuture;
|
||||
|
||||
@override
|
||||
LibraryCollectionsState build() {
|
||||
_loadFuture = _load();
|
||||
return LibraryCollectionsState();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
await _db.migrateFromSharedPreferences();
|
||||
final snapshot = await _db.loadSnapshot();
|
||||
|
||||
final wishlist = <CollectionTrackEntry>[];
|
||||
for (final row in snapshot.wishlistRows) {
|
||||
final parsed = _parseTrackEntryRow(row);
|
||||
if (parsed != null) {
|
||||
wishlist.add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
final loved = <CollectionTrackEntry>[];
|
||||
for (final row in snapshot.lovedRows) {
|
||||
final parsed = _parseTrackEntryRow(row);
|
||||
if (parsed != null) {
|
||||
loved.add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
final tracksByPlaylist = <String, List<CollectionTrackEntry>>{};
|
||||
for (final row in snapshot.playlistTrackRows) {
|
||||
final playlistId = row['playlist_id'] as String?;
|
||||
if (playlistId == null || playlistId.isEmpty) continue;
|
||||
final parsed = _parseTrackEntryRow(row);
|
||||
if (parsed == null) continue;
|
||||
tracksByPlaylist.putIfAbsent(playlistId, () => []).add(parsed);
|
||||
}
|
||||
|
||||
final playlists = <UserPlaylistCollection>[];
|
||||
for (final row in snapshot.playlistRows) {
|
||||
final id = row['id'] as String?;
|
||||
if (id == null || id.isEmpty) continue;
|
||||
|
||||
final createdAtRaw = row['created_at'] as String?;
|
||||
final updatedAtRaw = row['updated_at'] as String?;
|
||||
final createdAt =
|
||||
DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now();
|
||||
final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt;
|
||||
|
||||
playlists.add(
|
||||
UserPlaylistCollection(
|
||||
id: id,
|
||||
name: row['name'] as String? ?? '',
|
||||
coverImagePath: row['cover_image_path'] as String?,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
tracks: tracksByPlaylist[id] ?? const <CollectionTrackEntry>[],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
state = LibraryCollectionsState(
|
||||
wishlist: wishlist,
|
||||
loved: loved,
|
||||
playlists: playlists,
|
||||
isLoaded: true,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoaded: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureLoaded() async {
|
||||
if (state.isLoaded) return;
|
||||
await (_loadFuture ?? _load());
|
||||
}
|
||||
|
||||
CollectionTrackEntry? _parseTrackEntryRow(Map<String, dynamic> row) {
|
||||
final key = row['track_key'] as String?;
|
||||
final trackJson = row['track_json'] as String?;
|
||||
if (key == null || key.isEmpty || trackJson == null || trackJson.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(trackJson);
|
||||
if (decoded is! Map) return null;
|
||||
final track = Track.fromJson(Map<String, dynamic>.from(decoded));
|
||||
final addedAtRaw = row['added_at'] as String?;
|
||||
return CollectionTrackEntry(
|
||||
key: key,
|
||||
track: track,
|
||||
addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _replacePlaylistById(
|
||||
String playlistId,
|
||||
UserPlaylistCollection Function(UserPlaylistCollection playlist) update,
|
||||
) {
|
||||
final playlist = state.playlistById(playlistId);
|
||||
if (playlist == null) return false;
|
||||
|
||||
final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId);
|
||||
if (playlistIndex < 0) return false;
|
||||
|
||||
final nextPlaylist = update(playlist);
|
||||
if (identical(nextPlaylist, playlist)) return false;
|
||||
|
||||
final updatedPlaylists = [...state.playlists];
|
||||
updatedPlaylists[playlistIndex] = nextPlaylist;
|
||||
state = state.copyWith(playlists: updatedPlaylists);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> toggleWishlist(Track track) async {
|
||||
await _ensureLoaded();
|
||||
final key = trackCollectionKey(track);
|
||||
if (state.containsWishlistKey(key)) {
|
||||
await _db.deleteWishlistEntry(key);
|
||||
final updated = state.wishlist
|
||||
.where((entry) => entry.key != key)
|
||||
.toList(growable: false);
|
||||
state = state.copyWith(wishlist: updated);
|
||||
return false;
|
||||
}
|
||||
|
||||
final entry = CollectionTrackEntry(
|
||||
key: key,
|
||||
track: track,
|
||||
addedAt: DateTime.now(),
|
||||
);
|
||||
await _db.upsertWishlistEntry(
|
||||
trackKey: key,
|
||||
trackJson: jsonEncode(track.toJson()),
|
||||
addedAt: entry.addedAt.toIso8601String(),
|
||||
);
|
||||
final updated = [entry, ...state.wishlist];
|
||||
state = state.copyWith(wishlist: updated);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> toggleLoved(Track track) async {
|
||||
await _ensureLoaded();
|
||||
final key = trackCollectionKey(track);
|
||||
if (state.containsLovedKey(key)) {
|
||||
await _db.deleteLovedEntry(key);
|
||||
final updated = state.loved
|
||||
.where((entry) => entry.key != key)
|
||||
.toList(growable: false);
|
||||
state = state.copyWith(loved: updated);
|
||||
return false;
|
||||
}
|
||||
|
||||
final entry = CollectionTrackEntry(
|
||||
key: key,
|
||||
track: track,
|
||||
addedAt: DateTime.now(),
|
||||
);
|
||||
await _db.upsertLovedEntry(
|
||||
trackKey: key,
|
||||
trackJson: jsonEncode(track.toJson()),
|
||||
addedAt: entry.addedAt.toIso8601String(),
|
||||
);
|
||||
final updated = [entry, ...state.loved];
|
||||
state = state.copyWith(loved: updated);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> removeFromWishlist(String trackKey) async {
|
||||
await _ensureLoaded();
|
||||
if (!state.containsWishlistKey(trackKey)) return;
|
||||
|
||||
await _db.deleteWishlistEntry(trackKey);
|
||||
final updated = state.wishlist
|
||||
.where((entry) => entry.key != trackKey)
|
||||
.toList(growable: false);
|
||||
state = state.copyWith(wishlist: updated);
|
||||
}
|
||||
|
||||
Future<void> removeFromLoved(String trackKey) async {
|
||||
await _ensureLoaded();
|
||||
if (!state.containsLovedKey(trackKey)) return;
|
||||
|
||||
await _db.deleteLovedEntry(trackKey);
|
||||
final updated = state.loved
|
||||
.where((entry) => entry.key != trackKey)
|
||||
.toList(growable: false);
|
||||
state = state.copyWith(loved: updated);
|
||||
}
|
||||
|
||||
Future<String> createPlaylist(String name) async {
|
||||
await _ensureLoaded();
|
||||
final now = DateTime.now();
|
||||
final id = 'pl_${now.microsecondsSinceEpoch}';
|
||||
final trimmedName = name.trim();
|
||||
|
||||
final playlist = UserPlaylistCollection(
|
||||
id: id,
|
||||
name: trimmedName,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
tracks: const [],
|
||||
);
|
||||
|
||||
await _db.upsertPlaylist(
|
||||
id: id,
|
||||
name: trimmedName,
|
||||
coverImagePath: null,
|
||||
createdAt: now.toIso8601String(),
|
||||
updatedAt: now.toIso8601String(),
|
||||
);
|
||||
state = state.copyWith(playlists: [playlist, ...state.playlists]);
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<void> renamePlaylist(String playlistId, String newName) async {
|
||||
await _ensureLoaded();
|
||||
final trimmed = newName.trim();
|
||||
if (trimmed.isEmpty) return;
|
||||
final playlist = state.playlistById(playlistId);
|
||||
if (playlist == null || playlist.name == trimmed) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
await _db.renamePlaylist(
|
||||
playlistId: playlistId,
|
||||
name: trimmed,
|
||||
updatedAt: now.toIso8601String(),
|
||||
);
|
||||
_replacePlaylistById(playlistId, (playlist) {
|
||||
return playlist.copyWith(name: trimmed, updatedAt: now);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deletePlaylist(String playlistId) async {
|
||||
await _ensureLoaded();
|
||||
final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId);
|
||||
if (playlistIndex < 0) return;
|
||||
|
||||
await _db.deletePlaylist(playlistId);
|
||||
final updatedPlaylists = [...state.playlists]..removeAt(playlistIndex);
|
||||
state = state.copyWith(playlists: updatedPlaylists);
|
||||
}
|
||||
|
||||
Future<bool> addTrackToPlaylist(String playlistId, Track track) async {
|
||||
await _ensureLoaded();
|
||||
final playlist = state.playlistById(playlistId);
|
||||
if (playlist == null) return false;
|
||||
|
||||
final key = trackCollectionKey(track);
|
||||
if (playlist.containsTrackKey(key)) return false;
|
||||
|
||||
final now = DateTime.now();
|
||||
final entry = CollectionTrackEntry(key: key, track: track, addedAt: now);
|
||||
await _db.upsertPlaylistTrack(
|
||||
playlistId: playlistId,
|
||||
trackKey: key,
|
||||
trackJson: jsonEncode(track.toJson()),
|
||||
addedAt: entry.addedAt.toIso8601String(),
|
||||
playlistUpdatedAt: now.toIso8601String(),
|
||||
);
|
||||
final changed = _replacePlaylistById(playlistId, (playlist) {
|
||||
if (playlist.containsTrackKey(key)) return playlist;
|
||||
return playlist.copyWith(
|
||||
tracks: [entry, ...playlist.tracks],
|
||||
updatedAt: now,
|
||||
);
|
||||
});
|
||||
if (!changed) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<PlaylistAddBatchResult> addTracksToPlaylist(
|
||||
String playlistId,
|
||||
Iterable<Track> tracks,
|
||||
) async {
|
||||
await _ensureLoaded();
|
||||
final playlist = state.playlistById(playlistId);
|
||||
if (playlist == null) {
|
||||
return const PlaylistAddBatchResult(
|
||||
addedCount: 0,
|
||||
alreadyInPlaylistCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final knownKeys = <String>{...playlist._trackKeys};
|
||||
final entriesToAdd = <CollectionTrackEntry>[];
|
||||
var alreadyInPlaylistCount = 0;
|
||||
|
||||
for (final track in tracks) {
|
||||
final key = trackCollectionKey(track);
|
||||
if (!knownKeys.add(key)) {
|
||||
alreadyInPlaylistCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
entriesToAdd.add(
|
||||
CollectionTrackEntry(key: key, track: track, addedAt: now),
|
||||
);
|
||||
}
|
||||
|
||||
if (entriesToAdd.isEmpty) {
|
||||
return PlaylistAddBatchResult(
|
||||
addedCount: 0,
|
||||
alreadyInPlaylistCount: alreadyInPlaylistCount,
|
||||
);
|
||||
}
|
||||
|
||||
await _db.upsertPlaylistTracksBatch(
|
||||
playlistId: playlistId,
|
||||
playlistUpdatedAt: now.toIso8601String(),
|
||||
tracks: entriesToAdd
|
||||
.map(
|
||||
(entry) => <String, String>{
|
||||
'track_key': entry.key,
|
||||
'track_json': jsonEncode(entry.track.toJson()),
|
||||
'added_at': entry.addedAt.toIso8601String(),
|
||||
},
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
final changed = _replacePlaylistById(playlistId, (current) {
|
||||
return current.copyWith(
|
||||
tracks: [...entriesToAdd.reversed, ...current.tracks],
|
||||
updatedAt: now,
|
||||
);
|
||||
});
|
||||
if (!changed) {
|
||||
return PlaylistAddBatchResult(
|
||||
addedCount: 0,
|
||||
alreadyInPlaylistCount: alreadyInPlaylistCount,
|
||||
);
|
||||
}
|
||||
return PlaylistAddBatchResult(
|
||||
addedCount: entriesToAdd.length,
|
||||
alreadyInPlaylistCount: alreadyInPlaylistCount,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeTrackFromPlaylist(
|
||||
String playlistId,
|
||||
String trackKey,
|
||||
) async {
|
||||
await _ensureLoaded();
|
||||
final playlist = state.playlistById(playlistId);
|
||||
if (playlist == null || !playlist.containsTrackKey(trackKey)) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
await _db.deletePlaylistTrack(
|
||||
playlistId: playlistId,
|
||||
trackKey: trackKey,
|
||||
playlistUpdatedAt: now.toIso8601String(),
|
||||
);
|
||||
_replacePlaylistById(playlistId, (playlist) {
|
||||
final nextTracks = playlist.tracks
|
||||
.where((entry) => entry.key != trackKey)
|
||||
.toList(growable: false);
|
||||
if (nextTracks.length == playlist.tracks.length) return playlist;
|
||||
return playlist.copyWith(tracks: nextTracks, updatedAt: now);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Directory> _playlistCoversDir() async {
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
final dir = Directory(p.join(appDir.path, 'playlist_covers'));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
Future<void> setPlaylistCover(
|
||||
String playlistId,
|
||||
String sourceFilePath,
|
||||
) async {
|
||||
await _ensureLoaded();
|
||||
final playlist = state.playlistById(playlistId);
|
||||
if (playlist == null) return;
|
||||
|
||||
final coversDir = await _playlistCoversDir();
|
||||
final ext = p.extension(sourceFilePath).toLowerCase();
|
||||
final destPath = p.join(coversDir.path, '$playlistId$ext');
|
||||
if (playlist.coverImagePath == destPath) return;
|
||||
|
||||
// Copy image to persistent location
|
||||
await File(sourceFilePath).copy(destPath);
|
||||
|
||||
final now = DateTime.now();
|
||||
await _db.updatePlaylistCover(
|
||||
playlistId: playlistId,
|
||||
coverImagePath: destPath,
|
||||
updatedAt: now.toIso8601String(),
|
||||
);
|
||||
_replacePlaylistById(playlistId, (playlist) {
|
||||
if (playlist.coverImagePath == destPath) return playlist;
|
||||
return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> removePlaylistCover(String playlistId) async {
|
||||
await _ensureLoaded();
|
||||
final playlist = state.playlistById(playlistId);
|
||||
if (playlist == null || playlist.coverImagePath == null) return;
|
||||
|
||||
// Delete the file if it exists
|
||||
final path = playlist.coverImagePath;
|
||||
if (path != null) {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
await _db.updatePlaylistCover(
|
||||
playlistId: playlistId,
|
||||
coverImagePath: null,
|
||||
updatedAt: now.toIso8601String(),
|
||||
);
|
||||
_replacePlaylistById(playlistId, (playlist) {
|
||||
if (playlist.coverImagePath == null) return playlist;
|
||||
return playlist.copyWith(coverImagePath: () => null, updatedAt: now);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final libraryCollectionsProvider =
|
||||
NotifierProvider<LibraryCollectionsNotifier, LibraryCollectionsState>(
|
||||
LibraryCollectionsNotifier.new,
|
||||
);
|
||||
@@ -121,15 +121,25 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
static const _progressPollingInterval = Duration(milliseconds: 800);
|
||||
Timer? _progressTimer;
|
||||
Timer? _progressStreamBootstrapTimer;
|
||||
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
|
||||
bool _isLoaded = false;
|
||||
bool _scanCancelRequested = false;
|
||||
int _progressPollingErrorCount = 0;
|
||||
bool _isProgressPollingInFlight = false;
|
||||
bool _hasReceivedProgressStreamEvent = false;
|
||||
bool _usingProgressStream = false;
|
||||
static const _scanNotificationHeartbeat = Duration(seconds: 4);
|
||||
int _lastScanNotificationPercent = -1;
|
||||
int _lastScanNotificationTotalFiles = -1;
|
||||
DateTime _lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@override
|
||||
LocalLibraryState build() {
|
||||
ref.onDispose(() {
|
||||
_progressTimer?.cancel();
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamSub?.cancel();
|
||||
});
|
||||
|
||||
Future.microtask(() async {
|
||||
@@ -188,7 +198,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
if (raw.isEmpty) return const {};
|
||||
|
||||
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
|
||||
final keys = <String>{cleaned};
|
||||
final keys = <String>{};
|
||||
|
||||
void addNormalized(String value) {
|
||||
final trimmed = value.trim();
|
||||
@@ -207,18 +217,42 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
keys.add(decoded.toLowerCase());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Uri? parsed;
|
||||
try {
|
||||
parsed = Uri.parse(trimmed);
|
||||
} catch (_) {}
|
||||
|
||||
if (parsed != null && parsed.hasScheme) {
|
||||
final noQueryOrFragment = parsed.replace(query: null, fragment: null);
|
||||
keys.add(noQueryOrFragment.toString());
|
||||
keys.add(noQueryOrFragment.toString().toLowerCase());
|
||||
|
||||
if (parsed.scheme == 'file') {
|
||||
try {
|
||||
final fileOnly = parsed.toFilePath();
|
||||
if (fileOnly.isNotEmpty) {
|
||||
keys.add(fileOnly);
|
||||
keys.add(fileOnly.toLowerCase());
|
||||
if (fileOnly.contains('\\')) {
|
||||
final slash = fileOnly.replaceAll('\\', '/');
|
||||
keys.add(slash);
|
||||
keys.add(slash.toLowerCase());
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
} else if (trimmed.startsWith('/')) {
|
||||
try {
|
||||
final asFileUri = Uri.file(trimmed).toString();
|
||||
keys.add(asFileUri);
|
||||
keys.add(asFileUri.toLowerCase());
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
addNormalized(cleaned);
|
||||
|
||||
if (cleaned.startsWith('content://')) {
|
||||
try {
|
||||
final uri = Uri.parse(cleaned);
|
||||
addNormalized(uri.toString());
|
||||
addNormalized(uri.replace(query: null, fragment: null).toString());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
@@ -257,12 +291,19 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
scanErrorCount: 0,
|
||||
scanWasCancelled: false,
|
||||
);
|
||||
await _showScanProgressNotification(
|
||||
_resetScanNotificationTracking();
|
||||
if (_shouldShowScanProgressNotification(
|
||||
progress: 0,
|
||||
scannedFiles: 0,
|
||||
totalFiles: 0,
|
||||
currentFile: null,
|
||||
);
|
||||
isComplete: false,
|
||||
)) {
|
||||
await _showScanProgressNotification(
|
||||
progress: 0,
|
||||
scannedFiles: 0,
|
||||
totalFiles: 0,
|
||||
currentFile: null,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final appSupportDir = await getApplicationSupportDirectory();
|
||||
@@ -328,7 +369,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Skipped $skippedDownloads files already in download history');
|
||||
}
|
||||
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
// Full scan should replace library index entirely.
|
||||
await _db.clearAll();
|
||||
if (items.isNotEmpty) {
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
@@ -420,10 +465,24 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final currentByPath = <String, LocalLibraryItem>{
|
||||
for (final item in state.items) item.filePath: item,
|
||||
};
|
||||
final existingDownloadedPaths = <String>[];
|
||||
currentByPath.removeWhere((path, _) {
|
||||
final shouldExclude = _isDownloadedPath(path, downloadedPathKeys);
|
||||
if (shouldExclude) {
|
||||
existingDownloadedPaths.add(path);
|
||||
}
|
||||
return shouldExclude;
|
||||
});
|
||||
if (existingDownloadedPaths.isNotEmpty) {
|
||||
final removed = await _db.deleteByPaths(existingDownloadedPaths);
|
||||
_log.i(
|
||||
'Removed $removed downloaded tracks already present in local library index',
|
||||
);
|
||||
}
|
||||
|
||||
// Upsert new/modified items (excluding downloaded files)
|
||||
final updatedItems = <LocalLibraryItem>[];
|
||||
int skippedDownloads = 0;
|
||||
int skippedDownloads = existingDownloadedPaths.length;
|
||||
if (scannedList.isNotEmpty) {
|
||||
for (final json in scannedList) {
|
||||
final map = json as Map<String, dynamic>;
|
||||
@@ -499,49 +558,75 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
|
||||
void _startProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamBootstrapTimer = null;
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_hasReceivedProgressStreamEvent = false;
|
||||
_usingProgressStream = false;
|
||||
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
_progressStreamSub = PlatformBridge.libraryScanProgressStream().listen(
|
||||
(progress) async {
|
||||
_hasReceivedProgressStreamEvent = true;
|
||||
_usingProgressStream = true;
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamBootstrapTimer = null;
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
await _handleLibraryScanProgress(progress);
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Library scan progress stream processing failed: $e');
|
||||
}
|
||||
} finally {
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (_usingProgressStream) {
|
||||
_log.w(
|
||||
'Library scan progress stream failed, fallback to polling: $error',
|
||||
);
|
||||
}
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_usingProgressStream = false;
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamBootstrapTimer = null;
|
||||
_startProgressPollingTimer();
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
_progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (_hasReceivedProgressStreamEvent) {
|
||||
return;
|
||||
}
|
||||
_log.w('Library scan progress stream timeout, fallback to polling');
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_usingProgressStream = false;
|
||||
_startProgressPollingTimer();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_startProgressPollingTimer();
|
||||
}
|
||||
|
||||
void _startProgressPollingTimer() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||
final nextProgress =
|
||||
(progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
||||
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
||||
0.0,
|
||||
100.0,
|
||||
);
|
||||
final currentFile = progress['current_file'] as String?;
|
||||
final totalFiles = progress['total_files'] as int? ?? 0;
|
||||
final scannedFiles = progress['scanned_files'] as int? ?? 0;
|
||||
final errorCount = progress['error_count'] as int? ?? 0;
|
||||
|
||||
final shouldUpdateState =
|
||||
state.scanProgress != normalizedProgress ||
|
||||
state.scanCurrentFile != currentFile ||
|
||||
state.scanTotalFiles != totalFiles ||
|
||||
state.scannedFiles != scannedFiles ||
|
||||
state.scanErrorCount != errorCount;
|
||||
|
||||
if (shouldUpdateState) {
|
||||
state = state.copyWith(
|
||||
scanProgress: normalizedProgress,
|
||||
scanCurrentFile: currentFile,
|
||||
scanTotalFiles: totalFiles,
|
||||
scannedFiles: scannedFiles,
|
||||
scanErrorCount: errorCount,
|
||||
);
|
||||
await _showScanProgressNotification(
|
||||
progress: normalizedProgress,
|
||||
scannedFiles: scannedFiles,
|
||||
totalFiles: totalFiles,
|
||||
currentFile: currentFile,
|
||||
);
|
||||
}
|
||||
|
||||
if (progress['is_complete'] == true) {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
await _handleLibraryScanProgress(progress);
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
@@ -554,11 +639,93 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleLibraryScanProgress(Map<String, dynamic> progress) async {
|
||||
final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
||||
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
||||
0.0,
|
||||
100.0,
|
||||
);
|
||||
final currentFile = progress['current_file'] as String?;
|
||||
final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0;
|
||||
final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0;
|
||||
final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0;
|
||||
final isComplete = progress['is_complete'] == true;
|
||||
|
||||
final shouldUpdateState =
|
||||
state.scanProgress != normalizedProgress ||
|
||||
state.scanCurrentFile != currentFile ||
|
||||
state.scanTotalFiles != totalFiles ||
|
||||
state.scannedFiles != scannedFiles ||
|
||||
state.scanErrorCount != errorCount;
|
||||
|
||||
if (shouldUpdateState) {
|
||||
state = state.copyWith(
|
||||
scanProgress: normalizedProgress,
|
||||
scanCurrentFile: currentFile,
|
||||
scanTotalFiles: totalFiles,
|
||||
scannedFiles: scannedFiles,
|
||||
scanErrorCount: errorCount,
|
||||
);
|
||||
}
|
||||
|
||||
if (_shouldShowScanProgressNotification(
|
||||
progress: normalizedProgress,
|
||||
totalFiles: totalFiles,
|
||||
isComplete: isComplete,
|
||||
)) {
|
||||
await _showScanProgressNotification(
|
||||
progress: normalizedProgress,
|
||||
scannedFiles: scannedFiles,
|
||||
totalFiles: totalFiles,
|
||||
currentFile: currentFile,
|
||||
);
|
||||
}
|
||||
|
||||
if (isComplete) {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
}
|
||||
|
||||
void _stopProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamSub?.cancel();
|
||||
_progressTimer = null;
|
||||
_progressStreamBootstrapTimer = null;
|
||||
_progressStreamSub = null;
|
||||
_progressPollingErrorCount = 0;
|
||||
_isProgressPollingInFlight = false;
|
||||
_hasReceivedProgressStreamEvent = false;
|
||||
_usingProgressStream = false;
|
||||
_resetScanNotificationTracking();
|
||||
}
|
||||
|
||||
void _resetScanNotificationTracking() {
|
||||
_lastScanNotificationPercent = -1;
|
||||
_lastScanNotificationTotalFiles = -1;
|
||||
_lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
|
||||
bool _shouldShowScanProgressNotification({
|
||||
required double progress,
|
||||
required int totalFiles,
|
||||
required bool isComplete,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final percent = progress.round().clamp(0, 100);
|
||||
final percentChanged = percent != _lastScanNotificationPercent;
|
||||
final totalFilesChanged = totalFiles != _lastScanNotificationTotalFiles;
|
||||
final heartbeatDue =
|
||||
now.difference(_lastScanNotificationAt) >= _scanNotificationHeartbeat;
|
||||
|
||||
if (!percentChanged && !totalFilesChanged && !isComplete && !heartbeatDue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_lastScanNotificationPercent = percent;
|
||||
_lastScanNotificationTotalFiles = totalFiles;
|
||||
_lastScanNotificationAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> cancelScan() async {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('PlaybackProvider');
|
||||
|
||||
class PlaybackState {
|
||||
const PlaybackState();
|
||||
}
|
||||
|
||||
class PlaybackController extends Notifier<PlaybackState> {
|
||||
@override
|
||||
PlaybackState build() => const PlaybackState();
|
||||
|
||||
Future<void> playLocalPath({
|
||||
required String path,
|
||||
required String title,
|
||||
required String artist,
|
||||
String album = '',
|
||||
String coverUrl = '',
|
||||
Track? track,
|
||||
}) async {
|
||||
_log.d('Opening external player for "$title" by $artist: $path');
|
||||
await openFile(path);
|
||||
}
|
||||
|
||||
Future<void> playTrackList(List<Track> tracks, {int startIndex = 0}) async {
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
|
||||
for (final track in orderedTracks) {
|
||||
final resolvedPath = await _resolveTrackPath(track);
|
||||
if (resolvedPath == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
_log.d(
|
||||
'Opening first available external track for list playback: '
|
||||
'"${track.name}" by ${track.artistName} -> $resolvedPath',
|
||||
);
|
||||
await openFile(resolvedPath);
|
||||
return;
|
||||
}
|
||||
|
||||
throw Exception(
|
||||
'No local audio file is available to open. Download the track first.',
|
||||
);
|
||||
}
|
||||
|
||||
List<Track> _orderedTracksFromStartIndex(List<Track> tracks, int startIndex) {
|
||||
final safeStart = startIndex.clamp(0, tracks.length - 1);
|
||||
if (safeStart == 0) {
|
||||
return List<Track>.from(tracks, growable: false);
|
||||
}
|
||||
|
||||
return <Track>[
|
||||
...tracks.sublist(safeStart),
|
||||
...tracks.sublist(0, safeStart),
|
||||
];
|
||||
}
|
||||
|
||||
Future<String?> _resolveTrackPath(Track track) async {
|
||||
final localState = ref.read(localLibraryProvider);
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
|
||||
final localItem = _findLocalLibraryItemForTrack(track, localState);
|
||||
if (localItem != null && await fileExists(localItem.filePath)) {
|
||||
return localItem.filePath;
|
||||
}
|
||||
|
||||
final historyItem = _findDownloadHistoryItemForTrack(track, historyState);
|
||||
if (historyItem != null) {
|
||||
if (await fileExists(historyItem.filePath)) {
|
||||
return historyItem.filePath;
|
||||
}
|
||||
historyNotifier.removeFromHistory(historyItem.id);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
LocalLibraryItem? _findLocalLibraryItemForTrack(
|
||||
Track track,
|
||||
LocalLibraryState localState,
|
||||
) {
|
||||
final isLocalSource = (track.source ?? '').toLowerCase() == 'local';
|
||||
if (isLocalSource) {
|
||||
for (final item in localState.items) {
|
||||
if (item.id == track.id) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final isrc = track.isrc?.trim();
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = localState.getByIsrc(isrc);
|
||||
if (byIsrc != null) {
|
||||
return byIsrc;
|
||||
}
|
||||
}
|
||||
|
||||
return localState.findByTrackAndArtist(track.name, track.artistName);
|
||||
}
|
||||
|
||||
DownloadHistoryItem? _findDownloadHistoryItemForTrack(
|
||||
Track track,
|
||||
DownloadHistoryState historyState,
|
||||
) {
|
||||
for (final candidateId in _spotifyIdLookupCandidates(track.id)) {
|
||||
final bySpotifyId = historyState.getBySpotifyId(candidateId);
|
||||
if (bySpotifyId != null) {
|
||||
return bySpotifyId;
|
||||
}
|
||||
}
|
||||
|
||||
final isrc = track.isrc?.trim();
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = historyState.getByIsrc(isrc);
|
||||
if (byIsrc != null) {
|
||||
return byIsrc;
|
||||
}
|
||||
}
|
||||
|
||||
return historyState.findByTrackAndArtist(track.name, track.artistName);
|
||||
}
|
||||
|
||||
List<String> _spotifyIdLookupCandidates(String rawId) {
|
||||
final trimmed = rawId.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final candidates = <String>{trimmed};
|
||||
final lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith('spotify:track:')) {
|
||||
final compact = trimmed.split(':').last.trim();
|
||||
if (compact.isNotEmpty) {
|
||||
candidates.add(compact);
|
||||
}
|
||||
} else if (!trimmed.contains(':')) {
|
||||
candidates.add('spotify:track:$trimmed');
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
final segments = uri?.pathSegments ?? const <String>[];
|
||||
final trackIndex = segments.indexOf('track');
|
||||
if (trackIndex >= 0 && trackIndex + 1 < segments.length) {
|
||||
final pathId = segments[trackIndex + 1].trim();
|
||||
if (pathId.isNotEmpty) {
|
||||
candidates.add(pathId);
|
||||
candidates.add('spotify:track:$pathId');
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
final playbackProvider = NotifierProvider<PlaybackController, PlaybackState>(
|
||||
PlaybackController.new,
|
||||
);
|
||||
@@ -1,18 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/app_state_database.dart';
|
||||
|
||||
const _recentAccessKey = 'recent_access_history';
|
||||
const _hiddenDownloadsKey = 'hidden_downloads_in_recents';
|
||||
const _maxRecentItems = 20;
|
||||
|
||||
/// Types of items that can be accessed
|
||||
enum RecentAccessType {
|
||||
artist,
|
||||
album,
|
||||
track,
|
||||
playlist,
|
||||
}
|
||||
enum RecentAccessType { artist, album, track, playlist }
|
||||
|
||||
/// Represents a recently accessed item
|
||||
class RecentAccessItem {
|
||||
@@ -100,7 +94,7 @@ class RecentAccessState {
|
||||
|
||||
/// Provider for managing recent access history
|
||||
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
|
||||
|
||||
@override
|
||||
RecentAccessState build() {
|
||||
@@ -109,40 +103,36 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
final prefs = await _prefs;
|
||||
final json = prefs.getString(_recentAccessKey);
|
||||
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
|
||||
|
||||
List<RecentAccessItem> items = [];
|
||||
Set<String> hiddenIds = {};
|
||||
|
||||
if (json != null) {
|
||||
try {
|
||||
final List<dynamic> decoded = jsonDecode(json);
|
||||
items = decoded
|
||||
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
// Ignore JSON parse errors, use empty list
|
||||
try {
|
||||
await _appStateDb.migrateRecentAccessFromSharedPreferences();
|
||||
final rows = await _appStateDb.getRecentAccessRows(
|
||||
limit: _maxRecentItems,
|
||||
);
|
||||
final hiddenIds = await _appStateDb.getHiddenRecentDownloadIds();
|
||||
|
||||
final items = <RecentAccessItem>[];
|
||||
for (final row in rows) {
|
||||
final itemJson = row['item_json'] as String?;
|
||||
if (itemJson == null || itemJson.isEmpty) continue;
|
||||
try {
|
||||
final decoded = jsonDecode(itemJson);
|
||||
if (decoded is! Map) continue;
|
||||
items.add(
|
||||
RecentAccessItem.fromJson(Map<String, dynamic>.from(decoded)),
|
||||
);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hiddenJson != null) {
|
||||
hiddenIds = hiddenJson.toSet();
|
||||
}
|
||||
|
||||
state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true);
|
||||
}
|
||||
|
||||
Future<void> _saveHistory() async {
|
||||
final prefs = await _prefs;
|
||||
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
|
||||
await prefs.setString(_recentAccessKey, json);
|
||||
}
|
||||
|
||||
Future<void> _saveHiddenDownloads() async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
hiddenDownloadIds: hiddenIds,
|
||||
isLoaded: true,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoaded: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an access to an artist
|
||||
@@ -152,14 +142,16 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
String? imageUrl,
|
||||
String? providerId,
|
||||
}) {
|
||||
_recordAccess(RecentAccessItem(
|
||||
id: id,
|
||||
name: name,
|
||||
imageUrl: imageUrl,
|
||||
type: RecentAccessType.artist,
|
||||
accessedAt: DateTime.now(),
|
||||
providerId: providerId,
|
||||
));
|
||||
_recordAccess(
|
||||
RecentAccessItem(
|
||||
id: id,
|
||||
name: name,
|
||||
imageUrl: imageUrl,
|
||||
type: RecentAccessType.artist,
|
||||
accessedAt: DateTime.now(),
|
||||
providerId: providerId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Record an access to an album
|
||||
@@ -170,15 +162,17 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
String? imageUrl,
|
||||
String? providerId,
|
||||
}) {
|
||||
_recordAccess(RecentAccessItem(
|
||||
id: id,
|
||||
name: name,
|
||||
subtitle: artistName,
|
||||
imageUrl: imageUrl,
|
||||
type: RecentAccessType.album,
|
||||
accessedAt: DateTime.now(),
|
||||
providerId: providerId,
|
||||
));
|
||||
_recordAccess(
|
||||
RecentAccessItem(
|
||||
id: id,
|
||||
name: name,
|
||||
subtitle: artistName,
|
||||
imageUrl: imageUrl,
|
||||
type: RecentAccessType.album,
|
||||
accessedAt: DateTime.now(),
|
||||
providerId: providerId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Record an access to a track
|
||||
@@ -189,15 +183,17 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
String? imageUrl,
|
||||
String? providerId,
|
||||
}) {
|
||||
_recordAccess(RecentAccessItem(
|
||||
id: id,
|
||||
name: name,
|
||||
subtitle: artistName,
|
||||
imageUrl: imageUrl,
|
||||
type: RecentAccessType.track,
|
||||
accessedAt: DateTime.now(),
|
||||
providerId: providerId,
|
||||
));
|
||||
_recordAccess(
|
||||
RecentAccessItem(
|
||||
id: id,
|
||||
name: name,
|
||||
subtitle: artistName,
|
||||
imageUrl: imageUrl,
|
||||
type: RecentAccessType.track,
|
||||
accessedAt: DateTime.now(),
|
||||
providerId: providerId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Record an access to a playlist
|
||||
@@ -208,30 +204,42 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
String? imageUrl,
|
||||
String? providerId,
|
||||
}) {
|
||||
_recordAccess(RecentAccessItem(
|
||||
id: id,
|
||||
name: name,
|
||||
subtitle: ownerName,
|
||||
imageUrl: imageUrl,
|
||||
type: RecentAccessType.playlist,
|
||||
accessedAt: DateTime.now(),
|
||||
providerId: providerId,
|
||||
));
|
||||
_recordAccess(
|
||||
RecentAccessItem(
|
||||
id: id,
|
||||
name: name,
|
||||
subtitle: ownerName,
|
||||
imageUrl: imageUrl,
|
||||
type: RecentAccessType.playlist,
|
||||
accessedAt: DateTime.now(),
|
||||
providerId: providerId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _recordAccess(RecentAccessItem item) {
|
||||
final updatedItems = state.items
|
||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||
.toList();
|
||||
|
||||
|
||||
updatedItems.insert(0, item);
|
||||
|
||||
|
||||
RecentAccessItem? removedTail;
|
||||
if (updatedItems.length > _maxRecentItems) {
|
||||
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
|
||||
removedTail = updatedItems.removeLast();
|
||||
}
|
||||
|
||||
|
||||
state = state.copyWith(items: updatedItems);
|
||||
_saveHistory();
|
||||
unawaited(
|
||||
_appStateDb.upsertRecentAccessRow(
|
||||
uniqueKey: item.uniqueKey,
|
||||
itemJson: jsonEncode(item.toJson()),
|
||||
accessedAt: item.accessedAt.toIso8601String(),
|
||||
),
|
||||
);
|
||||
if (removedTail != null) {
|
||||
unawaited(_appStateDb.deleteRecentAccessRow(removedTail.uniqueKey));
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific item from history
|
||||
@@ -240,14 +248,14 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||
.toList();
|
||||
state = state.copyWith(items: updatedItems);
|
||||
_saveHistory();
|
||||
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
|
||||
}
|
||||
|
||||
/// Hide a download item from recents (without deleting the actual download)
|
||||
void hideDownloadFromRecents(String downloadId) {
|
||||
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
|
||||
state = state.copyWith(hiddenDownloadIds: updatedHidden);
|
||||
_saveHiddenDownloads();
|
||||
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
|
||||
}
|
||||
|
||||
/// Check if a download is hidden from recents
|
||||
@@ -258,16 +266,17 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
/// Clear all history
|
||||
void clearHistory() {
|
||||
state = state.copyWith(items: []);
|
||||
_saveHistory();
|
||||
unawaited(_appStateDb.clearRecentAccessRows());
|
||||
}
|
||||
|
||||
/// Clear hidden downloads (show all again)
|
||||
void clearHiddenDownloads() {
|
||||
state = state.copyWith(hiddenDownloadIds: {});
|
||||
_saveHiddenDownloads();
|
||||
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
|
||||
}
|
||||
}
|
||||
|
||||
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
|
||||
RecentAccessNotifier.new,
|
||||
);
|
||||
final recentAccessProvider =
|
||||
NotifierProvider<RecentAccessNotifier, RecentAccessState>(
|
||||
RecentAccessNotifier.new,
|
||||
);
|
||||
|
||||
@@ -3,16 +3,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 2;
|
||||
const _currentMigrationVersion = 4;
|
||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||
final _log = AppLogger('SettingsProvider');
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
|
||||
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
||||
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
||||
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
bool _isSavingSettings = false;
|
||||
@@ -32,6 +37,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = AppSettings.fromJson(jsonDecode(json));
|
||||
|
||||
await _runMigrations(prefs);
|
||||
await _normalizeYouTubeBitratesIfNeeded();
|
||||
await _normalizeSongLinkRegionIfNeeded();
|
||||
}
|
||||
|
||||
await _loadSpotifyClientSecret(prefs);
|
||||
@@ -41,6 +48,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
|
||||
_syncLyricsSettingsToBackend();
|
||||
_syncNetworkCompatibilitySettingsToBackend();
|
||||
}
|
||||
|
||||
void _syncLyricsSettingsToBackend() {
|
||||
@@ -58,6 +66,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
});
|
||||
}
|
||||
|
||||
void _syncNetworkCompatibilitySettingsToBackend() {
|
||||
final compatibilityMode = state.networkCompatibilityMode;
|
||||
PlatformBridge.setNetworkCompatibilityOptions(
|
||||
allowHttp: compatibilityMode,
|
||||
insecureTls: compatibilityMode,
|
||||
).catchError((e) {
|
||||
_log.w('Failed to sync network compatibility options to backend: $e');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||
|
||||
@@ -76,6 +94,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
if (!state.isFirstLaunch && !state.hasCompletedTutorial) {
|
||||
state = state.copyWith(hasCompletedTutorial: true);
|
||||
}
|
||||
// Migration 4: include Spotify Lyrics API in provider order for existing users
|
||||
if (!state.lyricsProviders.contains('spotify_api')) {
|
||||
final updatedProviders = List<String>.from(state.lyricsProviders);
|
||||
final lrclibIndex = updatedProviders.indexOf('lrclib');
|
||||
if (lrclibIndex >= 0) {
|
||||
updatedProviders.insert(lrclibIndex + 1, 'spotify_api');
|
||||
} else {
|
||||
updatedProviders.add('spotify_api');
|
||||
}
|
||||
state = state.copyWith(lyricsProviders: updatedProviders);
|
||||
}
|
||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||
await _saveSettings();
|
||||
}
|
||||
@@ -107,6 +137,62 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
int _nearestSupportedBitrate(int value, List<int> supported) {
|
||||
var nearest = supported.first;
|
||||
var nearestDistance = (value - nearest).abs();
|
||||
|
||||
for (final option in supported.skip(1)) {
|
||||
final distance = (value - option).abs();
|
||||
// On tie, prefer higher quality bitrate.
|
||||
if (distance < nearestDistance ||
|
||||
(distance == nearestDistance && option > nearest)) {
|
||||
nearest = option;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
int _normalizeYouTubeOpusBitrate(int bitrate) {
|
||||
return _nearestSupportedBitrate(bitrate, _youtubeOpusSupportedBitrates);
|
||||
}
|
||||
|
||||
int _normalizeYouTubeMp3Bitrate(int bitrate) {
|
||||
return _nearestSupportedBitrate(bitrate, _youtubeMp3SupportedBitrates);
|
||||
}
|
||||
|
||||
Future<void> _normalizeYouTubeBitratesIfNeeded() async {
|
||||
final normalizedOpus = _normalizeYouTubeOpusBitrate(
|
||||
state.youtubeOpusBitrate,
|
||||
);
|
||||
final normalizedMp3 = _normalizeYouTubeMp3Bitrate(state.youtubeMp3Bitrate);
|
||||
|
||||
if (normalizedOpus == state.youtubeOpusBitrate &&
|
||||
normalizedMp3 == state.youtubeMp3Bitrate) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
youtubeOpusBitrate: normalizedOpus,
|
||||
youtubeMp3Bitrate: normalizedMp3,
|
||||
);
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
String _normalizeSongLinkRegion(String region) {
|
||||
final normalized = region.trim().toUpperCase();
|
||||
if (_isoRegionPattern.hasMatch(normalized)) return normalized;
|
||||
return 'US';
|
||||
}
|
||||
|
||||
Future<void> _normalizeSongLinkRegionIfNeeded() async {
|
||||
final normalized = _normalizeSongLinkRegion(state.songLinkRegion);
|
||||
if (normalized == state.songLinkRegion) return;
|
||||
state = state.copyWith(songLinkRegion: normalized);
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
|
||||
final storedSecret = await _secureStorage.read(
|
||||
key: _spotifyClientSecretKey,
|
||||
@@ -198,6 +284,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEmbedMetadata(bool enabled) {
|
||||
state = state.copyWith(embedMetadata: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLyricsMode(String mode) {
|
||||
if (mode == 'embed' || mode == 'external' || mode == 'both') {
|
||||
state = state.copyWith(lyricsMode: mode);
|
||||
@@ -230,7 +321,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void setMusixmatchLanguage(String languageCode) {
|
||||
state = state.copyWith(musixmatchLanguage: languageCode.trim().toLowerCase());
|
||||
state = state.copyWith(
|
||||
musixmatchLanguage: languageCode.trim().toLowerCase(),
|
||||
);
|
||||
_saveSettings();
|
||||
_syncLyricsSettingsToBackend();
|
||||
}
|
||||
@@ -390,6 +483,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setYoutubeOpusBitrate(int bitrate) {
|
||||
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
|
||||
state = state.copyWith(youtubeOpusBitrate: normalized);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setYoutubeMp3Bitrate(int bitrate) {
|
||||
final normalized = _normalizeYouTubeMp3Bitrate(bitrate);
|
||||
state = state.copyWith(youtubeMp3Bitrate: normalized);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAllFilesAccess(bool enabled) {
|
||||
state = state.copyWith(useAllFilesAccess: enabled);
|
||||
_saveSettings();
|
||||
@@ -405,6 +510,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setNetworkCompatibilityMode(bool enabled) {
|
||||
state = state.copyWith(networkCompatibilityMode: enabled);
|
||||
_saveSettings();
|
||||
_syncNetworkCompatibilitySettingsToBackend();
|
||||
}
|
||||
|
||||
void setSongLinkRegion(String region) {
|
||||
final normalized = _normalizeSongLinkRegion(region);
|
||||
state = state.copyWith(songLinkRegion: normalized);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocalLibraryEnabled(bool enabled) {
|
||||
state = state.copyWith(localLibraryEnabled: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -551,6 +551,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
|
||||
@@ -713,6 +714,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
searchPlaylists: playlists,
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter, // Preserve filter in results
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
@@ -722,6 +724,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
}
|
||||
@@ -737,6 +740,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve filter during loading
|
||||
);
|
||||
@@ -776,6 +780,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
searchArtists: [],
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
searchExtensionId: extensionId, // Store which extension was used
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve selected filter
|
||||
@@ -787,6 +792,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -808,6 +814,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
artistName: track.artistName,
|
||||
albumName: track.albumName,
|
||||
albumArtist: track.albumArtist,
|
||||
artistId: track.artistId,
|
||||
albumId: track.albumId,
|
||||
coverUrl: track.coverUrl,
|
||||
isrc: track.isrc,
|
||||
duration: track.duration,
|
||||
@@ -876,19 +884,23 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
playlistName: playlistName,
|
||||
coverUrl: coverUrl,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
);
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
final durationMs = _extractDurationMs(data);
|
||||
return Track(
|
||||
id: data['spotify_id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
artistName: data['artists'] as String? ?? '',
|
||||
albumName: data['album_name'] as String? ?? '',
|
||||
albumArtist: data['album_artist'] as String?,
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: data['images'] as String?,
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date'] as String?,
|
||||
@@ -896,13 +908,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
|
||||
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) {
|
||||
durationMs = durationValue;
|
||||
} else if (durationValue is double) {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
final durationMs = _extractDurationMs(data);
|
||||
|
||||
final itemType = data['item_type']?.toString();
|
||||
|
||||
@@ -912,6 +918,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
@@ -927,6 +935,32 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
int _extractDurationMs(Map<String, dynamic> data) {
|
||||
final durationMsRaw = data['duration_ms'];
|
||||
if (durationMsRaw is num && durationMsRaw > 0) {
|
||||
return durationMsRaw.toInt();
|
||||
}
|
||||
if (durationMsRaw is String) {
|
||||
final parsed = num.tryParse(durationMsRaw.trim());
|
||||
if (parsed != null && parsed > 0) {
|
||||
return parsed.toInt();
|
||||
}
|
||||
}
|
||||
|
||||
final durationSecRaw = data['duration'];
|
||||
if (durationSecRaw is num && durationSecRaw > 0) {
|
||||
return (durationSecRaw * 1000).toInt();
|
||||
}
|
||||
if (durationSecRaw is String) {
|
||||
final parsed = num.tryParse(durationSecRaw.trim());
|
||||
if (parsed != null && parsed > 0) {
|
||||
return (parsed * 1000).toInt();
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||
return ArtistAlbum(
|
||||
id: data['id'] as String? ?? '',
|
||||
|
||||
+363
-428
@@ -1,22 +1,21 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
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/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
show ExtensionArtistScreen;
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
class _AlbumCache {
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
@@ -117,12 +116,39 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final shouldShow =
|
||||
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
setState(() => _showTitleInAppBar = shouldShow);
|
||||
}
|
||||
}
|
||||
|
||||
double _calculateExpandedHeight(BuildContext context) {
|
||||
final mediaSize = MediaQuery.of(context).size;
|
||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||
}
|
||||
|
||||
/// Upgrade cover URL to a reasonable resolution for full-screen display.
|
||||
/// Spotify CDN only has 300, 640, ~2000 — we stay at 640 (no intermediate).
|
||||
/// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800).
|
||||
String? _highResCoverUrl(String? url) {
|
||||
if (url == null) return null;
|
||||
// Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000)
|
||||
if (url.contains('ab67616d00001e02')) {
|
||||
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
|
||||
}
|
||||
// Deezer CDN: upgrade to 1000x1000
|
||||
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
|
||||
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
|
||||
return url.replaceAllMapped(
|
||||
deezerRegex,
|
||||
(m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg',
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
String _formatReleaseDate(String date) {
|
||||
if (date.length >= 10) {
|
||||
final parts = date.substring(0, 10).split('-');
|
||||
@@ -160,7 +186,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
.toList();
|
||||
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||
final artistId = albumInfo?['artist_id'] as String?;
|
||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||
?.toString();
|
||||
|
||||
_AlbumCache.set(widget.albumId, tracks);
|
||||
|
||||
@@ -188,6 +215,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
artistName: data['artists'] as String? ?? '',
|
||||
albumName: data['album_name'] as String? ?? '',
|
||||
albumArtist: data['album_artist'] as String?,
|
||||
artistId:
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
||||
albumId: data['album_id']?.toString() ?? widget.albumId,
|
||||
coverUrl: data['images'] as String?,
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||
@@ -201,12 +231,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final tracks = _tracks ?? [];
|
||||
final pageBackgroundColor = colorScheme.surface;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: pageBackgroundColor,
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildAppBar(context, colorScheme, pageBackgroundColor),
|
||||
_buildInfoCard(context, colorScheme),
|
||||
if (_isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
@@ -223,7 +255,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
),
|
||||
),
|
||||
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
||||
_buildTrackListHeader(context, colorScheme),
|
||||
_buildTrackList(context, colorScheme, tracks),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
@@ -232,21 +263,21 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final mediaSize = MediaQuery.of(context).size;
|
||||
final screenWidth = mediaSize.width;
|
||||
final shortestSide = mediaSize.shortestSide;
|
||||
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
|
||||
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
|
||||
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||
Widget _buildAppBar(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
Color pageBackgroundColor,
|
||||
) {
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final tracks = _tracks ?? [];
|
||||
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
||||
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
backgroundColor: pageBackgroundColor,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
@@ -268,25 +299,18 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
final dpr = MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
).clamp(1.0, 3.0).toDouble();
|
||||
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
|
||||
.round()
|
||||
.clamp(720, 1440)
|
||||
.toInt();
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
collapseMode: CollapseMode.pin,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Blurred cover background
|
||||
// Full-screen cover background (no blur, full resolution)
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
imageUrl:
|
||||
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: backgroundMemCacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
@@ -294,80 +318,175 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
Container(color: colorScheme.surface),
|
||||
)
|
||||
else
|
||||
Container(color: colorScheme.surface),
|
||||
ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: Container(
|
||||
color: colorScheme.surface.withValues(alpha: 0.4),
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.album,
|
||||
size: 80,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom gradient for readability
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: bottomGradientHeight,
|
||||
height: expandedHeight * 0.65,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.surface.withValues(alpha: 0.0),
|
||||
colorScheme.surface,
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.85),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: coverTopPadding),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 15),
|
||||
),
|
||||
],
|
||||
// Album info overlay at bottom
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 40,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.albumName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.2,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (coverSize * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.album,
|
||||
size: fallbackIconSize,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
if (artistName != null && artistName.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClickableArtistName(
|
||||
artistName: artistName,
|
||||
artistId: _artistId,
|
||||
coverUrl: widget.coverUrl,
|
||||
extensionId: widget.extensionId,
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (tracks.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.music_note,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.tracksCount(tracks.length),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (releaseDate != null && releaseDate.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatReleaseDate(releaseDate),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildLoveAllButton(),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context),
|
||||
icon: Icon(Icons.download, size: 18),
|
||||
label: Text(
|
||||
context.l10n.downloadAllCount(tracks.length),
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
minimumSize: const Size(0, 48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_buildAddToPlaylistButton(context),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [
|
||||
StretchMode.zoomBackground,
|
||||
StretchMode.blurBackground,
|
||||
],
|
||||
stretchModes: const [StretchMode.zoomBackground],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -375,10 +494,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
child: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
@@ -386,151 +505,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
final tracks = _tracks ?? [];
|
||||
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
||||
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.albumName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (artistName != null && artistName.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToArtist(context, artistName),
|
||||
child: Text(
|
||||
artistName,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
if (tracks.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 14,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.tracksCount(tracks.length),
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (releaseDate != null && releaseDate.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 14,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatReleaseDate(releaseDate),
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (tracks.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.downloadAllCount(tracks.length)),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.tracksHeader,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
// Info is now displayed in the full-screen cover overlay
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
Widget _buildTrackList(
|
||||
@@ -615,47 +591,94 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToArtist(BuildContext context, String artistName) {
|
||||
final artistId =
|
||||
_artistId ??
|
||||
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
|
||||
Widget _buildLoveAllButton() {
|
||||
final collectionsState = ref.watch(libraryCollectionsProvider);
|
||||
final tracks = _tracks;
|
||||
final allLoved =
|
||||
tracks != null &&
|
||||
tracks.isNotEmpty &&
|
||||
tracks.every((t) => collectionsState.isLoved(t));
|
||||
|
||||
if (artistId == 'unknown' ||
|
||||
artistId == 'deezer:unknown' ||
|
||||
artistId.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Artist information not available')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.extensionId != null) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ExtensionArtistScreen(
|
||||
extensionId: widget.extensionId!,
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ArtistScreen(
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: tracks == null || tracks.isEmpty
|
||||
? null
|
||||
: () => _loveAll(tracks),
|
||||
icon: Icon(
|
||||
allLoved ? Icons.favorite : Icons.favorite_border,
|
||||
size: 22,
|
||||
color: allLoved ? Colors.redAccent : Colors.white,
|
||||
),
|
||||
tooltip: allLoved ? 'Remove from Loved' : 'Love All',
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddToPlaylistButton(BuildContext context) {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: _tracks == null || _tracks!.isEmpty
|
||||
? null
|
||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
|
||||
icon: const Icon(Icons.add, size: 22, color: Colors.white),
|
||||
tooltip: 'Add to Playlist',
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loveAll(List<Track> tracks) async {
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
final state = ref.read(libraryCollectionsProvider);
|
||||
final allLoved = tracks.every((t) => state.isLoved(t));
|
||||
|
||||
if (allLoved) {
|
||||
for (final track in tracks) {
|
||||
final key = trackCollectionKey(track);
|
||||
await notifier.removeFromLoved(key);
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
int addedCount = 0;
|
||||
for (final track in tracks) {
|
||||
if (!state.isLoved(track)) {
|
||||
await notifier.toggleLoved(track);
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added $addedCount tracks to Loved')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit =
|
||||
error.contains('429') ||
|
||||
@@ -739,7 +762,12 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
|
||||
final isInHistory = ref.watch(
|
||||
downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
if (state.isDownloaded(track.id)) return true;
|
||||
final isrc = track.isrc?.trim();
|
||||
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
|
||||
return true;
|
||||
}
|
||||
return state.findByTrackAndArtist(track.name, track.artistName) != null;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -761,13 +789,6 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
: false;
|
||||
|
||||
final isQueued = queueItem != null;
|
||||
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||
final progress = queueItem?.progress ?? 0.0;
|
||||
|
||||
final showAsDownloaded =
|
||||
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
@@ -802,14 +823,16 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
track.artistName,
|
||||
child: ClickableArtistName(
|
||||
artistName: track.artistName,
|
||||
artistId: track.artistId,
|
||||
coverUrl: track.coverUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
if (isInLocalLibrary) ...[
|
||||
if (isInLocalLibrary || isInHistory) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -843,24 +866,12 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: _buildDownloadButton(
|
||||
trailing: TrackCollectionQuickActions(track: track),
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued),
|
||||
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
|
||||
context,
|
||||
ref,
|
||||
colorScheme,
|
||||
isQueued: isQueued,
|
||||
isDownloading: isDownloading,
|
||||
isFinalizing: isFinalizing,
|
||||
showAsDownloaded: showAsDownloaded,
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
progress: progress,
|
||||
),
|
||||
onTap: () => _handleTap(
|
||||
context,
|
||||
ref,
|
||||
isQueued: isQueued,
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
track,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -871,160 +882,84 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required bool isQueued,
|
||||
required bool isInHistory,
|
||||
required bool isInLocalLibrary,
|
||||
}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
if (isInLocalLibrary) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
|
||||
),
|
||||
);
|
||||
}
|
||||
final playedLocal = await _playLocalIfAvailable(context, ref);
|
||||
if (playedLocal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInHistory) {
|
||||
final historyItem = ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAlreadyDownloaded(track.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.removeBySpotifyId(track.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDownload();
|
||||
}
|
||||
|
||||
Widget _buildDownloadButton(
|
||||
Future<bool> _playLocalIfAvailable(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme, {
|
||||
required bool isQueued,
|
||||
required bool isDownloading,
|
||||
required bool isFinalizing,
|
||||
required bool showAsDownloaded,
|
||||
required bool isInHistory,
|
||||
required bool isInLocalLibrary,
|
||||
required double progress,
|
||||
}) {
|
||||
const double size = 44.0;
|
||||
const double iconSize = 20.0;
|
||||
) async {
|
||||
final localState = ref.read(localLibraryProvider);
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
|
||||
if (showAsDownloaded) {
|
||||
return GestureDetector(
|
||||
onTap: () => _handleTap(
|
||||
context,
|
||||
ref,
|
||||
isQueued: isQueued,
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
try {
|
||||
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
|
||||
track.id,
|
||||
);
|
||||
} else if (isFinalizing) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: colorScheme.tertiary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||
],
|
||||
),
|
||||
final isrc = track.isrc?.trim();
|
||||
historyItem ??= (isrc != null && isrc.isNotEmpty)
|
||||
? historyNotifier.getByIsrc(isrc)
|
||||
: null;
|
||||
historyItem ??= historyState.findByTrackAndArtist(
|
||||
track.name,
|
||||
track.artistName,
|
||||
);
|
||||
} else if (isDownloading) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: progress > 0 ? progress : null,
|
||||
strokeWidth: 3,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
if (progress > 0)
|
||||
Text(
|
||||
'${(progress * 100).toInt()}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (isQueued) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.hourglass_empty,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: iconSize,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return GestureDetector(
|
||||
onTap: onDownload,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.download,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
|
||||
if (historyItem != null) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playLocalPath(
|
||||
path: historyItem.filePath,
|
||||
title: track.name,
|
||||
artist: track.artistName,
|
||||
album: track.albumName,
|
||||
coverUrl: track.coverUrl ?? '',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
historyNotifier.removeFromHistory(historyItem.id);
|
||||
}
|
||||
|
||||
var localItem = (isrc != null && isrc.isNotEmpty)
|
||||
? localState.getByIsrc(isrc)
|
||||
: null;
|
||||
localItem ??= localState.findByTrackAndArtist(
|
||||
track.name,
|
||||
track.artistName,
|
||||
);
|
||||
|
||||
if (localItem != null && await fileExists(localItem.filePath)) {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playLocalPath(
|
||||
path: localItem.filePath,
|
||||
title: localItem.trackName,
|
||||
artist: localItem.artistName,
|
||||
album: localItem.albumName,
|
||||
coverUrl: localItem.coverPath ?? track.coverUrl ?? '',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+199
-223
@@ -6,18 +6,20 @@ import 'package:intl/intl.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
show ExtensionAlbumScreen;
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
/// Simple in-memory cache for artist data
|
||||
class _ArtistCache {
|
||||
@@ -309,6 +311,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId:
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
||||
widget.artistId,
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
@@ -675,6 +681,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -772,7 +779,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
List<ArtistAlbum> albums,
|
||||
) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
@@ -990,6 +996,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
.toString(),
|
||||
albumName: album.name,
|
||||
albumArtist: widget.artistName,
|
||||
artistId: widget.artistId,
|
||||
albumId: album.id.isNotEmpty ? album.id : null,
|
||||
coverUrl: album.coverUrl,
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
@@ -1100,63 +1108,72 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
widget.artistName,
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 4,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.artistName,
|
||||
style: Theme.of(context).textTheme.headlineLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 4,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (listenersText != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
listenersText,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
if (listenersText != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
listenersText,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
// Download Discography button
|
||||
),
|
||||
// Download Discography button (icon only, right-aligned)
|
||||
if (hasDiscography && !_isSelectionMode) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: FilledButton.icon(
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: () => _showDiscographyOptions(
|
||||
context,
|
||||
colorScheme,
|
||||
albums,
|
||||
),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.discographyDownload),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.download_rounded, size: 26),
|
||||
color: Colors.black87,
|
||||
tooltip: context.l10n.discographyDownload,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1227,9 +1244,17 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
);
|
||||
|
||||
final isInHistory = ref.watch(
|
||||
downloadHistoryProvider.select(
|
||||
(state) => state.isDownloaded(track.id),
|
||||
),
|
||||
downloadHistoryProvider.select((state) {
|
||||
if (state.isDownloaded(track.id)) return true;
|
||||
final isrc = track.isrc?.trim();
|
||||
if (isrc != null &&
|
||||
isrc.isNotEmpty &&
|
||||
state.getByIsrc(isrc) != null) {
|
||||
return true;
|
||||
}
|
||||
return state.findByTrackAndArtist(track.name, track.artistName) !=
|
||||
null;
|
||||
}),
|
||||
);
|
||||
|
||||
final showLocalLibraryIndicator = ref.watch(
|
||||
@@ -1250,20 +1275,13 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
: false;
|
||||
|
||||
final isQueued = queueItem != null;
|
||||
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||
final progress = queueItem?.progress ?? 0.0;
|
||||
|
||||
final showAsDownloaded =
|
||||
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _handlePopularTrackTap(
|
||||
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued),
|
||||
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
|
||||
context,
|
||||
ref,
|
||||
track,
|
||||
isQueued: isQueued,
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
@@ -1330,28 +1348,66 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (track.albumName.isNotEmpty)
|
||||
Text(
|
||||
track.albumName,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
if (track.albumName.isNotEmpty ||
|
||||
isInLocalLibrary ||
|
||||
isInHistory)
|
||||
Row(
|
||||
children: [
|
||||
if (track.albumName.isNotEmpty)
|
||||
Expanded(
|
||||
child: ClickableAlbumName(
|
||||
albumName: track.albumName,
|
||||
albumId: track.albumId,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
extensionId: widget.extensionId,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (isInLocalLibrary || isInHistory) ...[
|
||||
if (track.albumName.isNotEmpty)
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
context.l10n.libraryInLibrary,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildPopularDownloadButton(
|
||||
track: track,
|
||||
colorScheme: colorScheme,
|
||||
isQueued: isQueued,
|
||||
isDownloading: isDownloading,
|
||||
isFinalizing: isFinalizing,
|
||||
showAsDownloaded: showAsDownloaded,
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
progress: progress,
|
||||
),
|
||||
TrackCollectionQuickActions(track: track),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1361,162 +1417,82 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
|
||||
/// Handle tap on popular track item
|
||||
void _handlePopularTrackTap(
|
||||
Track track, {
|
||||
required bool isQueued,
|
||||
required bool isInHistory,
|
||||
required bool isInLocalLibrary,
|
||||
}) async {
|
||||
void _handlePopularTrackTap(Track track, {required bool isQueued}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
if (isInLocalLibrary) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
|
||||
),
|
||||
);
|
||||
}
|
||||
final playedLocal = await _playLocalIfAvailable(track);
|
||||
if (playedLocal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInHistory) {
|
||||
final historyItem = ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAlreadyDownloaded(track.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.removeBySpotifyId(track.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_downloadTrack(track);
|
||||
}
|
||||
|
||||
Widget _buildPopularDownloadButton({
|
||||
required Track track,
|
||||
required ColorScheme colorScheme,
|
||||
required bool isQueued,
|
||||
required bool isDownloading,
|
||||
required bool isFinalizing,
|
||||
required bool showAsDownloaded,
|
||||
required bool isInHistory,
|
||||
required bool isInLocalLibrary,
|
||||
required double progress,
|
||||
}) {
|
||||
const double size = 40.0;
|
||||
const double iconSize = 20.0;
|
||||
Future<bool> _playLocalIfAvailable(Track track) async {
|
||||
final localState = ref.read(localLibraryProvider);
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
|
||||
if (showAsDownloaded) {
|
||||
return GestureDetector(
|
||||
onTap: () => _handlePopularTrackTap(
|
||||
track,
|
||||
isQueued: isQueued,
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
try {
|
||||
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
|
||||
track.id,
|
||||
);
|
||||
} else if (isFinalizing) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: colorScheme.tertiary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 14),
|
||||
],
|
||||
),
|
||||
final isrc = track.isrc?.trim();
|
||||
historyItem ??= (isrc != null && isrc.isNotEmpty)
|
||||
? historyNotifier.getByIsrc(isrc)
|
||||
: null;
|
||||
historyItem ??= historyState.findByTrackAndArtist(
|
||||
track.name,
|
||||
track.artistName,
|
||||
);
|
||||
} else if (isDownloading) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: progress > 0 ? progress : null,
|
||||
strokeWidth: 2.5,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
if (progress > 0)
|
||||
Text(
|
||||
'${(progress * 100).toInt()}',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (isQueued) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.hourglass_empty,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: iconSize,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return GestureDetector(
|
||||
onTap: () => _downloadTrack(track),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.download,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
|
||||
if (historyItem != null) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playLocalPath(
|
||||
path: historyItem.filePath,
|
||||
title: track.name,
|
||||
artist: track.artistName,
|
||||
album: track.albumName,
|
||||
coverUrl: track.coverUrl ?? '',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
historyNotifier.removeFromHistory(historyItem.id);
|
||||
}
|
||||
|
||||
var localItem = (isrc != null && isrc.isNotEmpty)
|
||||
? localState.getByIsrc(isrc)
|
||||
: null;
|
||||
localItem ??= localState.findByTrackAndArtist(
|
||||
track.name,
|
||||
track.artistName,
|
||||
);
|
||||
|
||||
if (localItem != null && await fileExists(localItem.filePath)) {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playLocalPath(
|
||||
path: localItem.filePath,
|
||||
title: localItem.trackName,
|
||||
artist: localItem.artistName,
|
||||
album: localItem.albumName,
|
||||
coverUrl: localItem.coverPath ?? track.coverUrl ?? '',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void _downloadTrack(Track track) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+594
-526
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,585 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
|
||||
class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
const LibraryPlaylistsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final playlists = ref.watch(
|
||||
libraryCollectionsProvider.select((state) => state.playlists),
|
||||
);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
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(
|
||||
context.l10n.collectionPlaylists,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (playlists.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.playlist_play,
|
||||
size: 60,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
context.l10n.collectionNoPlaylistsYet,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.collectionNoPlaylistsSubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
// Even indices = playlist tiles, odd indices = dividers
|
||||
if (index.isOdd) {
|
||||
return const Divider(height: 1);
|
||||
}
|
||||
final playlistIndex = index ~/ 2;
|
||||
final playlist = playlists[playlistIndex];
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 2,
|
||||
),
|
||||
leading: _buildPlaylistThumbnail(context, playlist),
|
||||
title: Text(playlist.name),
|
||||
subtitle: Text(
|
||||
context.l10n.collectionPlaylistTracks(
|
||||
playlist.tracks.length,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LibraryTracksFolderScreen(
|
||||
mode: LibraryTracksFolderMode.playlist,
|
||||
playlistId: playlist.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onLongPress: () =>
|
||||
_showPlaylistOptionsSheet(context, ref, playlist),
|
||||
);
|
||||
}, childCount: playlists.length * 2 - 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _showCreatePlaylistDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(context.l10n.collectionCreatePlaylist),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlaylistOptionsSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
UserPlaylistCollection playlist,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header: drag handle + thumbnail + playlist info
|
||||
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: [
|
||||
_buildPlaylistThumbnail(context, playlist),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
playlist.name,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
context.l10n.collectionPlaylistTracks(
|
||||
playlist.tracks.length,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
|
||||
// Rename
|
||||
_PlaylistOptionTile(
|
||||
icon: Icons.edit_outlined,
|
||||
title: context.l10n.collectionRenamePlaylist,
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showRenamePlaylistDialog(
|
||||
context,
|
||||
ref,
|
||||
playlist.id,
|
||||
playlist.name,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Change cover
|
||||
_PlaylistOptionTile(
|
||||
icon: Icons.image_outlined,
|
||||
title: context.l10n.collectionPlaylistChangeCover,
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_pickCoverImage(context, ref, playlist.id);
|
||||
},
|
||||
),
|
||||
|
||||
// Delete
|
||||
_PlaylistOptionTile(
|
||||
icon: Icons.delete_outline,
|
||||
iconColor: colorScheme.error,
|
||||
title: context.l10n.collectionDeletePlaylist,
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_confirmDeletePlaylist(
|
||||
context,
|
||||
ref,
|
||||
playlist.id,
|
||||
playlist.name,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaylistThumbnail(
|
||||
BuildContext context,
|
||||
UserPlaylistCollection playlist,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
const double size = 48;
|
||||
final borderRadius = BorderRadius.circular(8);
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final cacheWidth = (size * dpr).round().clamp(64, 512);
|
||||
final placeholder = _playlistIconFallback(colorScheme, size);
|
||||
|
||||
// Priority: custom cover > first track cover URL > icon fallback
|
||||
final customCoverPath = playlist.coverImagePath;
|
||||
if (customCoverPath != null && customCoverPath.isNotEmpty) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: Image.file(
|
||||
File(customCoverPath),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return placeholder;
|
||||
},
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? firstCoverUrl;
|
||||
for (final entry in playlist.tracks) {
|
||||
final coverUrl = entry.track.coverUrl;
|
||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||
firstCoverUrl = coverUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstCoverUrl != null) {
|
||||
final isLocalPath =
|
||||
!firstCoverUrl.startsWith('http://') &&
|
||||
!firstCoverUrl.startsWith('https://');
|
||||
|
||||
if (isLocalPath) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: Image.file(
|
||||
File(firstCoverUrl),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return placeholder;
|
||||
},
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: firstCoverUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => placeholder,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
Widget _playlistIconFallback(ColorScheme colorScheme, double size) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickCoverImage(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String playlistId,
|
||||
) async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.image,
|
||||
allowMultiple: false,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
|
||||
final path = result.files.first.path;
|
||||
if (path == null || path.isEmpty) return;
|
||||
|
||||
await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.setPlaylistCover(playlistId, path);
|
||||
}
|
||||
|
||||
Future<void> _showCreatePlaylistDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
final controller = TextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
final playlistName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(dialogContext.l10n.collectionCreatePlaylist),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: dialogContext.l10n.collectionPlaylistNameHint,
|
||||
),
|
||||
validator: (value) {
|
||||
final trimmed = value?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return dialogContext.l10n.collectionPlaylistNameRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) {
|
||||
if (formKey.currentState?.validate() != true) return;
|
||||
Navigator.of(dialogContext).pop(controller.text.trim());
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text(dialogContext.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (formKey.currentState?.validate() != true) return;
|
||||
Navigator.of(dialogContext).pop(controller.text.trim());
|
||||
},
|
||||
child: Text(dialogContext.l10n.actionCreate),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (playlistName == null ||
|
||||
playlistName.trim().isEmpty ||
|
||||
!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.createPlaylist(playlistName.trim());
|
||||
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.collectionPlaylistCreated)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showRenamePlaylistDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String playlistId,
|
||||
String currentName,
|
||||
) async {
|
||||
final controller = TextEditingController(text: currentName);
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
final nextName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(dialogContext.l10n.collectionRenamePlaylist),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: dialogContext.l10n.collectionPlaylistNameHint,
|
||||
),
|
||||
validator: (value) {
|
||||
final trimmed = value?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return dialogContext.l10n.collectionPlaylistNameRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) {
|
||||
if (formKey.currentState?.validate() != true) return;
|
||||
Navigator.of(dialogContext).pop(controller.text.trim());
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text(dialogContext.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (formKey.currentState?.validate() != true) return;
|
||||
Navigator.of(dialogContext).pop(controller.text.trim());
|
||||
},
|
||||
child: Text(dialogContext.l10n.dialogSave),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (nextName == null || nextName.trim().isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.renamePlaylist(playlistId, nextName.trim());
|
||||
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.collectionPlaylistRenamed)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDeletePlaylist(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String playlistId,
|
||||
String playlistName,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(dialogContext.l10n.collectionDeletePlaylist),
|
||||
content: Text(
|
||||
dialogContext.l10n.collectionDeletePlaylistMessage(playlistName),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(dialogContext.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(dialogContext.l10n.dialogDelete),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.deletePlaylist(playlistId);
|
||||
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.collectionPlaylistDeleted)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Styled like _OptionTile in track_collection_quick_actions.dart
|
||||
class _PlaylistOptionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color? iconColor;
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _PlaylistOptionTile({
|
||||
required this.icon,
|
||||
this.iconColor,
|
||||
required this.title,
|
||||
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: iconColor ?? colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+931
-243
File diff suppressed because it is too large
Load Diff
+172
-50
@@ -16,6 +16,7 @@ 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/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/update_checker.dart';
|
||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||
@@ -36,11 +37,21 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
bool _hasCheckedUpdate = false;
|
||||
StreamSubscription<String>? _shareSubscription;
|
||||
DateTime? _lastBackPress;
|
||||
final GlobalKey<NavigatorState> _homeTabNavigatorKey =
|
||||
ShellNavigationService.homeTabNavigatorKey;
|
||||
final GlobalKey<NavigatorState> _libraryTabNavigatorKey =
|
||||
ShellNavigationService.libraryTabNavigatorKey;
|
||||
final GlobalKey<NavigatorState> _storeTabNavigatorKey =
|
||||
ShellNavigationService.storeTabNavigatorKey;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController(initialPage: _currentIndex);
|
||||
ShellNavigationService.syncState(
|
||||
currentTabIndex: _currentIndex,
|
||||
showStoreTab: false,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkForUpdates();
|
||||
_setupShareListener();
|
||||
@@ -86,6 +97,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
_homeTabNavigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
|
||||
if (_currentIndex != 0) {
|
||||
_onNavTap(0);
|
||||
@@ -213,10 +225,34 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _resetHomeToMain() {
|
||||
final showStore = ref.read(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
final homeNavigator = _navigatorForTab(0, showStore);
|
||||
homeNavigator?.popUntil((route) => route.isFirst);
|
||||
// Unfocus BEFORE clear so _onTrackStateChanged can properly
|
||||
// clear _urlController (it checks !_searchFocusNode.hasFocus)
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
}
|
||||
|
||||
void _onNavTap(int index) {
|
||||
if (index == 0 && _currentIndex == 0) {
|
||||
_resetHomeToMain();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex != index) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _currentIndex = index);
|
||||
final showStore = ref.read(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
ShellNavigationService.syncState(
|
||||
currentTabIndex: _currentIndex,
|
||||
showStoreTab: showStore,
|
||||
);
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
@@ -226,48 +262,121 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
|
||||
void _onPageChanged(int index) {
|
||||
final previousIndex = _currentIndex;
|
||||
if (_currentIndex != index) {
|
||||
setState(() => _currentIndex = index);
|
||||
final showStore = ref.read(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
ShellNavigationService.syncState(
|
||||
currentTabIndex: _currentIndex,
|
||||
showStoreTab: showStore,
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (index == 0 && previousIndex != 0) {
|
||||
_resetHomeToMain();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleBackPress() {
|
||||
final rootNavigator = Navigator.of(context, rootNavigator: true);
|
||||
if (rootNavigator.canPop()) {
|
||||
_log.i('Back: step 1 - root navigator pop');
|
||||
rootNavigator.pop();
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final showStore = ref.read(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
final currentNavigator = _navigatorForTab(_currentIndex, showStore);
|
||||
if (currentNavigator != null && currentNavigator.canPop()) {
|
||||
_log.i('Back: step 2 - tab navigator pop (tab=$_currentIndex)');
|
||||
currentNavigator.pop();
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final trackState = ref.read(trackProvider);
|
||||
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
if (isKeyboardVisible) {
|
||||
|
||||
_log.d(
|
||||
'Back: state check - tab=$_currentIndex, '
|
||||
'isShowingRecentAccess=${trackState.isShowingRecentAccess}, '
|
||||
'hasSearchText=${trackState.hasSearchText}, '
|
||||
'hasContent=${trackState.hasContent}, '
|
||||
'isLoading=${trackState.isLoading}, '
|
||||
'isKeyboardVisible=$isKeyboardVisible',
|
||||
);
|
||||
|
||||
if (_currentIndex == 0 &&
|
||||
trackState.isShowingRecentAccess &&
|
||||
!trackState.isLoading &&
|
||||
(trackState.hasSearchText || trackState.hasContent)) {
|
||||
// Has recent access AND search content — clear everything at once
|
||||
_log.i(
|
||||
'Back: step 3a - dismiss recent access + clear search/content '
|
||||
'(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})',
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
|
||||
// Recent access overlay only (no search content) — just dismiss it
|
||||
_log.i('Back: step 3b - dismiss recent access only');
|
||||
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex == 0 &&
|
||||
!trackState.isLoading &&
|
||||
(trackState.hasSearchText || trackState.hasContent)) {
|
||||
_log.i(
|
||||
'Back: step 4 - clear search/content '
|
||||
'(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})',
|
||||
);
|
||||
// Unfocus BEFORE clear so _onTrackStateChanged can properly
|
||||
// clear _urlController (it checks !_searchFocusNode.hasFocus)
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex == 0 && isKeyboardVisible) {
|
||||
_log.i('Back: step 5 - dismiss keyboard');
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex != 0) {
|
||||
_log.i('Back: step 6 - switch to home tab from tab=$_currentIndex');
|
||||
_onNavTap(0);
|
||||
_lastBackPress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (trackState.isLoading) {
|
||||
_log.i('Back: blocked - loading in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
if (_lastBackPress != null &&
|
||||
now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
|
||||
SystemNavigator.pop();
|
||||
_log.i('Back: step 8 - double-tap exit');
|
||||
unawaited(PlatformBridge.exitApp());
|
||||
} else {
|
||||
_log.i('Back: step 7 - first tap, showing exit snackbar');
|
||||
_lastBackPress = now;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -279,46 +388,46 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
}
|
||||
|
||||
NavigatorState? _navigatorForTab(int index, bool showStore) {
|
||||
if (index == 0) return _homeTabNavigatorKey.currentState;
|
||||
if (index == 1) return _libraryTabNavigatorKey.currentState;
|
||||
if (showStore && index == 2) return _storeTabNavigatorKey.currentState;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final queueState = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.queuedCount),
|
||||
);
|
||||
final trackHasSearchText = ref.watch(
|
||||
trackProvider.select((s) => s.hasSearchText),
|
||||
);
|
||||
final trackHasContent = ref.watch(
|
||||
trackProvider.select((s) => s.hasContent),
|
||||
);
|
||||
final trackIsLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||
final trackIsShowingRecentAccess = ref.watch(
|
||||
trackProvider.select((s) => s.isShowingRecentAccess),
|
||||
);
|
||||
final showStore = ref.watch(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
ShellNavigationService.syncState(
|
||||
currentTabIndex: _currentIndex,
|
||||
showStoreTab: showStore,
|
||||
);
|
||||
final storeUpdatesCount = ref.watch(
|
||||
storeProvider.select((s) => s.updatesAvailableCount),
|
||||
);
|
||||
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
|
||||
final canPop =
|
||||
_currentIndex == 0 &&
|
||||
!trackHasSearchText &&
|
||||
!trackHasContent &&
|
||||
!trackIsLoading &&
|
||||
!trackIsShowingRecentAccess &&
|
||||
!isKeyboardVisible;
|
||||
|
||||
final tabs = <Widget>[
|
||||
const HomeTab(),
|
||||
QueueTab(
|
||||
parentPageController: _pageController,
|
||||
parentPageIndex: 1,
|
||||
nextPageIndex: showStore ? 2 : 3,
|
||||
_TabNavigator(
|
||||
key: const ValueKey('tab-home'),
|
||||
navigatorKey: _homeTabNavigatorKey,
|
||||
child: const HomeTab(),
|
||||
),
|
||||
if (showStore) const StoreTab(),
|
||||
_TabNavigator(
|
||||
key: const ValueKey('tab-library'),
|
||||
navigatorKey: _libraryTabNavigatorKey,
|
||||
child: _LibraryTabRoot(parentPageController: _pageController),
|
||||
),
|
||||
if (showStore)
|
||||
_TabNavigator(
|
||||
key: const ValueKey('tab-store'),
|
||||
navigatorKey: _storeTabNavigatorKey,
|
||||
child: const StoreTab(),
|
||||
),
|
||||
const SettingsTab(),
|
||||
];
|
||||
|
||||
@@ -377,22 +486,16 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
});
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: canPop,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) {
|
||||
return;
|
||||
}
|
||||
|
||||
return BackButtonListener(
|
||||
onBackButtonPressed: () async {
|
||||
_handleBackPress();
|
||||
return true;
|
||||
},
|
||||
child: Scaffold(
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: (_currentIndex == 0 && trackIsShowingRecentAccess)
|
||||
? const _NoSwipeRightPhysics()
|
||||
: const ClampingScrollPhysics(),
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: tabs,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
@@ -415,23 +518,42 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom physics that blocks swiping to the right (next page) while
|
||||
/// still allowing vertical scrolling inside the page content.
|
||||
class _NoSwipeRightPhysics extends ScrollPhysics {
|
||||
const _NoSwipeRightPhysics({super.parent});
|
||||
class _TabNavigator extends StatelessWidget {
|
||||
final GlobalKey<NavigatorState> navigatorKey;
|
||||
final Widget child;
|
||||
|
||||
const _TabNavigator({
|
||||
super.key,
|
||||
required this.navigatorKey,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
_NoSwipeRightPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return _NoSwipeRightPhysics(parent: buildParent(ancestor));
|
||||
Widget build(BuildContext context) {
|
||||
return Navigator(
|
||||
key: navigatorKey,
|
||||
onGenerateInitialRoutes: (_, _) => [
|
||||
MaterialPageRoute<void>(builder: (_) => child),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LibraryTabRoot extends ConsumerWidget {
|
||||
final PageController parentPageController;
|
||||
|
||||
const _LibraryTabRoot({required this.parentPageController});
|
||||
|
||||
@override
|
||||
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
|
||||
// In a horizontal PageView, a negative offset means the user is
|
||||
// dragging left (i.e. trying to go to the next page / right).
|
||||
// Block that direction only.
|
||||
if (offset < 0) return 0.0;
|
||||
return super.applyPhysicsToUserOffset(position, offset);
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final showStore = ref.watch(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
return QueueTab(
|
||||
parentPageController: parentPageController,
|
||||
parentPageIndex: 1,
|
||||
nextPageIndex: showStore ? 2 : 3,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+342
-342
@@ -1,5 +1,3 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
@@ -7,12 +5,15 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
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/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
|
||||
class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
final String playlistName;
|
||||
@@ -110,6 +111,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
@@ -120,12 +123,37 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final shouldShow =
|
||||
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
setState(() => _showTitleInAppBar = shouldShow);
|
||||
}
|
||||
}
|
||||
|
||||
double _calculateExpandedHeight(BuildContext context) {
|
||||
final mediaSize = MediaQuery.of(context).size;
|
||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||
}
|
||||
|
||||
/// Upgrade cover URL to a reasonable resolution for full-screen display.
|
||||
String? _highResCoverUrl(String? url) {
|
||||
if (url == null) return null;
|
||||
// Spotify CDN: upgrade 300 → 640 only
|
||||
if (url.contains('ab67616d00001e02')) {
|
||||
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
|
||||
}
|
||||
// Deezer CDN: upgrade to 1000x1000
|
||||
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
|
||||
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
|
||||
return url.replaceAllMapped(
|
||||
deezerRegex,
|
||||
(m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg',
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -136,7 +164,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, colorScheme),
|
||||
_buildTrackListHeader(context, colorScheme),
|
||||
_buildTrackList(context, colorScheme),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
@@ -145,21 +172,13 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final mediaSize = MediaQuery.of(context).size;
|
||||
final screenWidth = mediaSize.width;
|
||||
final shortestSide = mediaSize.shortestSide;
|
||||
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
|
||||
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
|
||||
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor:
|
||||
colorScheme.surface, // Use theme color for collapsed state
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
@@ -181,25 +200,18 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
final dpr = MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
).clamp(1.0, 3.0).toDouble();
|
||||
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
|
||||
.round()
|
||||
.clamp(720, 1440)
|
||||
.toInt();
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
collapseMode: CollapseMode.pin,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Blurred cover background
|
||||
// Full-screen cover background
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
imageUrl:
|
||||
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: backgroundMemCacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
@@ -207,81 +219,107 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
Container(color: colorScheme.surface),
|
||||
)
|
||||
else
|
||||
Container(color: colorScheme.surface),
|
||||
ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: Container(
|
||||
color: colorScheme.surface.withValues(alpha: 0.4),
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.playlist_play,
|
||||
size: 80,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom gradient for readability
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: bottomGradientHeight,
|
||||
height: expandedHeight * 0.65,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.surface.withValues(alpha: 0.0),
|
||||
colorScheme.surface,
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.85),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Cover image centered - fade out when collapsing
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: coverTopPadding),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 15),
|
||||
),
|
||||
],
|
||||
// Playlist info overlay at bottom
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 40,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.playlistName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.2,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (coverSize * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.playlist_play,
|
||||
size: fallbackIconSize,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
if (_tracks.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.playlist_play,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.tracksCount(_tracks.length),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildLoveAllButton(),
|
||||
const SizedBox(width: 12),
|
||||
_buildDownloadAllCenterButton(context),
|
||||
const SizedBox(width: 12),
|
||||
_buildAddToPlaylistButton(context),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [
|
||||
StretchMode.zoomBackground,
|
||||
StretchMode.blurBackground,
|
||||
],
|
||||
stretchModes: const [StretchMode.zoomBackground],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -289,10 +327,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
child: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
@@ -300,98 +338,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.playlistName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.playlist_play,
|
||||
size: 14,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.tracksCount(_tracks.length),
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _tracks.isEmpty
|
||||
? null
|
||||
: () => _downloadAll(context),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.downloadAllCount(_tracks.length)),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.tracksHeader,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
// Info is now displayed in the full-screen cover overlay
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
|
||||
@@ -460,6 +408,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
|
||||
void _downloadTrack(BuildContext context, Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
@@ -487,22 +436,165 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadAll(BuildContext context) {
|
||||
// ── Shuffle / Love / Download buttons ──
|
||||
|
||||
Widget _buildCircleButton({
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
required VoidCallback? onPressed,
|
||||
}) {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: 22, color: Colors.white),
|
||||
tooltip: tooltip,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoveAllButton() {
|
||||
final collectionsState = ref.watch(libraryCollectionsProvider);
|
||||
final allLoved =
|
||||
_tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t));
|
||||
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks),
|
||||
icon: Icon(
|
||||
allLoved ? Icons.favorite : Icons.favorite_border,
|
||||
size: 22,
|
||||
color: allLoved ? Colors.redAccent : Colors.white,
|
||||
),
|
||||
tooltip: allLoved ? 'Remove from Loved' : 'Love All',
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadAllCenterButton(BuildContext context) {
|
||||
return FilledButton.icon(
|
||||
onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context),
|
||||
icon: const Icon(Icons.download_rounded, size: 18),
|
||||
label: Text(context.l10n.downloadAllCount(_tracks.length)),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
minimumSize: const Size(0, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddToPlaylistButton(BuildContext context) {
|
||||
return _buildCircleButton(
|
||||
icon: Icons.playlist_add,
|
||||
tooltip: 'Add to Playlist',
|
||||
onPressed: _tracks.isEmpty
|
||||
? null
|
||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDownloadAll(BuildContext context) {
|
||||
if (_tracks.isEmpty) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
final colorScheme = Theme.of(dialogContext).colorScheme;
|
||||
return AlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
title: const Text('Download All'),
|
||||
content: Text('Download ${_tracks.length} tracks?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
_downloadAll(context);
|
||||
},
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loveAll(List<Track> tracks) async {
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
final state = ref.read(libraryCollectionsProvider);
|
||||
final allLoved = tracks.every((t) => state.isLoved(t));
|
||||
|
||||
if (allLoved) {
|
||||
for (final track in tracks) {
|
||||
final key = trackCollectionKey(track);
|
||||
await notifier.removeFromLoved(key);
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
int addedCount = 0;
|
||||
for (final track in tracks) {
|
||||
if (!state.isLoved(track)) {
|
||||
await notifier.toggleLoved(track);
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added $addedCount tracks to Loved')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadAll(BuildContext context) {
|
||||
_downloadTracks(context, _tracks);
|
||||
}
|
||||
|
||||
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
||||
if (tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${_tracks.length} tracks',
|
||||
trackName: '${tracks.length} tracks',
|
||||
artistName: widget.playlistName,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(_tracks, service, qualityOverride: quality);
|
||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
|
||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -511,12 +603,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
} else {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(_tracks, settings.defaultService);
|
||||
.addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
|
||||
),
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -542,7 +632,12 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
|
||||
final isInHistory = ref.watch(
|
||||
downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
if (state.isDownloaded(track.id)) return true;
|
||||
final isrc = track.isrc?.trim();
|
||||
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
|
||||
return true;
|
||||
}
|
||||
return state.findByTrackAndArtist(track.name, track.artistName) != null;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -565,13 +660,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
: false;
|
||||
|
||||
final isQueued = queueItem != null;
|
||||
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||
final progress = queueItem?.progress ?? 0.0;
|
||||
|
||||
final showAsDownloaded =
|
||||
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
@@ -625,7 +713,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
if (isInLocalLibrary) ...[
|
||||
if (isInLocalLibrary || isInHistory) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -659,24 +747,12 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: _buildDownloadButton(
|
||||
trailing: TrackCollectionQuickActions(track: track),
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued),
|
||||
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
|
||||
context,
|
||||
ref,
|
||||
colorScheme,
|
||||
isQueued: isQueued,
|
||||
isDownloading: isDownloading,
|
||||
isFinalizing: isFinalizing,
|
||||
showAsDownloaded: showAsDownloaded,
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
progress: progress,
|
||||
),
|
||||
onTap: () => _handleTap(
|
||||
context,
|
||||
ref,
|
||||
isQueued: isQueued,
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
track,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -687,160 +763,84 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required bool isQueued,
|
||||
required bool isInHistory,
|
||||
required bool isInLocalLibrary,
|
||||
}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
if (isInLocalLibrary) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
|
||||
),
|
||||
);
|
||||
}
|
||||
final playedLocal = await _playLocalIfAvailable(context, ref);
|
||||
if (playedLocal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInHistory) {
|
||||
final historyItem = ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAlreadyDownloaded(track.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.removeBySpotifyId(track.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDownload();
|
||||
}
|
||||
|
||||
Widget _buildDownloadButton(
|
||||
Future<bool> _playLocalIfAvailable(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme, {
|
||||
required bool isQueued,
|
||||
required bool isDownloading,
|
||||
required bool isFinalizing,
|
||||
required bool showAsDownloaded,
|
||||
required bool isInHistory,
|
||||
required bool isInLocalLibrary,
|
||||
required double progress,
|
||||
}) {
|
||||
const double size = 44.0;
|
||||
const double iconSize = 20.0;
|
||||
) async {
|
||||
final localState = ref.read(localLibraryProvider);
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
|
||||
if (showAsDownloaded) {
|
||||
return GestureDetector(
|
||||
onTap: () => _handleTap(
|
||||
context,
|
||||
ref,
|
||||
isQueued: isQueued,
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
try {
|
||||
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
|
||||
track.id,
|
||||
);
|
||||
} else if (isFinalizing) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: colorScheme.tertiary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||
],
|
||||
),
|
||||
final isrc = track.isrc?.trim();
|
||||
historyItem ??= (isrc != null && isrc.isNotEmpty)
|
||||
? historyNotifier.getByIsrc(isrc)
|
||||
: null;
|
||||
historyItem ??= historyState.findByTrackAndArtist(
|
||||
track.name,
|
||||
track.artistName,
|
||||
);
|
||||
} else if (isDownloading) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: progress > 0 ? progress : null,
|
||||
strokeWidth: 3,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
if (progress > 0)
|
||||
Text(
|
||||
'${(progress * 100).toInt()}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (isQueued) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.hourglass_empty,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: iconSize,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return GestureDetector(
|
||||
onTap: onDownload,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.download,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
|
||||
if (historyItem != null) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playLocalPath(
|
||||
path: historyItem.filePath,
|
||||
title: track.name,
|
||||
artist: track.artistName,
|
||||
album: track.albumName,
|
||||
coverUrl: track.coverUrl ?? '',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
historyNotifier.removeFromHistory(historyItem.id);
|
||||
}
|
||||
|
||||
var localItem = (isrc != null && isrc.isNotEmpty)
|
||||
? localState.getByIsrc(isrc)
|
||||
: null;
|
||||
localItem ??= localState.findByTrackAndArtist(
|
||||
track.name,
|
||||
track.artistName,
|
||||
);
|
||||
|
||||
if (localItem != null && await fileExists(localItem.filePath)) {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playLocalPath(
|
||||
path: localItem.filePath,
|
||||
title: localItem.trackName,
|
||||
artist: localItem.artistName,
|
||||
album: localItem.albumName,
|
||||
coverUrl: localItem.coverPath ?? track.coverUrl ?? '',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+2581
-415
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ 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/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
final String query;
|
||||
@@ -61,9 +63,10 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
||||
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final tracks = trackState.tracks;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -86,15 +89,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (trackState.isLoading)
|
||||
LinearProgressIndicator(color: colorScheme.primary),
|
||||
if (trackState.error != null)
|
||||
if (isLoading) LinearProgressIndicator(color: colorScheme.primary),
|
||||
if (error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
trackState.error!,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
Expanded(
|
||||
child: tracks.isEmpty
|
||||
@@ -159,14 +158,19 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.artistName,
|
||||
ClickableArtistName(
|
||||
artistName: track.artistName,
|
||||
artistId: track.artistId,
|
||||
coverUrl: track.coverUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
Text(
|
||||
track.albumName,
|
||||
ClickableAlbumName(
|
||||
albumName: track.albumName,
|
||||
albumId: track.albumId,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
@@ -175,9 +179,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.download, color: colorScheme.primary),
|
||||
onPressed: () => _downloadTrack(track),
|
||||
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
|
||||
context,
|
||||
ref,
|
||||
track,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
tooltip: 'Download',
|
||||
onPressed: () => _downloadTrack(track),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _downloadTrack(track),
|
||||
);
|
||||
|
||||
@@ -149,6 +149,12 @@ class AboutPage extends StatelessWidget {
|
||||
subtitle:
|
||||
'Partner lyrics proxy for Apple Music and QQ Music sources',
|
||||
onTap: () => _launchUrl('https://lyrics.paxsenix.org'),
|
||||
showDivider: true,
|
||||
),
|
||||
_ContributorItem(
|
||||
name: 'Ruubiiiii',
|
||||
description: 'Provided Qobuz & Deezer API for the project',
|
||||
githubUsername: 'Ruubiiiii',
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -763,6 +763,7 @@ class _LanguageSelector extends StatelessWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
@@ -165,6 +166,7 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
const donorNames = <String>[];
|
||||
|
||||
// Match SettingsGroup color logic
|
||||
final cardColor = isDark
|
||||
@@ -207,17 +209,39 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DonorTile(name: 'J', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'Julian', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
||||
_DonorTile(name: '283Fabio', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'laflame', colorScheme: colorScheme),
|
||||
_DonorTile(
|
||||
name: 'Elias el Autentico',
|
||||
colorScheme: colorScheme,
|
||||
showDivider: false,
|
||||
),
|
||||
if (donorNames.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.emoji_events_outlined,
|
||||
size: 32,
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'No supporters yet — be the first!',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: donorNames
|
||||
.map(
|
||||
(name) =>
|
||||
_SupporterChip(name: name, colorScheme: colorScheme),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -273,6 +297,19 @@ class _DonateLinksCard extends StatelessWidget {
|
||||
url: AppInfo.githubSponsorsUrl,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 74,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
_CryptoWalletItem(
|
||||
title: 'USDT (TRC20)',
|
||||
walletAddress: 'TL7iAqjq9M8BwVMi9AtHvuAGHtdwEvsDta',
|
||||
color: const Color(0xFF26A17B),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -348,55 +385,166 @@ class _DonateCardItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DonorTile extends StatelessWidget {
|
||||
final String name;
|
||||
class _CryptoWalletItem extends StatelessWidget {
|
||||
final String title;
|
||||
final String walletAddress;
|
||||
final Color color;
|
||||
final ColorScheme colorScheme;
|
||||
final bool showDivider;
|
||||
|
||||
const _DonorTile({
|
||||
required this.name,
|
||||
const _CryptoWalletItem({
|
||||
required this.title,
|
||||
required this.walletAddress,
|
||||
required this.color,
|
||||
required this.colorScheme,
|
||||
this.showDivider = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: walletAddress));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$title address copied to clipboard'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
'\$',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
walletAddress,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.copy_rounded,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int _cr(String v) {
|
||||
int r = 0x1F;
|
||||
for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; }
|
||||
return r;
|
||||
}
|
||||
// Highlighted supporters (hashes of names): none for now.
|
||||
const _cv = <int>{};
|
||||
|
||||
class _SupporterChip extends StatelessWidget {
|
||||
final String name;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _SupporterChip({required this.name, required this.colorScheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final e = _cv.contains(_cr(name));
|
||||
const goldChipColor = Color(0xFFFFF8DC);
|
||||
const goldAccentColor = Color(0xFFB8860B);
|
||||
const goldDarkChipColor = Color(0xFF3A3000);
|
||||
|
||||
final chipColor = e
|
||||
? goldChipColor
|
||||
: colorScheme.secondaryContainer;
|
||||
final accentColor = e
|
||||
? goldAccentColor
|
||||
: colorScheme.primary;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final effectiveChipColor = e && isDark
|
||||
? goldDarkChipColor
|
||||
: chipColor;
|
||||
|
||||
return Material(
|
||||
color: effectiveChipColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
decoration: e
|
||||
? BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: accentColor.withValues(alpha: 0.4),
|
||||
width: 1,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: accentColor.withValues(alpha: 0.2),
|
||||
child: e
|
||||
? Icon(Icons.star_rounded, size: 12, color: accentColor)
|
||||
: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: e
|
||||
? accentColor
|
||||
: colorScheme.onSecondaryContainer,
|
||||
fontWeight: e ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,207 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon', 'deezer'];
|
||||
static const _songLinkRegions = [
|
||||
'AD',
|
||||
'AE',
|
||||
'AG',
|
||||
'AL',
|
||||
'AM',
|
||||
'AO',
|
||||
'AR',
|
||||
'AT',
|
||||
'AU',
|
||||
'AZ',
|
||||
'BA',
|
||||
'BB',
|
||||
'BD',
|
||||
'BE',
|
||||
'BF',
|
||||
'BG',
|
||||
'BH',
|
||||
'BI',
|
||||
'BJ',
|
||||
'BN',
|
||||
'BO',
|
||||
'BR',
|
||||
'BS',
|
||||
'BT',
|
||||
'BW',
|
||||
'BZ',
|
||||
'CA',
|
||||
'CD',
|
||||
'CG',
|
||||
'CH',
|
||||
'CI',
|
||||
'CL',
|
||||
'CM',
|
||||
'CO',
|
||||
'CR',
|
||||
'CV',
|
||||
'CW',
|
||||
'CY',
|
||||
'CZ',
|
||||
'DE',
|
||||
'DJ',
|
||||
'DK',
|
||||
'DM',
|
||||
'DO',
|
||||
'DZ',
|
||||
'EC',
|
||||
'EE',
|
||||
'EG',
|
||||
'ES',
|
||||
'ET',
|
||||
'FI',
|
||||
'FJ',
|
||||
'FM',
|
||||
'FR',
|
||||
'GA',
|
||||
'GB',
|
||||
'GD',
|
||||
'GE',
|
||||
'GH',
|
||||
'GM',
|
||||
'GN',
|
||||
'GQ',
|
||||
'GR',
|
||||
'GT',
|
||||
'GW',
|
||||
'GY',
|
||||
'HK',
|
||||
'HN',
|
||||
'HR',
|
||||
'HT',
|
||||
'HU',
|
||||
'ID',
|
||||
'IE',
|
||||
'IL',
|
||||
'IN',
|
||||
'IQ',
|
||||
'IS',
|
||||
'IT',
|
||||
'JM',
|
||||
'JO',
|
||||
'JP',
|
||||
'KE',
|
||||
'KG',
|
||||
'KH',
|
||||
'KI',
|
||||
'KM',
|
||||
'KN',
|
||||
'KR',
|
||||
'KW',
|
||||
'KZ',
|
||||
'LA',
|
||||
'LB',
|
||||
'LC',
|
||||
'LI',
|
||||
'LK',
|
||||
'LR',
|
||||
'LS',
|
||||
'LT',
|
||||
'LU',
|
||||
'LV',
|
||||
'LY',
|
||||
'MA',
|
||||
'MC',
|
||||
'MD',
|
||||
'ME',
|
||||
'MG',
|
||||
'MH',
|
||||
'MK',
|
||||
'ML',
|
||||
'MN',
|
||||
'MO',
|
||||
'MR',
|
||||
'MT',
|
||||
'MU',
|
||||
'MV',
|
||||
'MW',
|
||||
'MX',
|
||||
'MY',
|
||||
'MZ',
|
||||
'NA',
|
||||
'NE',
|
||||
'NG',
|
||||
'NI',
|
||||
'NL',
|
||||
'NO',
|
||||
'NP',
|
||||
'NR',
|
||||
'NZ',
|
||||
'OM',
|
||||
'PA',
|
||||
'PE',
|
||||
'PG',
|
||||
'PH',
|
||||
'PK',
|
||||
'PL',
|
||||
'PS',
|
||||
'PT',
|
||||
'PW',
|
||||
'PY',
|
||||
'QA',
|
||||
'RO',
|
||||
'RS',
|
||||
'RW',
|
||||
'SA',
|
||||
'SB',
|
||||
'SC',
|
||||
'SE',
|
||||
'SG',
|
||||
'SI',
|
||||
'SK',
|
||||
'SL',
|
||||
'SM',
|
||||
'SN',
|
||||
'SR',
|
||||
'ST',
|
||||
'SV',
|
||||
'SZ',
|
||||
'TD',
|
||||
'TG',
|
||||
'TH',
|
||||
'TJ',
|
||||
'TL',
|
||||
'TN',
|
||||
'TO',
|
||||
'TR',
|
||||
'TT',
|
||||
'TV',
|
||||
'TW',
|
||||
'TZ',
|
||||
'UA',
|
||||
'UG',
|
||||
'US',
|
||||
'UY',
|
||||
'UZ',
|
||||
'VC',
|
||||
'VE',
|
||||
'VN',
|
||||
'VU',
|
||||
'WS',
|
||||
'XK',
|
||||
'ZA',
|
||||
'ZM',
|
||||
'ZW',
|
||||
];
|
||||
static const _songLinkRegionNames = <String, String>{
|
||||
'US': 'United States',
|
||||
'GB': 'United Kingdom',
|
||||
'FR': 'France',
|
||||
'DE': 'Germany',
|
||||
'JP': 'Japan',
|
||||
'KR': 'South Korea',
|
||||
'IN': 'India',
|
||||
'ID': 'Indonesia',
|
||||
'BR': 'Brazil',
|
||||
'MX': 'Mexico',
|
||||
'AU': 'Australia',
|
||||
'CA': 'Canada',
|
||||
'XK': 'Kosovo',
|
||||
};
|
||||
int _androidSdkVersion = 0;
|
||||
bool _hasAllFilesAccess = false;
|
||||
bool _artistFolderFiltersExpanded = false;
|
||||
@@ -261,6 +461,33 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
),
|
||||
),
|
||||
],
|
||||
SettingsItem(
|
||||
title: context.l10n.youtubeOpusBitrateTitle,
|
||||
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256)',
|
||||
onTap: () => _showYoutubeBitratePicker(
|
||||
context: context,
|
||||
title: context.l10n.youtubeOpusBitrateTitle,
|
||||
currentValue: settings.youtubeOpusBitrate,
|
||||
options: const [128, 256],
|
||||
onSave: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setYoutubeOpusBitrate(value),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
title: context.l10n.youtubeMp3BitrateTitle,
|
||||
subtitle: '${settings.youtubeMp3Bitrate}kbps (128/256/320)',
|
||||
onTap: () => _showYoutubeBitratePicker(
|
||||
context: context,
|
||||
title: context.l10n.youtubeMp3BitrateTitle,
|
||||
currentValue: settings.youtubeMp3Bitrate,
|
||||
options: const [128, 256, 320],
|
||||
onSave: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setYoutubeMp3Bitrate(value),
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -271,73 +498,93 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.lyricsMode,
|
||||
subtitle: _getLyricsModeLabel(context, settings.lyricsMode),
|
||||
onTap: () => _showLyricsModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.subtitles_outlined,
|
||||
title: context.l10n.optionsEmbedLyrics,
|
||||
subtitle: settings.embedMetadata
|
||||
? context.l10n.optionsEmbedLyricsSubtitle
|
||||
: 'Disabled while Embed Metadata is turned off',
|
||||
value: settings.embedLyrics,
|
||||
enabled: settings.embedMetadata,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setEmbedLyrics(value),
|
||||
showDivider: settings.embedMetadata && settings.embedLyrics,
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.source_outlined,
|
||||
title: 'Lyrics Providers',
|
||||
subtitle: _getLyricsProvidersSubtitle(settings.lyricsProviders),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LyricsProviderPriorityPage(),
|
||||
if (settings.embedMetadata && settings.embedLyrics) ...[
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.lyricsMode,
|
||||
subtitle: _getLyricsModeLabel(
|
||||
context,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
onTap: () => _showLyricsModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.translate_outlined,
|
||||
title: 'Netease: Include Translation',
|
||||
subtitle: settings.lyricsIncludeTranslationNetease
|
||||
? 'Append translated lyrics when available'
|
||||
: 'Use original lyrics only',
|
||||
value: settings.lyricsIncludeTranslationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsIncludeTranslationNetease(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.text_fields_outlined,
|
||||
title: 'Netease: Include Romanization',
|
||||
subtitle: settings.lyricsIncludeRomanizationNetease
|
||||
? 'Append romanized lyrics when available'
|
||||
: 'Disabled',
|
||||
value: settings.lyricsIncludeRomanizationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsIncludeRomanizationNetease(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.record_voice_over_outlined,
|
||||
title: 'Apple/QQ Multi-Person Word-by-Word',
|
||||
subtitle: settings.lyricsMultiPersonWordByWord
|
||||
? 'Enable v1/v2 speaker and [bg:] tags'
|
||||
: 'Simplified word-by-word formatting',
|
||||
value: settings.lyricsMultiPersonWordByWord,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsMultiPersonWordByWord(value),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.language_outlined,
|
||||
title: 'Musixmatch Language',
|
||||
subtitle: settings.musixmatchLanguage.isEmpty
|
||||
? 'Auto (original)'
|
||||
: settings.musixmatchLanguage.toUpperCase(),
|
||||
onTap: () => _showMusixmatchLanguagePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.musixmatchLanguage,
|
||||
SettingsItem(
|
||||
icon: Icons.source_outlined,
|
||||
title: 'Lyrics Providers',
|
||||
subtitle: _getLyricsProvidersSubtitle(
|
||||
settings.lyricsProviders,
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LyricsProviderPriorityPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.translate_outlined,
|
||||
title: 'Netease: Include Translation',
|
||||
subtitle: settings.lyricsIncludeTranslationNetease
|
||||
? 'Append translated lyrics when available'
|
||||
: 'Use original lyrics only',
|
||||
value: settings.lyricsIncludeTranslationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsIncludeTranslationNetease(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.text_fields_outlined,
|
||||
title: 'Netease: Include Romanization',
|
||||
subtitle: settings.lyricsIncludeRomanizationNetease
|
||||
? 'Append romanized lyrics when available'
|
||||
: 'Disabled',
|
||||
value: settings.lyricsIncludeRomanizationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsIncludeRomanizationNetease(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.record_voice_over_outlined,
|
||||
title: 'Apple/QQ Multi-Person Word-by-Word',
|
||||
subtitle: settings.lyricsMultiPersonWordByWord
|
||||
? 'Enable v1/v2 speaker and [bg:] tags'
|
||||
: 'Simplified word-by-word formatting',
|
||||
value: settings.lyricsMultiPersonWordByWord,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsMultiPersonWordByWord(value),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.language_outlined,
|
||||
title: 'Musixmatch Language',
|
||||
subtitle: settings.musixmatchLanguage.isEmpty
|
||||
? 'Auto (original)'
|
||||
: settings.musixmatchLanguage.toUpperCase(),
|
||||
onTap: () => _showMusixmatchLanguagePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.musixmatchLanguage,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -504,6 +751,29 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
settings.downloadNetworkMode,
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.public,
|
||||
title: 'SongLink Region',
|
||||
subtitle: _getSongLinkRegionLabel(settings.songLinkRegion),
|
||||
onTap: () => _showSongLinkRegionPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.songLinkRegion,
|
||||
),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.security_outlined,
|
||||
title: 'Network compatibility mode',
|
||||
subtitle: settings.networkCompatibilityMode
|
||||
? 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'
|
||||
: 'Off: strict HTTPS certificate validation (recommended)',
|
||||
value: settings.networkCompatibilityMode,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setNetworkCompatibilityMode(value);
|
||||
},
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.file_download_outlined,
|
||||
title: context.l10n.settingsAutoExportFailed,
|
||||
@@ -603,6 +873,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -737,6 +1008,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
@@ -954,6 +1226,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1033,6 +1306,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1180,6 +1454,14 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
String _getSongLinkRegionLabel(String code) {
|
||||
final normalized = code.trim().toUpperCase();
|
||||
final effective = normalized.isEmpty ? 'US' : normalized;
|
||||
final name = _songLinkRegionNames[effective];
|
||||
if (name == null) return effective;
|
||||
return '$effective - $name';
|
||||
}
|
||||
|
||||
void _showLyricsModePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -1188,6 +1470,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1253,6 +1536,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
}
|
||||
|
||||
static const _providerDisplayNames = <String, String>{
|
||||
'spotify_api': 'Spotify Lyrics API',
|
||||
'lrclib': 'LRCLIB',
|
||||
'netease': 'Netease',
|
||||
'musixmatch': 'Musixmatch',
|
||||
@@ -1262,9 +1546,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
|
||||
String _getLyricsProvidersSubtitle(List<String> providers) {
|
||||
if (providers.isEmpty) return 'None enabled';
|
||||
return providers
|
||||
.map((p) => _providerDisplayNames[p] ?? p)
|
||||
.join(' > ');
|
||||
return providers.map((p) => _providerDisplayNames[p] ?? p).join(' > ');
|
||||
}
|
||||
|
||||
String _normalizeMusixmatchLanguage(String value) {
|
||||
@@ -1272,6 +1554,68 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), '');
|
||||
}
|
||||
|
||||
void _showYoutubeBitratePicker({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required int currentValue,
|
||||
required List<int> options,
|
||||
required void Function(int value) onSave,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(sheetContext).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final bitrate in options)
|
||||
ListTile(
|
||||
title: Text('$bitrate kbps'),
|
||||
trailing: bitrate == currentValue
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
onSave(bitrate);
|
||||
Navigator.pop(sheetContext);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMusixmatchLanguagePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -1282,6 +1626,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1300,9 +1645,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
children: [
|
||||
Text(
|
||||
'Musixmatch Language',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
@@ -1331,7 +1676,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(settingsProvider.notifier).setMusixmatchLanguage('');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setMusixmatchLanguage('');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Auto'),
|
||||
@@ -1378,6 +1725,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1462,6 +1810,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1524,6 +1873,75 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showSongLinkRegionPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final normalizedCurrent = current.trim().toUpperCase();
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
'SongLink Region',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'Used as userCountry for SongLink API lookup.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _songLinkRegions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final code = _songLinkRegions[index];
|
||||
final isSelected = code == normalizedCurrent;
|
||||
final displayName = _songLinkRegionNames[code];
|
||||
return ListTile(
|
||||
title: Text(code),
|
||||
subtitle: displayName != null ? Text(displayName) : null,
|
||||
trailing: isSelected
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSongLinkRegion(code);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFolderOrganizationPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -1532,6 +1950,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
@@ -1632,16 +2051,13 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'deezer', 'youtube'];
|
||||
|
||||
final extensionProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||
.toList();
|
||||
|
||||
final isExtensionService = ![
|
||||
'tidal',
|
||||
'qobuz',
|
||||
'amazon',
|
||||
].contains(currentService);
|
||||
final isExtensionService = !builtInServiceIds.contains(currentService);
|
||||
final isCurrentExtensionEnabled = isExtensionService
|
||||
? extensionProviders.any((e) => e.id == currentService)
|
||||
: true;
|
||||
@@ -1654,47 +2070,56 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_ServiceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Tidal',
|
||||
isSelected: effectiveService == 'tidal',
|
||||
onTap: () => onChanged('tidal'),
|
||||
Expanded(
|
||||
child: _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'),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: effectiveService == 'qobuz',
|
||||
onTap: () => onChanged('qobuz'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.shopping_bag_outlined,
|
||||
label: 'Amazon',
|
||||
isSelected: effectiveService == 'amazon',
|
||||
onTap: () => onChanged('amazon'),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.shopping_bag_outlined,
|
||||
label: 'Amazon',
|
||||
isSelected: effectiveService == 'amazon',
|
||||
onTap: () => onChanged('amazon'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.smart_display,
|
||||
label: 'YouTube',
|
||||
isSelected: effectiveService == 'youtube',
|
||||
onTap: () => onChanged('youtube'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (extensionProviders.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
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),
|
||||
),
|
||||
for (final extension in extensionProviders)
|
||||
_ServiceChip(
|
||||
icon: Icons.extension,
|
||||
label: extension.displayName,
|
||||
isSelected: effectiveService == extension.id,
|
||||
onTap: () => onChanged(extension.id),
|
||||
),
|
||||
],
|
||||
for (int i = extensionProviders.length; i < 3; i++) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1728,38 +2153,35 @@ class _ServiceChip extends StatelessWidget {
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
return Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,12 +20,15 @@ class ExtensionsPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
static final RegExp _platformExceptionPattern =
|
||||
RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),');
|
||||
static final RegExp _platformExceptionSimplePattern =
|
||||
RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null');
|
||||
static final RegExp _trailingNullsPattern =
|
||||
RegExp(r',\s*null\s*,\s*null\)?$');
|
||||
static final RegExp _platformExceptionPattern = RegExp(
|
||||
r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),',
|
||||
);
|
||||
static final RegExp _platformExceptionSimplePattern = RegExp(
|
||||
r'PlatformException\([^,]+,\s*(.+?),\s*null',
|
||||
);
|
||||
static final RegExp _trailingNullsPattern = RegExp(
|
||||
r',\s*null\s*,\s*null\)?$',
|
||||
);
|
||||
static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*');
|
||||
|
||||
@override
|
||||
@@ -40,11 +43,13 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
|
||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.initialize(extensionsDir, dataDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,67 +64,205 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
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(
|
||||
context.l10n.extensionsTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
title: Text(
|
||||
context.l10n.extensionsTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.error != null)
|
||||
if (extState.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.extensionsProviderPrioritySection,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_DownloadPriorityItem(),
|
||||
_MetadataPriorityItem(),
|
||||
_SearchProviderSelector(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.extensionsInstalledSection,
|
||||
),
|
||||
),
|
||||
|
||||
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(
|
||||
context.l10n.extensionsNoExtensions,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.extensionsNoExtensionsSubtitle,
|
||||
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(),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _installExtension,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(context.l10n.extensionsInstallButton),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
extState.error!,
|
||||
style: TextStyle(color: colorScheme.onErrorContainer),
|
||||
context.l10n.extensionsInfoTip,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -127,131 +270,9 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_DownloadPriorityItem(),
|
||||
_MetadataPriorityItem(),
|
||||
_SearchProviderSelector(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
|
||||
),
|
||||
|
||||
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(
|
||||
context.l10n.extensionsNoExtensions,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.extensionsNoExtensionsSubtitle,
|
||||
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(),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _installExtension,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(context.l10n.extensionsInstallButton),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
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(
|
||||
context.l10n.extensionsInfoTip,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,9 +288,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
if (!file.path!.endsWith('.spotiflac-ext')) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarSelectExtFile),
|
||||
),
|
||||
SnackBar(content: Text(context.l10n.snackbarSelectExtFile)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -287,12 +306,12 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
} else {
|
||||
message = _getFriendlyErrorMessage(extState.error);
|
||||
}
|
||||
|
||||
|
||||
ref.read(extensionProvider.notifier).clearError();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,9 +320,9 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
/// Parse error message to be more user-friendly
|
||||
String _getFriendlyErrorMessage(String? error) {
|
||||
if (error == null) return 'Failed to install extension';
|
||||
|
||||
|
||||
String message = error;
|
||||
|
||||
|
||||
if (message.contains('PlatformException')) {
|
||||
final match = _platformExceptionPattern.firstMatch(message);
|
||||
if (match != null) {
|
||||
@@ -315,10 +334,10 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
message = message.replaceAll(_trailingNullsPattern, '');
|
||||
message = message.replaceAll(_leadingCommaPattern, '');
|
||||
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -359,7 +378,9 @@ class _ExtensionItem extends StatelessWidget {
|
||||
: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: extension.iconPath != null && extension.iconPath!.isNotEmpty
|
||||
child:
|
||||
extension.iconPath != null &&
|
||||
extension.iconPath!.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(
|
||||
@@ -396,7 +417,8 @@ class _ExtensionItem extends StatelessWidget {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasError
|
||||
? extension.errorMessage ?? context.l10n.extensionsErrorLoading
|
||||
? extension.errorMessage ??
|
||||
context.l10n.extensionsErrorLoading
|
||||
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: hasError
|
||||
@@ -435,17 +457,16 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final hasDownloadExtensions = extState.extensions
|
||||
.any((e) => e.enabled && e.hasDownloadProvider);
|
||||
|
||||
|
||||
final hasDownloadExtensions = extState.extensions.any(
|
||||
(e) => e.enabled && e.hasDownloadProvider,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: hasDownloadExtensions
|
||||
onTap: hasDownloadExtensions
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ProviderPriorityPage(),
|
||||
),
|
||||
MaterialPageRoute(builder: (_) => const ProviderPriorityPage()),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
@@ -454,8 +475,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(
|
||||
Icons.download,
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -466,14 +487,12 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
Text(
|
||||
context.l10n.extensionsDownloadPriority,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: hasDownloadExtensions
|
||||
? null
|
||||
: colorScheme.outline,
|
||||
color: hasDownloadExtensions ? null : colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasDownloadExtensions
|
||||
hasDownloadExtensions
|
||||
? context.l10n.extensionsDownloadPrioritySubtitle
|
||||
: context.l10n.extensionsNoDownloadProvider,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
@@ -485,8 +504,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
],
|
||||
@@ -503,12 +522,13 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final hasMetadataExtensions = extState.extensions
|
||||
.any((e) => e.enabled && e.hasMetadataProvider);
|
||||
|
||||
|
||||
final hasMetadataExtensions = extState.extensions.any(
|
||||
(e) => e.enabled && e.hasMetadataProvider,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: hasMetadataExtensions
|
||||
onTap: hasMetadataExtensions
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -522,8 +542,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -534,14 +554,12 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
Text(
|
||||
context.l10n.extensionsMetadataPriority,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: hasMetadataExtensions
|
||||
? null
|
||||
: colorScheme.outline,
|
||||
color: hasMetadataExtensions ? null : colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasMetadataExtensions
|
||||
hasMetadataExtensions
|
||||
? context.l10n.extensionsMetadataPrioritySubtitle
|
||||
: context.l10n.extensionsNoMetadataProvider,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
@@ -553,8 +571,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
],
|
||||
@@ -572,32 +590,40 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
|
||||
final searchProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasCustomSearch)
|
||||
.toList();
|
||||
|
||||
|
||||
String currentProviderName = context.l10n.extensionDefaultProvider;
|
||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
|
||||
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),
|
||||
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
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -608,14 +634,14 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
Text(
|
||||
context.l10n.extensionsSearchProvider,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
searchProviders.isEmpty
|
||||
searchProviders.isEmpty
|
||||
? context.l10n.extensionsNoCustomSearch
|
||||
: currentProviderName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
@@ -627,8 +653,8 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
@@ -646,9 +672,10 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
List<Extension> searchProviders,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -662,9 +689,9 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
ctx.l10n.extensionsSearchProvider,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
@@ -680,7 +707,9 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||
title: Text(ctx.l10n.extensionDefaultProvider),
|
||||
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
|
||||
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
|
||||
trailing:
|
||||
(settings.searchProvider == null ||
|
||||
settings.searchProvider!.isEmpty)
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
@@ -688,18 +717,23 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
...searchProviders.map((ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
title: Text(ext.displayName),
|
||||
subtitle: Text(ext.searchBehavior?.placeholder ?? ctx.l10n.extensionsCustomSearch),
|
||||
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);
|
||||
},
|
||||
)),
|
||||
...searchProviders.map(
|
||||
(ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
title: Text(ext.displayName),
|
||||
subtitle: Text(
|
||||
ext.searchBehavior?.placeholder ??
|
||||
ctx.l10n.extensionsCustomSearch,
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -656,14 +656,8 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isScanning
|
||||
? context.l10n
|
||||
.libraryTracksCount(scannedFiles)
|
||||
.replaceAll(scannedFiles.toString(), '')
|
||||
.trim()
|
||||
: context.l10n
|
||||
.libraryTracksCount(displayCount)
|
||||
.replaceAll(displayCount.toString(), '')
|
||||
.trim(),
|
||||
? context.l10n.libraryTracksUnit(scannedFiles)
|
||||
: context.l10n.libraryTracksUnit(displayCount),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class LyricsProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
@@ -16,6 +16,7 @@ class _LyricsProviderPriorityPageState
|
||||
extends ConsumerState<LyricsProviderPriorityPage> {
|
||||
static const _allProviderIds = [
|
||||
'lrclib',
|
||||
'spotify_api',
|
||||
'netease',
|
||||
'musixmatch',
|
||||
'apple_music',
|
||||
@@ -26,9 +27,8 @@ class _LyricsProviderPriorityPageState
|
||||
late List<String> _initialProviders;
|
||||
bool _hasChanges = false;
|
||||
|
||||
List<String> get _disabledProviders => _allProviderIds
|
||||
.where((id) => !_enabledProviders.contains(id))
|
||||
.toList();
|
||||
List<String> get _disabledProviders =>
|
||||
_allProviderIds.where((id) => !_enabledProviders.contains(id)).toList();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -39,204 +39,86 @@ class _LyricsProviderPriorityPageState
|
||||
}
|
||||
|
||||
void _markChanged() {
|
||||
final changed = _enabledProviders.length != _initialProviders.length ||
|
||||
!_enabledProviders
|
||||
.asMap()
|
||||
.entries
|
||||
.every((e) =>
|
||||
e.key < _initialProviders.length &&
|
||||
_initialProviders[e.key] == e.value);
|
||||
final changed =
|
||||
_enabledProviders.length != _initialProviders.length ||
|
||||
!_enabledProviders.asMap().entries.every(
|
||||
(e) =>
|
||||
e.key < _initialProviders.length &&
|
||||
_initialProviders[e.key] == e.value,
|
||||
);
|
||||
setState(() => _hasChanges = changed);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final disabled = _disabledProviders;
|
||||
|
||||
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: [
|
||||
// ── Collapsing 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(
|
||||
'Lyrics Providers',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
return PrioritySettingsScaffold(
|
||||
hasChanges: _hasChanges,
|
||||
title: 'Lyrics Providers',
|
||||
description:
|
||||
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.',
|
||||
infoText:
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.',
|
||||
onSave: _saveChanges,
|
||||
onConfirmDiscard: _confirmDiscard,
|
||||
slivers: [
|
||||
if (_enabledProviders.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: 'Enabled (${_enabledProviders.length})',
|
||||
),
|
||||
|
||||
// ── Description ──
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: Text(
|
||||
'Enable, disable and reorder lyrics sources. '
|
||||
'Providers are tried top-to-bottom until lyrics are found.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_enabledProviders.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: _enabledProviders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final id = _enabledProviders[index];
|
||||
final info = _getLyricsProviderInfo(id);
|
||||
return _EnabledProviderItem(
|
||||
key: ValueKey(id),
|
||||
providerId: id,
|
||||
info: info,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
onToggle: () => _disableProvider(id),
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final item = _enabledProviders.removeAt(oldIndex);
|
||||
_enabledProviders.insert(newIndex, item);
|
||||
});
|
||||
_markChanged();
|
||||
},
|
||||
),
|
||||
|
||||
// ── Enabled section header ──
|
||||
if (_enabledProviders.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: 'Enabled (${_enabledProviders.length})',
|
||||
),
|
||||
),
|
||||
|
||||
// ── Reorderable enabled list ──
|
||||
if (_enabledProviders.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: _enabledProviders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final id = _enabledProviders[index];
|
||||
final info = _getLyricsProviderInfo(id);
|
||||
return _EnabledProviderItem(
|
||||
key: ValueKey(id),
|
||||
providerId: id,
|
||||
info: info,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
onToggle: () => _disableProvider(id),
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final item = _enabledProviders.removeAt(oldIndex);
|
||||
_enabledProviders.insert(newIndex, item);
|
||||
});
|
||||
_markChanged();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── Disabled section header ──
|
||||
if (disabled.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: 'Disabled (${disabled.length})',
|
||||
),
|
||||
),
|
||||
|
||||
// ── Disabled list ──
|
||||
if (disabled.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final id = disabled[index];
|
||||
final info = _getLyricsProviderInfo(id);
|
||||
return _DisabledProviderItem(
|
||||
key: ValueKey(id),
|
||||
providerId: id,
|
||||
info: info,
|
||||
onToggle: () => _enableProvider(id),
|
||||
);
|
||||
},
|
||||
childCount: disabled.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Info banner ──
|
||||
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(
|
||||
'Extension lyrics providers always run before '
|
||||
'built-in providers. At least one provider must '
|
||||
'remain enabled.',
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (disabled.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: 'Disabled (${disabled.length})',
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (disabled.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final id = disabled[index];
|
||||
final info = _getLyricsProviderInfo(id);
|
||||
return _DisabledProviderItem(
|
||||
key: ValueKey(id),
|
||||
providerId: id,
|
||||
info: info,
|
||||
onToggle: () => _enableProvider(id),
|
||||
);
|
||||
}, childCount: disabled.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,8 +164,7 @@ class _LyricsProviderPriorityPageState
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Discard changes?'),
|
||||
content:
|
||||
const Text('You have unsaved changes that will be lost.'),
|
||||
content: const Text('You have unsaved changes that will be lost.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
@@ -303,6 +184,12 @@ class _LyricsProviderPriorityPageState
|
||||
|
||||
static _LyricsProviderInfo _getLyricsProviderInfo(String id) {
|
||||
switch (id) {
|
||||
case 'spotify_api':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'Spotify Lyrics API',
|
||||
description: 'Spotify-sourced synced lyrics via community API',
|
||||
icon: Icons.music_note_outlined,
|
||||
);
|
||||
case 'lrclib':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'LRCLIB',
|
||||
@@ -419,17 +306,15 @@ class _EnabledProviderItem extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.description,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -438,18 +323,12 @@ class _EnabledProviderItem extends StatelessWidget {
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: FittedBox(
|
||||
child: Switch(
|
||||
value: true,
|
||||
onChanged: (_) => onToggle(),
|
||||
),
|
||||
child: Switch(value: true, onChanged: (_) => onToggle()),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Drag handle
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -498,8 +377,7 @@ class _DisabledProviderItem extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onToggle,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Empty space aligned with numbered badge
|
||||
@@ -515,9 +393,7 @@ class _DisabledProviderItem extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
@@ -525,12 +401,8 @@ class _DisabledProviderItem extends StatelessWidget {
|
||||
),
|
||||
Text(
|
||||
info.description,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -539,10 +411,7 @@ class _DisabledProviderItem extends StatelessWidget {
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: FittedBox(
|
||||
child: Switch(
|
||||
value: false,
|
||||
onChanged: (_) => onToggle(),
|
||||
),
|
||||
child: Switch(value: false, onChanged: (_) => onToggle()),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,16 +2,18 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
|
||||
|
||||
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const MetadataProviderPriorityPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MetadataProviderPriorityPage> createState() => _MetadataProviderPriorityPageState();
|
||||
ConsumerState<MetadataProviderPriorityPage> createState() =>
|
||||
_MetadataProviderPriorityPageState();
|
||||
}
|
||||
|
||||
class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderPriorityPage> {
|
||||
class _MetadataProviderPriorityPageState
|
||||
extends ConsumerState<MetadataProviderPriorityPage> {
|
||||
late List<String> _providers;
|
||||
bool _hasChanges = false;
|
||||
|
||||
@@ -23,8 +25,10 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
||||
|
||||
void _loadProviders() {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final allProviders = ref.read(extensionProvider.notifier).getAllMetadataProviders();
|
||||
|
||||
final allProviders = ref
|
||||
.read(extensionProvider.notifier)
|
||||
.getAllMetadataProviders();
|
||||
|
||||
if (extState.metadataProviderPriority.isNotEmpty) {
|
||||
_providers = List.from(extState.metadataProviderPriority);
|
||||
for (final provider in allProviders) {
|
||||
@@ -40,142 +44,43 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
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: [
|
||||
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: Text(context.l10n.dialogSave),
|
||||
),
|
||||
],
|
||||
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(
|
||||
context.l10n.metadataProviderPriorityTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
context.l10n.metadataProviderPriorityDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
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;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
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(
|
||||
context.l10n.metadataProviderPriorityInfo,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
return PrioritySettingsScaffold(
|
||||
hasChanges: _hasChanges,
|
||||
title: context.l10n.metadataProviderPriorityTitle,
|
||||
description: context.l10n.metadataProviderPriorityDescription,
|
||||
descriptionPadding: const EdgeInsets.all(16),
|
||||
infoText: context.l10n.metadataProviderPriorityInfo,
|
||||
saveLabel: context.l10n.dialogSave,
|
||||
onSave: _saveChanges,
|
||||
onConfirmDiscard: _confirmDiscard,
|
||||
slivers: [
|
||||
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;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,7 +106,9 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
||||
}
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
await ref.read(extensionProvider.notifier).setMetadataProviderPriority(_providers);
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.setMetadataProviderPriority(_providers);
|
||||
setState(() {
|
||||
_hasChanges = false;
|
||||
});
|
||||
@@ -300,10 +207,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -312,7 +216,10 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
_MetadataProviderInfo _getProviderInfo(BuildContext context, String provider) {
|
||||
_MetadataProviderInfo _getProviderInfo(
|
||||
BuildContext context,
|
||||
String provider,
|
||||
) {
|
||||
switch (provider) {
|
||||
case 'deezer':
|
||||
return _MetadataProviderInfo(
|
||||
|
||||
@@ -164,11 +164,24 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
.read(settingsProvider.notifier)
|
||||
.setUseExtensionProviders(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.sell_outlined,
|
||||
title: 'Embed Metadata',
|
||||
subtitle: settings.embedMetadata
|
||||
? 'Write metadata, cover art, and embedded lyrics to files'
|
||||
: 'Disabled (advanced): skip all metadata embedding',
|
||||
value: settings.embedMetadata,
|
||||
onChanged: (v) =>
|
||||
ref.read(settingsProvider.notifier).setEmbedMetadata(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.image,
|
||||
title: context.l10n.optionsMaxQualityCover,
|
||||
subtitle: context.l10n.optionsMaxQualityCoverSubtitle,
|
||||
subtitle: settings.embedMetadata
|
||||
? context.l10n.optionsMaxQualityCoverSubtitle
|
||||
: 'Disabled when metadata embedding is off',
|
||||
value: settings.maxQualityCover,
|
||||
enabled: settings.embedMetadata,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setMaxQualityCover(v),
|
||||
@@ -375,6 +388,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
@@ -972,9 +986,9 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.optionsSpotifyDeprecationWarning,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -8,7 +8,8 @@ class ProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const ProviderPriorityPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProviderPriorityPage> createState() => _ProviderPriorityPageState();
|
||||
ConsumerState<ProviderPriorityPage> createState() =>
|
||||
_ProviderPriorityPageState();
|
||||
}
|
||||
|
||||
class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
@@ -23,8 +24,10 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
|
||||
void _loadProviders() {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
|
||||
|
||||
final allProviders = ref
|
||||
.read(extensionProvider.notifier)
|
||||
.getAllDownloadProviders();
|
||||
|
||||
if (extState.providerPriority.isNotEmpty) {
|
||||
_providers = List.from(extState.providerPriority);
|
||||
for (final provider in allProviders) {
|
||||
@@ -86,13 +89,17 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
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);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.providerPriorityTitle,
|
||||
style: TextStyle(
|
||||
@@ -156,14 +163,19 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.providerPriorityInfo,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -292,7 +304,9 @@ class _ProviderItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.isBuiltIn ? context.l10n.providerBuiltIn : context.l10n.providerExtension,
|
||||
info.isBuiltIn
|
||||
? context.l10n.providerBuiltIn
|
||||
: context.l10n.providerExtension,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -300,10 +314,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -321,17 +332,19 @@ class _ProviderItem extends StatelessWidget {
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'qobuz':
|
||||
return _ProviderInfo(
|
||||
name: 'Qobuz',
|
||||
icon: Icons.album,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true);
|
||||
case 'amazon':
|
||||
return _ProviderInfo(
|
||||
name: 'Amazon Music',
|
||||
icon: Icons.shopping_bag,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'youtube':
|
||||
return _ProviderInfo(
|
||||
name: 'YouTube',
|
||||
icon: Icons.play_circle_outline,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
default:
|
||||
return _ProviderInfo(
|
||||
name: provider,
|
||||
|
||||
+11
-143
@@ -30,14 +30,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
bool _isLoading = false;
|
||||
int _androidSdkVersion = 0;
|
||||
|
||||
// Spotify form
|
||||
final _clientIdController = TextEditingController();
|
||||
final _clientSecretController = TextEditingController();
|
||||
bool _useSpotifyApi = false;
|
||||
bool _showClientSecret = false;
|
||||
|
||||
// We add 1 for the Welcome step
|
||||
int get _totalSteps => (_androidSdkVersion >= 33 ? 4 : 3) + 1;
|
||||
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -48,8 +41,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_clientIdController.dispose();
|
||||
_clientSecretController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -291,6 +282,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -339,8 +331,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(validation.errorReason ?? 'Invalid folder selected'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
content: Text(
|
||||
validation.errorReason ??
|
||||
'Invalid folder selected',
|
||||
),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
@@ -402,20 +399,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
if (_useSpotifyApi &&
|
||||
_clientIdController.text.trim().isNotEmpty &&
|
||||
_clientSecretController.text.trim().isNotEmpty) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSpotifyCredentials(
|
||||
_clientIdController.text.trim(),
|
||||
_clientSecretController.text.trim(),
|
||||
);
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
||||
} else {
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
}
|
||||
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
ref.read(settingsProvider.notifier).setFirstLaunchComplete();
|
||||
|
||||
if (mounted) context.go('/tutorial');
|
||||
@@ -474,8 +458,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
return _notificationPermissionGranted;
|
||||
case 2:
|
||||
return _selectedDirectory != null;
|
||||
case 3:
|
||||
return false; // Spotify is last/submit
|
||||
}
|
||||
} else {
|
||||
switch (logicStep) {
|
||||
@@ -483,8 +465,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
return _storagePermissionGranted;
|
||||
case 1:
|
||||
return _selectedDirectory != null;
|
||||
case 2:
|
||||
return false; // Spotify
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -561,7 +541,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
if (_androidSdkVersion >= 33)
|
||||
_buildNotificationStep(colorScheme),
|
||||
_buildDirectoryStep(colorScheme),
|
||||
_buildSpotifyStep(colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -581,12 +560,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
icon: const SizedBox.shrink(), // Custom layout
|
||||
)
|
||||
: FloatingActionButton.extended(
|
||||
onPressed:
|
||||
(!_useSpotifyApi ||
|
||||
(_clientIdController.text.isNotEmpty &&
|
||||
_clientSecretController.text.isNotEmpty))
|
||||
? _completeSetup
|
||||
: null,
|
||||
onPressed: _isLoading ? null : _completeSetup,
|
||||
label: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
@@ -760,112 +734,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpotifyStep(ColorScheme colorScheme) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.api, size: 48, color: colorScheme.primary),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.setupSpotifyApiOptional,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.setupSpotifyApiDescription,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: _useSpotifyApi,
|
||||
onChanged: (v) => setState(() => _useSpotifyApi = v),
|
||||
title: Text(context.l10n.setupUseSpotifyApi),
|
||||
subtitle: Text(
|
||||
_useSpotifyApi
|
||||
? context.l10n.setupEnterCredentialsBelow
|
||||
: "Using bundled metadata",
|
||||
),
|
||||
),
|
||||
if (_useSpotifyApi) ...[
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _clientIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.credentialsClientId,
|
||||
prefixIcon: const Icon(Icons.key),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outline,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _clientSecretController,
|
||||
obscureText: !_showClientSecret,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.credentialsClientSecret,
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outline,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_showClientSecret
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => _showClientSecret = !_showClientSecret,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepLayout extends StatelessWidget {
|
||||
|
||||
+103
-72
@@ -43,7 +43,30 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(storeProvider);
|
||||
final storeFilterState = ref.watch(
|
||||
storeProvider.select(
|
||||
(s) => (s.extensions, s.selectedCategory, s.searchQuery),
|
||||
),
|
||||
);
|
||||
final extensions = storeFilterState.$1;
|
||||
final selectedCategory = storeFilterState.$2;
|
||||
final searchQuery = storeFilterState.$3;
|
||||
final isLoading = ref.watch(storeProvider.select((s) => s.isLoading));
|
||||
final error = ref.watch(storeProvider.select((s) => s.error));
|
||||
final downloadingId = ref.watch(
|
||||
storeProvider.select((s) => s.downloadingId),
|
||||
);
|
||||
final filteredExtensions = StoreState(
|
||||
extensions: extensions,
|
||||
selectedCategory: selectedCategory,
|
||||
searchQuery: searchQuery,
|
||||
).filteredExtensions;
|
||||
if (_searchController.text != searchQuery) {
|
||||
_searchController.value = TextEditingValue(
|
||||
text: searchQuery,
|
||||
selection: TextSelection.collapsed(offset: searchQuery.length),
|
||||
);
|
||||
}
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
@@ -89,41 +112,46 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.storeSearch,
|
||||
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(() {});
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _searchController,
|
||||
builder: (context, value, _) {
|
||||
return TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.storeSearch,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: value.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);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -141,7 +169,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_CategoryChip(
|
||||
label: context.l10n.storeFilterAll,
|
||||
icon: Icons.apps,
|
||||
isSelected: state.selectedCategory == null,
|
||||
isSelected: selectedCategory == null,
|
||||
onTap: () =>
|
||||
ref.read(storeProvider.notifier).setCategory(null),
|
||||
),
|
||||
@@ -149,8 +177,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_CategoryChip(
|
||||
label: context.l10n.storeFilterMetadata,
|
||||
icon: Icons.label_outline,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.metadata,
|
||||
isSelected: selectedCategory == StoreCategory.metadata,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.metadata),
|
||||
@@ -159,8 +186,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_CategoryChip(
|
||||
label: context.l10n.storeFilterDownload,
|
||||
icon: Icons.download_outlined,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.download,
|
||||
isSelected: selectedCategory == StoreCategory.download,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.download),
|
||||
@@ -169,8 +195,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_CategoryChip(
|
||||
label: context.l10n.storeFilterUtility,
|
||||
icon: Icons.build_outlined,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.utility,
|
||||
isSelected: selectedCategory == StoreCategory.utility,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.utility),
|
||||
@@ -179,8 +204,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_CategoryChip(
|
||||
label: context.l10n.storeFilterLyrics,
|
||||
icon: Icons.lyrics_outlined,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.lyrics,
|
||||
isSelected: selectedCategory == StoreCategory.lyrics,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.lyrics),
|
||||
@@ -189,8 +213,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_CategoryChip(
|
||||
label: context.l10n.storeFilterIntegration,
|
||||
icon: Icons.link,
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.integration,
|
||||
isSelected: selectedCategory == StoreCategory.integration,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.integration),
|
||||
@@ -200,22 +223,26 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
),
|
||||
),
|
||||
|
||||
if (state.isLoading && state.extensions.isEmpty)
|
||||
if (isLoading && extensions.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (state.error != null && state.extensions.isEmpty)
|
||||
else if (error != null && extensions.isEmpty)
|
||||
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
|
||||
else if (filteredExtensions.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: _buildErrorState(state.error!, colorScheme),
|
||||
child: _buildEmptyState(
|
||||
hasFilters:
|
||||
searchQuery.isNotEmpty || selectedCategory != null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
)
|
||||
else if (state.filteredExtensions.isEmpty)
|
||||
SliverFillRemaining(child: _buildEmptyState(state, colorScheme))
|
||||
else ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'${state.filteredExtensions.length} ${state.filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
|
||||
'${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -227,16 +254,13 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SettingsGroup(
|
||||
children: state.filteredExtensions.asMap().entries.map((
|
||||
entry,
|
||||
) {
|
||||
children: 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,
|
||||
showDivider: index < filteredExtensions.length - 1,
|
||||
isDownloading: downloadingId == ext.id,
|
||||
onInstall: () => _installExtension(ext),
|
||||
onUpdate: () => _updateExtension(ext),
|
||||
onTap: () => _showExtensionDetails(ext),
|
||||
@@ -288,10 +312,10 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) {
|
||||
final hasFilters =
|
||||
state.searchQuery.isNotEmpty || state.selectedCategory != null;
|
||||
|
||||
Widget _buildEmptyState({
|
||||
required bool hasFilters,
|
||||
required ColorScheme colorScheme,
|
||||
}) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -541,7 +565,10 @@ class _ExtensionItem extends StatelessWidget {
|
||||
if (extension.requiresNewerApp) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -549,14 +576,19 @@ class _ExtensionItem extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 12, color: colorScheme.onErrorContainer),
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 12,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Requires v${extension.minAppVersion}+',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -565,9 +597,8 @@ class _ExtensionItem extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
extension.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -14,10 +13,12 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
final _log = AppLogger('TrackMetadata');
|
||||
|
||||
@@ -52,6 +53,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_embeddedCoverPreviewCache = {};
|
||||
|
||||
bool _fileExists = false;
|
||||
bool _hasCheckedFile = false;
|
||||
int? _fileSize;
|
||||
String? _lyrics; // Cleaned lyrics for display (no timestamps)
|
||||
String? _rawLyrics; // Raw LRC with timestamps for embedding
|
||||
@@ -76,6 +78,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
);
|
||||
static final RegExp _lrcSpeakerPrefixPattern = RegExp(r'^(v1|v2):\s*');
|
||||
static final RegExp _lrcBackgroundLinePattern = RegExp(r'^\[bg:(.*)\]$');
|
||||
static final RegExp _invalidFileNameChars = RegExp(r'[<>:"/\\|?*\x00-\x1f]');
|
||||
static final RegExp _multiUnderscore = RegExp(r'_+');
|
||||
static final RegExp _leadingOrTrailingDots = RegExp(r'^\.+|\.+$');
|
||||
static const List<String> _months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
@@ -178,14 +183,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return cached.previewPath;
|
||||
}
|
||||
|
||||
String? _normalizeOptionalString(String? value) {
|
||||
if (value == null) return null;
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
if (trimmed.toLowerCase() == 'null') return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -202,12 +199,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final shouldShow =
|
||||
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
setState(() => _showTitleInAppBar = shouldShow);
|
||||
}
|
||||
}
|
||||
|
||||
double _calculateExpandedHeight(BuildContext context) {
|
||||
final mediaSize = MediaQuery.of(context).size;
|
||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||
}
|
||||
|
||||
Future<void> _checkFile() async {
|
||||
var filePath = _filePath;
|
||||
if (filePath.startsWith('EXISTS:')) {
|
||||
@@ -224,10 +228,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (mounted && (exists != _fileExists || size != _fileSize)) {
|
||||
if (mounted &&
|
||||
(exists != _fileExists || size != _fileSize || !_hasCheckedFile)) {
|
||||
setState(() {
|
||||
_fileExists = exists;
|
||||
_fileSize = size;
|
||||
_hasCheckedFile = true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -371,7 +377,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
String? get albumArtist {
|
||||
final edited = _editedMetadata?['album_artist']?.toString();
|
||||
if (edited != null && edited.isNotEmpty) return edited;
|
||||
return _normalizeOptionalString(
|
||||
return normalizeOptionalString(
|
||||
_isLocalItem
|
||||
? _localLibraryItem!.albumArtist
|
||||
: _downloadItem!.albumArtist,
|
||||
@@ -506,19 +512,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5;
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 320,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor:
|
||||
colorScheme.surface, // Use theme color for collapsed state
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
@@ -538,21 +542,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
builder: (context, constraints) {
|
||||
final collapseRatio =
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(320 - kToolbarHeight);
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
collapseMode: CollapseMode.pin,
|
||||
background: _buildHeaderBackground(
|
||||
context,
|
||||
colorScheme,
|
||||
coverSize,
|
||||
expandedHeight,
|
||||
showContent,
|
||||
),
|
||||
stretchModes: const [
|
||||
StretchMode.zoomBackground,
|
||||
StretchMode.blurBackground,
|
||||
],
|
||||
stretchModes: const [StretchMode.zoomBackground],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -560,10 +561,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
child: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
),
|
||||
onPressed: _popWithMetadataResult,
|
||||
),
|
||||
@@ -572,10 +573,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.more_vert, color: colorScheme.onSurface),
|
||||
child: const Icon(Icons.more_vert, color: Colors.white),
|
||||
),
|
||||
onPressed: () => _showOptionsMenu(context, ref, colorScheme),
|
||||
),
|
||||
@@ -588,10 +589,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTrackInfoCard(context, colorScheme, _fileExists),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildMetadataCard(context, colorScheme, _fileSize),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
@@ -624,34 +621,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Widget _buildHeaderBackground(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
double coverSize,
|
||||
double expandedHeight,
|
||||
bool showContent,
|
||||
) {
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||
final backgroundCacheWidth = (screenSize.width * pixelRatio).round();
|
||||
final backgroundCacheHeight = (screenSize.height * 0.65 * pixelRatio)
|
||||
.round();
|
||||
final coverCacheSize = (coverSize * pixelRatio).round();
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Blurred cover art background
|
||||
// Full-screen cover background
|
||||
if (_hasPath(_embeddedCoverPreviewPath))
|
||||
Image.file(
|
||||
File(_embeddedCoverPreviewPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: backgroundCacheWidth,
|
||||
cacheHeight: backgroundCacheHeight,
|
||||
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
else if (_coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: _coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: backgroundCacheWidth,
|
||||
memCacheHeight: backgroundCacheHeight,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
||||
@@ -660,113 +646,206 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Image.file(
|
||||
File(_localCoverPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: backgroundCacheWidth,
|
||||
cacheHeight: backgroundCacheHeight,
|
||||
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
else
|
||||
Container(color: colorScheme.surface),
|
||||
|
||||
// Blur filter
|
||||
ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 80,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom fade to surface
|
||||
// Bottom gradient for readability
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 80,
|
||||
height: expandedHeight * 0.65,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.surface.withValues(alpha: 0.0),
|
||||
colorScheme.surface,
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.85),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Cover art
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Hero(
|
||||
tag: 'cover_$_itemId',
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: _hasPath(_embeddedCoverPreviewPath)
|
||||
? Image.file(
|
||||
File(_embeddedCoverPreviewPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: coverCacheSize,
|
||||
cacheHeight: coverCacheSize,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: _coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (coverSize * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _localCoverPath != null && _localCoverPath!.isNotEmpty
|
||||
? Image.file(
|
||||
File(_localCoverPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: coverCacheSize,
|
||||
cacheHeight: coverCacheSize,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
// Track info overlay at bottom
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 40,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
trackName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.2,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
artistName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
albumName,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (_quality != null && _quality!.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_quality!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (duration != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_formatDuration(duration!),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_service != 'local')
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_service[0].toUpperCase() + _service.substring(1),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.folder,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'Local',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_hasCheckedFile && !_fileExists)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_rounded,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.trackFileNotFound,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -774,94 +853,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackInfoCard(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
bool fileExists,
|
||||
) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
trackName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
Text(
|
||||
artistName,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.album,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
albumName,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (!fileExists) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_rounded,
|
||||
size: 16,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackFileNotFound,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetadataCard(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
@@ -1722,9 +1713,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
String _sanitizeFileNameSegment(String value) {
|
||||
var sanitized = value.replaceAll(_invalidFileNameChars, '_').trim();
|
||||
sanitized = sanitized.replaceAll(_leadingOrTrailingDots, '');
|
||||
sanitized = sanitized.replaceAll(_multiUnderscore, '_');
|
||||
if (sanitized.isEmpty) {
|
||||
return 'untitled';
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
String _buildSaveBaseName() {
|
||||
final artist = artistName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
||||
final track = trackName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
||||
final artist = _sanitizeFileNameSegment(artistName);
|
||||
final track = _sanitizeFileNameSegment(trackName);
|
||||
return '$artist - $track';
|
||||
}
|
||||
|
||||
@@ -2336,6 +2337,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
@@ -2566,6 +2568,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
@@ -3003,6 +3006,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
final saved = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
@@ -3039,6 +3043,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.trackDeleteConfirmTitle),
|
||||
content: Text(context.l10n.trackDeleteConfirmMessage),
|
||||
@@ -3088,7 +3093,15 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
||||
try {
|
||||
await openFile(filePath);
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playLocalPath(
|
||||
path: filePath,
|
||||
title: trackName,
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
coverUrl: _coverUrl ?? '',
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('AppStateDb');
|
||||
|
||||
const _dbFileName = 'app_state.db';
|
||||
const _dbVersion = 1;
|
||||
|
||||
const _queueTable = 'download_queue_items';
|
||||
const _recentTable = 'recent_access_items';
|
||||
const _hiddenRecentTable = 'hidden_recent_downloads';
|
||||
|
||||
const _legacyQueueKey = 'download_queue';
|
||||
const _legacyRecentAccessKey = 'recent_access_history';
|
||||
const _legacyHiddenDownloadsKey = 'hidden_downloads_in_recents';
|
||||
|
||||
const _queueMigrationKey = 'app_state_migrated_queue_to_sqlite_v1';
|
||||
const _recentMigrationKey = 'app_state_migrated_recent_to_sqlite_v1';
|
||||
|
||||
class AppStateDatabase {
|
||||
static final AppStateDatabase instance = AppStateDatabase._init();
|
||||
static Database? _database;
|
||||
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
AppStateDatabase._init();
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDb();
|
||||
return _database!;
|
||||
}
|
||||
|
||||
Future<Database> _initDb() async {
|
||||
final dbPath = await getApplicationDocumentsDirectory();
|
||||
final path = join(dbPath.path, _dbFileName);
|
||||
|
||||
_log.i('Initializing app state database at: $path');
|
||||
|
||||
return openDatabase(
|
||||
path,
|
||||
version: _dbVersion,
|
||||
onConfigure: (db) async {
|
||||
await db.rawQuery('PRAGMA journal_mode = WAL');
|
||||
await db.execute('PRAGMA synchronous = NORMAL');
|
||||
},
|
||||
onCreate: _createDb,
|
||||
onUpgrade: _upgradeDb,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createDb(Database db, int version) async {
|
||||
_log.i('Creating app state database schema v$version');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE $_queueTable (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_${_queueTable}_status ON $_queueTable(status)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_${_queueTable}_created ON $_queueTable(created_at ASC)',
|
||||
);
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE $_recentTable (
|
||||
unique_key TEXT PRIMARY KEY,
|
||||
item_json TEXT NOT NULL,
|
||||
accessed_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_${_recentTable}_accessed ON $_recentTable(accessed_at DESC)',
|
||||
);
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE $_hiddenRecentTable (
|
||||
download_id TEXT PRIMARY KEY,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _upgradeDb(Database db, int oldVersion, int newVersion) async {
|
||||
_log.i('Upgrading app state database from v$oldVersion to v$newVersion');
|
||||
}
|
||||
|
||||
Future<bool> migrateQueueFromSharedPreferences() async {
|
||||
final prefs = await _prefs;
|
||||
if (prefs.getBool(_queueMigrationKey) == true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final raw = prefs.getString(_legacyQueueKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
await prefs.setBool(_queueMigrationKey, true);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
await prefs.setBool(_queueMigrationKey, true);
|
||||
return false;
|
||||
}
|
||||
|
||||
final nowIso = DateTime.now().toIso8601String();
|
||||
final db = await database;
|
||||
await db.transaction((txn) async {
|
||||
final batch = txn.batch();
|
||||
for (final entry in decoded.whereType<Map>()) {
|
||||
final map = Map<String, dynamic>.from(entry);
|
||||
final id = map['id'] as String?;
|
||||
if (id == null || id.isEmpty) continue;
|
||||
|
||||
final status = map['status'] as String? ?? 'queued';
|
||||
if (status != 'queued' && status != 'downloading') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status == 'downloading') {
|
||||
map['status'] = 'queued';
|
||||
map['progress'] = 0.0;
|
||||
map['speedMBps'] = 0.0;
|
||||
map['bytesReceived'] = 0;
|
||||
}
|
||||
|
||||
final createdAt = map['createdAt'] as String? ?? nowIso;
|
||||
batch.insert(_queueTable, {
|
||||
'id': id,
|
||||
'item_json': jsonEncode(map),
|
||||
'status': 'queued',
|
||||
'created_at': createdAt,
|
||||
'updated_at': nowIso,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
|
||||
await prefs.setBool(_queueMigrationKey, true);
|
||||
_log.i('Migrated legacy queue data to SQLite');
|
||||
return true;
|
||||
} catch (e, stack) {
|
||||
_log.e('Failed queue migration to SQLite: $e', e, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> migrateRecentAccessFromSharedPreferences() async {
|
||||
final prefs = await _prefs;
|
||||
if (prefs.getBool(_recentMigrationKey) == true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final rawRecent = prefs.getString(_legacyRecentAccessKey);
|
||||
final hiddenIds = prefs.getStringList(_legacyHiddenDownloadsKey);
|
||||
if ((rawRecent == null || rawRecent.isEmpty) &&
|
||||
(hiddenIds == null || hiddenIds.isEmpty)) {
|
||||
await prefs.setBool(_recentMigrationKey, true);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final nowIso = DateTime.now().toIso8601String();
|
||||
final db = await database;
|
||||
await db.transaction((txn) async {
|
||||
if (rawRecent != null && rawRecent.isNotEmpty) {
|
||||
final decoded = jsonDecode(rawRecent);
|
||||
if (decoded is List) {
|
||||
final batch = txn.batch();
|
||||
for (final entry in decoded.whereType<Map>()) {
|
||||
final map = Map<String, dynamic>.from(entry);
|
||||
final type = map['type'] as String?;
|
||||
final id = map['id'] as String?;
|
||||
final providerId = map['providerId'] as String?;
|
||||
if (type == null || id == null || type.isEmpty || id.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final uniqueKey = '$type:${providerId ?? 'default'}:$id';
|
||||
final accessedAt = map['accessedAt'] as String? ?? nowIso;
|
||||
batch.insert(_recentTable, {
|
||||
'unique_key': uniqueKey,
|
||||
'item_json': jsonEncode(map),
|
||||
'accessed_at': accessedAt,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
}
|
||||
|
||||
if (hiddenIds != null && hiddenIds.isNotEmpty) {
|
||||
final batch = txn.batch();
|
||||
for (final id in hiddenIds) {
|
||||
if (id.isEmpty) continue;
|
||||
batch.insert(_hiddenRecentTable, {
|
||||
'download_id': id,
|
||||
'updated_at': nowIso,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
});
|
||||
|
||||
await prefs.setBool(_recentMigrationKey, true);
|
||||
_log.i('Migrated legacy recent-access data to SQLite');
|
||||
return true;
|
||||
} catch (e, stack) {
|
||||
_log.e('Failed recent-access migration to SQLite: $e', e, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getPendingDownloadQueueRows() async {
|
||||
final db = await database;
|
||||
return db.query(
|
||||
_queueTable,
|
||||
where: 'status = ? OR status = ?',
|
||||
whereArgs: ['queued', 'downloading'],
|
||||
orderBy: 'created_at ASC, rowid ASC',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> replacePendingDownloadQueueRows(
|
||||
List<Map<String, dynamic>> rows,
|
||||
) async {
|
||||
final db = await database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete(_queueTable);
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final batch = txn.batch();
|
||||
for (final row in rows) {
|
||||
batch.insert(
|
||||
_queueTable,
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getRecentAccessRows({int? limit}) async {
|
||||
final db = await database;
|
||||
return db.query(
|
||||
_recentTable,
|
||||
orderBy: 'accessed_at DESC, rowid DESC',
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> upsertRecentAccessRow({
|
||||
required String uniqueKey,
|
||||
required String itemJson,
|
||||
required String accessedAt,
|
||||
}) async {
|
||||
final db = await database;
|
||||
await db.insert(_recentTable, {
|
||||
'unique_key': uniqueKey,
|
||||
'item_json': itemJson,
|
||||
'accessed_at': accessedAt,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
Future<void> deleteRecentAccessRow(String uniqueKey) async {
|
||||
final db = await database;
|
||||
await db.delete(
|
||||
_recentTable,
|
||||
where: 'unique_key = ?',
|
||||
whereArgs: [uniqueKey],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearRecentAccessRows() async {
|
||||
final db = await database;
|
||||
await db.delete(_recentTable);
|
||||
}
|
||||
|
||||
Future<Set<String>> getHiddenRecentDownloadIds() async {
|
||||
final db = await database;
|
||||
final rows = await db.query(_hiddenRecentTable, columns: ['download_id']);
|
||||
return rows
|
||||
.map((row) => row['download_id'] as String?)
|
||||
.whereType<String>()
|
||||
.toSet();
|
||||
}
|
||||
|
||||
Future<void> addHiddenRecentDownloadId(String downloadId) async {
|
||||
final id = downloadId.trim();
|
||||
if (id.isEmpty) return;
|
||||
final db = await database;
|
||||
await db.insert(_hiddenRecentTable, {
|
||||
'download_id': id,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
Future<void> clearHiddenRecentDownloadIds() async {
|
||||
final db = await database;
|
||||
await db.delete(_hiddenRecentTable);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -41,17 +42,7 @@ class CoverCacheManager {
|
||||
|
||||
debugPrint('CoverCacheManager: Initializing at $_cachePath');
|
||||
|
||||
_instance = CacheManager(
|
||||
Config(
|
||||
_cacheKey,
|
||||
stalePeriod: _maxCacheAge,
|
||||
maxNrOfCacheObjects: _maxCacheObjects,
|
||||
// Use path only (not databaseName) to store database in persistent directory
|
||||
repo: JsonCacheInfoRepository(path: _cachePath),
|
||||
fileSystem: IOFileSystem(_cachePath!),
|
||||
fileService: HttpFileService(),
|
||||
),
|
||||
);
|
||||
_instance = _createManager(_cachePath!);
|
||||
|
||||
_initialized = true;
|
||||
debugPrint('CoverCacheManager: Initialized successfully');
|
||||
@@ -62,12 +53,47 @@ class CoverCacheManager {
|
||||
}
|
||||
|
||||
static Future<void> clearCache() async {
|
||||
if (!_initialized || _instance == null) return;
|
||||
await _instance!.emptyCache();
|
||||
if (!_initialized || _instance == null || _cachePath == null) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
final instance = _instance;
|
||||
final cachePath = _cachePath;
|
||||
|
||||
if (instance == null || cachePath == null) return;
|
||||
|
||||
// Ask cache manager to clear indexed entries first.
|
||||
try {
|
||||
await instance.emptyCache();
|
||||
} catch (e) {
|
||||
debugPrint('CoverCacheManager: emptyCache failed, fallback to wipe: $e');
|
||||
}
|
||||
|
||||
// Then wipe the directory to remove orphaned files/metadata leftovers.
|
||||
await _wipeDirectory(cachePath);
|
||||
|
||||
// Clear in-memory image cache so cleared covers are not retained in RAM.
|
||||
final imageCache = PaintingBinding.instance.imageCache;
|
||||
imageCache.clear();
|
||||
imageCache.clearLiveImages();
|
||||
|
||||
// Reset manager memory/index state after on-disk wipe.
|
||||
instance.store.emptyMemoryCache();
|
||||
_instance = _createManager(cachePath);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static Future<CacheStats> getStats() async {
|
||||
if (!_initialized || _cachePath == null) {
|
||||
if (_cachePath == null) {
|
||||
try {
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
_cachePath = p.join(appDir.path, 'cover_cache');
|
||||
} catch (_) {
|
||||
return const CacheStats(fileCount: 0, totalSizeBytes: 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (_cachePath == null) {
|
||||
return const CacheStats(fileCount: 0, totalSizeBytes: 0);
|
||||
}
|
||||
|
||||
@@ -93,6 +119,45 @@ class CoverCacheManager {
|
||||
|
||||
return CacheStats(fileCount: fileCount, totalSizeBytes: totalSize);
|
||||
}
|
||||
|
||||
static CacheManager _createManager(String cachePath) {
|
||||
return CacheManager(
|
||||
Config(
|
||||
_cacheKey,
|
||||
stalePeriod: _maxCacheAge,
|
||||
maxNrOfCacheObjects: _maxCacheObjects,
|
||||
// Use path only (not databaseName) to store database in persistent directory
|
||||
repo: JsonCacheInfoRepository(path: cachePath),
|
||||
fileSystem: IOFileSystem(cachePath),
|
||||
fileService: HttpFileService(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _wipeDirectory(String path) async {
|
||||
final directory = Directory(path);
|
||||
if (!await directory.exists()) {
|
||||
await directory.create(recursive: true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final entities = <FileSystemEntity>[];
|
||||
await for (final entity in directory.list(followLinks: false)) {
|
||||
entities.add(entity);
|
||||
}
|
||||
|
||||
for (final entity in entities) {
|
||||
try {
|
||||
await entity.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
await directory.create(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
class CacheStats {
|
||||
|
||||
@@ -106,6 +106,8 @@ class CsvImportService {
|
||||
artistName: trackData['artists'] as String? ?? track.artistName,
|
||||
albumName: trackData['album_name'] as String? ?? track.albumName,
|
||||
albumArtist: trackData['album_artist'] as String?,
|
||||
artistId: trackData['artist_id']?.toString(),
|
||||
albumId: trackData['album_id']?.toString(),
|
||||
coverUrl: coverUrl ?? track.coverUrl,
|
||||
isrc: trackData['isrc'] as String? ?? track.isrc,
|
||||
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
|
||||
|
||||
@@ -10,6 +10,7 @@ class DownloadRequestPayload {
|
||||
final String outputDir;
|
||||
final String filenameFormat;
|
||||
final String quality;
|
||||
final bool embedMetadata;
|
||||
final bool embedLyrics;
|
||||
final bool embedMaxQualityCover;
|
||||
final int trackNumber;
|
||||
@@ -33,6 +34,7 @@ class DownloadRequestPayload {
|
||||
final String safRelativeDir;
|
||||
final String safFileName;
|
||||
final String safOutputExt;
|
||||
final String songLinkRegion;
|
||||
|
||||
const DownloadRequestPayload({
|
||||
this.isrc = '',
|
||||
@@ -46,6 +48,7 @@ class DownloadRequestPayload {
|
||||
required this.outputDir,
|
||||
required this.filenameFormat,
|
||||
this.quality = 'LOSSLESS',
|
||||
this.embedMetadata = true,
|
||||
this.embedLyrics = true,
|
||||
this.embedMaxQualityCover = true,
|
||||
this.trackNumber = 1,
|
||||
@@ -69,6 +72,7 @@ class DownloadRequestPayload {
|
||||
this.safRelativeDir = '',
|
||||
this.safFileName = '',
|
||||
this.safOutputExt = '',
|
||||
this.songLinkRegion = 'US',
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
@@ -84,6 +88,7 @@ class DownloadRequestPayload {
|
||||
'output_dir': outputDir,
|
||||
'filename_format': filenameFormat,
|
||||
'quality': quality,
|
||||
'embed_metadata': embedMetadata,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'track_number': trackNumber,
|
||||
@@ -107,6 +112,7 @@ class DownloadRequestPayload {
|
||||
'saf_relative_dir': safRelativeDir,
|
||||
'saf_file_name': safFileName,
|
||||
'saf_output_ext': safOutputExt,
|
||||
'songlink_region': songLinkRegion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,6 +132,7 @@ class DownloadRequestPayload {
|
||||
outputDir: outputDir,
|
||||
filenameFormat: filenameFormat,
|
||||
quality: quality,
|
||||
embedMetadata: embedMetadata,
|
||||
embedLyrics: embedLyrics,
|
||||
embedMaxQualityCover: embedMaxQualityCover,
|
||||
trackNumber: trackNumber,
|
||||
@@ -149,6 +156,7 @@ class DownloadRequestPayload {
|
||||
safRelativeDir: safRelativeDir,
|
||||
safFileName: safFileName,
|
||||
safOutputExt: safOutputExt,
|
||||
songLinkRegion: songLinkRegion,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ class DownloadedEmbeddedCoverResolver {
|
||||
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
|
||||
static final Set<String> _pendingExtract = <String>{};
|
||||
static final Set<String> _pendingRefresh = <String>{};
|
||||
static final Set<String> _pendingPreviewValidation = <String>{};
|
||||
static final Set<String> _failedExtract = <String>{};
|
||||
|
||||
static String cleanFilePath(String? filePath) {
|
||||
@@ -66,12 +67,9 @@ class DownloadedEmbeddedCoverResolver {
|
||||
|
||||
final cached = _cache[cleanPath];
|
||||
if (cached != null) {
|
||||
if (File(cached.previewPath).existsSync()) {
|
||||
_touch(cleanPath, cached);
|
||||
return cached.previewPath;
|
||||
}
|
||||
_cache.remove(cleanPath);
|
||||
_cleanupTempCoverPathSync(cached.previewPath);
|
||||
_touch(cleanPath, cached);
|
||||
_validateCachedPreviewAsync(cleanPath, cached, onChanged: onChanged);
|
||||
return cached.previewPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -106,6 +104,7 @@ class DownloadedEmbeddedCoverResolver {
|
||||
final cached = _cache.remove(cleanPath);
|
||||
_pendingExtract.remove(cleanPath);
|
||||
_pendingRefresh.remove(cleanPath);
|
||||
_pendingPreviewValidation.remove(cleanPath);
|
||||
_failedExtract.remove(cleanPath);
|
||||
if (cached != null) {
|
||||
_cleanupTempCoverPathSync(cached.previewPath);
|
||||
@@ -144,10 +143,36 @@ class DownloadedEmbeddedCoverResolver {
|
||||
}
|
||||
_pendingExtract.remove(oldestKey);
|
||||
_pendingRefresh.remove(oldestKey);
|
||||
_pendingPreviewValidation.remove(oldestKey);
|
||||
_failedExtract.remove(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
static void _validateCachedPreviewAsync(
|
||||
String cleanPath,
|
||||
_EmbeddedCoverCacheEntry entry, {
|
||||
VoidCallback? onChanged,
|
||||
}) {
|
||||
if (_pendingPreviewValidation.contains(cleanPath)) return;
|
||||
_pendingPreviewValidation.add(cleanPath);
|
||||
Future.microtask(() async {
|
||||
try {
|
||||
final exists = await fileExists(entry.previewPath);
|
||||
if (!exists) {
|
||||
final latest = _cache[cleanPath];
|
||||
if (latest != null && latest.previewPath == entry.previewPath) {
|
||||
_cache.remove(cleanPath);
|
||||
_failedExtract.remove(cleanPath);
|
||||
onChanged?.call();
|
||||
}
|
||||
_cleanupTempCoverPathSync(entry.previewPath);
|
||||
}
|
||||
} finally {
|
||||
_pendingPreviewValidation.remove(cleanPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static void _ensureCover(
|
||||
String cleanPath, {
|
||||
bool forceRefresh = false,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit_config.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
@@ -11,7 +13,20 @@ final _log = AppLogger('FFmpeg');
|
||||
|
||||
class FFmpegService {
|
||||
static const int _commandLogPreviewLength = 300;
|
||||
static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8);
|
||||
static const Duration _liveTunnelStartupPollInterval = Duration(
|
||||
milliseconds: 200,
|
||||
);
|
||||
static const Duration _liveTunnelStabilizationDelay = Duration(
|
||||
milliseconds: 900,
|
||||
);
|
||||
static int _tempEmbedCounter = 0;
|
||||
static FFmpegSession? _activeLiveDecryptSession;
|
||||
static String? _activeLiveDecryptUrl;
|
||||
static String? _activeLiveTempInputPath;
|
||||
static String? _activeNativeDashManifestPath;
|
||||
static String? _activeNativeDashManifestUrl;
|
||||
static final Set<String> _preparedNativeDashManifestPaths = <String>{};
|
||||
|
||||
static String _buildOutputPath(String inputPath, String extension) {
|
||||
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
|
||||
@@ -305,6 +320,433 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool isActiveLiveDecryptedUrl(String url) {
|
||||
final active = _activeLiveDecryptUrl;
|
||||
if (active == null || active.isEmpty) return false;
|
||||
return active == url.trim();
|
||||
}
|
||||
|
||||
static bool isActiveNativeDashManifestUrl(String url) {
|
||||
final activeUrl = _activeNativeDashManifestUrl;
|
||||
if (activeUrl == null || activeUrl.isEmpty) return false;
|
||||
|
||||
final normalized = url.trim();
|
||||
if (activeUrl == normalized) return true;
|
||||
|
||||
try {
|
||||
final activePath = Uri.parse(activeUrl).toFilePath();
|
||||
final incomingPath = Uri.parse(normalized).toFilePath();
|
||||
return activePath == incomingPath;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> prepareTidalDashManifestForNativePlayback({
|
||||
required String manifestPayload,
|
||||
bool registerAsActive = true,
|
||||
}) async {
|
||||
final rawPayload = manifestPayload.trim();
|
||||
if (rawPayload.isEmpty) return null;
|
||||
|
||||
final payload = rawPayload.startsWith('MANIFEST:')
|
||||
? rawPayload.substring('MANIFEST:'.length)
|
||||
: rawPayload;
|
||||
|
||||
final manifestPath = await _writeTempManifestFile(payload);
|
||||
if (manifestPath == null) {
|
||||
_log.e('Failed to prepare Tidal DASH manifest for native playback');
|
||||
return null;
|
||||
}
|
||||
|
||||
final manifestUrl = Uri.file(manifestPath).toString();
|
||||
_preparedNativeDashManifestPaths.add(manifestPath);
|
||||
if (registerAsActive) {
|
||||
await activatePreparedNativeDashManifest(manifestUrl);
|
||||
}
|
||||
return manifestUrl;
|
||||
}
|
||||
|
||||
static Future<void> activatePreparedNativeDashManifest(String url) async {
|
||||
final normalized = url.trim();
|
||||
if (normalized.isEmpty) return;
|
||||
|
||||
final manifestPath = _nativeDashManifestPathFromUrl(normalized);
|
||||
if (manifestPath == null ||
|
||||
!_preparedNativeDashManifestPaths.contains(manifestPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final previousPath = _activeNativeDashManifestPath;
|
||||
_activeNativeDashManifestPath = manifestPath;
|
||||
_activeNativeDashManifestUrl = Uri.file(manifestPath).toString();
|
||||
|
||||
if (previousPath != null &&
|
||||
previousPath.isNotEmpty &&
|
||||
previousPath != manifestPath) {
|
||||
_preparedNativeDashManifestPaths.remove(previousPath);
|
||||
await _deleteNativeDashManifestFile(previousPath);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> stopNativeDashManifestPlayback() async {
|
||||
final manifestPath = _activeNativeDashManifestPath;
|
||||
_activeNativeDashManifestPath = null;
|
||||
_activeNativeDashManifestUrl = null;
|
||||
|
||||
if (manifestPath == null || manifestPath.isEmpty) return;
|
||||
_preparedNativeDashManifestPaths.remove(manifestPath);
|
||||
await _deleteNativeDashManifestFile(manifestPath);
|
||||
}
|
||||
|
||||
static Future<void> cleanupInactivePreparedNativeDashManifests() async {
|
||||
final activePath = _activeNativeDashManifestPath;
|
||||
final stalePaths = _preparedNativeDashManifestPaths
|
||||
.where((path) => path != activePath)
|
||||
.toList(growable: false);
|
||||
|
||||
for (final path in stalePaths) {
|
||||
_preparedNativeDashManifestPaths.remove(path);
|
||||
await _deleteNativeDashManifestFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
static String? _nativeDashManifestPathFromUrl(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (uri.scheme.toLowerCase() != 'file') {
|
||||
return null;
|
||||
}
|
||||
final path = uri.toFilePath();
|
||||
return path.trim().isEmpty ? null : path;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _deleteNativeDashManifestFile(String path) async {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> stopLiveDecryptedStream() async {
|
||||
final session = _activeLiveDecryptSession;
|
||||
final tempInputPath = _activeLiveTempInputPath;
|
||||
_activeLiveDecryptSession = null;
|
||||
_activeLiveDecryptUrl = null;
|
||||
_activeLiveTempInputPath = null;
|
||||
|
||||
if (session != null) {
|
||||
try {
|
||||
await session.cancel();
|
||||
} catch (e) {
|
||||
final sessionId = session.getSessionId();
|
||||
if (sessionId != null) {
|
||||
try {
|
||||
await FFmpegKit.cancel(sessionId);
|
||||
} catch (_) {}
|
||||
}
|
||||
_log.w('Failed to stop live decrypt session cleanly: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (tempInputPath != null && tempInputPath.isNotEmpty) {
|
||||
try {
|
||||
final file = File(tempInputPath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<LiveDecryptedStreamResult?> startTidalDashLiveStream({
|
||||
required String manifestPayload,
|
||||
String preferredFormat = 'm4a',
|
||||
}) async {
|
||||
final rawPayload = manifestPayload.trim();
|
||||
if (rawPayload.isEmpty) return null;
|
||||
|
||||
final payload = rawPayload.startsWith('MANIFEST:')
|
||||
? rawPayload.substring('MANIFEST:'.length)
|
||||
: rawPayload;
|
||||
|
||||
final manifestPath = await _writeTempManifestFile(payload);
|
||||
if (manifestPath == null) {
|
||||
_log.e('Failed to prepare Tidal DASH manifest for live stream');
|
||||
return null;
|
||||
}
|
||||
|
||||
await stopLiveDecryptedStream();
|
||||
await stopNativeDashManifestPlayback();
|
||||
|
||||
final attempts = _buildLiveDashFormatAttempts(preferredFormat);
|
||||
for (final format in attempts) {
|
||||
final stream = await _tryStartLiveDashAttempt(
|
||||
manifestPath: manifestPath,
|
||||
format: format,
|
||||
);
|
||||
if (stream != null) {
|
||||
_activeLiveDecryptSession = stream.session;
|
||||
_activeLiveDecryptUrl = stream.localUrl;
|
||||
_activeLiveTempInputPath = manifestPath;
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final file = File(manifestPath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> _writeTempManifestFile(String payload) async {
|
||||
if (payload.trim().isEmpty) return null;
|
||||
|
||||
Uint8List bytes;
|
||||
try {
|
||||
bytes = base64Decode(payload);
|
||||
} catch (_) {
|
||||
bytes = Uint8List.fromList(utf8.encode(payload));
|
||||
}
|
||||
|
||||
final manifestText = utf8.decode(bytes, allowMalformed: true).trim();
|
||||
if (manifestText.isEmpty) return null;
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final manifestPath =
|
||||
'${tempDir.path}${Platform.pathSeparator}tidal_dash_${DateTime.now().microsecondsSinceEpoch}.mpd';
|
||||
await File(manifestPath).writeAsString(manifestText, flush: true);
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
static List<_LiveDecryptFormat> _buildLiveDashFormatAttempts(
|
||||
String preferredFormat,
|
||||
) {
|
||||
final normalized = preferredFormat.trim().toLowerCase();
|
||||
if (normalized == 'flac') {
|
||||
return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a];
|
||||
}
|
||||
return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac];
|
||||
}
|
||||
|
||||
static Future<bool> _awaitLiveTunnelReady(FFmpegSession session) async {
|
||||
final deadline = DateTime.now().add(_liveTunnelStartupTimeout);
|
||||
var seenRunning = false;
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
final state = await session.getState();
|
||||
if (state == SessionState.running) {
|
||||
seenRunning = true;
|
||||
break;
|
||||
}
|
||||
if (state != SessionState.created) {
|
||||
return false;
|
||||
}
|
||||
await Future<void>.delayed(_liveTunnelStartupPollInterval);
|
||||
}
|
||||
|
||||
if (!seenRunning) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await Future<void>.delayed(_liveTunnelStabilizationDelay);
|
||||
return (await session.getState()) == SessionState.running;
|
||||
}
|
||||
|
||||
static Future<LiveDecryptedStreamResult?> _tryStartLiveDashAttempt({
|
||||
required String manifestPath,
|
||||
required _LiveDecryptFormat format,
|
||||
}) async {
|
||||
final port = await _allocateLoopbackPort();
|
||||
final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a';
|
||||
final mimeType = format == _LiveDecryptFormat.flac
|
||||
? 'audio/flac'
|
||||
: 'audio/mp4';
|
||||
final localUrl = 'http://localhost:$port/stream.$ext';
|
||||
|
||||
final commandArguments = <String>[
|
||||
'-nostdin',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-protocol_whitelist',
|
||||
'file,http,https,tcp,tls,crypto,data',
|
||||
'-i',
|
||||
manifestPath,
|
||||
'-map',
|
||||
'0:a:0',
|
||||
'-c:a',
|
||||
'copy',
|
||||
if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'],
|
||||
if (format == _LiveDecryptFormat.m4a) ...[
|
||||
'-movflags',
|
||||
'+frag_keyframe+empty_moov+default_base_moof',
|
||||
'-f',
|
||||
'mp4',
|
||||
],
|
||||
'-content_type',
|
||||
mimeType,
|
||||
'-listen',
|
||||
'1',
|
||||
localUrl,
|
||||
];
|
||||
|
||||
_log.d(
|
||||
'Starting Tidal DASH tunnel: ${_previewCommandForLog(commandArguments.join(' '))}',
|
||||
);
|
||||
|
||||
final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments);
|
||||
final isReady = await _awaitLiveTunnelReady(session);
|
||||
if (isReady) {
|
||||
return LiveDecryptedStreamResult(
|
||||
localUrl: localUrl,
|
||||
format: ext,
|
||||
session: session,
|
||||
);
|
||||
}
|
||||
|
||||
final state = await session.getState();
|
||||
final output = (await session.getOutput() ?? '').trim();
|
||||
if (output.isNotEmpty) {
|
||||
_log.w('Tidal DASH tunnel failed ($ext): $output');
|
||||
} else {
|
||||
_log.w('Tidal DASH tunnel failed ($ext) with session state: $state');
|
||||
}
|
||||
|
||||
try {
|
||||
await session.cancel();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<LiveDecryptedStreamResult?> startAmazonLiveDecryptedStream({
|
||||
required String encryptedStreamUrl,
|
||||
required String decryptionKey,
|
||||
String preferredFormat = 'flac',
|
||||
}) async {
|
||||
final inputUrl = encryptedStreamUrl.trim();
|
||||
if (inputUrl.isEmpty) return null;
|
||||
|
||||
final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey);
|
||||
if (keyCandidates.isEmpty) {
|
||||
_log.e('No usable decryption key candidates for live stream');
|
||||
return null;
|
||||
}
|
||||
|
||||
await stopLiveDecryptedStream();
|
||||
|
||||
final attempts = _buildLiveDecryptFormatAttempts(preferredFormat);
|
||||
for (final format in attempts) {
|
||||
for (final keyCandidate in keyCandidates) {
|
||||
final stream = await _tryStartLiveDecryptAttempt(
|
||||
inputUrl: inputUrl,
|
||||
decryptionKey: keyCandidate,
|
||||
format: format,
|
||||
);
|
||||
if (stream != null) {
|
||||
_activeLiveDecryptSession = stream.session;
|
||||
_activeLiveDecryptUrl = stream.localUrl;
|
||||
_activeLiveTempInputPath = null;
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<_LiveDecryptFormat> _buildLiveDecryptFormatAttempts(
|
||||
String preferredFormat,
|
||||
) {
|
||||
final normalized = preferredFormat.trim().toLowerCase();
|
||||
if (normalized == 'm4a' || normalized == 'mp4' || normalized == 'aac') {
|
||||
return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac];
|
||||
}
|
||||
return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a];
|
||||
}
|
||||
|
||||
static Future<LiveDecryptedStreamResult?> _tryStartLiveDecryptAttempt({
|
||||
required String inputUrl,
|
||||
required String decryptionKey,
|
||||
required _LiveDecryptFormat format,
|
||||
}) async {
|
||||
final port = await _allocateLoopbackPort();
|
||||
final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a';
|
||||
final mimeType = format == _LiveDecryptFormat.flac
|
||||
? 'audio/flac'
|
||||
: 'audio/mp4';
|
||||
final localUrl = 'http://localhost:$port/stream.$ext';
|
||||
|
||||
final commandArguments = <String>[
|
||||
'-nostdin',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-decryption_key',
|
||||
decryptionKey,
|
||||
'-i',
|
||||
inputUrl,
|
||||
'-map',
|
||||
'0:a:0',
|
||||
'-c:a',
|
||||
'copy',
|
||||
if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'],
|
||||
if (format == _LiveDecryptFormat.m4a) ...[
|
||||
'-movflags',
|
||||
'+frag_keyframe+empty_moov+default_base_moof',
|
||||
'-f',
|
||||
'mp4',
|
||||
],
|
||||
'-content_type',
|
||||
mimeType,
|
||||
'-listen',
|
||||
'1',
|
||||
localUrl,
|
||||
];
|
||||
|
||||
_log.d(
|
||||
'Starting live decrypt tunnel: ${_previewCommandForLog(commandArguments.join(' '))}',
|
||||
);
|
||||
|
||||
final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments);
|
||||
final isReady = await _awaitLiveTunnelReady(session);
|
||||
if (isReady) {
|
||||
return LiveDecryptedStreamResult(
|
||||
localUrl: localUrl,
|
||||
format: ext,
|
||||
session: session,
|
||||
);
|
||||
}
|
||||
|
||||
final state = await session.getState();
|
||||
final output = (await session.getOutput() ?? '').trim();
|
||||
if (output.isNotEmpty) {
|
||||
_log.w('Live decrypt attempt failed ($ext): $output');
|
||||
} else {
|
||||
_log.w('Live decrypt attempt failed ($ext) with session state: $state');
|
||||
}
|
||||
|
||||
try {
|
||||
await session.cancel();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<int> _allocateLoopbackPort() async {
|
||||
final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final port = socket.port;
|
||||
await socket.close();
|
||||
return port;
|
||||
}
|
||||
|
||||
static Future<String?> convertFlacToOpus(
|
||||
String inputPath, {
|
||||
String bitrate = '128k',
|
||||
@@ -861,9 +1303,10 @@ class FFmpegService {
|
||||
|
||||
for (final entry in vorbisMetadata.entries) {
|
||||
final key = entry.key.toUpperCase();
|
||||
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final value = entry.value;
|
||||
|
||||
switch (key) {
|
||||
switch (normalizedKey) {
|
||||
case 'TITLE':
|
||||
id3Map['title'] = value;
|
||||
break;
|
||||
@@ -878,10 +1321,12 @@ class FFmpegService {
|
||||
break;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
id3Map['track'] = value;
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
id3Map['disc'] = value;
|
||||
break;
|
||||
case 'DATE':
|
||||
@@ -921,3 +1366,17 @@ class FFmpegResult {
|
||||
required this.output,
|
||||
});
|
||||
}
|
||||
|
||||
enum _LiveDecryptFormat { flac, m4a }
|
||||
|
||||
class LiveDecryptedStreamResult {
|
||||
final String localUrl;
|
||||
final String format;
|
||||
final FFmpegSession session;
|
||||
|
||||
LiveDecryptedStreamResult({
|
||||
required this.localUrl,
|
||||
required this.format,
|
||||
required this.session,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ class HistoryDatabase {
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 3,
|
||||
onConfigure: (db) async {
|
||||
await db.rawQuery('PRAGMA journal_mode = WAL');
|
||||
await db.execute('PRAGMA synchronous = NORMAL');
|
||||
},
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('LibraryCollectionsDb');
|
||||
|
||||
const _dbFileName = 'library_collections.db';
|
||||
const _dbVersion = 1;
|
||||
|
||||
const _tableWishlist = 'wishlist_tracks';
|
||||
const _tableLoved = 'loved_tracks';
|
||||
const _tablePlaylists = 'playlists';
|
||||
const _tablePlaylistTracks = 'playlist_tracks';
|
||||
|
||||
const _legacyCollectionsStorageKey = 'library_collections_v1';
|
||||
const _migrationDoneKey = 'library_collections_migrated_to_sqlite_v1';
|
||||
|
||||
class LibraryCollectionsSnapshot {
|
||||
final List<Map<String, dynamic>> wishlistRows;
|
||||
final List<Map<String, dynamic>> lovedRows;
|
||||
final List<Map<String, dynamic>> playlistRows;
|
||||
final List<Map<String, dynamic>> playlistTrackRows;
|
||||
|
||||
const LibraryCollectionsSnapshot({
|
||||
required this.wishlistRows,
|
||||
required this.lovedRows,
|
||||
required this.playlistRows,
|
||||
required this.playlistTrackRows,
|
||||
});
|
||||
}
|
||||
|
||||
class LibraryCollectionsDatabase {
|
||||
static final LibraryCollectionsDatabase instance =
|
||||
LibraryCollectionsDatabase._init();
|
||||
static Database? _database;
|
||||
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
LibraryCollectionsDatabase._init();
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDb();
|
||||
return _database!;
|
||||
}
|
||||
|
||||
Future<Database> _initDb() async {
|
||||
final dbPath = await getApplicationDocumentsDirectory();
|
||||
final path = join(dbPath.path, _dbFileName);
|
||||
|
||||
_log.i('Initializing collections database at: $path');
|
||||
|
||||
return openDatabase(
|
||||
path,
|
||||
version: _dbVersion,
|
||||
onConfigure: (db) async {
|
||||
await db.execute('PRAGMA foreign_keys = ON');
|
||||
await db.rawQuery('PRAGMA journal_mode = WAL');
|
||||
await db.execute('PRAGMA synchronous = NORMAL');
|
||||
},
|
||||
onCreate: _createDb,
|
||||
onUpgrade: _upgradeDb,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createDb(Database db, int version) async {
|
||||
_log.i('Creating collections database schema v$version');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE $_tableWishlist (
|
||||
track_key TEXT PRIMARY KEY,
|
||||
track_json TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE $_tableLoved (
|
||||
track_key TEXT PRIMARY KEY,
|
||||
track_json TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE $_tablePlaylists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
cover_image_path TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE $_tablePlaylistTracks (
|
||||
playlist_id TEXT NOT NULL,
|
||||
track_key TEXT NOT NULL,
|
||||
track_json TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
PRIMARY KEY (playlist_id, track_key),
|
||||
FOREIGN KEY (playlist_id) REFERENCES $_tablePlaylists(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_${_tableWishlist}_added_at ON $_tableWishlist(added_at DESC)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_${_tableLoved}_added_at ON $_tableLoved(added_at DESC)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_${_tablePlaylists}_created_at ON $_tablePlaylists(created_at DESC)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_${_tablePlaylistTracks}_playlist_id ON $_tablePlaylistTracks(playlist_id)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_${_tablePlaylistTracks}_added_at ON $_tablePlaylistTracks(added_at DESC)',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _upgradeDb(Database db, int oldVersion, int newVersion) async {
|
||||
_log.i('Upgrading collections database from v$oldVersion to v$newVersion');
|
||||
}
|
||||
|
||||
Future<bool> migrateFromSharedPreferences() async {
|
||||
final prefs = await _prefs;
|
||||
if (prefs.getBool(_migrationDoneKey) == true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final raw = prefs.getString(_legacyCollectionsStorageKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
await prefs.setBool(_migrationDoneKey, true);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) {
|
||||
await prefs.setBool(_migrationDoneKey, true);
|
||||
return false;
|
||||
}
|
||||
|
||||
final root = Map<String, dynamic>.from(decoded);
|
||||
final wishlistRaw = (root['wishlist'] as List?) ?? const [];
|
||||
final lovedRaw = (root['loved'] as List?) ?? const [];
|
||||
final playlistsRaw = (root['playlists'] as List?) ?? const [];
|
||||
final nowIso = DateTime.now().toIso8601String();
|
||||
|
||||
final db = await database;
|
||||
await db.transaction((txn) async {
|
||||
for (final entry in wishlistRaw.whereType<Map>()) {
|
||||
final map = Map<String, dynamic>.from(entry);
|
||||
final trackKey = map['key'] as String?;
|
||||
final track = map['track'];
|
||||
if (trackKey == null || track is! Map) continue;
|
||||
final addedAt = (map['addedAt'] as String?) ?? nowIso;
|
||||
await txn.insert(_tableWishlist, {
|
||||
'track_key': trackKey,
|
||||
'track_json': jsonEncode(track),
|
||||
'added_at': addedAt,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
for (final entry in lovedRaw.whereType<Map>()) {
|
||||
final map = Map<String, dynamic>.from(entry);
|
||||
final trackKey = map['key'] as String?;
|
||||
final track = map['track'];
|
||||
if (trackKey == null || track is! Map) continue;
|
||||
final addedAt = (map['addedAt'] as String?) ?? nowIso;
|
||||
await txn.insert(_tableLoved, {
|
||||
'track_key': trackKey,
|
||||
'track_json': jsonEncode(track),
|
||||
'added_at': addedAt,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
for (final playlistEntry in playlistsRaw.whereType<Map>()) {
|
||||
final playlist = Map<String, dynamic>.from(playlistEntry);
|
||||
final playlistId = playlist['id'] as String?;
|
||||
if (playlistId == null || playlistId.isEmpty) continue;
|
||||
|
||||
final createdAt = (playlist['createdAt'] as String?) ?? nowIso;
|
||||
final updatedAt = (playlist['updatedAt'] as String?) ?? createdAt;
|
||||
await txn.insert(_tablePlaylists, {
|
||||
'id': playlistId,
|
||||
'name': (playlist['name'] as String?) ?? '',
|
||||
'cover_image_path': playlist['coverImagePath'] as String?,
|
||||
'created_at': createdAt,
|
||||
'updated_at': updatedAt,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
|
||||
final tracksRaw = (playlist['tracks'] as List?) ?? const [];
|
||||
for (final trackEntry in tracksRaw.whereType<Map>()) {
|
||||
final trackMap = Map<String, dynamic>.from(trackEntry);
|
||||
final trackKey = trackMap['key'] as String?;
|
||||
final track = trackMap['track'];
|
||||
if (trackKey == null || track is! Map) continue;
|
||||
final addedAt = (trackMap['addedAt'] as String?) ?? nowIso;
|
||||
await txn.insert(_tablePlaylistTracks, {
|
||||
'playlist_id': playlistId,
|
||||
'track_key': trackKey,
|
||||
'track_json': jsonEncode(track),
|
||||
'added_at': addedAt,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await prefs.setBool(_migrationDoneKey, true);
|
||||
_log.i('Migrated legacy collections data to SQLite');
|
||||
return true;
|
||||
} catch (e, stack) {
|
||||
_log.e('Failed migrating collections to SQLite: $e', e, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<LibraryCollectionsSnapshot> loadSnapshot() async {
|
||||
final db = await database;
|
||||
final wishlistRows = await db.query(
|
||||
_tableWishlist,
|
||||
orderBy: 'added_at DESC, rowid DESC',
|
||||
);
|
||||
final lovedRows = await db.query(
|
||||
_tableLoved,
|
||||
orderBy: 'added_at DESC, rowid DESC',
|
||||
);
|
||||
final playlistRows = await db.query(
|
||||
_tablePlaylists,
|
||||
orderBy: 'created_at DESC, rowid DESC',
|
||||
);
|
||||
final playlistTrackRows = await db.query(
|
||||
_tablePlaylistTracks,
|
||||
orderBy: 'playlist_id ASC, added_at DESC, rowid DESC',
|
||||
);
|
||||
|
||||
return LibraryCollectionsSnapshot(
|
||||
wishlistRows: wishlistRows,
|
||||
lovedRows: lovedRows,
|
||||
playlistRows: playlistRows,
|
||||
playlistTrackRows: playlistTrackRows,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> upsertWishlistEntry({
|
||||
required String trackKey,
|
||||
required String trackJson,
|
||||
required String addedAt,
|
||||
}) async {
|
||||
final db = await database;
|
||||
await db.insert(_tableWishlist, {
|
||||
'track_key': trackKey,
|
||||
'track_json': trackJson,
|
||||
'added_at': addedAt,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
Future<void> deleteWishlistEntry(String trackKey) async {
|
||||
final db = await database;
|
||||
await db.delete(
|
||||
_tableWishlist,
|
||||
where: 'track_key = ?',
|
||||
whereArgs: [trackKey],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> upsertLovedEntry({
|
||||
required String trackKey,
|
||||
required String trackJson,
|
||||
required String addedAt,
|
||||
}) async {
|
||||
final db = await database;
|
||||
await db.insert(_tableLoved, {
|
||||
'track_key': trackKey,
|
||||
'track_json': trackJson,
|
||||
'added_at': addedAt,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
Future<void> deleteLovedEntry(String trackKey) async {
|
||||
final db = await database;
|
||||
await db.delete(_tableLoved, where: 'track_key = ?', whereArgs: [trackKey]);
|
||||
}
|
||||
|
||||
Future<void> upsertPlaylist({
|
||||
required String id,
|
||||
required String name,
|
||||
required String createdAt,
|
||||
required String updatedAt,
|
||||
String? coverImagePath,
|
||||
}) async {
|
||||
final db = await database;
|
||||
await db.insert(_tablePlaylists, {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'cover_image_path': coverImagePath,
|
||||
'created_at': createdAt,
|
||||
'updated_at': updatedAt,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
Future<void> renamePlaylist({
|
||||
required String playlistId,
|
||||
required String name,
|
||||
required String updatedAt,
|
||||
}) async {
|
||||
final db = await database;
|
||||
await db.update(
|
||||
_tablePlaylists,
|
||||
{'name': name, 'updated_at': updatedAt},
|
||||
where: 'id = ?',
|
||||
whereArgs: [playlistId],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updatePlaylistCover({
|
||||
required String playlistId,
|
||||
required String updatedAt,
|
||||
String? coverImagePath,
|
||||
}) async {
|
||||
final db = await database;
|
||||
await db.update(
|
||||
_tablePlaylists,
|
||||
{'cover_image_path': coverImagePath, 'updated_at': updatedAt},
|
||||
where: 'id = ?',
|
||||
whereArgs: [playlistId],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deletePlaylist(String playlistId) async {
|
||||
final db = await database;
|
||||
await db.delete(_tablePlaylists, where: 'id = ?', whereArgs: [playlistId]);
|
||||
}
|
||||
|
||||
Future<void> upsertPlaylistTrack({
|
||||
required String playlistId,
|
||||
required String trackKey,
|
||||
required String trackJson,
|
||||
required String addedAt,
|
||||
required String playlistUpdatedAt,
|
||||
}) async {
|
||||
final db = await database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.insert(_tablePlaylistTracks, {
|
||||
'playlist_id': playlistId,
|
||||
'track_key': trackKey,
|
||||
'track_json': trackJson,
|
||||
'added_at': addedAt,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
await txn.update(
|
||||
_tablePlaylists,
|
||||
{'updated_at': playlistUpdatedAt},
|
||||
where: 'id = ?',
|
||||
whereArgs: [playlistId],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertPlaylistTracksBatch({
|
||||
required String playlistId,
|
||||
required String playlistUpdatedAt,
|
||||
required List<Map<String, String>> tracks,
|
||||
}) async {
|
||||
if (tracks.isEmpty) return;
|
||||
final db = await database;
|
||||
await db.transaction((txn) async {
|
||||
final batch = txn.batch();
|
||||
for (final track in tracks) {
|
||||
batch.insert(_tablePlaylistTracks, {
|
||||
'playlist_id': playlistId,
|
||||
'track_key': track['track_key'],
|
||||
'track_json': track['track_json'],
|
||||
'added_at': track['added_at'],
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
batch.update(
|
||||
_tablePlaylists,
|
||||
{'updated_at': playlistUpdatedAt},
|
||||
where: 'id = ?',
|
||||
whereArgs: [playlistId],
|
||||
);
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deletePlaylistTrack({
|
||||
required String playlistId,
|
||||
required String trackKey,
|
||||
required String playlistUpdatedAt,
|
||||
}) async {
|
||||
final db = await database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete(
|
||||
_tablePlaylistTracks,
|
||||
where: 'playlist_id = ? AND track_key = ?',
|
||||
whereArgs: [playlistId, trackKey],
|
||||
);
|
||||
await txn.update(
|
||||
_tablePlaylists,
|
||||
{'updated_at': playlistUpdatedAt},
|
||||
where: 'id = ?',
|
||||
whereArgs: [playlistId],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -95,39 +95,45 @@ class LocalLibraryItem {
|
||||
);
|
||||
|
||||
/// Create a unique key for matching tracks
|
||||
String get matchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
String get albumKey => '${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}';
|
||||
String get matchKey =>
|
||||
'${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
String get albumKey =>
|
||||
'${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}';
|
||||
}
|
||||
|
||||
class LibraryDatabase {
|
||||
static final LibraryDatabase instance = LibraryDatabase._init();
|
||||
static Database? _database;
|
||||
|
||||
|
||||
LibraryDatabase._init();
|
||||
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDB('local_library.db');
|
||||
return _database!;
|
||||
}
|
||||
|
||||
|
||||
Future<Database> _initDB(String fileName) async {
|
||||
final dbPath = await getApplicationDocumentsDirectory();
|
||||
final path = join(dbPath.path, fileName);
|
||||
|
||||
|
||||
_log.i('Initializing library database at: $path');
|
||||
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 4, // Bumped version for bitrate column
|
||||
onConfigure: (db) async {
|
||||
await db.rawQuery('PRAGMA journal_mode = WAL');
|
||||
await db.execute('PRAGMA synchronous = NORMAL');
|
||||
},
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> _createDB(Database db, int version) async {
|
||||
_log.i('Creating library database schema v$version');
|
||||
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE library (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -151,37 +157,43 @@ class LibraryDatabase {
|
||||
format TEXT
|
||||
)
|
||||
''');
|
||||
|
||||
|
||||
await db.execute('CREATE INDEX idx_library_isrc ON library(isrc)');
|
||||
await db.execute('CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)');
|
||||
await db.execute('CREATE INDEX idx_library_album ON library(album_name, album_artist)');
|
||||
await db.execute('CREATE INDEX idx_library_file_path ON library(file_path)');
|
||||
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_library_album ON library(album_name, album_artist)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_library_file_path ON library(file_path)',
|
||||
);
|
||||
|
||||
_log.i('Library database schema created with indexes');
|
||||
}
|
||||
|
||||
|
||||
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
|
||||
_log.i('Upgrading library database from v$oldVersion to v$newVersion');
|
||||
|
||||
|
||||
if (oldVersion < 2) {
|
||||
// Add cover_path column
|
||||
await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT');
|
||||
_log.i('Added cover_path column');
|
||||
}
|
||||
|
||||
|
||||
if (oldVersion < 3) {
|
||||
// Add file_mod_time column for incremental scanning
|
||||
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
|
||||
_log.i('Added file_mod_time column for incremental scanning');
|
||||
}
|
||||
|
||||
|
||||
if (oldVersion < 4) {
|
||||
// Add bitrate column for lossy format quality info
|
||||
await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER');
|
||||
_log.i('Added bitrate column for lossy format quality');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||
return {
|
||||
'id': json['id'],
|
||||
@@ -205,7 +217,7 @@ class LibraryDatabase {
|
||||
'format': json['format'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
|
||||
return {
|
||||
'id': row['id'],
|
||||
@@ -229,9 +241,9 @@ class LibraryDatabase {
|
||||
'format': row['format'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// CRUD Operations
|
||||
|
||||
|
||||
Future<void> upsert(Map<String, dynamic> json) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
@@ -240,12 +252,12 @@ class LibraryDatabase {
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||
if (items.isEmpty) return;
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
|
||||
|
||||
for (final json in items) {
|
||||
batch.insert(
|
||||
'library',
|
||||
@@ -253,11 +265,11 @@ class LibraryDatabase {
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
await batch.commit(noResult: true);
|
||||
_log.i('Batch inserted ${items.length} items');
|
||||
}
|
||||
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
@@ -268,7 +280,7 @@ class LibraryDatabase {
|
||||
);
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
|
||||
Future<Map<String, dynamic>?> getById(String id) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
@@ -280,7 +292,7 @@ class LibraryDatabase {
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
|
||||
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
@@ -292,7 +304,7 @@ class LibraryDatabase {
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
|
||||
Future<bool> existsByIsrc(String isrc) async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery(
|
||||
@@ -301,7 +313,7 @@ class LibraryDatabase {
|
||||
);
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
|
||||
Future<List<Map<String, dynamic>>> findByTrackAndArtist(
|
||||
String trackName,
|
||||
String artistName,
|
||||
@@ -314,7 +326,7 @@ class LibraryDatabase {
|
||||
);
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
|
||||
Future<Map<String, dynamic>?> findExisting({
|
||||
String? isrc,
|
||||
String? trackName,
|
||||
@@ -325,42 +337,42 @@ class LibraryDatabase {
|
||||
final byIsrc = await getByIsrc(isrc);
|
||||
if (byIsrc != null) return byIsrc;
|
||||
}
|
||||
|
||||
|
||||
// Then try name matching
|
||||
if (trackName != null && artistName != null) {
|
||||
final matches = await findByTrackAndArtist(trackName, artistName);
|
||||
if (matches.isNotEmpty) return matches.first;
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Future<Set<String>> getAllIsrcs() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT isrc FROM library WHERE isrc IS NOT NULL AND isrc != ""'
|
||||
'SELECT isrc FROM library WHERE isrc IS NOT NULL AND isrc != ""',
|
||||
);
|
||||
return rows.map((r) => r['isrc'] as String).toSet();
|
||||
}
|
||||
|
||||
|
||||
Future<Set<String>> getAllTrackKeys() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT LOWER(track_name) || "|" || LOWER(artist_name) as match_key FROM library'
|
||||
'SELECT LOWER(track_name) || "|" || LOWER(artist_name) as match_key FROM library',
|
||||
);
|
||||
return rows.map((r) => r['match_key'] as String).toSet();
|
||||
}
|
||||
|
||||
|
||||
Future<void> deleteByPath(String filePath) async {
|
||||
final db = await database;
|
||||
await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]);
|
||||
}
|
||||
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
final db = await database;
|
||||
await db.delete('library', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
|
||||
Future<int> cleanupMissingFiles() async {
|
||||
final db = await database;
|
||||
final rows = await db.query('library', columns: ['id', 'file_path']);
|
||||
@@ -409,44 +421,48 @@ class LibraryDatabase {
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
|
||||
Future<void> clearAll() async {
|
||||
final db = await database;
|
||||
await db.delete('library');
|
||||
_log.i('Cleared all library data');
|
||||
}
|
||||
|
||||
|
||||
Future<int> getCount() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM library');
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> search(String query, {int limit = 50}) async {
|
||||
|
||||
Future<List<Map<String, dynamic>>> search(
|
||||
String query, {
|
||||
int limit = 50,
|
||||
}) async {
|
||||
final db = await database;
|
||||
final searchQuery = '%${query.toLowerCase()}%';
|
||||
final rows = await db.query(
|
||||
'library',
|
||||
where: 'LOWER(track_name) LIKE ? OR LOWER(artist_name) LIKE ? OR LOWER(album_name) LIKE ?',
|
||||
where:
|
||||
'LOWER(track_name) LIKE ? OR LOWER(artist_name) LIKE ? OR LOWER(album_name) LIKE ?',
|
||||
whereArgs: [searchQuery, searchQuery, searchQuery],
|
||||
orderBy: 'track_name',
|
||||
limit: limit,
|
||||
);
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
|
||||
Future<void> close() async {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
_database = null;
|
||||
}
|
||||
|
||||
|
||||
/// Get all file paths with their modification times for incremental scanning
|
||||
/// Returns a map of filePath -> fileModTime (unix timestamp in milliseconds)
|
||||
Future<Map<String, int>> getFileModTimes() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library'
|
||||
'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library',
|
||||
);
|
||||
final result = <String, int>{};
|
||||
for (final row in rows) {
|
||||
@@ -456,7 +472,7 @@ class LibraryDatabase {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/// Update file_mod_time for existing rows using file_path as key.
|
||||
Future<void> updateFileModTimes(Map<String, int> fileModTimes) async {
|
||||
if (fileModTimes.isEmpty) return;
|
||||
@@ -472,14 +488,14 @@ class LibraryDatabase {
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
|
||||
/// Get all file paths in the library (for detecting deleted files)
|
||||
Future<Set<String>> getAllFilePaths() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery('SELECT file_path FROM library');
|
||||
return rows.map((r) => r['file_path'] as String).toSet();
|
||||
}
|
||||
|
||||
|
||||
/// Delete multiple items by their file paths
|
||||
Future<int> deleteByPaths(List<String> filePaths) async {
|
||||
if (filePaths.isEmpty) return 0;
|
||||
|
||||
@@ -7,6 +7,12 @@ final _log = AppLogger('PlatformBridge');
|
||||
|
||||
class PlatformBridge {
|
||||
static const _channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
static const _downloadProgressEvents = EventChannel(
|
||||
'com.zarz.spotiflac/download_progress_stream',
|
||||
);
|
||||
static const _libraryScanProgressEvents = EventChannel(
|
||||
'com.zarz.spotiflac/library_scan_progress_stream',
|
||||
);
|
||||
|
||||
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
||||
_log.d('parseSpotifyUrl: $url');
|
||||
@@ -48,6 +54,17 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getSpotifyRelatedArtists(
|
||||
String artistId, {
|
||||
int limit = 12,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('getSpotifyRelatedArtists', {
|
||||
'artist_id': artistId,
|
||||
'limit': limit,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> checkAvailability(
|
||||
String spotifyId,
|
||||
String isrc,
|
||||
@@ -113,6 +130,22 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Stream<Map<String, dynamic>> downloadProgressStream() {
|
||||
return _downloadProgressEvents.receiveBroadcastStream().map((event) {
|
||||
if (event is String) {
|
||||
return jsonDecode(event) as Map<String, dynamic>;
|
||||
}
|
||||
if (event is Map) {
|
||||
return Map<String, dynamic>.from(event);
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> exitApp() async {
|
||||
await _channel.invokeMethod('exitApp');
|
||||
}
|
||||
|
||||
static Future<void> initItemProgress(String itemId) async {
|
||||
await _channel.invokeMethod('initItemProgress', {'item_id': itemId});
|
||||
}
|
||||
@@ -133,6 +166,16 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
||||
}
|
||||
|
||||
static Future<void> setNetworkCompatibilityOptions({
|
||||
required bool allowHttp,
|
||||
required bool insecureTls,
|
||||
}) async {
|
||||
await _channel.invokeMethod('setNetworkCompatibilityOptions', {
|
||||
'allow_http': allowHttp,
|
||||
'insecure_tls': insecureTls,
|
||||
});
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> checkDuplicate(
|
||||
String outputDir,
|
||||
String isrc,
|
||||
@@ -244,6 +287,17 @@ class PlatformBridge {
|
||||
return result as bool? ?? false;
|
||||
}
|
||||
|
||||
static Future<bool> shareMultipleContentUris(
|
||||
List<String> uris, {
|
||||
String title = '',
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('shareMultipleContentUris', {
|
||||
'uris': uris,
|
||||
'title': title,
|
||||
});
|
||||
return result as bool? ?? false;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> fetchLyrics(
|
||||
String spotifyId,
|
||||
String trackName,
|
||||
@@ -511,6 +565,17 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getDeezerRelatedArtists(
|
||||
String artistId, {
|
||||
int limit = 12,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('getDeezerRelatedArtists', {
|
||||
'artist_id': artistId,
|
||||
'limit': limit,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getDeezerMetadata(
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
@@ -1077,6 +1142,18 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Stream<Map<String, dynamic>> libraryScanProgressStream() {
|
||||
return _libraryScanProgressEvents.receiveBroadcastStream().map((event) {
|
||||
if (event is String) {
|
||||
return jsonDecode(event) as Map<String, dynamic>;
|
||||
}
|
||||
if (event is Map) {
|
||||
return Map<String, dynamic>.from(event);
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
});
|
||||
}
|
||||
|
||||
/// Cancel ongoing library scan
|
||||
static Future<void> cancelLibraryScan() async {
|
||||
await _channel.invokeMethod('cancelLibraryScan');
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ShellNavigationService {
|
||||
static final GlobalKey<NavigatorState> homeTabNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
static final GlobalKey<NavigatorState> libraryTabNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
static final GlobalKey<NavigatorState> storeTabNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
|
||||
static int _currentTabIndex = 0;
|
||||
static bool _showStoreTab = false;
|
||||
|
||||
static void syncState({
|
||||
required int currentTabIndex,
|
||||
required bool showStoreTab,
|
||||
}) {
|
||||
_currentTabIndex = currentTabIndex;
|
||||
_showStoreTab = showStoreTab;
|
||||
}
|
||||
|
||||
static NavigatorState? activeTabNavigator() {
|
||||
if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState;
|
||||
if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState;
|
||||
if (_showStoreTab && _currentTabIndex == 2) {
|
||||
return storeTabNavigatorKey.currentState;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -28,36 +27,6 @@ class UpdateChecker {
|
||||
static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
||||
static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases';
|
||||
|
||||
static Future<String> _getDeviceArch() async {
|
||||
if (!Platform.isAndroid) return 'unknown';
|
||||
|
||||
try {
|
||||
final cpuInfo = await File('/proc/cpuinfo').readAsString();
|
||||
|
||||
if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) {
|
||||
return 'arm64';
|
||||
}
|
||||
|
||||
final result = await Process.run('uname', ['-m']);
|
||||
final arch = result.stdout.toString().trim().toLowerCase();
|
||||
|
||||
if (arch.contains('aarch64') || arch.contains('arm64')) {
|
||||
return 'arm64';
|
||||
} else if (arch.contains('armv7') || arch.contains('arm')) {
|
||||
return 'arm32';
|
||||
} else if (arch.contains('x86_64')) {
|
||||
return 'x86_64';
|
||||
} else if (arch.contains('x86') || arch.contains('i686')) {
|
||||
return 'x86';
|
||||
}
|
||||
|
||||
return 'arm64';
|
||||
} catch (e) {
|
||||
_log.e('Error detecting arch: $e');
|
||||
return 'arm64';
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for updates based on channel preference
|
||||
/// [channel] can be 'stable' or 'preview'
|
||||
static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
|
||||
@@ -109,11 +78,7 @@ class UpdateChecker {
|
||||
final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
||||
final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now();
|
||||
|
||||
final deviceArch = await _getDeviceArch();
|
||||
_log.d('Device architecture: $deviceArch');
|
||||
|
||||
String? arm64Url;
|
||||
String? arm32Url;
|
||||
String? universalUrl;
|
||||
|
||||
final assets = releaseData['assets'] as List<dynamic>? ?? [];
|
||||
@@ -128,22 +93,14 @@ class UpdateChecker {
|
||||
}
|
||||
if (name.contains('arm64') || name.contains('v8a')) {
|
||||
arm64Url = downloadUrl;
|
||||
} else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) {
|
||||
arm32Url = downloadUrl;
|
||||
} else if (name.contains('universal')) {
|
||||
universalUrl = downloadUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? apkUrl;
|
||||
if (deviceArch == 'arm64') {
|
||||
apkUrl = arm64Url ?? universalUrl ?? arm32Url;
|
||||
} else if (deviceArch == 'arm32') {
|
||||
apkUrl = arm32Url ?? universalUrl;
|
||||
} else {
|
||||
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
|
||||
}
|
||||
// Only arm64 is supported; fall back to universal if available
|
||||
final apkUrl = arm64Url ?? universalUrl;
|
||||
|
||||
_log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl');
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
final RegExp _artistNameSplitPattern = RegExp(
|
||||
r'\s*(?:,|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?(?=\s|$)\s*',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
List<String> splitArtistNames(String rawArtists) {
|
||||
final raw = rawArtists.trim();
|
||||
if (raw.isEmpty) return const [];
|
||||
|
||||
return raw
|
||||
.split(_artistNameSplitPattern)
|
||||
.map((part) => part.trim())
|
||||
.where((part) => part.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
show ExtensionArtistScreen, ExtensionAlbumScreen;
|
||||
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('ClickableMetadata');
|
||||
|
||||
/// Navigate to an artist screen by searching Deezer for the artist ID.
|
||||
///
|
||||
/// If [artistId] is provided and valid, navigates directly.
|
||||
/// Otherwise, searches Deezer by [artistName] to resolve the ID first.
|
||||
/// For extension-based content, pass [extensionId] to use ExtensionArtistScreen.
|
||||
Future<void> navigateToArtist(
|
||||
BuildContext context, {
|
||||
required String artistName,
|
||||
String? artistId,
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) async {
|
||||
if (artistName.isEmpty) return;
|
||||
|
||||
final normalizedArtistId = _normalizeArtistId(artistId);
|
||||
|
||||
// If we have a valid artist ID already, navigate directly
|
||||
if (normalizedArtistId != null &&
|
||||
_canNavigateArtistDirectly(
|
||||
artistId: normalizedArtistId,
|
||||
extensionId: extensionId,
|
||||
)) {
|
||||
_pushArtistScreen(
|
||||
context,
|
||||
artistId: normalizedArtistId,
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
extensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Search Deezer to resolve the artist ID
|
||||
_showLoadingSnackBar(context, 'Looking up artist...');
|
||||
try {
|
||||
final results = await PlatformBridge.searchDeezerAll(
|
||||
artistName,
|
||||
trackLimit: 0,
|
||||
artistLimit: 3,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
|
||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||
if (artistList.isEmpty) {
|
||||
_showUnavailable(context, 'Artist');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find best match - prefer exact name match (case-insensitive)
|
||||
Map<String, dynamic>? bestMatch;
|
||||
final lowerName = artistName.toLowerCase().trim();
|
||||
for (final a in artistList) {
|
||||
if (a is Map<String, dynamic>) {
|
||||
final name = (a['name'] as String? ?? '').toLowerCase().trim();
|
||||
if (name == lowerName) {
|
||||
bestMatch = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bestMatch ??= artistList.first as Map<String, dynamic>;
|
||||
|
||||
final resolvedId = bestMatch['id'] as String? ?? '';
|
||||
final resolvedName = bestMatch['name'] as String? ?? artistName;
|
||||
final resolvedImage = bestMatch['images'] as String?;
|
||||
|
||||
if (resolvedId.isEmpty) {
|
||||
_showUnavailable(context, 'Artist');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
_pushArtistScreen(
|
||||
context,
|
||||
artistId: resolvedId,
|
||||
artistName: resolvedName,
|
||||
coverUrl: resolvedImage ?? coverUrl,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('Failed to look up artist "$artistName": $e', e);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
_showUnavailable(context, 'Artist');
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to an album screen by searching Deezer for the album ID.
|
||||
///
|
||||
/// If [albumId] is provided and valid, navigates directly.
|
||||
/// Otherwise, searches Deezer by [albumName] (optionally with [artistName]) to resolve the ID.
|
||||
/// For extension-based content, pass [extensionId] to use ExtensionAlbumScreen.
|
||||
Future<void> navigateToAlbum(
|
||||
BuildContext context, {
|
||||
required String albumName,
|
||||
String? albumId,
|
||||
String? artistName,
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) async {
|
||||
if (albumName.isEmpty) return;
|
||||
|
||||
// If we have a valid album ID already, navigate directly
|
||||
if (albumId != null &&
|
||||
albumId.isNotEmpty &&
|
||||
albumId != 'unknown' &&
|
||||
albumId != 'deezer:unknown') {
|
||||
_pushAlbumScreen(
|
||||
context,
|
||||
albumId: albumId,
|
||||
albumName: albumName,
|
||||
coverUrl: coverUrl,
|
||||
extensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's extension-based content without an ID, can't search Deezer for it
|
||||
if (extensionId != null) {
|
||||
_showUnavailable(context, 'Album');
|
||||
return;
|
||||
}
|
||||
|
||||
// Search Deezer to resolve the album ID
|
||||
_showLoadingSnackBar(context, 'Looking up album...');
|
||||
try {
|
||||
// Build search query: "albumName artistName" for better accuracy
|
||||
final query = artistName != null && artistName.isNotEmpty
|
||||
? '$albumName $artistName'
|
||||
: albumName;
|
||||
|
||||
final results = await PlatformBridge.searchDeezerAll(
|
||||
query,
|
||||
trackLimit: 0,
|
||||
artistLimit: 0,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
|
||||
final albumList = results['albums'] as List<dynamic>? ?? [];
|
||||
if (albumList.isEmpty) {
|
||||
_showUnavailable(context, 'Album');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find best match - prefer exact name match (case-insensitive)
|
||||
Map<String, dynamic>? bestMatch;
|
||||
final lowerName = albumName.toLowerCase().trim();
|
||||
for (final a in albumList) {
|
||||
if (a is Map<String, dynamic>) {
|
||||
final name = (a['name'] as String? ?? '').toLowerCase().trim();
|
||||
if (name == lowerName) {
|
||||
bestMatch = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bestMatch ??= albumList.first as Map<String, dynamic>;
|
||||
|
||||
final resolvedId = bestMatch['id'] as String? ?? '';
|
||||
final resolvedName = bestMatch['name'] as String? ?? albumName;
|
||||
final resolvedImage = bestMatch['images'] as String?;
|
||||
|
||||
if (resolvedId.isEmpty) {
|
||||
_showUnavailable(context, 'Album');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
_pushAlbumScreen(
|
||||
context,
|
||||
albumId: resolvedId,
|
||||
albumName: resolvedName,
|
||||
coverUrl: resolvedImage ?? coverUrl,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('Failed to look up album "$albumName": $e', e);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
_showUnavailable(context, 'Album');
|
||||
}
|
||||
}
|
||||
|
||||
void _pushArtistScreen(
|
||||
BuildContext context, {
|
||||
required String artistId,
|
||||
required String artistName,
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) {
|
||||
_pushViaPreferredNavigator(
|
||||
context,
|
||||
(context) => extensionId != null
|
||||
? ExtensionArtistScreen(
|
||||
extensionId: extensionId,
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
)
|
||||
: ArtistScreen(
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pushAlbumScreen(
|
||||
BuildContext context, {
|
||||
required String albumId,
|
||||
required String albumName,
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) {
|
||||
_pushViaPreferredNavigator(
|
||||
context,
|
||||
(context) => extensionId != null
|
||||
? ExtensionAlbumScreen(
|
||||
extensionId: extensionId,
|
||||
albumId: albumId,
|
||||
albumName: albumName,
|
||||
coverUrl: coverUrl,
|
||||
)
|
||||
: AlbumScreen(
|
||||
albumId: albumId,
|
||||
albumName: albumName,
|
||||
coverUrl: coverUrl,
|
||||
tracks: const [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) {
|
||||
final currentNavigator = Navigator.of(context);
|
||||
final rootNavigator = Navigator.of(context, rootNavigator: true);
|
||||
final activeTabNavigator = ShellNavigationService.activeTabNavigator();
|
||||
|
||||
final shouldRouteToTabNavigator =
|
||||
identical(currentNavigator, rootNavigator) && activeTabNavigator != null;
|
||||
|
||||
if (!shouldRouteToTabNavigator) {
|
||||
currentNavigator.push(MaterialPageRoute(builder: builder));
|
||||
return;
|
||||
}
|
||||
|
||||
final currentRoute = ModalRoute.of(context);
|
||||
final shouldPopCurrentRoute =
|
||||
currentRoute != null && currentRoute.isFirst == false;
|
||||
|
||||
if (shouldPopCurrentRoute && currentNavigator.canPop()) {
|
||||
currentNavigator.pop();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!activeTabNavigator.mounted) return;
|
||||
activeTabNavigator.push(MaterialPageRoute(builder: builder));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabNavigator.push(MaterialPageRoute(builder: builder));
|
||||
}
|
||||
|
||||
void _showLoadingSnackBar(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(message),
|
||||
],
|
||||
),
|
||||
duration: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showUnavailable(BuildContext context, String type) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('$type information not available')));
|
||||
}
|
||||
|
||||
/// A reusable widget that makes text tappable to navigate to an artist screen.
|
||||
///
|
||||
/// Wraps the text in a GestureDetector that, when tapped, looks up the artist
|
||||
/// via Deezer search and navigates to the ArtistScreen.
|
||||
class ClickableArtistName extends StatefulWidget {
|
||||
final String artistName;
|
||||
final String? artistId;
|
||||
final String? coverUrl;
|
||||
final String? extensionId;
|
||||
final TextStyle? style;
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
final TextAlign? textAlign;
|
||||
|
||||
const ClickableArtistName({
|
||||
super.key,
|
||||
required this.artistName,
|
||||
this.artistId,
|
||||
this.coverUrl,
|
||||
this.extensionId,
|
||||
this.style,
|
||||
this.maxLines,
|
||||
this.overflow,
|
||||
this.textAlign,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ClickableArtistName> createState() => _ClickableArtistNameState();
|
||||
}
|
||||
|
||||
class _ClickableArtistNameState extends State<ClickableArtistName> {
|
||||
List<_ArtistTapTarget> _artistTargets = const [];
|
||||
final List<TapGestureRecognizer> _recognizers = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_rebuildArtistTargets();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ClickableArtistName oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.artistName != widget.artistName ||
|
||||
oldWidget.artistId != widget.artistId ||
|
||||
oldWidget.coverUrl != widget.coverUrl ||
|
||||
oldWidget.extensionId != widget.extensionId) {
|
||||
_rebuildArtistTargets();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeRecognizers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _disposeRecognizers() {
|
||||
for (final recognizer in _recognizers) {
|
||||
recognizer.dispose();
|
||||
}
|
||||
_recognizers.clear();
|
||||
}
|
||||
|
||||
void _rebuildArtistTargets() {
|
||||
_disposeRecognizers();
|
||||
_artistTargets = _buildArtistTapTargets(widget.artistName, widget.artistId);
|
||||
if (_artistTargets.length <= 1) return;
|
||||
|
||||
for (final target in _artistTargets) {
|
||||
final recognizer = TapGestureRecognizer()
|
||||
..onTap = () => navigateToArtist(
|
||||
context,
|
||||
artistName: target.name,
|
||||
artistId: target.artistId,
|
||||
coverUrl: widget.coverUrl,
|
||||
extensionId: _extensionIdForTarget(target),
|
||||
);
|
||||
_recognizers.add(recognizer);
|
||||
}
|
||||
}
|
||||
|
||||
String? _extensionIdForTarget(_ArtistTapTarget target) {
|
||||
if (widget.extensionId == null) return null;
|
||||
if (_artistTargets.length == 1) return widget.extensionId;
|
||||
return target.artistId != null ? widget.extensionId : null;
|
||||
}
|
||||
|
||||
List<InlineSpan> _buildMultiArtistSpans() {
|
||||
final spans = <InlineSpan>[];
|
||||
for (var i = 0; i < _artistTargets.length; i++) {
|
||||
final target = _artistTargets[i];
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: target.name,
|
||||
style: widget.style,
|
||||
recognizer: _recognizers[i],
|
||||
),
|
||||
);
|
||||
if (i < _artistTargets.length - 1) {
|
||||
spans.add(TextSpan(text: ', ', style: widget.style));
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_artistTargets.isEmpty) {
|
||||
return Text(
|
||||
widget.artistName,
|
||||
style: widget.style,
|
||||
maxLines: widget.maxLines,
|
||||
overflow: widget.overflow,
|
||||
textAlign: widget.textAlign,
|
||||
);
|
||||
}
|
||||
|
||||
if (_artistTargets.length == 1) {
|
||||
final target = _artistTargets.first;
|
||||
return GestureDetector(
|
||||
onTap: () => navigateToArtist(
|
||||
context,
|
||||
artistName: target.name,
|
||||
artistId: target.artistId,
|
||||
coverUrl: widget.coverUrl,
|
||||
extensionId: _extensionIdForTarget(target),
|
||||
),
|
||||
child: Text(
|
||||
target.name,
|
||||
style: widget.style,
|
||||
maxLines: widget.maxLines,
|
||||
overflow: widget.overflow,
|
||||
textAlign: widget.textAlign,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Text.rich(
|
||||
TextSpan(style: widget.style, children: _buildMultiArtistSpans()),
|
||||
maxLines: widget.maxLines,
|
||||
overflow: widget.overflow ?? TextOverflow.clip,
|
||||
textAlign: widget.textAlign ?? TextAlign.start,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtistTapTarget {
|
||||
final String name;
|
||||
final String? artistId;
|
||||
|
||||
const _ArtistTapTarget({required this.name, this.artistId});
|
||||
}
|
||||
|
||||
List<_ArtistTapTarget> _buildArtistTapTargets(
|
||||
String rawArtistNames,
|
||||
String? rawArtistIds,
|
||||
) {
|
||||
final parsedNames = splitArtistNames(rawArtistNames);
|
||||
if (parsedNames.isEmpty) return const [];
|
||||
|
||||
final uniqueNames = <String>[];
|
||||
final seen = <String>{};
|
||||
for (final parsed in parsedNames) {
|
||||
final key = parsed.toLowerCase().replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
if (key.isEmpty || !seen.add(key)) continue;
|
||||
uniqueNames.add(parsed);
|
||||
}
|
||||
if (uniqueNames.isEmpty) return const [];
|
||||
|
||||
if (uniqueNames.length == 1) {
|
||||
return [
|
||||
_ArtistTapTarget(
|
||||
name: uniqueNames.first,
|
||||
artistId: _normalizeArtistId(rawArtistIds),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
final parsedIds = _parseArtistIds(rawArtistIds);
|
||||
if (parsedIds.length == uniqueNames.length) {
|
||||
return List<_ArtistTapTarget>.generate(
|
||||
uniqueNames.length,
|
||||
(index) => _ArtistTapTarget(
|
||||
name: uniqueNames[index],
|
||||
artistId: parsedIds[index],
|
||||
),
|
||||
growable: false,
|
||||
);
|
||||
}
|
||||
|
||||
return uniqueNames
|
||||
.map((name) => _ArtistTapTarget(name: name))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> _parseArtistIds(String? rawArtistIds) {
|
||||
final raw = rawArtistIds?.trim();
|
||||
if (raw == null || raw.isEmpty) return const [];
|
||||
|
||||
final parsed = <String>[];
|
||||
for (final part in raw.split(RegExp(r'\s*,\s*'))) {
|
||||
final normalized = _normalizeArtistId(part);
|
||||
if (normalized != null) {
|
||||
parsed.add(normalized);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
String? _normalizeArtistId(String? artistId) {
|
||||
final id = artistId?.trim();
|
||||
if (id == null || id.isEmpty || id == 'unknown' || id == 'deezer:unknown') {
|
||||
return null;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
bool _canNavigateArtistDirectly({
|
||||
required String artistId,
|
||||
required String? extensionId,
|
||||
}) {
|
||||
if (extensionId != null) return true;
|
||||
if (artistId.startsWith('deezer:')) return true;
|
||||
return _spotifyArtistIdPattern.hasMatch(artistId);
|
||||
}
|
||||
|
||||
final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
|
||||
|
||||
/// A reusable widget that makes text tappable to navigate to an album screen.
|
||||
///
|
||||
/// Wraps the text in a GestureDetector that, when tapped, looks up the album
|
||||
/// via Deezer search and navigates to the AlbumScreen.
|
||||
class ClickableAlbumName extends StatelessWidget {
|
||||
final String albumName;
|
||||
final String? albumId;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
final String? extensionId;
|
||||
final TextStyle? style;
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
final TextAlign? textAlign;
|
||||
|
||||
const ClickableAlbumName({
|
||||
super.key,
|
||||
required this.albumName,
|
||||
this.albumId,
|
||||
this.artistName,
|
||||
this.coverUrl,
|
||||
this.extensionId,
|
||||
this.style,
|
||||
this.maxLines,
|
||||
this.overflow,
|
||||
this.textAlign,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => navigateToAlbum(
|
||||
context,
|
||||
albumName: albumName,
|
||||
albumId: albumId,
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
extensionId: extensionId,
|
||||
),
|
||||
child: Text(
|
||||
albumName,
|
||||
style: style,
|
||||
maxLines: maxLines,
|
||||
overflow: overflow,
|
||||
textAlign: textAlign,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
|
||||
bool hasEmbeddedLyricsMetadata(Map<String, String> metadata) {
|
||||
final lyrics = (metadata['LYRICS'] ?? '').trim();
|
||||
if (lyrics.isNotEmpty) return true;
|
||||
|
||||
final unsyncedLyrics = (metadata['UNSYNCEDLYRICS'] ?? '').trim();
|
||||
if (unsyncedLyrics.isNotEmpty) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
String _sidecarLrcPath(String path) {
|
||||
final slash = path.lastIndexOf(Platform.pathSeparator);
|
||||
final dot = path.lastIndexOf('.');
|
||||
if (dot > slash) {
|
||||
return '${path.substring(0, dot)}.lrc';
|
||||
}
|
||||
return '$path.lrc';
|
||||
}
|
||||
|
||||
Future<void> ensureLyricsMetadataForConversion({
|
||||
required Map<String, String> metadata,
|
||||
required String sourcePath,
|
||||
required bool shouldEmbedLyrics,
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
String spotifyId = '',
|
||||
int durationMs = 0,
|
||||
}) async {
|
||||
if (!shouldEmbedLyrics || hasEmbeddedLyricsMetadata(metadata)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String? lyrics;
|
||||
|
||||
// Prefer sidecar .lrc when available to avoid network calls.
|
||||
if (!isContentUri(sourcePath)) {
|
||||
try {
|
||||
final lrcPath = _sidecarLrcPath(sourcePath);
|
||||
final lrcFile = File(lrcPath);
|
||||
if (await lrcFile.exists()) {
|
||||
final content = (await lrcFile.readAsString()).trim();
|
||||
if (content.isNotEmpty) {
|
||||
lyrics = content;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (lyrics == null || lyrics.isEmpty) {
|
||||
try {
|
||||
final fetched = await PlatformBridge.getLyricsLRC(
|
||||
spotifyId,
|
||||
trackName,
|
||||
artistName,
|
||||
durationMs: durationMs,
|
||||
);
|
||||
final normalized = fetched.trim();
|
||||
if (normalized.isNotEmpty &&
|
||||
normalized.toLowerCase() != '[instrumental:true]') {
|
||||
lyrics = normalized;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (lyrics == null || lyrics.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
metadata['LYRICS'] = lyrics;
|
||||
metadata['UNSYNCEDLYRICS'] = lyrics;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
String? normalizeOptionalString(String? value) {
|
||||
if (value == null) return null;
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
if (trimmed.toLowerCase() == 'null') return null;
|
||||
return trimmed;
|
||||
}
|
||||
@@ -22,25 +22,49 @@ class BuiltInService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Default quality options for built-in services (Tidal, Qobuz, Amazon, YouTube)
|
||||
/// Default quality options for built-in services
|
||||
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
|
||||
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'),
|
||||
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'),
|
||||
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(
|
||||
@@ -54,12 +78,31 @@ const _builtInServices = [
|
||||
),
|
||||
],
|
||||
),
|
||||
BuiltInService(
|
||||
id: 'deezer',
|
||||
label: 'Deezer',
|
||||
qualityOptions: [
|
||||
QualityOption(
|
||||
id: 'FLAC',
|
||||
label: 'FLAC Lossless',
|
||||
description: '16-bit / 44.1kHz (CD Quality)',
|
||||
),
|
||||
],
|
||||
),
|
||||
BuiltInService(
|
||||
id: 'youtube',
|
||||
label: 'YouTube',
|
||||
qualityOptions: [
|
||||
QualityOption(id: 'opus_256', label: 'Opus 256kbps', description: 'Best quality lossy (~8MB per track)'),
|
||||
QualityOption(id: 'mp3_320', label: 'MP3 320kbps', description: 'Best compatibility (~10MB per track)'),
|
||||
QualityOption(
|
||||
id: 'opus_256',
|
||||
label: 'Opus 256kbps',
|
||||
description: 'Best quality lossy (~8MB per track)',
|
||||
),
|
||||
QualityOption(
|
||||
id: 'mp3_320',
|
||||
label: 'MP3 320kbps',
|
||||
description: 'Best compatibility (~10MB per track)',
|
||||
),
|
||||
],
|
||||
isDisabled: false,
|
||||
disabledReason: null,
|
||||
@@ -82,7 +125,8 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<DownloadServicePicker> createState() => _DownloadServicePickerState();
|
||||
ConsumerState<DownloadServicePicker> createState() =>
|
||||
_DownloadServicePickerState();
|
||||
|
||||
/// Show the download service picker as a modal bottom sheet
|
||||
static void show(
|
||||
@@ -93,9 +137,10 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
required void Function(String quality, String service) onSelect,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -112,6 +157,9 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
|
||||
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
||||
|
||||
late String _selectedService;
|
||||
|
||||
@override
|
||||
@@ -122,28 +170,76 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
|
||||
/// Get quality options for the selected service
|
||||
List<QualityOption> _getQualityOptions() {
|
||||
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (_selectedService == 'youtube') {
|
||||
final opusBitrate = _nearestSupportedBitrate(
|
||||
settings.youtubeOpusBitrate,
|
||||
_youtubeOpusSupportedBitrates,
|
||||
);
|
||||
final mp3Bitrate = _nearestSupportedBitrate(
|
||||
settings.youtubeMp3Bitrate,
|
||||
_youtubeMp3SupportedBitrates,
|
||||
);
|
||||
return [
|
||||
QualityOption(
|
||||
id: 'opus_$opusBitrate',
|
||||
label: 'Opus ${opusBitrate}kbps',
|
||||
description: 'Configured from YouTube settings',
|
||||
),
|
||||
QualityOption(
|
||||
id: 'mp3_$mp3Bitrate',
|
||||
label: 'MP3 ${mp3Bitrate}kbps',
|
||||
description: 'Configured from YouTube settings',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
final builtIn = _builtInServices
|
||||
.where((s) => s.id == _selectedService)
|
||||
.firstOrNull;
|
||||
if (builtIn != null) {
|
||||
return builtIn.qualityOptions;
|
||||
}
|
||||
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
|
||||
final ext = extensionState.extensions
|
||||
.where((e) => e.id == _selectedService)
|
||||
.firstOrNull;
|
||||
if (ext != null && ext.qualityOptions.isNotEmpty) {
|
||||
return ext.qualityOptions;
|
||||
}
|
||||
|
||||
// Default fallback options
|
||||
return [
|
||||
const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
|
||||
const QualityOption(
|
||||
id: 'DEFAULT',
|
||||
label: 'Default Quality',
|
||||
description: 'Best available',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
int _nearestSupportedBitrate(int value, List<int> supported) {
|
||||
var nearest = supported.first;
|
||||
var nearestDistance = (value - nearest).abs();
|
||||
|
||||
for (final option in supported.skip(1)) {
|
||||
final distance = (value - option).abs();
|
||||
if (distance < nearestDistance ||
|
||||
(distance == nearestDistance && option > nearest)) {
|
||||
nearest = option;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final extensionState = ref.watch(extensionProvider);
|
||||
|
||||
|
||||
final downloadExtensions = extensionState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
||||
.toList();
|
||||
@@ -162,7 +258,10 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
artistName: widget.artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
@@ -181,11 +280,13 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.downloadFrom,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
@@ -193,13 +294,13 @@ Padding(
|
||||
children: [
|
||||
for (final service in _builtInServices)
|
||||
_ServiceChip(
|
||||
label: service.isDisabled
|
||||
label: service.isDisabled
|
||||
? '${service.label} (${service.disabledReason})'
|
||||
: service.label,
|
||||
isSelected: _selectedService == service.id,
|
||||
isDisabled: service.isDisabled,
|
||||
onTap: service.isDisabled
|
||||
? null
|
||||
onTap: service.isDisabled
|
||||
? null
|
||||
: () => setState(() => _selectedService = service.id),
|
||||
),
|
||||
for (final ext in downloadExtensions)
|
||||
@@ -217,11 +318,15 @@ Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.downloadSelectQuality,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
if (_builtInServices.any((s) => s.id == _selectedService && s.id != 'youtube'))
|
||||
if (_builtInServices.any(
|
||||
(s) => s.id == _selectedService && s.id != 'youtube',
|
||||
))
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
@@ -264,27 +369,27 @@ Padding(
|
||||
}
|
||||
|
||||
IconData _getQualityIcon(String qualityId) {
|
||||
switch (qualityId.toUpperCase()) {
|
||||
final normalized = qualityId.toUpperCase();
|
||||
if (normalized.startsWith('MP3_') || normalized == 'MP3') {
|
||||
return Icons.audiotrack;
|
||||
}
|
||||
if (normalized.startsWith('OPUS_') || normalized == 'OPUS') {
|
||||
return Icons.graphic_eq;
|
||||
}
|
||||
|
||||
switch (normalized) {
|
||||
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':
|
||||
case 'OPUS_256':
|
||||
return Icons.graphic_eq;
|
||||
default:
|
||||
return Icons.music_note;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
@@ -313,7 +418,10 @@ class _QualityOption extends StatelessWidget {
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: subtitle.isNotEmpty
|
||||
? Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant))
|
||||
? Text(
|
||||
subtitle,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
)
|
||||
: null,
|
||||
onTap: onTap,
|
||||
);
|
||||
@@ -344,13 +452,17 @@ class _ServiceChip extends StatelessWidget {
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isDisabled
|
||||
color: isDisabled
|
||||
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
|
||||
: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
border: isSelected
|
||||
? null
|
||||
: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -366,11 +478,11 @@ class _ServiceChip extends StatelessWidget {
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
Icons.extension,
|
||||
size: 18,
|
||||
color: isDisabled
|
||||
color: isDisabled
|
||||
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
||||
: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -380,11 +492,11 @@ class _ServiceChip extends StatelessWidget {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isDisabled
|
||||
color: isDisabled
|
||||
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
||||
: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -419,7 +531,9 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||
onTap: _isOverflowing
|
||||
? () => setState(() => _expanded = !_expanded)
|
||||
: null,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
@@ -447,26 +561,39 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
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),
|
||||
),
|
||||
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),
|
||||
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 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,
|
||||
@@ -487,17 +614,22 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
widget.trackName,
|
||||
style: titleStyle,
|
||||
maxLines: _expanded ? 10 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
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,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: _expanded ? 3 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
overflow: _expanded
|
||||
? TextOverflow.visible
|
||||
: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
Future<void> showAddTrackToPlaylistSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Track track,
|
||||
) async {
|
||||
return showAddTracksToPlaylistSheet(context, ref, [track]);
|
||||
}
|
||||
|
||||
Future<void> showAddTracksToPlaylistSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<Track> tracks,
|
||||
) async {
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) {
|
||||
return _PlaylistPickerSheetContent(tracks: tracks);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _PlaylistPickerSheetContent extends ConsumerStatefulWidget {
|
||||
final List<Track> tracks;
|
||||
|
||||
const _PlaylistPickerSheetContent({required this.tracks});
|
||||
|
||||
@override
|
||||
ConsumerState<_PlaylistPickerSheetContent> createState() =>
|
||||
_PlaylistPickerSheetContentState();
|
||||
}
|
||||
|
||||
class _PlaylistPickerSheetContentState
|
||||
extends ConsumerState<_PlaylistPickerSheetContent> {
|
||||
final Set<String> _selectedPlaylistIds = {};
|
||||
final Set<String> _initialDisabledIds = {};
|
||||
bool _initialized = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_initialized) {
|
||||
final playlists = ref.read(libraryCollectionsProvider).playlists;
|
||||
for (final playlist in playlists) {
|
||||
final alreadyInPlaylist =
|
||||
widget.tracks.every((t) => playlist.containsTrack(t));
|
||||
if (alreadyInPlaylist) {
|
||||
_initialDisabledIds.add(playlist.id);
|
||||
_selectedPlaylistIds.add(playlist.id);
|
||||
}
|
||||
}
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDone() async {
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds);
|
||||
final addedNames = <String>[];
|
||||
|
||||
for (final playlistId in idsToAdd) {
|
||||
final playlist =
|
||||
ref.read(libraryCollectionsProvider).playlistById(playlistId);
|
||||
if (playlist != null) {
|
||||
addedNames.add(playlist.name);
|
||||
}
|
||||
await notifier.addTracksToPlaylist(playlistId, widget.tracks);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
|
||||
if (addedNames.isNotEmpty) {
|
||||
final name =
|
||||
addedNames.length == 1 ? addedNames.first : addedNames.join(', ');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.collectionAddedToPlaylist(name)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final playlists = ref.watch(
|
||||
libraryCollectionsProvider.select((state) => state.playlists),
|
||||
);
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
|
||||
final String subtitle;
|
||||
if (widget.tracks.length == 1) {
|
||||
final track = widget.tracks.first;
|
||||
subtitle = '${track.name} • ${track.artistName}';
|
||||
} else {
|
||||
subtitle =
|
||||
'${widget.tracks.length} ${widget.tracks.length == 1 ? 'track' : 'tracks'}';
|
||||
}
|
||||
|
||||
final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds);
|
||||
final hasNewSelections = idsToAdd.isNotEmpty;
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.playlist_add),
|
||||
title: Text(context.l10n.collectionAddToPlaylist),
|
||||
subtitle: Text(subtitle),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_circle_outline),
|
||||
title: Text(context.l10n.collectionCreatePlaylist),
|
||||
onTap: () async {
|
||||
final name = await _promptPlaylistName(context);
|
||||
if (name == null || name.trim().isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
final playlistId = await notifier.createPlaylist(name.trim());
|
||||
await notifier.addTracksToPlaylist(playlistId, widget.tracks);
|
||||
setState(() {
|
||||
_initialDisabledIds.add(playlistId);
|
||||
_selectedPlaylistIds.add(playlistId);
|
||||
});
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.collectionAddedToPlaylist(name.trim())),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (playlists.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Text(
|
||||
context.l10n.collectionNoPlaylistsYet,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 320),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: playlists.length,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = playlists[index];
|
||||
final isAlreadyIn = _initialDisabledIds.contains(playlist.id);
|
||||
final isSelected = _selectedPlaylistIds.contains(playlist.id);
|
||||
|
||||
return ListTile(
|
||||
leading: _PlaylistPickerThumbnail(
|
||||
playlist: playlist,
|
||||
isSelected: isSelected,
|
||||
),
|
||||
title: Text(playlist.name),
|
||||
subtitle: Text(
|
||||
context.l10n.collectionPlaylistTracks(
|
||||
playlist.tracks.length,
|
||||
),
|
||||
),
|
||||
enabled: !isAlreadyIn,
|
||||
onTap: !isAlreadyIn
|
||||
? () {
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_selectedPlaylistIds.remove(playlist.id);
|
||||
} else {
|
||||
_selectedPlaylistIds.add(playlist.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
if (hasNewSelections) {
|
||||
_handleDone();
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Text(context.l10n.dialogDone),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _promptPlaylistName(BuildContext context) async {
|
||||
final controller = TextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(dialogContext.l10n.collectionCreatePlaylist),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
hintText: dialogContext.l10n.collectionPlaylistNameHint,
|
||||
),
|
||||
validator: (value) {
|
||||
final trimmed = value?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return dialogContext.l10n.collectionPlaylistNameRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) {
|
||||
if (formKey.currentState?.validate() != true) return;
|
||||
Navigator.of(dialogContext).pop(controller.text.trim());
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text(dialogContext.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (formKey.currentState?.validate() != true) return;
|
||||
Navigator.of(dialogContext).pop(controller.text.trim());
|
||||
},
|
||||
child: Text(dialogContext.l10n.actionCreate),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
class _PlaylistPickerThumbnail extends StatelessWidget {
|
||||
final UserPlaylistCollection playlist;
|
||||
final bool isSelected;
|
||||
|
||||
const _PlaylistPickerThumbnail({
|
||||
required this.playlist,
|
||||
required this.isSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
const double size = 48;
|
||||
final borderRadius = BorderRadius.circular(8);
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: _buildCoverImage(colorScheme, size),
|
||||
),
|
||||
if (isSelected) ...[
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withValues(alpha: 0.3),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 2,
|
||||
top: 2,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: colorScheme.primary, width: 1.5),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCoverImage(ColorScheme colorScheme, double size) {
|
||||
final customCoverPath = playlist.coverImagePath;
|
||||
if (customCoverPath != null && customCoverPath.isNotEmpty) {
|
||||
return Image.file(
|
||||
File(customCoverPath),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _iconFallback(colorScheme, size),
|
||||
);
|
||||
}
|
||||
|
||||
String? firstCoverUrl;
|
||||
for (final entry in playlist.tracks) {
|
||||
final coverUrl = entry.track.coverUrl;
|
||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||
firstCoverUrl = coverUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstCoverUrl != null) {
|
||||
final isLocalPath =
|
||||
!firstCoverUrl.startsWith('http://') &&
|
||||
!firstCoverUrl.startsWith('https://');
|
||||
|
||||
if (isLocalPath) {
|
||||
return Image.file(
|
||||
File(firstCoverUrl),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _iconFallback(colorScheme, size),
|
||||
);
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: firstCoverUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (size * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => _iconFallback(colorScheme, size),
|
||||
errorWidget: (_, _, _) => _iconFallback(colorScheme, size),
|
||||
);
|
||||
}
|
||||
|
||||
return _iconFallback(colorScheme, size);
|
||||
}
|
||||
|
||||
Widget _iconFallback(ColorScheme colorScheme, double size) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
|
||||
class PrioritySettingsScaffold extends StatelessWidget {
|
||||
final bool hasChanges;
|
||||
final String title;
|
||||
final String description;
|
||||
final String infoText;
|
||||
final String saveLabel;
|
||||
final EdgeInsetsGeometry descriptionPadding;
|
||||
final List<Widget> slivers;
|
||||
final Future<void> Function() onSave;
|
||||
final Future<bool> Function(BuildContext context) onConfirmDiscard;
|
||||
|
||||
const PrioritySettingsScaffold({
|
||||
super.key,
|
||||
required this.hasChanges,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.infoText,
|
||||
required this.slivers,
|
||||
required this.onSave,
|
||||
required this.onConfirmDiscard,
|
||||
this.saveLabel = 'Save',
|
||||
this.descriptionPadding = const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
});
|
||||
|
||||
Future<void> _handleBack(BuildContext context) async {
|
||||
if (!hasChanges) {
|
||||
Navigator.pop(context);
|
||||
return;
|
||||
}
|
||||
final shouldPop = await onConfirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: !hasChanges,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await onConfirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
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: () => _handleBack(context),
|
||||
),
|
||||
actions: [
|
||||
if (hasChanges)
|
||||
TextButton(onPressed: onSave, child: Text(saveLabel)),
|
||||
],
|
||||
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(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: descriptionPadding,
|
||||
child: Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
...slivers,
|
||||
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(
|
||||
infoText,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
class TrackCollectionQuickActions extends ConsumerWidget {
|
||||
final Track track;
|
||||
|
||||
const TrackCollectionQuickActions({super.key, required this.track});
|
||||
|
||||
static void showTrackOptionsSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Track track,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (sheetContext) => _TrackOptionsSheet(track: track),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => showTrackOptionsSheet(context, ref, track),
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackOptionsSheet extends ConsumerWidget {
|
||||
final Track track;
|
||||
|
||||
const _TrackOptionsSheet({required this.track});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final isLoved = ref.watch(
|
||||
libraryCollectionsProvider.select((state) => state.isLoved(track)),
|
||||
);
|
||||
final isInWishlist = ref.watch(
|
||||
libraryCollectionsProvider.select((state) => state.isInWishlist(track)),
|
||||
);
|
||||
|
||||
return SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.82,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header with drag handle + track info (matches _TrackInfoHeader)
|
||||
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:
|
||||
track.coverUrl != null &&
|
||||
track.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
errorWidget: (context, url, error) =>
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.name,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ClickableArtistName(
|
||||
artistName: track.artistName,
|
||||
artistId: track.artistId,
|
||||
coverUrl: track.coverUrl,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
|
||||
// Action items (matches _QualityOption style)
|
||||
_OptionTile(
|
||||
icon: isLoved ? Icons.favorite : Icons.favorite_border,
|
||||
iconColor: isLoved ? colorScheme.error : null,
|
||||
title: isLoved
|
||||
? context.l10n.trackOptionRemoveFromLoved
|
||||
: context.l10n.trackOptionAddToLoved,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final added = await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.toggleLoved(track);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
added
|
||||
? context.l10n.collectionAddedToLoved(track.name)
|
||||
: context.l10n.collectionRemovedFromLoved(
|
||||
track.name,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_OptionTile(
|
||||
icon: isInWishlist
|
||||
? Icons.playlist_add_check_circle
|
||||
: Icons.add_circle_outline,
|
||||
iconColor: isInWishlist ? colorScheme.primary : null,
|
||||
title: isInWishlist
|
||||
? context.l10n.trackOptionRemoveFromWishlist
|
||||
: context.l10n.trackOptionAddToWishlist,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final added = await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.toggleWishlist(track);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
added
|
||||
? context.l10n.collectionAddedToWishlist(track.name)
|
||||
: context.l10n.collectionRemovedFromWishlist(
|
||||
track.name,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_OptionTile(
|
||||
icon: Icons.playlist_add,
|
||||
title: context.l10n.collectionAddToPlaylist,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showAddTrackToPlaylistSheet(context, ref, track);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Styled like _QualityOption in download_service_picker.dart
|
||||
class _OptionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color? iconColor;
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _OptionTile({
|
||||
required this.icon,
|
||||
this.iconColor,
|
||||
required this.title,
|
||||
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: iconColor ?? colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user