mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
feat(player): add beat-matched AutoMix and refine Mornye navigation
Add default-off local beat analysis, pitch-preserving tempo matching, crossfade fallback, and cancellation-safe two-deck playback. Expose AutoMix in settings and the Mornye queue. Separate Search from Home, remove the Mornye greeting, and keep Home/Search icons moving continuously between full and compact navigation. Restore expansion on upward Library scrolling. Validate beat analysis, transport handoff and cancellation, settings persistence, and navigation behavior. Flutter analysis and the signed iOS simulator build pass.
This commit is contained in:
1 parent
31ef7c7c88
commit
5b519070a3
23 files changed
+2074
-298
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
@@ -20,7 +21,9 @@ class MornyeChromeController extends ValueNotifier<bool> {
|
||||
}
|
||||
|
||||
bool handleScroll(ScrollNotification notification) {
|
||||
if (notification.depth != 0 || notification.metrics.axis != Axis.vertical) {
|
||||
// Library's active list sits inside a PageView and NestedScrollView. Its
|
||||
// drag notifications have a greater depth than the outer header's scroll.
|
||||
if (notification.metrics.axis != Axis.vertical) {
|
||||
return false;
|
||||
}
|
||||
if (notification is ScrollStartNotification ||
|
||||
@@ -60,7 +63,7 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
required this.destinations,
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
required this.onExpand,
|
||||
required this.onHome,
|
||||
required this.onSearch,
|
||||
required this.blurEnabled,
|
||||
});
|
||||
@@ -69,7 +72,7 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
final List<NavigationDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
final VoidCallback onExpand;
|
||||
final VoidCallback onHome;
|
||||
final VoidCallback onSearch;
|
||||
final bool blurEnabled;
|
||||
|
||||
@@ -79,47 +82,29 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
currentMediaItemProvider.select((item) => item.value != null),
|
||||
);
|
||||
// Animated glass tabs already reserve 8px above their visible capsule.
|
||||
final tabGap =
|
||||
final glassTabs =
|
||||
blurEnabled &&
|
||||
!MediaQuery.disableAnimationsOf(context) &&
|
||||
!MediaQuery.highContrastOf(context)
|
||||
? 0.0
|
||||
: 8.0;
|
||||
!MediaQuery.disableAnimationsOf(context) &&
|
||||
!MediaQuery.highContrastOf(context);
|
||||
final tabGap = glassTabs ? 0.0 : 8.0;
|
||||
// These contents do not depend on animation progress. Retain their widget
|
||||
// instances so folding only updates size/opacity wrappers each frame.
|
||||
Widget sideContent({
|
||||
required Widget icon,
|
||||
required String tooltip,
|
||||
required VoidCallback onPressed,
|
||||
}) => RepaintBoundary(
|
||||
Widget sideSurface() => RepaintBoundary(
|
||||
child: MornyeGlass.navigation(
|
||||
blurEnabled: blurEnabled,
|
||||
strongTint: true,
|
||||
tintOpacity: MornyeTheme.chromeOpacity(context),
|
||||
radius: 26,
|
||||
child: SizedBox.square(
|
||||
dimension: 52,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
icon: icon,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const SizedBox.square(dimension: 52),
|
||||
),
|
||||
);
|
||||
final expandButton = sideContent(
|
||||
icon: destinations[selectedIndex].icon,
|
||||
tooltip: context.l10n.mornyeShowTabs,
|
||||
onPressed: onExpand,
|
||||
);
|
||||
final searchButton = sideContent(
|
||||
icon: const Icon(CupertinoIcons.search),
|
||||
tooltip: context.l10n.mornyeSearch,
|
||||
onPressed: onSearch,
|
||||
final homeSurface = sideSurface();
|
||||
final searchSurface = sideSurface();
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final inactiveIconColor = Color.lerp(
|
||||
scheme.onSurfaceVariant,
|
||||
scheme.onSurface,
|
||||
scheme.brightness == Brightness.dark ? 0.5 : 0.4,
|
||||
);
|
||||
final player = MiniPlayer(compact: collapsed, bottomPadding: 0);
|
||||
final tabs = TickerMode(
|
||||
@@ -130,76 +115,140 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
selectedIndex: selectedIndex,
|
||||
onSelected: onSelected,
|
||||
blurEnabled: blurEnabled,
|
||||
hideEdgeIcons: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: collapsed ? 1 : 0),
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 380),
|
||||
curve: Curves.easeInOutCubic,
|
||||
builder: (context, amount, _) {
|
||||
Widget sideButton({
|
||||
required Widget child,
|
||||
required Alignment alignment,
|
||||
}) => ClipRect(
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
widthFactor: amount,
|
||||
child: SizedBox(
|
||||
width: 60,
|
||||
height: 48 + 4 * amount,
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Match the tab's actual label height, including accessibility scaling.
|
||||
// Both icons stay mounted above the fading capsule for the whole trip.
|
||||
final labelStyle = Theme.of(context).textTheme.labelSmall!.copyWith(
|
||||
fontSize: glassTabs ? 11 : null,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
final labelPainter = TextPainter(
|
||||
text: TextSpan(text: destinations.first.label, style: labelStyle),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
final labelHeight = labelPainter.height;
|
||||
labelPainter.dispose();
|
||||
final fullIconBottom = glassTabs
|
||||
? 40 + (labelHeight + 2) / 2
|
||||
: math.max(64.0, 49 + labelHeight) - 23.5;
|
||||
final tabInset = glassTabs ? 6.0 : 5.0;
|
||||
final fullIconStart =
|
||||
tabInset +
|
||||
(constraints.maxWidth - tabInset * 2) / destinations.length / 2;
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: collapsed ? 1 : 0),
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 380),
|
||||
curve: Curves.easeInOutCubic,
|
||||
builder: (context, amount, _) {
|
||||
Widget movingIcon({required bool home, required Widget surface}) {
|
||||
final index = home ? 0 : destinations.length - 1;
|
||||
final offset = (fullIconStart - 26) * (1 - amount);
|
||||
return PositionedDirectional(
|
||||
start: home ? offset : null,
|
||||
end: home ? null : offset,
|
||||
bottom: fullIconBottom * (1 - amount) + 34 * amount - 26,
|
||||
width: 52,
|
||||
height: 52,
|
||||
child: IgnorePointer(
|
||||
ignoring: !collapsed,
|
||||
ignoring: amount < 0.5,
|
||||
child: ExcludeSemantics(
|
||||
excluding: !collapsed,
|
||||
child: Opacity(opacity: amount, child: child),
|
||||
excluding: amount < 0.5,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Opacity(opacity: amount, child: surface),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: IconButton(
|
||||
key: ValueKey(
|
||||
home
|
||||
? 'mornye-compact-home'
|
||||
: 'mornye-compact-search',
|
||||
),
|
||||
tooltip: home
|
||||
? context.l10n.navHome
|
||||
: context.l10n.mornyeSearch,
|
||||
iconSize: 25,
|
||||
color: Color.lerp(
|
||||
index == selectedIndex
|
||||
? scheme.primary
|
||||
: inactiveIconColor,
|
||||
scheme.primary,
|
||||
amount,
|
||||
),
|
||||
icon: destinations[index].icon,
|
||||
onPressed: home ? onHome : onSearch,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (hasPlayer || amount > 0)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: tabGap + (8 - tabGap) * amount,
|
||||
),
|
||||
child: Row(
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
sideButton(
|
||||
child: expandButton,
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
Expanded(child: player),
|
||||
sideButton(
|
||||
child: searchButton,
|
||||
alignment: Alignment.centerRight,
|
||||
if (hasPlayer || amount > 0)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
// With no track, introducing the row at full height would
|
||||
// make the bar jump taller on the first animation frame.
|
||||
heightFactor: hasPlayer ? 1 : amount,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: hasPlayer
|
||||
? tabGap + (8 - tabGap) * amount
|
||||
: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 60 * amount,
|
||||
height: hasPlayer ? 48 + 4 * amount : 52,
|
||||
),
|
||||
Expanded(child: player),
|
||||
SizedBox(
|
||||
width: 60 * amount,
|
||||
height: hasPlayer ? 48 + 4 * amount : 52,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
heightFactor: 1 - amount,
|
||||
child: IgnorePointer(
|
||||
ignoring: amount > 0.5,
|
||||
child: ExcludeSemantics(
|
||||
excluding: amount > 0.5,
|
||||
child: Opacity(opacity: 1 - amount, child: tabs),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
heightFactor: 1 - amount,
|
||||
child: IgnorePointer(
|
||||
ignoring: collapsed,
|
||||
child: ExcludeSemantics(
|
||||
excluding: collapsed,
|
||||
child: Opacity(opacity: 1 - amount, child: tabs),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
movingIcon(home: true, surface: homeSurface),
|
||||
movingIcon(home: false, surface: searchSurface),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -437,12 +437,16 @@ class MornyeTabBar extends StatelessWidget {
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
required this.blurEnabled,
|
||||
this.hideEdgeIcons = false,
|
||||
});
|
||||
|
||||
final List<NavigationDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
final bool blurEnabled;
|
||||
// The folding bottom bar paints these two icons above both layouts so they
|
||||
// can travel without fading along with the full tab capsule.
|
||||
final bool hideEdgeIcons;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -516,7 +520,7 @@ class MornyeTabBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
items: [
|
||||
for (final destination in destinations)
|
||||
for (final (index, destination) in destinations.indexed)
|
||||
LiquidGlassTabBarItem(
|
||||
label: destination.label,
|
||||
iconBuilder: (context, icon) => IconTheme(
|
||||
@@ -526,10 +530,20 @@ class MornyeTabBar extends StatelessWidget {
|
||||
? scheme.primary
|
||||
: inactiveIconColor,
|
||||
),
|
||||
child: destination.icon,
|
||||
child: Opacity(
|
||||
opacity:
|
||||
hideEdgeIcons &&
|
||||
(index == 0 ||
|
||||
index == destinations.length - 1)
|
||||
? 0
|
||||
: 1,
|
||||
child: destination.icon,
|
||||
),
|
||||
),
|
||||
labelBuilder: (context, label) => Text(
|
||||
destination.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
fontSize: label.textStyle.fontSize,
|
||||
@@ -593,7 +607,15 @@ class MornyeTabBar extends StatelessWidget {
|
||||
),
|
||||
// Keep tab selection quiet, as in Mornye. Badges
|
||||
// stay live without the Material bounce/spin.
|
||||
child: destinations[index].icon,
|
||||
child: Opacity(
|
||||
opacity:
|
||||
hideEdgeIcons &&
|
||||
(index == 0 ||
|
||||
index == destinations.length - 1)
|
||||
? 0
|
||||
: 1,
|
||||
child: destinations[index].icon,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
|
||||
|
||||
@@ -44,6 +45,7 @@ class MornyePlayerQueue extends ConsumerWidget {
|
||||
final start = currentIndex + 1;
|
||||
final count = queue.length - start;
|
||||
final controller = ref.read(musicPlayerControllerProvider);
|
||||
final autoMix = ref.watch(settingsProvider.select((s) => s.autoMix));
|
||||
final type = Theme.of(context).textTheme;
|
||||
final repeatLabel = switch (playback.repeat) {
|
||||
AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne,
|
||||
@@ -87,6 +89,17 @@ class MornyePlayerQueue extends ConsumerWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
modeButton(
|
||||
icon: CupertinoIcons.arrow_merge,
|
||||
label: autoMix
|
||||
? context.l10n.autoMixOn
|
||||
: context.l10n.autoMixOff,
|
||||
selected: autoMix,
|
||||
onPressed: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAutoMix(!autoMix),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
modeButton(
|
||||
icon: CupertinoIcons.shuffle,
|
||||
label: playback.shuffle
|
||||
|
||||
Reference in new issue
Block a user