Files
SpotiFLAC-Mobile/lib/screens/main_shell.dart
T
zarzet 4598773cd3 refactor(storage): share the SAF download tree pick
Three screens repeated the pick, parse and store sequence. The helper keeps
each site's call order; the SAF migration prompt skips the storage mode
switch as before.
2026-10-19 00:47:55 +07:00

994 lines
36 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:ui' show ImageFilter;
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:flutter/cupertino.dart' show CupertinoIcons;
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/screens/upgrade_intro_screen.dart';
import 'package:spotiflac_android/services/upgrade_intro_service.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/playback_notification_provider.dart';
import 'package:spotiflac_android/providers/repo_provider.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/preview_player_provider.dart';
import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/screens/repo_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/music_playback_deck.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
import 'package:spotiflac_android/services/discord_presence_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/app_announcement_dialog.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:spotiflac_android/widgets/download_queue_feedback.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/nav_destination_icons.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/mini_player.dart';
import 'package:spotiflac_android/widgets/lazy_tab_view.dart';
import 'package:spotiflac_android/widgets/expressive_navigation_bar.dart';
import 'package:spotiflac_android/widgets/mornye_bottom_bar.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/saf_download_tree.dart';
part 'main_shell_startup_prompts.dart';
final _log = AppLogger('MainShell');
class MainShell extends ConsumerStatefulWidget {
const MainShell({super.key});
@override
ConsumerState<MainShell> createState() => _MainShellState();
}
class _MainShellState extends ConsumerState<MainShell>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
int _currentIndex = 0;
final _mornyeChrome = MornyeChromeController();
// Preserves the tab view element (and its kept-alive tabs) when the body
// structure swaps between rail and bottom-bar layouts on rotation.
final GlobalKey _pageViewKey = GlobalKey();
late final PageController _pageController;
late final AnimationController _tabJumpTransitionController;
// Transitions update their layers directly; no shell subtree rebuilds per
// animation frame.
late final CurvedAnimation _tabJumpOpacity = CurvedAnimation(
parent: _tabJumpTransitionController,
curve: Curves.easeOutCubic,
);
late final Animation<double> _tabJumpScale = Tween<double>(
begin: 0.985,
end: 1,
).animate(_tabJumpOpacity);
bool _hasCheckedUpdate = false;
bool _hasCheckedAppAnnouncement = false;
bool _initialSafRepairComplete = false;
bool _safRepairDialogVisible = false;
bool _usbVolumeWarningShown = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress;
final GlobalKey<NavigatorState> _homeTabNavigatorKey =
ShellNavigationService.homeTabNavigatorKey;
final GlobalKey<NavigatorState> _libraryTabNavigatorKey =
ShellNavigationService.libraryTabNavigatorKey;
final GlobalKey<NavigatorState> _repoTabNavigatorKey =
ShellNavigationService.repoTabNavigatorKey;
final GlobalKey<NavigatorState> _searchTabNavigatorKey =
ShellNavigationService.searchTabNavigatorKey;
late final _PreviewStopNavigatorObserver _homePreviewStopObserver;
late final _PreviewStopNavigatorObserver _libraryPreviewStopObserver;
late final _PreviewStopNavigatorObserver _repoPreviewStopObserver;
late final _PreviewStopNavigatorObserver _searchPreviewStopObserver;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final l10n = context.l10n;
NotificationService().updateStrings(l10n);
final playback = ref.read(musicPlayerRuntimeProvider);
playback.updateStrings(
unknownTitle: l10n.unknownTitle,
unknownArtist: l10n.unknownArtist,
);
final settings = ref.read(settingsProvider);
playback.configure(settings);
unawaited(
DiscordPresenceService.instance.setEnabled(
settings.discordRichPresenceEnabled,
),
);
listeningRecorder.setEnabled(settings.listeningStatisticsEnabled);
// Deezer & co. localize artist/genre names by IP unless told the app's
// language (issue #480).
unawaited(
PlatformBridge.setMetadataLanguage(
Localizations.localeOf(context).toLanguageTag(),
),
);
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
usbAudioStatus.addListener(_onUsbAudioStatusChanged);
_homePreviewStopObserver = _PreviewStopNavigatorObserver(
() => ref.read(previewPlayerProvider.notifier).stop(),
);
_libraryPreviewStopObserver = _PreviewStopNavigatorObserver(
() => ref.read(previewPlayerProvider.notifier).stop(),
);
_repoPreviewStopObserver = _PreviewStopNavigatorObserver(
() => ref.read(previewPlayerProvider.notifier).stop(),
);
_searchPreviewStopObserver = _PreviewStopNavigatorObserver(
() => ref.read(previewPlayerProvider.notifier).stop(),
);
_pageController = PageController(initialPage: _currentIndex);
_tabJumpTransitionController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 180),
value: 1,
);
ShellNavigationService.registerTabSelectionHandler(
owner: this,
handler: _onShellTabRequested,
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showRepoTab: false,
);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _repairSafAccessIfNeeded(
knownLost: ref.read(initialSafAccessLostProvider),
);
_initialSafRepairComplete = true;
if (!mounted) return;
unawaited(ref.read(musicPlayerRuntimeProvider).restoreSession());
await _checkUpgradeIntro();
if (!mounted) return;
_setupShareListener();
await _checkSafMigration();
final updateDialogShown = await _checkForUpdates();
if (!updateDialogShown) {
await _checkAppAnnouncement();
}
});
}
void _onUsbAudioStatusChanged() {
if (usbAudioStatus.value.reason == 'active') {
_usbVolumeWarningShown = false;
}
if (usbAudioStatus.value.reason != 'volume_unavailable' ||
_usbVolumeWarningShown) {
return;
}
_usbVolumeWarningShown = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || usbAudioStatus.value.reason != 'volume_unavailable') {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.usbVolumeUnavailable),
duration: const Duration(seconds: 8),
),
);
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && _initialSafRepairComplete) {
unawaited(_repairSafAccessIfNeeded());
} else if (state == AppLifecycleState.paused) {
unawaited(
ref.read(musicPlayerRuntimeProvider).handler?.persistCurrentSession(),
);
}
}
void _setupShareListener() {
final pendingUrl = ShareIntentService().consumePendingUrl();
if (pendingUrl != null) {
_handleSharedUrl(pendingUrl);
}
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
(url) {
_handleSharedUrl(url);
},
onError: (Object error) {
_log.e('Share stream error: $error');
},
cancelOnError: false,
);
}
Future<void> _handleSharedUrl(String url) async {
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
_onShellTabRequested(ShellTab.search);
final searchNavigator = context.isMornye
? _searchTabNavigatorKey
: _homeTabNavigatorKey;
searchNavigator.currentState?.popUntil((route) => route.isFirst);
// Mount the lazy Search page before the metadata listener handles the link.
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
ref.read(settingsProvider.notifier).setHasSearchedBefore();
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.loadingSharedLink)));
}
await ref.read(trackProvider.notifier).fetchFromUrl(url);
final trackState = ref.read(trackProvider);
if (trackState.error != null && mounted) {
final l10n = context.l10n;
final errorMsg = trackState.error!;
final isRateLimit =
errorMsg.contains('429') ||
errorMsg.toLowerCase().contains('rate limit') ||
errorMsg.toLowerCase().contains('too many requests');
final displayMessage = errorMsg == 'url_not_recognized'
? l10n.errorUrlNotRecognizedMessage
: isRateLimit
? l10n.errorRateLimitedMessage
: l10n.errorUrlFetchFailed;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(displayMessage),
// Retrying an unrecognized URL is deterministic; skip the action.
action: errorMsg == 'url_not_recognized'
? null
: SnackBarAction(
label: l10n.dialogRetry,
onPressed: () => _handleSharedUrl(url),
),
),
);
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
usbAudioStatus.removeListener(_onUsbAudioStatusChanged);
ShellNavigationService.unregisterTabSelectionHandler(this);
_shareSubscription?.cancel();
_pageController.dispose();
_tabJumpOpacity.dispose();
_tabJumpTransitionController.dispose();
_mornyeChrome.dispose();
super.dispose();
}
void _resetHomeToMain() {
ref.read(previewPlayerProvider.notifier).stop();
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();
if (!context.isMornye) ref.read(trackProvider.notifier).clear();
}
void _onShellTabRequested(ShellTab tab) {
if (tab == ShellTab.settings && context.isMornye) {
unawaited(_openProfileSettings());
return;
}
final showStore = ref.read(
settingsProvider.select((s) => s.showExtensionStore),
);
final index = switch (tab) {
ShellTab.home => 0,
ShellTab.search => context.isMornye ? (showStore ? 3 : 2) : 0,
ShellTab.library => 1,
ShellTab.repository => showStore ? 2 : null,
ShellTab.settings => showStore ? 3 : 2,
};
if (index != null) _onNavTap(index, resetHome: tab != ShellTab.search);
}
bool _settingsPageOpen = false;
Future<void> _openProfileSettings() async {
if (_settingsPageOpen) return;
_settingsPageOpen = true;
FocusManager.instance.primaryFocus?.unfocus();
ref.read(previewPlayerProvider.notifier).stop();
try {
await Navigator.of(context).push(
slidePageRoute<void>(
page: const Scaffold(body: SettingsTab(asPage: true)),
),
);
} finally {
_settingsPageOpen = false;
}
}
void _onNavTap(int index, {bool resetHome = true}) {
final showStore = ref.read(
settingsProvider.select((s) => s.showExtensionStore),
);
_mornyeChrome.expand();
if (index == 0 && resetHome && (context.isMornye || _currentIndex == 0)) {
_resetHomeToMain();
}
if (index == 0 && _currentIndex == 0) {
return;
}
if (_currentIndex != index) {
final previousIndex = _currentIndex;
final isNonAdjacentJump = (previousIndex - index).abs() > 1;
HapticFeedback.selectionClick();
// Stop any preview snippet when leaving the current tab. (_onPageChanged
// cannot do this because _currentIndex is already updated below.)
ref.read(previewPlayerProvider.notifier).stop();
setState(() => _currentIndex = index);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showRepoTab: showStore,
showSearchTab: context.isMornye,
);
FocusManager.instance.primaryFocus?.unfocus();
// Jump directly when skipping intermediate tabs to avoid
// sliding through them. For those jumps, keep a short fade-in
// so the transition still feels intentional.
if (context.isMornye) {
// The glass pill owns the tab transition. Sliding/fading the whole
// page at the same time continuously invalidates its live backdrop.
_tabJumpTransitionController.value = 1;
} else if (isNonAdjacentJump) {
_pageController.jumpToPage(index);
_tabJumpTransitionController.forward(from: 0);
} else {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
}
}
}
void _onPageChanged(int index) {
_mornyeChrome.expand();
if (_currentIndex != index) {
ref.read(previewPlayerProvider.notifier).stop();
setState(() => _currentIndex = index);
final showStore = ref.read(
settingsProvider.select((s) => s.showExtensionStore),
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showRepoTab: showStore,
showSearchTab: context.isMornye,
);
FocusManager.instance.primaryFocus?.unfocus();
}
}
Future<void> _handleBackPress() async {
final rootNavigator = Navigator.of(context, rootNavigator: true);
final handledByRootNavigator = await rootNavigator.maybePop();
if (handledByRootNavigator) {
_lastBackPress = null;
return;
}
final showStore = ref.read(
settingsProvider.select((s) => s.showExtensionStore),
);
final currentNavigator = _navigatorForTab(_currentIndex, showStore);
final handledByCurrentNavigator =
await currentNavigator?.maybePop() ?? false;
if (handledByCurrentNavigator) {
_lastBackPress = null;
return;
}
if (!mounted) return;
final trackState = ref.read(trackProvider);
final isSearchTab =
_currentIndex == (context.isMornye ? (showStore ? 3 : 2) : 0);
final isKeyboardVisible = MediaQuery.viewInsetsOf(context).bottom > 0;
if (isSearchTab &&
trackState.isShowingRecentAccess &&
!trackState.isLoading &&
(trackState.hasSearchText || trackState.hasContent)) {
FocusManager.instance.primaryFocus?.unfocus();
ref.read(previewPlayerProvider.notifier).stop();
ref.read(trackProvider.notifier).clear();
_lastBackPress = null;
return;
}
if (isSearchTab && trackState.isShowingRecentAccess) {
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
FocusManager.instance.primaryFocus?.unfocus();
_lastBackPress = null;
return;
}
if (isSearchTab &&
!trackState.isLoading &&
(trackState.hasSearchText || trackState.hasContent)) {
// Unfocus BEFORE clear so _onTrackStateChanged can properly
// clear _urlController (it checks !_searchFocusNode.hasFocus)
FocusManager.instance.primaryFocus?.unfocus();
ref.read(previewPlayerProvider.notifier).stop();
ref.read(trackProvider.notifier).clear();
_lastBackPress = null;
return;
}
if (isSearchTab && isKeyboardVisible) {
FocusManager.instance.primaryFocus?.unfocus();
_lastBackPress = null;
return;
}
if (_currentIndex != 0) {
_onNavTap(0);
_lastBackPress = null;
return;
}
if (isSearchTab && trackState.isLoading) {
return;
}
final now = DateTime.now();
if (_lastBackPress != null &&
now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
unawaited(PlatformBridge.exitApp());
} else {
_lastBackPress = now;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.pressBackAgainToExit),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
}
NavigatorState? _navigatorForTab(int index, bool showStore) {
if (index == 0) return _homeTabNavigatorKey.currentState;
if (index == 1) return _libraryTabNavigatorKey.currentState;
if (showStore && index == 2) return _repoTabNavigatorKey.currentState;
if (context.isMornye && index == (showStore ? 3 : 2)) {
return _searchTabNavigatorKey.currentState;
}
return null;
}
@override
Widget build(BuildContext context) {
ref.watch(
playbackNotificationProvider((
favorite: context.l10n.trackOptionAddToLoved,
unfavorite: context.l10n.trackOptionRemoveFromLoved,
shuffleOn: context.l10n.nowPlayingShuffleOn,
shuffleOff: context.l10n.nowPlayingPlayInOrder,
)),
);
ref.listen(settingsProvider, (_, settings) {
ref.read(musicPlayerRuntimeProvider).configure(settings);
});
ref.listen(settingsProvider.select((s) => s.discordRichPresenceEnabled), (
_,
enabled,
) {
unawaited(DiscordPresenceService.instance.setEnabled(enabled));
});
ref.listen(
settingsProvider.select((s) => s.listeningStatisticsEnabled),
(_, enabled) => listeningRecorder.setEnabled(enabled),
);
final queueState = ref.watch(
downloadQueueProvider.select((s) => s.queuedCount),
);
final showStore = ref.watch(
settingsProvider.select((s) => s.showExtensionStore),
);
final heroAnimationsEnabled = ref.watch(
settingsProvider.select((s) => s.heroAnimationsEnabled),
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showRepoTab: showStore,
showSearchTab: context.isMornye,
);
final repoUpdatesCount = ref.watch(
repoProvider.select((s) => s.updatesAvailableCount),
);
final tabs = <Widget>[
_TabNavigator(
key: const ValueKey('tab-home'),
navigatorKey: _homeTabNavigatorKey,
observers: [_homePreviewStopObserver],
heroAnimationsEnabled: heroAnimationsEnabled,
child: Builder(
builder: (context) => HomeTab(
mode: context.isMornye ? HomeTabMode.browse : HomeTabMode.combined,
),
),
),
_TabNavigator(
key: const ValueKey('tab-library'),
navigatorKey: _libraryTabNavigatorKey,
observers: [_libraryPreviewStopObserver],
heroAnimationsEnabled: heroAnimationsEnabled,
child: _LibraryTabRoot(isTabActive: _currentIndex == 1),
),
if (showStore)
_TabNavigator(
key: const ValueKey('tab-repo'),
navigatorKey: _repoTabNavigatorKey,
observers: [_repoPreviewStopObserver],
heroAnimationsEnabled: heroAnimationsEnabled,
child: const RepoTab(),
),
if (!context.isMornye) const SettingsTab(),
if (context.isMornye)
_TabNavigator(
key: const ValueKey('tab-search'),
navigatorKey: _searchTabNavigatorKey,
observers: [_searchPreviewStopObserver],
heroAnimationsEnabled: heroAnimationsEnabled,
child: const HomeTab(mode: HomeTabMode.search),
),
];
final l10n = context.l10n;
final destinations = <NavigationDestination>[
NavigationDestination(
icon: AppIcon(
context.isMornye ? CupertinoIcons.house_fill : Icons.home_outlined,
),
selectedIcon: BouncingIcon(child: const AppIcon(Icons.home)),
label: l10n.navHome,
),
NavigationDestination(
icon: context.isMornye
? MornyeNavigationIcon(
icon: CupertinoIcons.square_stack_fill,
badgeCount: queueState,
)
: AnimatedBadge(
count: queueState,
child: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const AppIcon(Icons.library_music_outlined),
),
),
selectedIcon: SlidingIcon(
child: AnimatedBadge(
count: queueState,
child: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const AppIcon(Icons.library_music),
),
),
),
label: l10n.navLibrary,
),
if (showStore)
NavigationDestination(
icon: context.isMornye
? MornyeNavigationIcon(
icon: CupertinoIcons.square_grid_2x2,
badgeCount: repoUpdatesCount,
)
: AnimatedBadge(
count: repoUpdatesCount,
child: Badge(
isLabelVisible: repoUpdatesCount > 0,
label: Text('$repoUpdatesCount'),
child: const AppIcon(Icons.extension_outlined),
),
),
selectedIcon: BouncingIcon(
child: AnimatedBadge(
count: repoUpdatesCount,
child: Badge(
isLabelVisible: repoUpdatesCount > 0,
label: Text('$repoUpdatesCount'),
child: const AppIcon(Icons.extension),
),
),
),
label: l10n.navStore,
),
if (!context.isMornye)
NavigationDestination(
icon: const AppIcon(Icons.settings_outlined),
selectedIcon: SpinIcon(child: const AppIcon(Icons.settings)),
label: l10n.navSettings,
),
if (context.isMornye)
NavigationDestination(
icon: const Icon(CupertinoIcons.search),
label: l10n.mornyeSearch,
),
];
final maxIndex = tabs.length - 1;
final selectedDestination = _currentIndex.clamp(0, maxIndex);
if (_currentIndex > maxIndex) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() => _currentIndex = maxIndex);
if (_pageController.hasClients) {
_pageController.jumpToPage(maxIndex);
}
}
});
}
final screenSize = MediaQuery.sizeOf(context);
final isTablet = screenSize.shortestSide >= 600;
// Material follows its expanded window class: landscape tablets get the
// rail, portrait tablets keep the bottom bar. Mornye keeps a bottom tab
// bar on tablets, compact and centered once the window is expanded, and
// reserves the rail for desktop-width windows.
final useNavigationRail = context.isMornye
? useNavigationRailForWidth(screenSize.width)
: useMaterialNavigationRailForWidth(screenSize.width);
final compactMornyeChrome =
context.isMornye && screenSize.width >= expandedLayoutBreakpoint;
final canMinimizeChrome =
context.isMornye &&
!useNavigationRail &&
MediaQuery.viewInsetsOf(context).bottom == 0 &&
MediaQuery.textScalerOf(context).scale(15) <= 20;
Widget observeScroll(Widget child, int index) =>
NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (canMinimizeChrome && index == _currentIndex) {
return _mornyeChrome.handleScroll(notification);
}
return false;
},
child: child,
);
final pageView = KeyedSubtree(
key: _pageViewKey,
child: context.isMornye
? LazyTabView(
index: selectedDestination,
preloadKeys: {const ValueKey('tab-search')},
children: [
for (var index = 0; index < tabs.length; index++)
KeyedSubtree(
key: tabs[index].key,
child: observeScroll(tabs[index], index),
),
],
)
: FadeTransition(
opacity: _tabJumpOpacity,
child: ScaleTransition(
scale: _tabJumpScale,
child: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
// TickerMode mutes animations and lets visibility-aware widgets
// (e.g. MotionHeaderBanner) pause when their tab is hidden —
// kept-alive pages otherwise keep running offscreen.
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: TickerMode(
enabled: index == _currentIndex,
child: observeScroll(tabs[index], index),
),
),
),
),
),
);
final shell = SelectionOverlayHost(
child: BackButtonListener(
onBackButtonPressed: () async {
await _handleBackPress();
return true;
},
child: Scaffold(
extendBody: true,
// The tab view keeps one element across the rail<->bar structure
// swap via _pageViewKey so rotation doesn't remount tab navigators.
body: useNavigationRail
? Row(
children: [
SafeArea(
right: false,
bottom: false,
child: ExpressiveNavigationRail(
selectedIndex: selectedDestination,
onDestinationSelected: _onNavTap,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainer,
destinations: destinations,
),
),
const VerticalDivider(width: 1),
// Width-based layout (centered columns, grids) measures
// the space beside the rail rather than the whole screen.
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final mediaQuery = MediaQuery.of(context);
return MediaQuery(
data: mediaQuery
.copyWith(
size: Size(
constraints.maxWidth,
mediaQuery.size.height,
),
)
.removePadding(removeLeft: true),
child: pageView,
);
},
),
),
],
)
: pageView,
bottomNavigationBar: Builder(
builder: (context) {
if (context.isMornye) {
return ListenableBuilder(
listenable: Listenable.merge([
ShellNavigationService.chromeBrightness,
ShellNavigationService.chromeSurface,
]),
builder: (context, child) => Theme(
data: ShellNavigationService.chromeBrightness.value == null
? Theme.of(context)
: MornyeTheme.fromContext(
context,
brightness:
ShellNavigationService.chromeBrightness.value!,
chromeSurface:
ShellNavigationService.chromeSurface.value,
),
child: child!,
),
child: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(20, 0, 20, 8),
// iPadOS-style chrome: one compact, centered pill rather
// than a bar spread across a landscape tablet.
child: Center(
heightFactor: 1,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: compactMornyeChrome ? 600 : double.infinity,
),
child: useNavigationRail
? const MiniPlayer()
: ValueListenableBuilder<bool>(
valueListenable: _mornyeChrome,
builder: (context, collapsed, _) =>
MornyeBottomBar(
collapsed: canMinimizeChrome && collapsed,
destinations: destinations,
selectedIndex: selectedDestination,
onSelected: _onNavTap,
onHome: () => _onNavTap(0),
onSearch:
ShellNavigationService.requestSearch,
blurEnabled: ref.watch(
mornyeBlurEnabledProvider,
),
),
),
),
),
),
);
}
final bottomBar = Column(
mainAxisSize: MainAxisSize.min,
children: [
const MiniPlayer(),
if (!useNavigationRail)
DecoratedBox(
position: DecorationPosition.foreground,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(
context,
).colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
),
child: ExpressiveNavigationBar(
selectedIndex: _currentIndex.clamp(0, maxIndex),
onDestinationSelected: _onNavTap,
isTablet: isTablet,
backgroundColor: settingsGroupColor(
context,
).withValues(alpha: 0.72),
destinations: destinations,
),
),
],
);
// The backdrop blur re-filters everything scrolling underneath on
// every frame; low-end devices get an opaque base instead unless
// the user forces blur on in appearance settings.
if (!ref.watch(backdropBlurEnabledProvider)) {
return ColoredBox(
color: settingsGroupColor(context),
child: bottomBar,
);
}
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
blendMode: BlendMode.src,
child: bottomBar,
),
);
},
),
),
),
);
return DownloadQueueFeedback(child: shell);
}
}
class _TabNavigator extends StatefulWidget {
final GlobalKey<NavigatorState> navigatorKey;
final Widget child;
final List<NavigatorObserver> observers;
final bool heroAnimationsEnabled;
const _TabNavigator({
super.key,
required this.navigatorKey,
required this.child,
this.observers = const [],
required this.heroAnimationsEnabled,
});
@override
State<_TabNavigator> createState() => _TabNavigatorState();
}
class _TabNavigatorState extends State<_TabNavigator> {
late final ShellChromeObserver _chromeObserver = ShellChromeObserver(
widget.navigatorKey,
);
// Nested navigators get no HeroController from MaterialApp; without one,
// Hero widgets on routes pushed inside a tab never fly.
final HeroController _heroController =
MaterialApp.createMaterialHeroController();
@override
void dispose() {
_chromeObserver.detach();
_heroController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Navigator(
key: widget.navigatorKey,
observers: [
_chromeObserver,
if (widget.heroAnimationsEnabled) _heroController,
...widget.observers,
],
onGenerateInitialRoutes: (_, _) => [
MaterialPageRoute<void>(builder: (_) => widget.child),
],
);
}
}
class _PreviewStopNavigatorObserver extends NavigatorObserver {
_PreviewStopNavigatorObserver(this._onNavigate);
final VoidCallback _onNavigate;
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPush(route, previousRoute);
if (previousRoute != null) {
_onNavigate();
}
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPop(route, previousRoute);
_onNavigate();
}
}
class _LibraryTabRoot extends ConsumerWidget {
final bool isTabActive;
const _LibraryTabRoot({required this.isTabActive});
@override
Widget build(BuildContext context, WidgetRef ref) {
return QueueTab(isTabActive: isTabActive);
}
}
class _KeepAliveTabPage extends StatefulWidget {
final Widget child;
const _KeepAliveTabPage({super.key, required this.child});
@override
State<_KeepAliveTabPage> createState() => _KeepAliveTabPageState();
}
class _KeepAliveTabPageState extends State<_KeepAliveTabPage>
with AutomaticKeepAliveClientMixin<_KeepAliveTabPage> {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}