- Now Playing shows where music plays and opens the phone's output switcher - Settings hub: Wi-Fi-only downloads (default on), storage, sound, help, legal - Privacy Policy and Terms bundled as Markdown (assets/legal), shown in-app - 'Is this your song?' report form feeding takedown_requests (+ kind, user_id) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
83 lines
3.4 KiB
Dart
83 lines
3.4 KiB
Dart
import 'package:audio_session/audio_session.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../app/theme.dart';
|
|
|
|
/// Where music plays right now, as the phone reports it.
|
|
class OutputDevice {
|
|
const OutputDevice(this.label, this.icon);
|
|
|
|
final String label;
|
|
final IconData icon;
|
|
|
|
static const speaker = OutputDevice('Phone speaker', Icons.speaker_phone_rounded);
|
|
|
|
/// Android sends media to the most recently connected external device, so
|
|
/// pick Bluetooth, then wired/USB/car, then the built-in speaker.
|
|
static OutputDevice from(Set<AudioDevice> devices) {
|
|
final outputs = devices.where((d) => d.isOutput).toList();
|
|
int rank(AudioDevice d) {
|
|
final t = d.type.name.toLowerCase();
|
|
if (t.contains('bluetooth') && t != 'bluetoothsco') return 0;
|
|
if (t.contains('car') || t.contains('wired') || t.contains('usb') || t.contains('line')) return 1;
|
|
if (t.contains('hdmi') || t.contains('airplay') || t.contains('dock')) return 2;
|
|
if (t == 'builtinspeaker') return 3;
|
|
return 9;
|
|
}
|
|
|
|
outputs.sort((a, b) => rank(a).compareTo(rank(b)));
|
|
if (outputs.isEmpty || rank(outputs.first) >= 3) return speaker;
|
|
final d = outputs.first;
|
|
final t = d.type.name.toLowerCase();
|
|
final name = d.name.trim().isEmpty ? null : d.name.trim();
|
|
if (t.contains('bluetooth')) return OutputDevice(name ?? 'Bluetooth', Icons.bluetooth_audio_rounded);
|
|
if (t.contains('car')) return OutputDevice(name ?? 'Car audio', Icons.directions_car_rounded);
|
|
if (t.contains('wired') || t.contains('line')) return const OutputDevice('Headphones', Icons.headphones_rounded);
|
|
if (t.contains('usb')) return OutputDevice(name ?? 'USB audio', Icons.usb_rounded);
|
|
return OutputDevice(name ?? 'External speaker', Icons.speaker_rounded);
|
|
}
|
|
}
|
|
|
|
final outputDeviceProvider = StreamProvider<OutputDevice>((ref) async* {
|
|
final session = await AudioSession.instance;
|
|
yield OutputDevice.from(await session.getDevices(includeInputs: false));
|
|
yield* session.devicesStream.map(OutputDevice.from);
|
|
});
|
|
|
|
const _channel = MethodChannel('nk/output');
|
|
|
|
/// Shows where music is playing; tap to choose another speaker or Bluetooth device.
|
|
class OutputDeviceButton extends ConsumerWidget {
|
|
const OutputDeviceButton({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final device = ref.watch(outputDeviceProvider).value ?? OutputDevice.speaker;
|
|
final external = device != OutputDevice.speaker;
|
|
final color = external ? NkColors.teaLeaf : NkColors.mutedOnPhoto;
|
|
return TextButton.icon(
|
|
onPressed: () => _choose(context),
|
|
style: TextButton.styleFrom(foregroundColor: color, visualDensity: VisualDensity.compact),
|
|
icon: Icon(device.icon, size: 20),
|
|
label: Text(device.label, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
);
|
|
}
|
|
|
|
Future<void> _choose(BuildContext context) async {
|
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
|
try {
|
|
if (await _channel.invokeMethod<bool>('showOutputSwitcher') == true) return;
|
|
} on PlatformException {
|
|
// Fall through to the hint below.
|
|
}
|
|
}
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
|
content: Text('Choose a speaker or Bluetooth device from Control Center (swipe down from the top right).'),
|
|
));
|
|
}
|
|
}
|