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:
zarzet committed 2026-09-19 12:10:10 +07:00
1 parent 1ebdac8645
commit 63e1a9b9c8
155 files changed
+15707 -2318

No files matched your search

+16 -8
View File
@@ -13,12 +13,14 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/replaygain_service.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/utils/saf_display_path.dart';
@@ -97,6 +99,8 @@ Future<void> showBatchConvertSheet(
context: modalContext,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: modalContext.isMornye ? Colors.transparent : null,
elevation: modalContext.isMornye ? 0 : null,
builder: (sheetContext) => BatchConvertSheet(
formats: formats,
title: sheetTitle,
@@ -165,9 +169,9 @@ Future<void> _performBatchConversion(
final isLossless = isLosslessConversionTarget(targetFormat);
final losslessLabels = context.l10n.losslessConversionLabels;
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
builder: (ctx) => AppAlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
keepOriginal
@@ -197,11 +201,13 @@ Future<void> _performBatchConversion(
),
),
actions: [
TextButton(
AppDialogAction(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
AppDialogAction(
filled: true,
isDefault: true,
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.trackConvertFormat),
),
@@ -546,19 +552,21 @@ Future<void> runBatchReplayGain(
onConfirmOpen?.call();
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
builder: (ctx) => AppAlertDialog(
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
content: Text(
ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length),
),
actions: [
TextButton(
AppDialogAction(
onPressed: () => Navigator.pop(ctx, false),
child: Text(ctx.l10n.dialogCancel),
),
FilledButton(
AppDialogAction(
filled: true,
isDefault: true,
onPressed: () => Navigator.pop(ctx, true),
child: Text(ctx.l10n.replayGainBatchConfirmTitle),
),
+8 -3
View File
@@ -77,16 +77,21 @@ class LibraryDatabase {
columns: ['id', 'path'],
where: localSources,
);
final localSourceIds = sources.map((source) => source['id']).toSet();
final rows = await txn.query(
'library',
columns: ['id', 'file_path', 'cover_path'],
where:
'source_id IN (SELECT id FROM library_sources WHERE $localSources)',
columns: ['id', 'source_id', 'file_path', 'cover_path'],
);
final batch = txn.batch();
for (final row in rows) {
final updates = <String, Object?>{};
for (final column in ['file_path', 'cover_path']) {
// Bookmarks own external audio paths, but extracted covers always
// live in our sandbox, including covers from bookmarked folders.
if (column == 'file_path' &&
!localSourceIds.contains(row['source_id'])) {
continue;
}
final previous = row[column] as String?;
if (previous == null) continue;
final current = rebaseIosSandboxPath(previous, documents.path);
+6 -4
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
@@ -28,17 +29,18 @@ Future<void> queueLocalTracksAsFlac(
return;
}
final confirmed = await showDialog<bool>(
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
builder: (ctx) => AppAlertDialog(
title: Text(context.l10n.queueFlacAction),
content: Text(context.l10n.queueFlacConfirmMessage(selected.length)),
actions: [
TextButton(
AppDialogAction(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
AppDialogAction(
filled: true,
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.queueFlacAction),
),
+55 -5
View File
@@ -7,10 +7,14 @@ import 'package:spotiflac_android/services/discord_presence_service.dart';
import 'package:audio_session/audio_session.dart'
show AudioSession, AudioSessionConfiguration, AudioInterruptionType;
import 'package:audioplayers/audioplayers.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/playback_normalization.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/ios_container_paths.dart';
import 'package:spotiflac_android/utils/playback_artwork.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
@@ -117,20 +121,36 @@ class PlayableMedia {
if (explicit) 'explicit': true,
};
static PlayableMedia? fromJson(Map<String, dynamic> json) {
static PlayableMedia? fromJson(
Map<String, dynamic> json, {
String? iosDocumentsPath,
}) {
final id = json['id'] as String?;
final source = json['source'] as String?;
if (id == null || id.isEmpty || source == null || source.isEmpty) {
return null;
}
final durationMs = (json['durationMs'] as num?)?.toInt();
var artwork = json['artUri'] as String?;
if (artwork != null && iosDocumentsPath != null) {
final uri = Uri.tryParse(artwork);
if (uri != null && uri.scheme == 'file' && uri.host.isEmpty) {
try {
final path = uri.toFilePath();
final rebased = rebaseIosSandboxPath(path, iosDocumentsPath);
if (rebased != path) artwork = Uri.file(rebased).toString();
} on UnsupportedError {
// Leave malformed or non-local artwork URIs to the image fallback.
}
}
}
return PlayableMedia(
id: id,
source: source,
title: json['title'] as String? ?? '',
artist: json['artist'] as String? ?? '',
album: json['album'] as String? ?? '',
artUri: json['artUri'] as String?,
artUri: artwork,
duration: (durationMs != null && durationMs > 0)
? Duration(milliseconds: durationMs)
: null,
@@ -1571,15 +1591,45 @@ Future<void> restorePersistedPlaybackSession() async {
final items = <PlayableMedia>[];
final keptOriginalIndices = <int>[];
final iosDocumentsPath = Platform.isIOS
? (await getApplicationDocumentsDirectory()).path
: null;
var artworkRelocated = false;
for (var i = 0; i < rawMedia.length; i++) {
final entry = rawMedia[i];
if (entry is! Map) continue;
final media = PlayableMedia.fromJson(Map<String, dynamic>.from(entry));
final media = PlayableMedia.fromJson(
Map<String, dynamic>.from(entry),
iosDocumentsPath: iosDocumentsPath,
);
if (media == null) continue;
if (!media.isContentUri && !await File(media.source).exists()) {
continue;
}
items.add(media);
final artwork = await resolveRestoredArtworkUri(
media.artUri,
loadLibraryCoverPath: () async {
final database = LibraryDatabase.instance;
final row = await database.getById(media.id);
if (row != null) return row['coverPath'] as String?;
final matches = await database.findByTrackAndArtist(
media.title,
media.artist,
);
for (final row in matches) {
if (row['filePath'] == media.source) {
return row['coverPath'] as String?;
}
}
return null;
},
);
items.add(
artwork == media.artUri
? media
: PlayableMedia.fromJson({...media.toJson(), 'artUri': artwork})!,
);
artworkRelocated |= artwork != entry['artUri'];
keptOriginalIndices.add(i);
}
if (items.isEmpty) {
@@ -1607,7 +1657,7 @@ Future<void> restorePersistedPlaybackSession() async {
index: index,
position: position,
shuffle: session['shuffle'] == true,
queueNeedsRewrite: items.length != rawMedia.length,
queueNeedsRewrite: items.length != rawMedia.length || artworkRelocated,
repeatMode: AudioServiceRepeatMode.values.firstWhere(
(mode) => mode.name == session['repeat'],
orElse: () => AudioServiceRepeatMode.none,
+139 -4
View File
@@ -3,6 +3,76 @@ import 'package:flutter/widgets.dart';
enum ShellTab { home, library, repository, settings }
class ShellNavigationService {
static final homeSearchRequests = ValueNotifier<int>(0);
static final chromeBrightness = ValueNotifier<Brightness?>(null);
static final chromeSurface = ValueNotifier<Color?>(null);
static final _visiblePages = <GlobalKey<NavigatorState>, Route<dynamic>?>{};
static final _chromeScopes =
<
Object,
({ModalRoute<dynamic> route, Brightness brightness, Color? surface})
>{};
static bool _chromeUpdateScheduled = false;
static int _homeSearchGeneration = 0;
static void setChromeBrightness({
required Object owner,
required ModalRoute<dynamic> route,
required Brightness brightness,
Color? surface,
}) {
final scope = (route: route, brightness: brightness, surface: surface);
if (_chromeScopes[owner] == scope) return;
_chromeScopes[owner] = scope;
_scheduleChromeUpdate();
}
static void clearChromeBrightness(Object owner) {
_chromeScopes.remove(owner);
_scheduleChromeUpdate();
}
static void _scheduleChromeUpdate() {
if (_chromeUpdateScheduled) return;
_chromeUpdateScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_chromeUpdateScheduled = false;
final activePage = _visiblePages[_activeTabNavigatorKey()];
Brightness? brightness;
Color? surface;
for (final scope in _chromeScopes.values) {
if (identical(scope.route, activePage)) {
brightness = scope.brightness;
surface = scope.surface;
}
}
chromeBrightness.value = brightness;
chromeSurface.value = surface;
});
WidgetsBinding.instance.ensureVisualUpdate();
}
static void requestHomeSearch() {
if (!requestTab(ShellTab.home)) return;
final generation = ++_homeSearchGeneration;
final owner = _tabSelectionOwner;
// PageView creates Home lazily. Deliver the request after its navigator
// and focus listener mount, rather than losing the first tap on another tab.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (generation != _homeSearchGeneration ||
!identical(owner, _tabSelectionOwner) ||
_currentTabIndex != 0) {
return;
}
homeTabNavigatorKey.currentState?.popUntil((route) => route.isFirst);
homeSearchRequests.value++;
// Home scrolls to its search field and requests focus after layout.
// Scheduling that frame avoids waiting for another tap to wake it up.
WidgetsBinding.instance.ensureVisualUpdate();
});
WidgetsBinding.instance.ensureVisualUpdate();
}
static final GlobalKey<NavigatorState> homeTabNavigatorKey =
GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> libraryTabNavigatorKey =
@@ -40,16 +110,81 @@ class ShellNavigationService {
required int currentTabIndex,
required bool showRepoTab,
}) {
if (_currentTabIndex == currentTabIndex && _showRepoTab == showRepoTab) {
return;
}
_currentTabIndex = currentTabIndex;
_showRepoTab = showRepoTab;
_scheduleChromeUpdate();
}
static NavigatorState? activeTabNavigator() {
if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState;
if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState;
static GlobalKey<NavigatorState>? _activeTabNavigatorKey() {
if (_currentTabIndex == 0) return homeTabNavigatorKey;
if (_currentTabIndex == 1) return libraryTabNavigatorKey;
if (_showRepoTab && _currentTabIndex == 2) {
return repoTabNavigatorKey.currentState;
return repoTabNavigatorKey;
}
return null;
}
static NavigatorState? activeTabNavigator() =>
_activeTabNavigatorKey()?.currentState;
}
/// Track the top page in each tab. Dialogs keep the underlying page's contrast;
/// a pushed album, another tab, or a back gesture restores its own appearance.
class ShellChromeObserver extends NavigatorObserver {
ShellChromeObserver(this.navigatorKey);
final GlobalKey<NavigatorState> navigatorKey;
final _routes = <Route<dynamic>>[];
void _update() {
Route<dynamic>? page;
for (final route in _routes.reversed) {
if (route is PageRoute<dynamic>) {
page = route;
break;
}
}
ShellNavigationService._visiblePages[navigatorKey] = page;
ShellNavigationService._scheduleChromeUpdate();
}
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
_routes.add(route);
_update();
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
_routes.remove(route);
_update();
}
@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
_routes.remove(route);
_update();
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
final index = oldRoute == null ? -1 : _routes.indexOf(oldRoute);
if (index >= 0) {
if (newRoute == null) {
_routes.removeAt(index);
} else {
_routes[index] = newRoute;
}
}
_update();
}
void detach() {
_routes.clear();
ShellNavigationService._visiblePages.remove(navigatorKey);
ShellNavigationService._scheduleChromeUpdate();
}
}