import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; import '../services/acr_cloud.dart'; import '../shared/theme.dart'; /// Wie lange zugehört wird. Kürzer erkennt ACRCloud oft nicht mehr sicher. const _aufnahmeSekunden = 10; /// Musikerkennung: nimmt kurz über das Mikrofon auf und fragt ACRCloud, /// welches Stück gerade läuft. class MusicRecognitionSheet extends StatefulWidget { const MusicRecognitionSheet({super.key}); static Future show(BuildContext context) { return showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: MeloTheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (_) => const MusicRecognitionSheet(), ); } @override State createState() => _MusicRecognitionSheetState(); } /// Was das Sheet gerade anzeigt. enum _Phase { start, aufnahme, suche, ergebnis, fehler } class _MusicRecognitionSheetState extends State { final _zugang = AcrZugang(); AudioRecorder? _rekorder; _Phase _phase = _Phase.start; int _restSekunden = _aufnahmeSekunden; AcrTreffer? _treffer; String? _fehler; @override void initState() { super.initState(); _starte(); } @override void dispose() { _rekorder?.dispose(); super.dispose(); } /// Zugangsdaten holen (notfalls erfragen) und dann zuhören. Future _starte() async { await _zugang.laden(); if (!mounted) return; if (!_zugang.istKonfiguriert) { final gespeichert = await _frageZugang(); if (!mounted) return; if (!gespeichert) { Navigator.of(context).pop(); return; } } await _hoereZu(); } /// Dialog für die beiden ACRCloud-Schlüssel. `true`, wenn gespeichert wurde. Future _frageZugang() async { final accessCtrl = TextEditingController(); final secretCtrl = TextEditingController(); final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: MeloTheme.surface, title: const Text('ACRCloud-Zugang', style: TextStyle(color: Colors.white, fontSize: 16)), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('ACRCloud-Zugang (steht in deinen Notizen)', style: TextStyle(color: MeloTheme.text2, fontSize: 12)), const SizedBox(height: 12), TextField( controller: accessCtrl, style: const TextStyle(color: Colors.white), decoration: const InputDecoration( labelText: 'Access Key', labelStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: secretCtrl, style: const TextStyle(color: Colors.white), obscureText: true, decoration: const InputDecoration( labelText: 'Secret Key', labelStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder(), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Abbrechen'), ), FilledButton( style: FilledButton.styleFrom(backgroundColor: MeloTheme.red), onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Speichern'), ), ], ), ); final access = accessCtrl.text.trim(); final secret = secretCtrl.text.trim(); accessCtrl.dispose(); secretCtrl.dispose(); if (ok != true || access.isEmpty || secret.isEmpty) return false; await _zugang.speichern(access, secret); return true; } /// Nimmt [_aufnahmeSekunden] Sekunden auf und schickt sie zur Erkennung. Future _hoereZu() async { setState(() { _phase = _Phase.aufnahme; _restSekunden = _aufnahmeSekunden; _treffer = null; _fehler = null; }); final rekorder = _rekorder = AudioRecorder(); try { if (!await rekorder.hasPermission()) { _scheitere('Ohne Mikrofon-Erlaubnis kann ich nicht zuhören'); return; } final ordner = await getTemporaryDirectory(); final pfad = '${ordner.path}/melo_erkennung.wav'; await rekorder.start( const RecordConfig( encoder: AudioEncoder.wav, sampleRate: 8000, numChannels: 1, ), path: pfad, ); for (var rest = _aufnahmeSekunden; rest > 0; rest--) { await Future.delayed(const Duration(seconds: 1)); if (!mounted) return; setState(() => _restSekunden = rest - 1); } await rekorder.stop(); await rekorder.dispose(); _rekorder = null; if (!mounted) return; setState(() => _phase = _Phase.suche); final aufnahme = await File(pfad).readAsBytes(); final dienst = AcrCloudService( accessKey: _zugang.accessKey!, secretKey: _zugang.secretKey!, ); final treffer = await dienst.erkenne(aufnahme); if (!mounted) return; setState(() { _treffer = treffer; _phase = _Phase.ergebnis; }); } on AcrCloudException catch (e) { _scheitere(e.nachricht); } catch (e) { debugPrint('Musikerkennung fehlgeschlagen: $e'); _scheitere('Die Aufnahme hat nicht geklappt'); } } void _scheitere(String text) { if (!mounted) return; setState(() { _fehler = text; _phase = _Phase.fehler; }); } @override Widget build(BuildContext context) { return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('Musik erkennen', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), const SizedBox(height: 24), _inhalt(), ], ), ), ); } Widget _inhalt() => switch (_phase) { _Phase.start => const CircularProgressIndicator(color: MeloTheme.red), _Phase.aufnahme => Column( children: [ const Icon(Icons.mic, color: MeloTheme.red, size: 64), const SizedBox(height: 16), const Text('Ich höre zu …', style: TextStyle(color: MeloTheme.text2)), const SizedBox(height: 8), Text('$_restSekunden', style: const TextStyle( fontSize: 32, fontWeight: FontWeight.w700, color: MeloTheme.red)), ], ), _Phase.suche => const Column( children: [ CircularProgressIndicator(color: MeloTheme.red), SizedBox(height: 16), Text('Ich suche den Titel …', style: TextStyle(color: MeloTheme.text2)), ], ), _Phase.ergebnis => _treffer == null ? _meldung(Icons.search_off, 'Nicht erkannt — probier es nochmal näher an der Musik') : Column( children: [ const Icon(Icons.music_note, color: MeloTheme.red, size: 48), const SizedBox(height: 16), Text(_treffer!.titel, textAlign: TextAlign.center, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w700)), const SizedBox(height: 6), Text(_treffer!.kuenstler, textAlign: TextAlign.center, style: const TextStyle( fontSize: 16, color: MeloTheme.text2)), if (_treffer!.album.isNotEmpty) ...[ const SizedBox(height: 4), Text(_treffer!.album, textAlign: TextAlign.center, style: const TextStyle( fontSize: 13, color: MeloTheme.text3)), ], const SizedBox(height: 20), _nochmalKnopf(), ], ), _Phase.fehler => _meldung(Icons.error_outline, _fehler ?? 'Etwas ist schiefgelaufen'), }; Widget _meldung(IconData icon, String text) => Column( children: [ Icon(icon, color: MeloTheme.red, size: 48), const SizedBox(height: 16), Text(text, textAlign: TextAlign.center, style: const TextStyle(color: MeloTheme.text2)), const SizedBox(height: 20), _nochmalKnopf(), ], ); Widget _nochmalKnopf() => FilledButton.icon( style: FilledButton.styleFrom(backgroundColor: MeloTheme.red), onPressed: _hoereZu, icon: const Icon(Icons.refresh), label: const Text('Nochmal'), ); }