mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +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:
@@ -0,0 +1,125 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/automix_analysis.dart';
|
||||
|
||||
Uint8List drumPattern(double bpm, {double phase = 0.17}) {
|
||||
const sampleRate = 11025;
|
||||
final bytes = ByteData(sampleRate * 24 * 2);
|
||||
final random = math.Random(7);
|
||||
for (var i = 0; i < sampleRate * 24; i++) {
|
||||
final time = i / sampleRate;
|
||||
final sinceBeat = (time - phase) % (60 / bpm);
|
||||
final transient = time < phase ? 0.0 : math.exp(-sinceBeat * 50);
|
||||
final value =
|
||||
0.75 * transient * math.sin(time * 2 * math.pi * 100) +
|
||||
(random.nextDouble() - 0.5) * 0.003;
|
||||
bytes.setInt16(i * 2, (value * 32767).round(), Endian.little);
|
||||
}
|
||||
return bytes.buffer.asUint8List();
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final bpm in [90.0, 120.0, 124.0, 160.0]) {
|
||||
test('detects $bpm BPM and beat phase from audio', () {
|
||||
final grid = analyzeAutoMixPcm(drumPattern(bpm));
|
||||
expect(grid.reliable, isTrue, reason: '${grid.bpm}, ${grid.confidence}');
|
||||
expect(grid.bpm, closeTo(bpm, 0.75));
|
||||
expect(grid.beatAtOrAfter(0), closeTo(0.17, 0.04));
|
||||
});
|
||||
}
|
||||
|
||||
test('silence and unstructured noise do not invent a reliable beat', () {
|
||||
expect(analyzeAutoMixPcm(Uint8List(11025 * 24 * 2)).reliable, isFalse);
|
||||
final random = math.Random(11);
|
||||
final noise = ByteData(11025 * 24 * 2);
|
||||
for (var i = 0; i < noise.lengthInBytes; i += 2) {
|
||||
noise.setInt16(i, random.nextInt(32000) - 16000, Endian.little);
|
||||
}
|
||||
expect(analyzeAutoMixPcm(noise.buffer.asUint8List()).reliable, isFalse);
|
||||
});
|
||||
|
||||
AutoMixBeatGrid grid(double bpm, {double confidence = 0.9}) =>
|
||||
AutoMixBeatGrid(
|
||||
bpm: bpm,
|
||||
phase: 0.17,
|
||||
confidence: confidence,
|
||||
firstSound: 0.1,
|
||||
);
|
||||
|
||||
AutoMixPlan plan(double from, double to, {double confidence = 0.9}) =>
|
||||
AutoMixPlan.create(
|
||||
outgoingDuration: const Duration(seconds: 180),
|
||||
incomingDuration: const Duration(seconds: 200),
|
||||
outro: grid(from, confidence: confidence),
|
||||
intro: grid(to),
|
||||
outroOffset: 156,
|
||||
)!;
|
||||
|
||||
test('compatible tempi align outgoing and incoming beats', () {
|
||||
final transition = plan(120, 124);
|
||||
expect(transition.beatMatched, isTrue);
|
||||
expect(transition.rate, closeTo(120 / 124, 0.00001));
|
||||
final beat = (transition.start.inMicroseconds / 1e6 - 156.17) / 0.5;
|
||||
expect(beat, closeTo(beat.round(), 0.00001));
|
||||
expect(transition.incomingStart.inMilliseconds, 170);
|
||||
expect(
|
||||
transition.start + transition.duration,
|
||||
lessThanOrEqualTo(const Duration(seconds: 180)),
|
||||
);
|
||||
});
|
||||
|
||||
test('incompatible or uncertain beats use unstretched crossfade', () {
|
||||
for (final transition in [
|
||||
plan(120, 160),
|
||||
plan(120, 124, confidence: 0.2),
|
||||
]) {
|
||||
expect(transition.beatMatched, isFalse);
|
||||
expect(transition.rate, 1);
|
||||
expect(transition.incomingStart, Duration.zero);
|
||||
}
|
||||
expect(
|
||||
AutoMixPlan.create(
|
||||
outgoingDuration: const Duration(seconds: 10),
|
||||
incomingDuration: const Duration(minutes: 2),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('equal-power overlap restores normal tempo smoothly', () {
|
||||
final transition = plan(120, 124);
|
||||
final start = autoMixEnvelope(Duration.zero, transition);
|
||||
expect(start.outgoing, 1);
|
||||
expect(start.incoming, 0);
|
||||
final middle = autoMixEnvelope(transition.duration ~/ 2, transition);
|
||||
expect(
|
||||
middle.outgoing * middle.outgoing + middle.incoming * middle.incoming,
|
||||
closeTo(1, 0.00001),
|
||||
);
|
||||
expect(middle.rate, transition.rate);
|
||||
final recovery = autoMixEnvelope(
|
||||
transition.duration + const Duration(seconds: 4),
|
||||
transition,
|
||||
);
|
||||
expect(recovery.rate, closeTo((transition.rate + 1) / 2, 0.00001));
|
||||
final end = autoMixEnvelope(
|
||||
transition.duration + const Duration(seconds: 8),
|
||||
transition,
|
||||
);
|
||||
expect(end.complete, isTrue);
|
||||
expect(end.rate, 1);
|
||||
expect(end.incoming, 1);
|
||||
expect(end.outgoing, closeTo(0, 0.00001));
|
||||
});
|
||||
|
||||
test('AutoMix is off for new and existing settings, and persists opt-in', () {
|
||||
expect(const AppSettings().autoMix, isFalse);
|
||||
expect(AppSettings.fromJson({}).autoMix, isFalse);
|
||||
final enabled = const AppSettings().copyWith(autoMix: true);
|
||||
expect(AppSettings.fromJson(enabled.toJson()).autoMix, isTrue);
|
||||
expect(enabled.copyWith(autoMix: false).autoMix, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||
import 'package:spotiflac_android/providers/explore_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'Home keeps its feed while Search retains its query and results',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(430, 932);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final search = _Search();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
settingsProvider.overrideWith(_Settings.new),
|
||||
extensionProvider.overrideWith(_Extensions.new),
|
||||
exploreProvider.overrideWith(_Explore.new),
|
||||
downloadHistoryProvider.overrideWith(_History.new),
|
||||
recentAccessProvider.overrideWith(_Recent.new),
|
||||
trackProvider.overrideWith(() => search),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(Brightness.dark),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const _Tabs(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Featured albums'), findsOneWidget);
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
expect(find.text('Recently visited artist'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Open Search'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Search'), findsWidgets);
|
||||
expect(find.text('Featured albums'), findsNothing);
|
||||
expect(find.text('Recently visited artist'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), 'Example');
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
||||
'Example',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
expect(search._requests, 1);
|
||||
expect(find.text('Found artist'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Open Home'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Featured albums'), findsOneWidget);
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
expect(find.text('Found artist'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Open Search'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
||||
'Example',
|
||||
);
|
||||
expect(find.text('Found artist'), findsOneWidget);
|
||||
expect(search._requests, 1);
|
||||
await tester.tap(find.byTooltip('Clear'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Found artist'), findsNothing);
|
||||
expect(find.text('Recently visited artist'), findsOneWidget);
|
||||
expect(find.text('Featured albums'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _Tabs extends StatefulWidget {
|
||||
const _Tabs();
|
||||
|
||||
@override
|
||||
State<_Tabs> createState() => _TabsState();
|
||||
}
|
||||
|
||||
class _TabsState extends State<_Tabs> {
|
||||
int _index = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: [
|
||||
TickerMode(
|
||||
enabled: _index == 0,
|
||||
child: const HomeTab(mode: HomeTabMode.browse),
|
||||
),
|
||||
TickerMode(
|
||||
enabled: _index == 1,
|
||||
child: const HomeTab(mode: HomeTabMode.search),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: Row(
|
||||
children: [
|
||||
for (final (index, label) in [(0, 'Open Home'), (1, 'Open Search')])
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() => _index = index);
|
||||
},
|
||||
child: Text(label),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Settings extends SettingsNotifier {
|
||||
@override
|
||||
AppSettings build() =>
|
||||
const AppSettings(searchProvider: 'example', hasSearchedBefore: true);
|
||||
}
|
||||
|
||||
class _Extensions extends ExtensionNotifier {
|
||||
@override
|
||||
ExtensionState build() => const ExtensionState(
|
||||
isInitialized: true,
|
||||
extensions: [
|
||||
Extension(
|
||||
id: 'example',
|
||||
name: 'example',
|
||||
displayName: 'Example',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
enabled: true,
|
||||
status: 'loaded',
|
||||
hasMetadataProvider: true,
|
||||
searchBehavior: SearchBehavior(enabled: true),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _Explore extends ExploreNotifier {
|
||||
@override
|
||||
ExploreState build() => const ExploreState(
|
||||
sections: [
|
||||
ExploreSection(
|
||||
uri: 'example:featured',
|
||||
title: 'Featured albums',
|
||||
items: [],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _History extends DownloadHistoryNotifier {
|
||||
@override
|
||||
DownloadHistoryState build() => DownloadHistoryState();
|
||||
}
|
||||
|
||||
class _Recent extends RecentAccessNotifier {
|
||||
@override
|
||||
RecentAccessState build() => RecentAccessState(
|
||||
isLoaded: true,
|
||||
items: [
|
||||
RecentAccessItem(
|
||||
id: 'recent',
|
||||
name: 'Recently visited artist',
|
||||
type: RecentAccessType.artist,
|
||||
accessedAt: DateTime(2026),
|
||||
providerId: 'example',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _Search extends TrackNotifier {
|
||||
int _requests = 0;
|
||||
|
||||
@override
|
||||
Future<void> customSearch(
|
||||
String extensionId,
|
||||
String query, {
|
||||
Map<String, dynamic>? options,
|
||||
String? selectedFilter,
|
||||
bool allowVerificationRetry = true,
|
||||
}) async {
|
||||
_requests++;
|
||||
state = const TrackState(
|
||||
hasSearchText: true,
|
||||
searchExtensionId: 'example',
|
||||
tracks: [
|
||||
Track(
|
||||
id: 'artist',
|
||||
name: 'Found artist',
|
||||
artistName: '',
|
||||
albumName: '',
|
||||
duration: 0,
|
||||
itemType: 'artist',
|
||||
source: 'example',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,14 @@ void main() {
|
||||
late MornyeChromeController chrome;
|
||||
late ScrollController scroll;
|
||||
late int searches;
|
||||
late int selected;
|
||||
final capture = GlobalKey();
|
||||
|
||||
setUp(() {
|
||||
chrome = MornyeChromeController();
|
||||
scroll = ScrollController();
|
||||
searches = 0;
|
||||
selected = 0;
|
||||
});
|
||||
tearDown(() {
|
||||
chrome.dispose();
|
||||
@@ -95,14 +97,22 @@ void main() {
|
||||
icon: Icon(Icons.music_note),
|
||||
label: 'Library',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.grid_view),
|
||||
label: 'Repo',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.search),
|
||||
label: 'Search',
|
||||
),
|
||||
],
|
||||
selectedIndex: 0,
|
||||
onSelected: (_) {},
|
||||
onExpand: chrome.expand,
|
||||
onSelected: (index) => selected = index,
|
||||
onHome: chrome.expand,
|
||||
onSearch: () => searches++,
|
||||
blurEnabled: blur,
|
||||
),
|
||||
@@ -115,6 +125,117 @@ void main() {
|
||||
}
|
||||
|
||||
const albumBlue = Color(0xff464566);
|
||||
|
||||
for (final blur in [false, true]) {
|
||||
testWidgets(
|
||||
'edge icons travel continuously without fading (glass: $blur)',
|
||||
(tester) async {
|
||||
await pumpShell(tester, blur: blur);
|
||||
final homeButton = find.byKey(const ValueKey('mornye-compact-home'));
|
||||
final icon = find.descendant(
|
||||
of: homeButton,
|
||||
matching: find.byIcon(Icons.home),
|
||||
);
|
||||
final element = tester.element(icon);
|
||||
final tabIcon = find
|
||||
.descendant(
|
||||
of: find.byType(MornyeTabBar),
|
||||
matching: find.byIcon(Icons.home),
|
||||
)
|
||||
.first;
|
||||
final start = tester.getCenter(icon);
|
||||
expect((start - tester.getCenter(tabIcon)).distance, lessThan(0.5));
|
||||
chrome.value = true;
|
||||
await tester.pump();
|
||||
var previous = start;
|
||||
for (var frame = 0; frame < 12; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(tester.element(icon), same(element));
|
||||
for (final opacity in tester.widgetList<Opacity>(
|
||||
find.ancestor(of: icon, matching: find.byType(Opacity)),
|
||||
)) {
|
||||
expect(opacity.opacity, 1);
|
||||
}
|
||||
final center = tester.getCenter(icon);
|
||||
expect((center - previous).distance, lessThan(8));
|
||||
previous = center;
|
||||
}
|
||||
expect(previous.dx, lessThan(start.dx));
|
||||
chrome.expand();
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.element(icon), same(element));
|
||||
expect((tester.getCenter(icon) - start).distance, lessThan(0.5));
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('five tabs keep Search at the right edge (glass: $blur)', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpShell(tester, blur: blur);
|
||||
final tabs = find.byType(MornyeTabBar);
|
||||
final search = find
|
||||
.descendant(of: tabs, matching: find.text('Search'))
|
||||
.first;
|
||||
final settings = find
|
||||
.descendant(of: tabs, matching: find.text('Settings'))
|
||||
.first;
|
||||
expect(tester.widget<MornyeTabBar>(tabs).destinations, hasLength(5));
|
||||
expect(
|
||||
tester.getCenter(search).dx,
|
||||
greaterThan(tester.getCenter(settings).dx),
|
||||
);
|
||||
// The glass renderer paints the labels under a gesture overlay.
|
||||
await tester.tapAt(tester.getCenter(search));
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected, 4);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
for (final withPlayer in [false, true]) {
|
||||
testWidgets('compact transition has no height jump (player: $withPlayer)', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpShell(tester, withPlayer: withPlayer);
|
||||
final bar = find.byType(MornyeBottomBar);
|
||||
var previousHeight = tester.getSize(bar).height;
|
||||
final playerState = withPlayer
|
||||
? tester.state(find.byType(MiniPlayer))
|
||||
: null;
|
||||
chrome.value = true;
|
||||
await tester.pump();
|
||||
Offset? firstHomeCenter;
|
||||
for (var frame = 0; frame < 25; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
final height = tester.getSize(bar).height;
|
||||
expect(height, lessThanOrEqualTo(previousHeight + 0.01));
|
||||
previousHeight = height;
|
||||
firstHomeCenter ??= tester.getCenter(
|
||||
find.byKey(const ValueKey('mornye-compact-home')),
|
||||
);
|
||||
}
|
||||
final homeCenter = tester.getCenter(
|
||||
find.byKey(const ValueKey('mornye-compact-home')),
|
||||
);
|
||||
expect((homeCenter.dy - firstHomeCenter!.dy).abs(), lessThan(20));
|
||||
expect(find.byTooltip('Home').hitTestable(), findsOneWidget);
|
||||
expect(find.byTooltip('Search').hitTestable(), findsOneWidget);
|
||||
if (withPlayer) {
|
||||
expect(tester.state(find.byType(MiniPlayer)), same(playerState));
|
||||
}
|
||||
chrome.expand();
|
||||
await tester.pump();
|
||||
for (var frame = 0; frame < 25; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
final height = tester.getSize(bar).height;
|
||||
expect(height, greaterThanOrEqualTo(previousHeight - 0.01));
|
||||
previousHeight = height;
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
for (final brightness in Brightness.values) {
|
||||
for (final backdrop in [
|
||||
Colors.white,
|
||||
@@ -214,7 +335,7 @@ void main() {
|
||||
lessThan(expandedHeight - 60),
|
||||
);
|
||||
expect(find.byTooltip('Next track'), findsNothing);
|
||||
await tester.tap(find.byTooltip('Show tabs'));
|
||||
await tester.tap(find.byTooltip('Home'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(chrome.value, isFalse);
|
||||
expect(tester.getSize(find.byType(MornyeBottomBar)).height, expandedHeight);
|
||||
@@ -367,6 +488,35 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Library inner scrolling expands before the header returns', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpShell(
|
||||
tester,
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (_, _) => const [
|
||||
SliverAppBar(expandedHeight: 180, title: Text('Library')),
|
||||
],
|
||||
body: PageView(
|
||||
children: [
|
||||
ListView.builder(
|
||||
itemExtent: 60,
|
||||
itemCount: 60,
|
||||
itemBuilder: (_, index) => Text('Track $index'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -600));
|
||||
await tester.pumpAndSettle();
|
||||
expect(chrome.value, isTrue);
|
||||
await tester.drag(find.byType(ListView), const Offset(0, 100));
|
||||
await tester.pumpAndSettle();
|
||||
expect(chrome.value, isFalse);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'glass bar stays mounted and pauses its tickers while collapsed',
|
||||
(tester) async {
|
||||
@@ -396,7 +546,7 @@ void main() {
|
||||
expect(chrome.value, isTrue);
|
||||
await tester.tap(find.byTooltip('Search'));
|
||||
expect(searches, 1);
|
||||
await tester.tap(find.byTooltip('Show tabs'));
|
||||
await tester.tap(find.byTooltip('Home'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(chrome.value, isFalse);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_player_queue.dart';
|
||||
|
||||
@@ -26,6 +28,14 @@ class _Player extends MusicPlayerController {
|
||||
repeat = mode;
|
||||
}
|
||||
|
||||
class _Settings extends SettingsNotifier {
|
||||
@override
|
||||
AppSettings build() => const AppSettings();
|
||||
|
||||
@override
|
||||
void setAutoMix(bool enabled) => state = state.copyWith(autoMix: enabled);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'upcoming queue maps taps and reorders to the complete playback queue',
|
||||
@@ -40,6 +50,7 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
settingsProvider.overrideWith(_Settings.new),
|
||||
musicPlayerControllerProvider.overrideWithValue(player),
|
||||
currentMediaItemProvider.overrideWith(
|
||||
(ref) => Stream.value(queue[1]),
|
||||
@@ -65,6 +76,12 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Previous'), findsNothing);
|
||||
expect(find.text('Current'), findsNothing);
|
||||
await tester.tap(find.byTooltip('AutoMix off'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('AutoMix on'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('AutoMix on'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('AutoMix off'), findsOneWidget);
|
||||
await tester.tap(find.text('Last'));
|
||||
expect(player.jumpedTo, 3);
|
||||
final firstHandle = find.byType(ReorderableDragStartListener).first;
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/automix_analysis.dart';
|
||||
import 'package:spotiflac_android/services/automix_analyzer.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
|
||||
class _Analyzer extends AutoMixAnalyzer {
|
||||
final calls = <String>[];
|
||||
Completer<AutoMixBeatGrid?>? pending;
|
||||
bool matchBeats = false;
|
||||
|
||||
@override
|
||||
Future<AutoMixBeatGrid?> analyze(String path, {double offset = 0}) async {
|
||||
calls.add(path);
|
||||
if (matchBeats) {
|
||||
return AutoMixBeatGrid(
|
||||
bpm: offset > 0 ? 120 : 124,
|
||||
phase: 0.17,
|
||||
confidence: 0.9,
|
||||
firstSound: 0.1,
|
||||
);
|
||||
}
|
||||
return pending?.future;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercises the real AudioPlayer transport/streams against a native-channel
|
||||
/// fake, including preparation, seek completion and outgoing completion events.
|
||||
class _AudioNative {
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
final calls = <(String, String, Map<Object?, Object?>)>[];
|
||||
final positions = <String, int>{};
|
||||
final live = <String>{};
|
||||
final playing = <String>{};
|
||||
|
||||
void install() {
|
||||
for (final name in [
|
||||
'xyz.luan/audioplayers.global',
|
||||
'xyz.luan/audioplayers.global/events',
|
||||
]) {
|
||||
messenger.setMockMethodCallHandler(
|
||||
MethodChannel(name),
|
||||
(_) async => null,
|
||||
);
|
||||
}
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('xyz.luan/audioplayers'),
|
||||
(call) async {
|
||||
final args = call.arguments as Map<Object?, Object?>;
|
||||
final id = args['playerId']! as String;
|
||||
calls.add((id, call.method, args));
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
live.add(id);
|
||||
positions[id] = 0;
|
||||
messenger.setMockMethodCallHandler(
|
||||
MethodChannel('xyz.luan/audioplayers/events/$id'),
|
||||
(_) async => null,
|
||||
);
|
||||
case 'setSourceUrl':
|
||||
unawaited(event(id, 'audio.onPrepared', true));
|
||||
unawaited(event(id, 'audio.onDuration', 60000));
|
||||
case 'seek':
|
||||
positions[id] = args['position']! as int;
|
||||
unawaited(event(id, 'audio.onSeekComplete'));
|
||||
case 'resume':
|
||||
playing.add(id);
|
||||
case 'pause' || 'stop':
|
||||
playing.remove(id);
|
||||
case 'dispose':
|
||||
playing.remove(id);
|
||||
live.remove(id);
|
||||
case 'getDuration':
|
||||
return 60000;
|
||||
case 'getCurrentPosition':
|
||||
return positions[id];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> event(String id, String name, [Object? value]) async {
|
||||
await messenger.handlePlatformMessage(
|
||||
'xyz.luan/audioplayers/events/$id',
|
||||
const StandardMethodCodec().encodeSuccessEnvelope({
|
||||
'event': name,
|
||||
'value': ?value,
|
||||
}),
|
||||
(_) {},
|
||||
);
|
||||
}
|
||||
|
||||
String get prepared => live.singleWhere((id) => id != 'music-player');
|
||||
|
||||
double? lastVolume(String id) =>
|
||||
calls
|
||||
.where((call) => call.$1 == id && call.$2 == 'setVolume')
|
||||
.lastOrNull
|
||||
?.$3['volume']
|
||||
as double?;
|
||||
}
|
||||
|
||||
const _tracks = [
|
||||
PlayableMedia(id: 'one', source: '/one.flac', title: 'One', artist: 'Artist'),
|
||||
PlayableMedia(id: 'two', source: '/two.flac', title: 'Two', artist: 'Artist'),
|
||||
PlayableMedia(
|
||||
id: 'three',
|
||||
source: '/three.flac',
|
||||
title: 'Three',
|
||||
artist: 'Artist',
|
||||
),
|
||||
];
|
||||
|
||||
Future<void> _until(bool Function() ready) async {
|
||||
for (var i = 0; i < 100; i++) {
|
||||
if (ready()) return;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
fail('Playback did not reach the expected state');
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
late _AudioNative native;
|
||||
late _Analyzer analyzer;
|
||||
late MusicPlayerHandler handler;
|
||||
|
||||
setUp(() {
|
||||
setAutoMixEnabled(false);
|
||||
native = _AudioNative()..install();
|
||||
analyzer = _Analyzer();
|
||||
handler = MusicPlayerHandler(autoMixAnalyzer: analyzer);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await handler.dispose();
|
||||
analyzer.pending?.complete(null);
|
||||
setAutoMixEnabled(false);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
expect(native.live, isEmpty);
|
||||
expect(native.playing, isEmpty);
|
||||
});
|
||||
|
||||
Future<void> prepare() async {
|
||||
setAutoMixEnabled(true);
|
||||
await handler.setQueueAndPlay(_tracks);
|
||||
await _until(() => analyzer.calls.length == 2);
|
||||
await _until(
|
||||
() => native.calls.any((call) => call.$2 == 'setPlaybackRate'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> startMix({int startPosition = 55000}) async {
|
||||
await prepare();
|
||||
final incoming = native.prepared;
|
||||
native.positions['music-player'] = startPosition;
|
||||
await _until(() => handler.mediaItem.value?.id == 'two');
|
||||
await _until(() => (native.lastVolume(incoming) ?? 0) > 0);
|
||||
return incoming;
|
||||
}
|
||||
|
||||
test(
|
||||
'disabled AutoMix uses only the ordinary player and no analysis',
|
||||
() async {
|
||||
await handler.setQueueAndPlay(_tracks);
|
||||
native.positions['music-player'] = 56000;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
expect(analyzer.calls, isEmpty);
|
||||
expect(native.live, {'music-player'});
|
||||
expect(handler.mediaItem.value?.id, 'one');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'handoff publishes native duration and ignores outgoing completion',
|
||||
() async {
|
||||
final incoming = await startMix();
|
||||
expect(native.playing, {'music-player', incoming});
|
||||
expect(handler.mediaItem.value?.duration, const Duration(minutes: 1));
|
||||
expect(handler.playbackState.value.queueIndex, 1);
|
||||
await native.event('music-player', 'audio.onComplete');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||
expect(handler.mediaItem.value?.id, 'two');
|
||||
expect(handler.playbackState.value.playing, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'pause during overlap stops both decks and restores incoming gain',
|
||||
() async {
|
||||
final incoming = await startMix();
|
||||
await handler.pause();
|
||||
expect(native.live, {incoming});
|
||||
expect(native.playing, isEmpty);
|
||||
expect(native.lastVolume(incoming), 1);
|
||||
expect(handler.playbackState.value.playing, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'matched transition releases outgoing and restores tempo after the fade',
|
||||
() async {
|
||||
analyzer.matchBeats = true;
|
||||
final incoming = await startMix(startPosition: 55670);
|
||||
expect(handler.playbackState.value.speed, closeTo(120 / 124, 0.0001));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 4200));
|
||||
expect(native.live, {incoming});
|
||||
expect(native.lastVolume(incoming), closeTo(1, 0.0001));
|
||||
await Future<void>.delayed(const Duration(seconds: 8));
|
||||
expect(handler.playbackState.value.speed, 1);
|
||||
expect(native.playing, {incoming});
|
||||
expect(
|
||||
native.calls.where(
|
||||
(call) => call.$1 == 'music-player' && call.$2 == 'dispose',
|
||||
),
|
||||
hasLength(1),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'seek during overlap cancels fade and seeks only the active deck',
|
||||
() async {
|
||||
final incoming = await startMix();
|
||||
await handler.seek(const Duration(seconds: 12));
|
||||
expect(native.live, {incoming});
|
||||
expect(native.positions[incoming], 12000);
|
||||
expect(native.lastVolume(incoming), 1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'turning AutoMix off releases the standby player without changing song',
|
||||
() async {
|
||||
await prepare();
|
||||
setAutoMixEnabled(false);
|
||||
await _until(() => native.live.length == 1);
|
||||
expect(native.live, {'music-player'});
|
||||
expect(handler.mediaItem.value?.id, 'one');
|
||||
expect(native.playing, {'music-player'});
|
||||
},
|
||||
);
|
||||
|
||||
test('queue edit never starts the previously prepared next song', () async {
|
||||
await prepare();
|
||||
await handler.enqueue(_tracks[2], playNext: true);
|
||||
native.positions['music-player'] = 55000;
|
||||
await _until(() => analyzer.calls.length >= 4);
|
||||
await _until(() => handler.mediaItem.value?.id == 'three');
|
||||
expect(handler.mediaItem.value?.id, 'three');
|
||||
});
|
||||
|
||||
test('manual skip invalidates an analysis still in flight', () async {
|
||||
analyzer.pending = Completer<AutoMixBeatGrid?>();
|
||||
setAutoMixEnabled(true);
|
||||
await handler.setQueueAndPlay(_tracks);
|
||||
await _until(() => analyzer.calls.isNotEmpty);
|
||||
await handler.skipToNext();
|
||||
await handler.pause();
|
||||
analyzer.pending!.complete(null);
|
||||
analyzer.pending = null;
|
||||
await _until(() => native.live.length == 1);
|
||||
expect(handler.mediaItem.value?.id, 'two');
|
||||
expect(native.playing, isEmpty);
|
||||
});
|
||||
|
||||
test('repeat one never prepares another deck', () async {
|
||||
setAutoMixEnabled(true);
|
||||
await handler.setRepeatMode(AudioServiceRepeatMode.one);
|
||||
await handler.setQueueAndPlay(_tracks);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
expect(native.live, {'music-player'});
|
||||
expect(analyzer.calls, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -5,67 +5,95 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||
import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('first search request reaches a lazily mounted Home tab', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: _SearchShell()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
for (final (separateSearch, showRepo) in [
|
||||
(false, false),
|
||||
(true, false),
|
||||
(true, true),
|
||||
]) {
|
||||
testWidgets(
|
||||
'first search request reaches the lazy page (separate=$separateSearch, repo=$showRepo)',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: _SearchShell(
|
||||
separateSearch: separateSearch,
|
||||
showRepo: showRepo,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
final field = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(field.focusNode!.hasFocus, isTrue);
|
||||
expect(tester.testTextInput.isVisible, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('one search request returns from an album and focuses Home', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: _SearchShell()));
|
||||
await tester.tap(find.text('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
final navigator = ShellNavigationService.homeTabNavigatorKey.currentState!;
|
||||
navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const Scaffold(body: Text('Album')),
|
||||
),
|
||||
await tester.tap(find.text('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
final field = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(field.focusNode!.hasFocus, isTrue);
|
||||
expect(tester.testTextInput.isVisible, isTrue);
|
||||
},
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Album'), findsNothing);
|
||||
final field = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(field.focusNode!.hasFocus, isTrue);
|
||||
expect(tester.testTextInput.isVisible, isTrue);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'search returns from an album to its own tab (separate=$separateSearch, repo=$showRepo)',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: _SearchShell(
|
||||
separateSearch: separateSearch,
|
||||
showRepo: showRepo,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
final navigator =
|
||||
(separateSearch
|
||||
? ShellNavigationService.searchTabNavigatorKey
|
||||
: ShellNavigationService.homeTabNavigatorKey)
|
||||
.currentState!;
|
||||
expect(ShellNavigationService.activeTabNavigator(), same(navigator));
|
||||
navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const Scaffold(body: Text('Album')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Album'), findsNothing);
|
||||
final field = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(field.focusNode!.hasFocus, isTrue);
|
||||
expect(tester.testTextInput.isVisible, isTrue);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'search selects Home before requesting focus and stops when shell is absent',
|
||||
'search selects its tab before requesting focus and stops when shell is absent',
|
||||
(tester) async {
|
||||
final owner = Object();
|
||||
var selectedHome = false;
|
||||
ShellNavigationService.syncState(currentTabIndex: 0, showRepoTab: false);
|
||||
var selectedSearch = false;
|
||||
var focusRequests = 0;
|
||||
void onSearch() {
|
||||
expect(selectedHome, isTrue);
|
||||
expect(selectedSearch, isTrue);
|
||||
focusRequests++;
|
||||
}
|
||||
|
||||
ShellNavigationService.homeSearchRequests.addListener(onSearch);
|
||||
ShellNavigationService.searchRequests.addListener(onSearch);
|
||||
addTearDown(() {
|
||||
ShellNavigationService.homeSearchRequests.removeListener(onSearch);
|
||||
ShellNavigationService.searchRequests.removeListener(onSearch);
|
||||
ShellNavigationService.unregisterTabSelectionHandler(owner);
|
||||
});
|
||||
ShellNavigationService.registerTabSelectionHandler(
|
||||
owner: owner,
|
||||
handler: (tab) => selectedHome = tab == ShellTab.home,
|
||||
handler: (tab) => selectedSearch = tab == ShellTab.search,
|
||||
);
|
||||
ShellNavigationService.requestHomeSearch();
|
||||
ShellNavigationService.requestSearch();
|
||||
expect(focusRequests, 0);
|
||||
await tester.pump();
|
||||
expect(focusRequests, 1);
|
||||
ShellNavigationService.unregisterTabSelectionHandler(owner);
|
||||
ShellNavigationService.requestHomeSearch();
|
||||
ShellNavigationService.requestSearch();
|
||||
expect(focusRequests, 1);
|
||||
},
|
||||
);
|
||||
@@ -155,7 +183,10 @@ void main() {
|
||||
}
|
||||
|
||||
class _SearchShell extends StatefulWidget {
|
||||
const _SearchShell();
|
||||
const _SearchShell({required this.separateSearch, required this.showRepo});
|
||||
|
||||
final bool separateSearch;
|
||||
final bool showRepo;
|
||||
|
||||
@override
|
||||
State<_SearchShell> createState() => _SearchShellState();
|
||||
@@ -163,22 +194,29 @@ class _SearchShell extends StatefulWidget {
|
||||
|
||||
class _SearchShellState extends State<_SearchShell> {
|
||||
final _pages = PageController(initialPage: 1);
|
||||
late final _observer = ShellChromeObserver(
|
||||
ShellNavigationService.homeTabNavigatorKey,
|
||||
);
|
||||
GlobalKey<NavigatorState> get _navigatorKey => widget.separateSearch
|
||||
? ShellNavigationService.searchTabNavigatorKey
|
||||
: ShellNavigationService.homeTabNavigatorKey;
|
||||
int get _searchIndex => widget.separateSearch ? (widget.showRepo ? 4 : 3) : 0;
|
||||
late final _observer = ShellChromeObserver(_navigatorKey);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ShellNavigationService.syncState(currentTabIndex: 1, showRepoTab: false);
|
||||
ShellNavigationService.syncState(
|
||||
currentTabIndex: 1,
|
||||
showRepoTab: widget.showRepo,
|
||||
showSearchTab: widget.separateSearch,
|
||||
);
|
||||
ShellNavigationService.registerTabSelectionHandler(
|
||||
owner: this,
|
||||
handler: (_) {
|
||||
ShellNavigationService.syncState(
|
||||
currentTabIndex: 0,
|
||||
showRepoTab: false,
|
||||
currentTabIndex: _searchIndex,
|
||||
showRepoTab: widget.showRepo,
|
||||
showSearchTab: widget.separateSearch,
|
||||
);
|
||||
_pages.jumpToPage(0);
|
||||
_pages.jumpToPage(_searchIndex);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -196,17 +234,17 @@ class _SearchShellState extends State<_SearchShell> {
|
||||
body: Column(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: ShellNavigationService.requestHomeSearch,
|
||||
onPressed: ShellNavigationService.requestSearch,
|
||||
child: const Text('Search'),
|
||||
),
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _pages,
|
||||
itemCount: 2,
|
||||
itemBuilder: (_, index) => index == 1
|
||||
itemCount: widget.separateSearch ? _searchIndex + 1 : 2,
|
||||
itemBuilder: (_, index) => index != _searchIndex
|
||||
? const Text('Library')
|
||||
: Navigator(
|
||||
key: ShellNavigationService.homeTabNavigatorKey,
|
||||
key: _navigatorKey,
|
||||
observers: [_observer],
|
||||
onGenerateRoute: (_) => MaterialPageRoute<void>(
|
||||
builder: (_) => const _SearchHome(),
|
||||
@@ -232,7 +270,7 @@ class _SearchHomeState extends State<_SearchHome> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ShellNavigationService.homeSearchRequests.addListener(_onSearch);
|
||||
ShellNavigationService.searchRequests.addListener(_onSearch);
|
||||
}
|
||||
|
||||
void _onSearch() {
|
||||
@@ -243,7 +281,7 @@ class _SearchHomeState extends State<_SearchHome> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
ShellNavigationService.homeSearchRequests.removeListener(_onSearch);
|
||||
ShellNavigationService.searchRequests.removeListener(_onSearch);
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user