mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
feat(ui): add Mornye glass theme and adaptive music player
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components. Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures. Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
This commit is contained in:
1 parent
1ebdac8645
commit
63e1a9b9c8
155 files changed
+15707
-2318
No files matched your search
@@ -194,12 +194,18 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
lyricsFetchOptions: snapshot.lyricsFetchOptions,
|
||||
allowedDirectories: allowedDirectories,
|
||||
);
|
||||
await loadExtensions(extensionsDir);
|
||||
final loaded = await loadExtensions(extensionsDir);
|
||||
final loadError = state.error;
|
||||
if (loadError != null) throw StateError(loadError);
|
||||
if (!loaded) {
|
||||
throw StateError(loadError ?? 'Failed to load installed extensions');
|
||||
}
|
||||
await loadProviderPriority();
|
||||
await loadMetadataProviderPriority();
|
||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
||||
state = state.copyWith(
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
error: loadError,
|
||||
);
|
||||
await _syncDownloadDirectory(ref.read(settingsProvider));
|
||||
_log.i('Extension system initialized');
|
||||
} catch (e) {
|
||||
@@ -234,17 +240,32 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadExtensions(String dirPath) async {
|
||||
Future<bool> loadExtensions(String dirPath) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.loadExtensionsFromDir(dirPath);
|
||||
_log.d('Load extensions result: $result');
|
||||
final loadErrors = (result['errors'] as List<dynamic>? ?? const [])
|
||||
.whereType<String>()
|
||||
.where((error) => error.trim().isNotEmpty)
|
||||
.toList();
|
||||
await refreshExtensions();
|
||||
state = state.copyWith(isLoading: false, error: state.error);
|
||||
final refreshError = state.error;
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error:
|
||||
refreshError ?? (loadErrors.isEmpty ? null : loadErrors.join('\n')),
|
||||
);
|
||||
// Native loading can succeed overall while individual packages fail.
|
||||
// Keep working extensions usable, but do not treat an unreadable install
|
||||
// directory as an empty, successfully initialized extension system.
|
||||
return refreshError == null &&
|
||||
(loadErrors.isEmpty || state.extensions.isNotEmpty);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load extensions: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/unified_library_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
@@ -8,6 +10,49 @@ final currentMediaItemProvider = StreamProvider<MediaItem?>((ref) {
|
||||
return musicPlayerMediaItemEvents();
|
||||
});
|
||||
|
||||
/// Use the Library's identity for favorites, including restored queues and
|
||||
/// playback started from a file path rather than a Library row ID.
|
||||
final playerCollectionTrackProvider = FutureProvider.autoDispose
|
||||
.family<Track, MediaItem>((ref, item) async {
|
||||
final source = item.extras?['source']?.toString() ?? '';
|
||||
if (source.isNotEmpty) {
|
||||
final history = await ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.getByFilePathAsync(source);
|
||||
if (history != null) {
|
||||
return UnifiedLibraryItem.fromDownloadHistory(history).toTrack();
|
||||
}
|
||||
}
|
||||
final database = LibraryDatabase.instance;
|
||||
final row = await database.getById(item.id);
|
||||
if (row != null) {
|
||||
return UnifiedLibraryItem.fromLocalLibrary(
|
||||
LocalLibraryItem.fromJson(row),
|
||||
).toTrack();
|
||||
}
|
||||
if (source.isNotEmpty) {
|
||||
final matches = await database.findByTrackAndArtist(
|
||||
item.title,
|
||||
item.artist ?? '',
|
||||
);
|
||||
for (final row in matches) {
|
||||
final local = LocalLibraryItem.fromJson(row);
|
||||
if (local.filePath == source) {
|
||||
return UnifiedLibraryItem.fromLocalLibrary(local).toTrack();
|
||||
}
|
||||
}
|
||||
}
|
||||
return Track(
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
artistName: item.artist ?? '',
|
||||
albumName: item.album ?? '',
|
||||
coverUrl: item.artUri?.toString(),
|
||||
duration: item.duration?.inSeconds ?? 0,
|
||||
source: 'local',
|
||||
);
|
||||
});
|
||||
|
||||
final playbackStateProvider = StreamProvider<PlaybackState>((ref) {
|
||||
return musicPlayerPlaybackStateEvents();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/ttl_cache.dart';
|
||||
|
||||
typedef PlayerArtworkAlbum = ({String album, String artist});
|
||||
|
||||
final _motionCache = TtlCache<Future<String?>>(
|
||||
const Duration(minutes: 10),
|
||||
maxEntries: 32,
|
||||
);
|
||||
|
||||
/// Resolve the same album motion artwork used by collection headers. Cache by
|
||||
/// album so advancing through its tracks does not repeat metadata requests.
|
||||
final playerMotionArtworkProvider = FutureProvider.autoDispose
|
||||
.family<String?, PlayerArtworkAlbum>((ref, album) {
|
||||
if (album.album.trim().isEmpty || album.artist.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final extensions = ref.watch(extensionProvider);
|
||||
final preferred = ref.watch(
|
||||
settingsProvider.select((s) => s.searchProvider),
|
||||
);
|
||||
final enabled = extensions.extensions
|
||||
.where((extension) => extension.enabled && extension.hasCustomSearch)
|
||||
.map((extension) => extension.id)
|
||||
.toSet();
|
||||
final providers = <String>{
|
||||
if (preferred != null && enabled.contains(preferred)) preferred,
|
||||
...extensions.metadataProviderPriority.where(enabled.contains),
|
||||
...enabled,
|
||||
}.toList();
|
||||
if (providers.isEmpty) return null;
|
||||
final key = jsonEncode([album.album, album.artist, providers]);
|
||||
final cached = _motionCache.get(key);
|
||||
if (cached != null) return cached;
|
||||
final request = findPlayerMotionArtwork(
|
||||
album: album,
|
||||
providerIds: providers,
|
||||
search: (provider, query) => PlatformBridge.customSearchWithExtension(
|
||||
provider,
|
||||
query,
|
||||
options: {'filter': 'album', 'limit': 8},
|
||||
).timeout(const Duration(seconds: 6)),
|
||||
loadAlbum: (provider, id) => PlatformBridge.getProviderMetadata(
|
||||
provider,
|
||||
'album',
|
||||
id,
|
||||
).timeout(const Duration(seconds: 6)),
|
||||
);
|
||||
_motionCache.set(key, request);
|
||||
return request;
|
||||
});
|
||||
|
||||
Future<String?> findPlayerMotionArtwork({
|
||||
required PlayerArtworkAlbum album,
|
||||
required List<String> providerIds,
|
||||
required Future<List<Map<String, dynamic>>> Function(
|
||||
String provider,
|
||||
String query,
|
||||
)
|
||||
search,
|
||||
required Future<Map<String, dynamic>> Function(String provider, String id)
|
||||
loadAlbum,
|
||||
}) async {
|
||||
String normalized(String value) =>
|
||||
value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
|
||||
for (final provider in providerIds) {
|
||||
try {
|
||||
final results = await search(provider, '${album.album} ${album.artist}');
|
||||
for (final result in results) {
|
||||
final name = (result['name'] ?? result['album_name'] ?? '').toString();
|
||||
final artist =
|
||||
(result['artists'] ??
|
||||
result['artist'] ??
|
||||
result['artist_name'] ??
|
||||
'')
|
||||
.toString();
|
||||
// Never attach a similarly named album's video to the playing track.
|
||||
if (normalized(name) != normalized(album.album) ||
|
||||
normalized(artist) != normalized(album.artist)) {
|
||||
continue;
|
||||
}
|
||||
final direct = normalizeRemoteHttpUrl(
|
||||
result['header_video']?.toString(),
|
||||
);
|
||||
if (direct != null) return direct;
|
||||
final id = (result['id'] ?? result['album_id'] ?? '').toString();
|
||||
if (id.isEmpty) continue;
|
||||
final metadata = await loadAlbum(provider, id);
|
||||
final info = metadata['album_info'];
|
||||
final video = normalizeRemoteHttpUrl(
|
||||
((info is Map ? info['header_video'] : null) ??
|
||||
metadata['header_video'])
|
||||
?.toString(),
|
||||
);
|
||||
if (video != null) return video;
|
||||
}
|
||||
} catch (_) {
|
||||
// Offline, unsupported providers and expired links keep the static cover.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ ThemeSettings loadBootstrapThemeSettings(SharedPreferences prefs) {
|
||||
useDynamicColor: prefs.getBool(kUseDynamicColorKey) ?? true,
|
||||
seedColorValue: prefs.getInt(kSeedColorKey) ?? kDefaultSeedColor,
|
||||
useAmoled: prefs.getBool(kUseAmoledKey) ?? false,
|
||||
style: themeStyleFromString(prefs.getString(kThemeStyleKey)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
useDynamicColor: useDynamic ?? true,
|
||||
seedColorValue: seedColor ?? kDefaultSeedColor,
|
||||
useAmoled: useAmoled ?? false,
|
||||
style: themeStyleFromString(prefs.getString(kThemeStyleKey)),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error loading theme settings: $e');
|
||||
@@ -55,6 +57,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
|
||||
await prefs.setInt(kSeedColorKey, state.seedColorValue);
|
||||
await prefs.setBool(kUseAmoledKey, state.useAmoled);
|
||||
await prefs.setString(kThemeStyleKey, state.style.name);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving theme settings: $e');
|
||||
}
|
||||
@@ -65,6 +68,11 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
Future<void> setStyle(AppThemeStyle style) async {
|
||||
state = state.copyWith(style: style);
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
Future<void> setUseDynamicColor(bool value) async {
|
||||
state = state.copyWith(useDynamicColor: value);
|
||||
await _saveToStorage();
|
||||
|
||||
@@ -562,7 +562,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
releaseDate: data['release_date'] as String? ?? '',
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
totalTracks: int.tryParse(data['total_tracks']?.toString() ?? '') ?? 0,
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user