nkbmusic/lib/audio/output_device.dart
metatroncubeswdev ad8c4f1c6b App: speaker/Bluetooth picker, Settings & help, Privacy Policy, Terms, FAQ, song reports
- 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>
2026-10-30 12:00:16 -04:00

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).'),
));
}
}