feat(player): add native audio output controls

This commit is contained in:
zarzet
2026-09-25 11:27:49 +07:00
parent 6b72aed3f6
commit 1d6ff18d06
10 changed files with 392 additions and 4 deletions
+115
View File
@@ -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<bool>? onPickerChanged;
@override
State<AudioOutputButton> createState() => _AudioOutputButtonState();
}
class _AudioOutputButtonState extends State<AudioOutputButton> {
static const _androidChannel = MethodChannel(
'com.zarz.spotiflac/audio_output',
);
MethodChannel? _viewChannel;
bool _opening = false;
Map<String, Object> 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<void>('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<void> _showAndroidPicker() async {
if (_opening) return;
_opening = true;
widget.onPickerChanged?.call(true);
var opened = false;
try {
opened = await _androidChannel.invokeMethod<bool>('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();
}
}
}
+41 -4
View File
@@ -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<MornyeLandscapePlayer> {
Timer? _hideTimer;
bool _actionsVisible = true;
bool _audioOutputOpen = false;
@override
void didChangeDependencies() {
@@ -62,6 +64,7 @@ class _MornyeLandscapePlayerState extends State<MornyeLandscapePlayer> {
_hideTimer?.cancel();
if (widget.page != 1 ||
!_actionsVisible ||
_audioOutputOpen ||
MediaQuery.accessibleNavigationOf(context)) {
return;
}
@@ -151,7 +154,35 @@ class _MornyeLandscapePlayerState extends State<MornyeLandscapePlayer> {
),
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<MornyeLandscapePlayer> {
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,