mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +02:00
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.
191 lines
5.8 KiB
Dart
191 lines
5.8 KiB
Dart
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 =
|
|
GlobalKey<NavigatorState>();
|
|
static final GlobalKey<NavigatorState> repoTabNavigatorKey =
|
|
GlobalKey<NavigatorState>();
|
|
|
|
static int _currentTabIndex = 0;
|
|
static bool _showRepoTab = false;
|
|
static Object? _tabSelectionOwner;
|
|
static ValueChanged<ShellTab>? _tabSelectionHandler;
|
|
|
|
static void registerTabSelectionHandler({
|
|
required Object owner,
|
|
required ValueChanged<ShellTab> handler,
|
|
}) {
|
|
_tabSelectionOwner = owner;
|
|
_tabSelectionHandler = handler;
|
|
}
|
|
|
|
static void unregisterTabSelectionHandler(Object owner) {
|
|
if (!identical(_tabSelectionOwner, owner)) return;
|
|
_tabSelectionOwner = null;
|
|
_tabSelectionHandler = null;
|
|
}
|
|
|
|
static bool requestTab(ShellTab tab) {
|
|
final handler = _tabSelectionHandler;
|
|
if (handler == null) return false;
|
|
handler(tab);
|
|
return true;
|
|
}
|
|
|
|
static void syncState({
|
|
required int currentTabIndex,
|
|
required bool showRepoTab,
|
|
}) {
|
|
if (_currentTabIndex == currentTabIndex && _showRepoTab == showRepoTab) {
|
|
return;
|
|
}
|
|
_currentTabIndex = currentTabIndex;
|
|
_showRepoTab = showRepoTab;
|
|
_scheduleChromeUpdate();
|
|
}
|
|
|
|
static GlobalKey<NavigatorState>? _activeTabNavigatorKey() {
|
|
if (_currentTabIndex == 0) return homeTabNavigatorKey;
|
|
if (_currentTabIndex == 1) return libraryTabNavigatorKey;
|
|
if (_showRepoTab && _currentTabIndex == 2) {
|
|
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();
|
|
}
|
|
}
|