From 1d6ff18d067cbee19c3a2fc98c5b8b747ff935d9 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:27:49 +0700 Subject: [PATCH] feat(player): add native audio output controls --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 23 ++++ ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 4 + ios/Runner/AudioOutputView.swift | 72 +++++++++++ lib/l10n/arb/app_en.arb | 2 + lib/l10n/arb/app_id.arb | 2 + lib/screens/now_playing_screen.dart | 12 ++ lib/widgets/audio_output_button.dart | 115 +++++++++++++++++ lib/widgets/mornye_landscape_player.dart | 45 ++++++- test/audio_output_button_test.dart | 117 ++++++++++++++++++ 10 files changed, 392 insertions(+), 4 deletions(-) create mode 100644 ios/Runner/AudioOutputView.swift create mode 100644 lib/widgets/audio_output_button.dart create mode 100644 test/audio_output_button_test.dart diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 64763c85..3ba6e746 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -77,6 +77,7 @@ class MainActivity: FlutterFragmentActivity() { private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var backendChannel: MethodChannel? = null + private var audioOutputChannel: MethodChannel? = null internal val coreBackend: CoreBackend by lazy { createCoreBackend(applicationContext) } private val nativeBackendMethods = setOf( "getBackendImplementations", @@ -933,6 +934,8 @@ class MainActivity: FlutterFragmentActivity() { } override fun onDestroy() { + audioOutputChannel?.setMethodCallHandler(null) + audioOutputChannel = null libraryStorageReceiver?.let { try { unregisterReceiver(it) @@ -971,6 +974,26 @@ class MainActivity: FlutterFragmentActivity() { onBackPressedDispatcher.addCallback(this, flutterBackCallback!!) val messenger = flutterEngine.dartExecutor.binaryMessenger + audioOutputChannel = MethodChannel(messenger, "com.zarz.spotiflac/audio_output").also { channel -> + channel.setMethodCallHandler { call, result -> + if (call.method != "show") { + result.notImplemented() + } else { + try { + val shown = if (Build.VERSION.SDK_INT >= 34) { + android.media.MediaRouter2.getInstance(this).showSystemOutputSwitcher() + } else { + // Older Android releases do not expose a public output picker. + startActivity(Intent(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS)) + true + } + result.success(shown) + } catch (_: Exception) { + result.success(false) + } + } + } + } EventChannel( messenger, diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index a7e970ad..97b7a07b 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + A0D100000000000000000001 /* AudioOutputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D100000000000000000002 /* AudioOutputView.swift */; }; F59500000000000000000011 /* PlayerWidgetState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000001 /* PlayerWidgetState.swift */; }; F59500000000000000000012 /* PlayerControlIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000002 /* PlayerControlIntent.swift */; }; F59500000000000000000013 /* PlayerWidgetBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000004 /* PlayerWidgetBridge.swift */; }; @@ -69,6 +70,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A0D100000000000000000002 /* AudioOutputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputView.swift; sourceTree = ""; }; F59500000000000000000009 /* PlayerWidget.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = PlayerWidget.xcconfig; sourceTree = ""; }; F59500000000000000000001 /* PlayerWidgetState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidgetState.swift; sourceTree = ""; }; F59500000000000000000002 /* PlayerControlIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlIntent.swift; sourceTree = ""; }; @@ -210,6 +212,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, F59500000000000000000004 /* PlayerWidgetBridge.swift */, + A0D100000000000000000002 /* AudioOutputView.swift */, F59500000000000000000007 /* Runner.entitlements */, C03400000000000000000002 /* CoreBackend.swift */, D08A594730CF000100000002 /* DownloadProgressSubscription.swift */, @@ -471,6 +474,7 @@ F59500000000000000000011 /* PlayerWidgetState.swift in Sources */, F59500000000000000000012 /* PlayerControlIntent.swift in Sources */, F59500000000000000000013 /* PlayerWidgetBridge.swift in Sources */, + A0D100000000000000000001 /* AudioOutputView.swift in Sources */, C03400000000000000000001 /* CoreBackend.swift in Sources */, D08A594730CF000100000001 /* DownloadProgressSubscription.swift in Sources */, A11CE0012F00000000000001 /* ExtensionCallbackParser.swift in Sources */, diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 382cc34a..bd42d332 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -106,6 +106,10 @@ import UniformTypeIdentifiers ) GeneratedPluginRegistrant.register(with: registry) + registry.registrar(forPlugin: "AudioOutputView")?.register( + AudioOutputViewFactory(messenger: messenger), + withId: "com.zarz.spotiflac/audio_output" + ) } /// The window of the foreground scene. Under the UIScene lifecycle the diff --git a/ios/Runner/AudioOutputView.swift b/ios/Runner/AudioOutputView.swift new file mode 100644 index 00000000..ceb57c93 --- /dev/null +++ b/ios/Runner/AudioOutputView.swift @@ -0,0 +1,72 @@ +import AVKit +import Flutter +import UIKit + +final class AudioOutputViewFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + + func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView { + AudioOutputView(frame: frame, id: viewId, messenger: messenger, arguments: args) + } +} + +private final class AudioOutputView: NSObject, FlutterPlatformView, AVRoutePickerViewDelegate { + private let picker: AVRoutePickerView + private let channel: FlutterMethodChannel + + init(frame: CGRect, id: Int64, messenger: FlutterBinaryMessenger, arguments: Any?) { + picker = AVRoutePickerView(frame: frame) + channel = FlutterMethodChannel(name: "com.zarz.spotiflac/audio_output/\(id)", binaryMessenger: messenger) + super.init() + picker.backgroundColor = .clear + picker.prioritizesVideoDevices = false + picker.delegate = self + update(arguments) + channel.setMethodCallHandler { [weak self] call, result in + if call.method == "update" { + self?.update(call.arguments) + result(nil) + } else { + result(FlutterMethodNotImplemented) + } + } + } + + private func update(_ arguments: Any?) { + guard let values = arguments as? [String: Any] else { return } + if let value = values["color"] as? NSNumber { + let argb = value.uint32Value + picker.tintColor = UIColor( + red: CGFloat((argb >> 16) & 0xff) / 255, + green: CGFloat((argb >> 8) & 0xff) / 255, + blue: CGFloat(argb & 0xff) / 255, + alpha: CGFloat((argb >> 24) & 0xff) / 255 + ) + picker.activeTintColor = picker.tintColor + } + picker.accessibilityLabel = values["label"] as? String + } + + func view() -> UIView { picker } + + func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) { + channel.invokeMethod("pickerChanged", arguments: true) + } + + func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) { + channel.invokeMethod("pickerChanged", arguments: false) + } + + deinit { + channel.setMethodCallHandler(nil) + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index ba4d7c9d..1fe9e1cc 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -6281,6 +6281,8 @@ "description": "Tooltip for minimizing the now playing screen" }, "nowPlayingUpNext": "Up next", + "nowPlayingAudioOutput": "Audio Output", + "nowPlayingAudioOutputUnavailable": "Unable to open audio output settings.", "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" }, diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 8cb655b5..86e3558c 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -4577,6 +4577,8 @@ "description": "Description for audio analysis tap-to-analyze prompt" }, "nowPlayingUpNext": "Up next", + "nowPlayingAudioOutput": "Output Audio", + "nowPlayingAudioOutputUnavailable": "Tidak dapat membuka pengaturan output audio.", "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" }, diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 4f0d32b4..c5842361 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -30,6 +30,7 @@ import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; +import 'package:spotiflac_android/widgets/audio_output_button.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/overflow_marquee.dart'; import 'package:spotiflac_android/widgets/playback_seek_slider.dart'; @@ -331,6 +332,7 @@ class _NowPlayingScreenState extends ConsumerState { String? _failedMotionSource; bool _lyricsControlsHidden = false; bool _lyricsOptionsOpen = false; + bool _audioOutputOpen = false; double _lyricsScrollDistance = 0; Timer? _lyricsIdleTimer; final _lyricsPointers = {}; @@ -654,6 +656,7 @@ class _NowPlayingScreenState extends ConsumerState { actions: mornye ? null : [ + const AudioOutputButton(), IconButton( tooltip: context.l10n.nowPlayingUpNext, icon: const Icon(Icons.queue_music), @@ -737,6 +740,14 @@ class _NowPlayingScreenState extends ConsumerState { icon: const Icon(CupertinoIcons.quote_bubble), onPressed: _toggleMornyeLyrics, ), + AudioOutputButton( + color: Colors.white, + onPickerChanged: (open) { + if (!mounted) return; + setState(() => _audioOutputOpen = open); + _scheduleLyricsControlsHide(); + }, + ), IconButton( tooltip: context.l10n.nowPlayingUpNext, color: Colors.white, @@ -1034,6 +1045,7 @@ class _NowPlayingScreenState extends ConsumerState { _lyricsIdleTimer?.cancel(); if (!_canAutoHideLyricsControls || _lyricsOptionsOpen || + _audioOutputOpen || _lyricsControlsHidden || _lyricsPointers.isNotEmpty) { return; diff --git a/lib/widgets/audio_output_button.dart b/lib/widgets/audio_output_button.dart new file mode 100644 index 00000000..340c05db --- /dev/null +++ b/lib/widgets/audio_output_button.dart @@ -0,0 +1,115 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; + +/// Uses the system's real audio routes and keeps native discovery in native UI. +class AudioOutputButton extends StatefulWidget { + const AudioOutputButton({super.key, this.color, this.onPickerChanged}); + + final Color? color; + final ValueChanged? onPickerChanged; + + @override + State createState() => _AudioOutputButtonState(); +} + +class _AudioOutputButtonState extends State { + static const _androidChannel = MethodChannel( + 'com.zarz.spotiflac/audio_output', + ); + MethodChannel? _viewChannel; + bool _opening = false; + + Map get _appearance => { + 'color': (widget.color ?? Theme.of(context).colorScheme.onSurface) + .toARGB32(), + 'label': context.l10n.nowPlayingAudioOutput, + }; + + @override + void didUpdateWidget(AudioOutputButton oldWidget) { + super.didUpdateWidget(oldWidget); + _updateAppearance(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateAppearance(); + } + + void _updateAppearance() { + final channel = _viewChannel; + if (channel != null) { + unawaited(channel.invokeMethod('update', _appearance)); + } + } + + void _attachView(int id) { + _viewChannel?.setMethodCallHandler(null); + _viewChannel = MethodChannel('com.zarz.spotiflac/audio_output/$id') + ..setMethodCallHandler((call) async { + if (mounted && call.method == 'pickerChanged') { + widget.onPickerChanged?.call(call.arguments == true); + } + }); + _updateAppearance(); + } + + Future _showAndroidPicker() async { + if (_opening) return; + _opening = true; + widget.onPickerChanged?.call(true); + var opened = false; + try { + opened = await _androidChannel.invokeMethod('show') ?? false; + } on PlatformException { + // Show a visible failure instead of leaving an unresponsive control. + } on MissingPluginException { + // The native bridge is not available on unsupported builds. + } finally { + _opening = false; + if (mounted) widget.onPickerChanged?.call(false); + } + if (mounted && !opened) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.nowPlayingAudioOutputUnavailable)), + ); + } + } + + @override + void dispose() { + _viewChannel?.setMethodCallHandler(null); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (kIsWeb) return const SizedBox.shrink(); + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + return SizedBox.square( + dimension: 48, + child: UiKitView( + viewType: 'com.zarz.spotiflac/audio_output', + creationParams: _appearance, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: _attachView, + ), + ); + case TargetPlatform.android: + return IconButton( + tooltip: context.l10n.nowPlayingAudioOutput, + color: widget.color, + icon: const Icon(Icons.speaker_group_outlined), + onPressed: _showAndroidPicker, + ); + default: + return const SizedBox.shrink(); + } + } +} diff --git a/lib/widgets/mornye_landscape_player.dart b/lib/widgets/mornye_landscape_player.dart index 1941000b..306b75cc 100644 --- a/lib/widgets/mornye_landscape_player.dart +++ b/lib/widgets/mornye_landscape_player.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/widgets/audio_output_button.dart'; /// Landscape opens on the player. Lyrics can hide their bottom actions until /// the user touches the player, while the cover and header stay in place. @@ -37,6 +38,7 @@ class MornyeLandscapePlayer extends StatefulWidget { class _MornyeLandscapePlayerState extends State { Timer? _hideTimer; bool _actionsVisible = true; + bool _audioOutputOpen = false; @override void didChangeDependencies() { @@ -62,6 +64,7 @@ class _MornyeLandscapePlayerState extends State { _hideTimer?.cancel(); if (widget.page != 1 || !_actionsVisible || + _audioOutputOpen || MediaQuery.accessibleNavigationOf(context)) { return; } @@ -151,7 +154,35 @@ class _MornyeLandscapePlayerState extends State { ), child: widget.page == 2 ? widget.queue - : widget.lyrics, + : Stack( + fit: StackFit.expand, + children: [ + widget.lyrics, + if (widget.lyricsOptions != null) + Positioned( + left: 20, + bottom: 52, + child: IgnorePointer( + ignoring: !_actionsVisible, + child: AnimatedOpacity( + opacity: _actionsVisible + ? 1 + : 0, + duration: + MediaQuery.disableAnimationsOf( + context, + ) + ? Duration.zero + : const Duration( + milliseconds: 180, + ), + child: + widget.lyricsOptions!, + ), + ), + ), + ], + ), ), ), Positioned( @@ -195,9 +226,15 @@ class _MornyeLandscapePlayerState extends State { widget.page == 1 ? 0 : 1, ), ), - if (widget.page == 1 && - widget.lyricsOptions != null) - widget.lyricsOptions!, + AudioOutputButton( + onPickerChanged: (open) { + if (!mounted) return; + setState( + () => _audioOutputOpen = open, + ); + _scheduleHide(); + }, + ), IconButton( tooltip: context.l10n.nowPlayingUpNext, diff --git a/test/audio_output_button_test.dart b/test/audio_output_button_test.dart new file mode 100644 index 00000000..49cc9429 --- /dev/null +++ b/test/audio_output_button_test.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/widgets/audio_output_button.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/audio_output'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + Widget app({ValueChanged? onChanged}) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: AudioOutputButton(onPickerChanged: onChanged)), + ); + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + messenger.setMockMethodCallHandler(SystemChannels.platform_views, null); + }); + + testWidgets( + 'iOS embeds the native audio route control and forwards its presentation', + (tester) async { + int? viewId; + final visibility = []; + final updates = []; + MethodChannel? viewChannel; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final args = call.arguments as Map; + expect(args['viewType'], 'com.zarz.spotiflac/audio_output'); + viewId = args['id'] as int; + viewChannel = MethodChannel( + 'com.zarz.spotiflac/audio_output/$viewId', + ); + messenger.setMockMethodCallHandler( + viewChannel!, + (call) async => updates.add(call), + ); + } + return null; + }); + await tester.pumpWidget(app(onChanged: visibility.add)); + await tester.pumpAndSettle(); + expect(viewId, isNotNull); + expect(updates.last.arguments, containsPair('label', 'Audio Output')); + for (final open in [true, false]) { + await messenger.handlePlatformMessage( + viewChannel!.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall('pickerChanged', open), + ), + (_) {}, + ); + } + expect(visibility, [true, false]); + await tester.pumpWidget(const SizedBox()); + messenger.setMockMethodCallHandler(viewChannel!, null); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets('opens native output picker once and reports failure visibly', ( + tester, + ) async { + final completion = Completer(); + var requests = 0; + final visibility = []; + messenger.setMockMethodCallHandler(channel, (call) { + expect(call.method, 'show'); + requests++; + return completion.future; + }); + await tester.pumpWidget(app(onChanged: visibility.add)); + await tester.tap(find.byTooltip('Audio Output')); + await tester.tap(find.byTooltip('Audio Output')); + expect(requests, 1); + expect(visibility, [true]); + completion.complete(false); + await tester.pumpAndSettle(); + expect(visibility, [true, false]); + expect(find.text('Unable to open audio output settings.'), findsOneWidget); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets('successful native request has no error and disposal is safe', ( + tester, + ) async { + messenger.setMockMethodCallHandler(channel, (_) async => true); + await tester.pumpWidget(app()); + await tester.tap(find.byTooltip('Audio Output')); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + + final completion = Completer(); + messenger.setMockMethodCallHandler(channel, (_) => completion.future); + await tester.tap(find.byTooltip('Audio Output')); + await tester.pumpWidget(const SizedBox()); + completion.complete(false); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets('unsupported desktop does not expose an inert control', ( + tester, + ) async { + await tester.pumpWidget(app()); + expect(find.byType(IconButton), findsNothing); + expect(find.byType(UiKitView), findsNothing); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); +}