sautiflow 0.3.0 copy "sautiflow: ^0.3.0" to clipboard
sautiflow: ^0.3.0 copied to clipboard

High-fidelity, cross-platform Dart/Flutter audio engine for audiophiles, powered by miniaudio and native C++ FFI.

example/lib/main.dart

import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui';

import 'package:file_picker/file_picker.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:sautiflow/sautiflow.dart';

import 'isolate_player.dart';

void main() {
  FlutterError.onError = (FlutterErrorDetails details) {
    FlutterError.presentError(details);
    debugPrint('[flutter-error] ${details.exceptionAsString()}');
    if (details.stack != null) {
      debugPrint('${details.stack}');
    }
  };

  PlatformDispatcher.instance.onError = (error, stack) {
    debugPrint('[platform-error] $error');
    debugPrint('$stack');
    return false;
  };

  runApp(const DemoApp());
}

class DemoApp extends StatelessWidget {
  const DemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'MiniAudio Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const PlayerShell(),
    );
  }
}

enum EqScreenMode {
  multibandEq('Enable Multiband EQ'),
  mixEq('Enable Mix EQ');

  const EqScreenMode(this.label);
  final String label;
}

class PlayerShell extends StatefulWidget {
  const PlayerShell({super.key});

  @override
  State<PlayerShell> createState() => _PlayerShellState();
}

class _PlayerShellState extends State<PlayerShell> {
  final IsolateAudioPlayer _player = IsolateAudioPlayer();
  final TextEditingController _singleUrlController = TextEditingController();
  final TextEditingController _multiUrlController = TextEditingController();
  final ValueNotifier<PlayerStatus> _status = ValueNotifier(
    const PlayerStatus(
      positionSeconds: 0,
      durationSeconds: 0,
      isPlaying: false,
      currentIndex: -1,
      playlistCount: 0,
      shuffleEnabled: false,
      loopMode: LoopMode.off,
    ),
  );

  final List<AudioSource> _playlist = <AudioSource>[];
  final List<String> _logs = <String>[];
  int _tabIndex = 0;
  int _lazyLoadSession = 0;
  bool _nativeNetworkStreamingSupported = false;
  bool _allowInvalidTlsForDownloads = false;
  final bool _systemMediaControlsEnabled = false;
  int _lastPublishedNowPlayingIndex = -1;

  bool _eqEnabled = false;
  bool _reverbEnabled = false;
  bool _lowpassEnabled = false;
  bool _highpassEnabled = false;
  bool _bandpassEnabled = false;
  bool _peakEqEnabled = false;
  bool _notchEnabled = false;
  bool _lowshelfEnabled = false;
  bool _highshelfEnabled = false;
  bool _delayEnabled = false;

  // Advanced Audio State
  EqScreenMode _eqMode = EqScreenMode.multibandEq;
  bool _multibandEqEnabled = false;
  bool _mixEqEnabled = false;
  final List<double> _eqBandGains = List.filled(10, 0.0);
  final List<double> _eqFrequencies = const [
    31.25,
    62.5,
    125,
    250,
    500,
    1000,
    2000,
    4000,
    8000,
    16000
  ];
  late final List<EqBandConfig> _mixedEqBands = _buildMixedEqBands();
  AudioFormat _outputFormat = AudioFormat.f32;
  int _outputSampleRate = 0; // 0=Native
  int _outputChannels = 2; // Stereo
  bool _crossfadeEnabled = false;
  int _crossfadeDurationMs = 250;

  double _low = 1.0;
  double _mid = 1.0;
  double _high = 1.0;
  double _gain = 1.0;
  double _pan = 0.0;
  double _lpCutoff = 12000;
  double _hpCutoff = 80;
  double _bpCutoff = 1000;
  double _bpQ = 0.707;
  double _peakFreq = 1000;
  double _peakQ = 1.0;
  double _peakGainDb = 0.0;
  double _notchFreq = 1000;
  double _notchQ = 1.0;
  double _lowshelfFreq = 200;
  double _lowshelfSlope = 1.0;
  double _lowshelfGainDb = 0.0;
  double _highshelfFreq = 4000;
  double _highshelfSlope = 1.0;
  double _highshelfGainDb = 0.0;
  double _rvMix = 0.15;
  double _rvFeedback = 0.65;
  double _rvDelay = 95;
  double _dlMix = 0.2;
  double _dlFeedback = 0.35;
  double _dlDelay = 240;

  bool _analyzerEnabled = false;
  int _analyzerFrameSize = 512;
  StreamSubscription<Float32List>? _analyzerSub;
  List<double> _analyzerValues = List<double>.filled(96, 0.0);

  @override
  void initState() {
    super.initState();
    // Initialize the engine with defaults
    _player.init(enableSystemAudio: true);

    // Initialize the multiband EQ (safe to call even if unnecessary, but good practice)
    _player.initMultibandEq(_eqFrequencies);
    _nativeNetworkStreamingSupported = _player.isNetworkStreamingSupported();
    _logs.insert(
      0,
      _nativeNetworkStreamingSupported
          ? '[init] Native URL byte-streaming: enabled'
          : '[init] Native URL byte-streaming: disabled (download fallback for URLs)',
    );
    final initErr = _player.getLastError();
    if (initErr.isNotEmpty) {
      _logs.insert(0, '[init] $initErr');
    }
    _player.statusStream.listen((s) {
      _status.value = s;
      _publishNowPlayingFromStatus(s);
      // Removed setState to prevent full rebuilds on every status update
    });
    _player.logStream.listen((line) {
      _logs.insert(0, '[${DateTime.now().toIso8601String()}] $line');
      if (_logs.length > 200) {
        _logs.removeRange(200, _logs.length);
      }
      if (mounted && _tabIndex == 2) {
        setState(() {}); // Only rebuild if Logs tab is active
      }
    });

    _player.configureAnalyzer(frameSize: _analyzerFrameSize);
    _analyzerSub = _player.analyzerStream.listen((frame) {
      if (frame.isEmpty) return;
      const targetBins = 96;
      final bins = List<double>.filled(targetBins, 0.0);
      final srcLen = frame.length;
      for (var i = 0; i < targetBins; i++) {
        final from = (i * srcLen / targetBins).floor();
        final to = ((i + 1) * srcLen / targetBins).ceil();
        var sum = 0.0;
        var count = 0;
        for (var j = from; j < to && j < srcLen; j++) {
          sum += frame[j].abs();
          count++;
        }
        bins[i] = count > 0 ? sum / count : 0.0;
      }
      if (!mounted) return;
      setState(() {
        _analyzerValues = bins;
      });
    });

    if (Platform.isAndroid) {
      unawaited(_ensureNotificationPermission());
    }
  }

  List<EqBandConfig> _buildMixedEqBands() {
    const types = <EqBandType>[
      EqBandType.lowshelf,
      EqBandType.peak,
      EqBandType.notch,
      EqBandType.bandpass,
      EqBandType.peak,
      EqBandType.peak,
      EqBandType.notch,
      EqBandType.bandpass,
      EqBandType.peak,
      EqBandType.highshelf,
    ];
    return List<EqBandConfig>.generate(_eqFrequencies.length, (i) {
      return EqBandConfig(
        type: types[i],
        frequencyHz: _eqFrequencies[i],
        gainDb: _eqBandGains[i],
        q: 1.0,
        slope: 1.0,
        enabled: true,
      );
    });
  }

  void _applyMixEq() {
    _player.setMultibandEqEnabled(false);
    _player.initMultibandFx(_mixedEqBands, enabled: _mixEqEnabled);
  }

  void _updateMixEqBand(int index,
      {EqBandType? type, double? q, double? gain}) {
    final current = _mixedEqBands[index];
    _mixedEqBands[index] = EqBandConfig(
      type: type ?? current.type,
      frequencyHz: current.frequencyHz,
      q: q ?? current.q,
      gainDb: gain ?? current.gainDb,
      slope: current.slope,
      enabled: current.enabled,
    );
    _applyMixEq();
  }

  @override
  void dispose() {
    _singleUrlController.dispose();
    _multiUrlController.dispose();
    _analyzerSub?.cancel();
    _status.dispose();
    _player.dispose();
    super.dispose();
  }

  Future<void> _ensureNotificationPermission() async {
    final notif = await Permission.notification.status;
    if (notif.isGranted) return;

    final req = await Permission.notification.request();
    if (!req.isGranted) {
      _logs.insert(
        0,
        '[permission] Notification permission denied. Media notification may not appear.',
      );
      if (req.isPermanentlyDenied) {
        _logs.insert(
          0,
          '[permission] Notification permission permanently denied. Open app settings.',
        );
      }
    }
  }

  void _publishNowPlayingFromStatus(PlayerStatus status) {
    final idx = status.currentIndex;
    if (idx < 0 || idx >= _playlist.length) return;
    if (idx == _lastPublishedNowPlayingIndex) return;

    final source = _playlist[idx];
    final title = _nameFromSource(source);
    final subtitle = _subtitleFromSource(source);
    _lastPublishedNowPlayingIndex = idx;

    unawaited(
      _player.updateNowPlaying(
        id: 'track_$idx',
        title: title,
        artist: subtitle,
        duration:
            Duration(milliseconds: (status.durationSeconds * 1000).round()),
      ),
    );
  }

  Uri? _parseInputToUri(String raw) {
    final text = raw
        .trim()
        .replaceAll('"', '')
        .replaceAll("'", '')
        .replaceAll('<', '')
        .replaceAll('>', '');
    if (text.isEmpty) return null;

    final isWindowsDrivePath = RegExp(r'^[a-zA-Z]:[\\/]').hasMatch(text);
    final isUncPath = text.startsWith('\\\\');
    if (isWindowsDrivePath || isUncPath) {
      return File(text).absolute.uri;
    }

    final uri = Uri.tryParse(text);
    if (uri == null) return null;

    if (uri.scheme == 'http' || uri.scheme == 'https') {
      if (!uri.hasAuthority || uri.host.trim().isEmpty) return null;
      final host = uri.host.toLowerCase().trim();
      if (host == 'link' || host == 'your-link' || host == 'placeholder') {
        return null;
      }
      return uri;
    }

    if (uri.scheme == 'file') {
      return uri;
    }

    if (!uri.hasScheme) {
      return File(text).absolute.uri;
    }

    return null;
  }

  List<Uri> _parseInputUris(String rawList) {
    final seen = <String>{};
    final out = <Uri>[];
    final parts = rawList
        .split(RegExp(r'[\r\n,;]+'))
        .map((s) => s.trim())
        .where((s) => s.isNotEmpty);
    for (final part in parts) {
      final uri = _parseInputToUri(part);
      if (uri == null) continue;
      final key = uri.toString();
      if (seen.add(key)) out.add(uri);
    }
    return out;
  }

  String? _safeFilePathFromUri(Uri uri) {
    if (uri.scheme != 'file') return null;
    try {
      return uri.toFilePath();
    } catch (_) {
      return null;
    }
  }

  Future<AudioSource?> _sourceFromPickedFile(PlatformFile f) async {
    if (f.path != null && f.path!.isNotEmpty) {
      final file = File(f.path!);
      if (file.existsSync()) {
        return AudioSource.uri(file.uri);
      }
    }

    final cacheDir = Directory(
      '${Directory.systemTemp.path}${Platform.pathSeparator}miniaudiodart_pick_cache',
    );
    if (!cacheDir.existsSync()) {
      cacheDir.createSync(recursive: true);
    }

    final ext = (() {
      final fromExt = f.extension?.trim();
      if (fromExt != null && fromExt.isNotEmpty) {
        return '.${fromExt.toLowerCase()}';
      }
      final fromName = f.name;
      final dot = fromName.lastIndexOf('.');
      if (dot > 0 && dot < fromName.length - 1) {
        return '.${fromName.substring(dot + 1).toLowerCase()}';
      }
      return '.bin';
    })();

    final out = File(
      '${cacheDir.path}${Platform.pathSeparator}pick_${DateTime.now().microsecondsSinceEpoch}_${f.name.hashCode}$ext',
    );

    if (f.readStream != null) {
      final sink = out.openWrite();
      try {
        await sink.addStream(f.readStream!);
        await sink.flush();
      } finally {
        await sink.close();
      }
      if (out.existsSync() && out.lengthSync() > 0) {
        return AudioSource.uri(out.uri);
      }
    }

    if (f.bytes != null && f.bytes!.isNotEmpty) {
      await out.writeAsBytes(f.bytes!, flush: true);
      if (out.existsSync() && out.lengthSync() > 0) {
        return AudioSource.uri(out.uri);
      }
    }

    _logs.insert(
      0,
      '[pick] Could not materialize: ${f.name} (no usable path/stream/bytes)',
    );
    return null;
  }

  Future<AudioSource?> _materializeSource(Uri uri) async {
    if (uri.scheme == 'file') {
      final filePath = _safeFilePathFromUri(uri);
      if (filePath == null || filePath.isEmpty) {
        _logs.insert(0, '[source] Invalid file URI: $uri');
        return null;
      }
      final file = File(filePath);
      if (!file.existsSync()) {
        _logs.insert(0, '[source] Local file not found: ${file.path}');
        return null;
      }
      return AudioSource.uri(uri);
    }

    if (uri.scheme != 'http' && uri.scheme != 'https') {
      _logs.insert(0, '[source] Unsupported URI scheme: ${uri.scheme}');
      return null;
    }

    final supportsNativeNetwork = _player.isNetworkStreamingSupported();
    if (supportsNativeNetwork) {
      _logs.insert(0, '[source] Using native network source in playlist: $uri');
      return AudioSource.network(uri.toString());
    }

    _logs.insert(
        0, '[source] Downloading URL for playlist compatibility: $uri');

    final cacheDir = Directory(
      '${Directory.systemTemp.path}${Platform.pathSeparator}miniaudiodart_stream_cache',
    );
    if (!cacheDir.existsSync()) {
      cacheDir.createSync(recursive: true);
    }

    final ext = () {
      final path = uri.path.toLowerCase();
      if (path.endsWith('.mp3')) return '.mp3';
      if (path.endsWith('.aac')) return '.aac';
      if (path.endsWith('.m4a')) return '.m4a';
      if (path.endsWith('.wav')) return '.wav';
      if (path.endsWith('.ogg')) return '.ogg';
      if (path.endsWith('.flac')) return '.flac';
      return '.mp3';
    }();

    final file = File(
      '${cacheDir.path}${Platform.pathSeparator}stream_${DateTime.now().microsecondsSinceEpoch}$ext',
    );

    final client = HttpClient();
    if (_allowInvalidTlsForDownloads && uri.scheme == 'https') {
      client.badCertificateCallback = (_, __, ___) => true;
    }
    try {
      final req = await client.getUrl(uri);
      req.headers.set('User-Agent', 'MiniAudioDart/1.0 (Flutter)');
      req.headers.set('Accept', '*/*');

      final res = await req.close();
      if (res.statusCode < 200 || res.statusCode >= 300) {
        _logs.insert(0, '[source] HTTP ${res.statusCode}: $uri');
        return null;
      }

      final sink = file.openWrite();
      await sink.addStream(res);
      await sink.flush();
      await sink.close();

      return AudioSource.uri(file.uri);
    } on HandshakeException catch (e) {
      final message = e.toString();
      if (message.contains('CERTIFICATE_VERIFY_FAILED') &&
          message.contains('not yet valid')) {
        _logs.insert(
          0,
          '[source] TLS certificate date check failed. Verify device/system clock and timezone, then retry.',
        );
        if (!_allowInvalidTlsForDownloads) {
          _logs.insert(
            0,
            '[source] Tip: enable "Allow invalid TLS certs" in Logs tab for testing only.',
          );
        }
      }
      _logs.insert(0, '[source] Download failed: $e');
      return null;
    } catch (e) {
      _logs.insert(0, '[source] Download failed: $e');
      return null;
    } finally {
      client.close(force: true);
    }
  }

  Future<void> _playSingleUrl() async {
    final uri = _parseInputToUri(_singleUrlController.text);
    if (uri == null) {
      _logs.insert(
        0,
        '[source] Invalid input. Use a local path, file:// URI, or http(s) URL.',
      );
      setState(() {});
      return;
    }

    if (uri.scheme == 'http' || uri.scheme == 'https') {
      _logs.insert(
        0,
        '[source] URL input detected. Downloading to local cache before playback: $uri',
      );
    }

    final src = await _materializeSource(uri);
    if (src == null) {
      setState(() {});
      return;
    }

    setState(() {
      _playlist
        ..clear()
        ..add(src);
    });

    _player.setAudioSources(
      _playlist,
      initialIndex: 0,
      initialPosition: Duration.zero,
      useLazyPreparation: true,
    );
    _player.play();

    final msg = _player.getLastError();
    if (msg.isNotEmpty) {
      _logs.insert(0, '[url-play] $msg');
      setState(() {});
    }
  }

  Future<void> _setMultiUrlPlaylist() async {
    final uris = _parseInputUris(_multiUrlController.text);
    if (uris.isEmpty) {
      _logs.insert(0, '[sources] No valid entries found. Use one per line.');
      setState(() {});
      return;
    }

    final resolved = await Future.wait(uris.map(_materializeSource));
    final sources = <AudioSource>[];
    for (final resolvedSrc in resolved) {
      if (resolvedSrc == null) continue;
      sources.add(resolvedSrc);
    }

    if (sources.isEmpty) {
      _logs.insert(0, '[sources] No valid entries found after resolution.');
      setState(() {});
      return;
    }

    setState(() {
      _playlist
        ..clear()
        ..addAll(sources);
    });

    _player.setAudioSources(
      _playlist,
      initialIndex: 0,
      initialPosition: Duration.zero,
      useLazyPreparation: true,
    );

    final msg = _player.getLastError();
    if (msg.isNotEmpty) {
      _logs.insert(0, '[sources-set] $msg');
      setState(() {});
    }
  }

  Future<void> _addMultiUrls() async {
    final uris = _parseInputUris(_multiUrlController.text);
    if (uris.isEmpty) {
      _logs.insert(0, '[sources] No valid entries found to add.');
      setState(() {});
      return;
    }

    final resolved = await Future.wait(uris.map(_materializeSource));
    for (final src in resolved.whereType<AudioSource>()) {
      _playlist.add(src);
      _player.addAudioSource(src);
      final msg = _player.getLastError();
      if (msg.isNotEmpty) {
        _logs.insert(0, '[sources-add] $msg');
      }
    }
    setState(() {});
  }

  Future<void> _appendPickedFilesLazily(
    List<PlatformFile> files, {
    required int sessionTag,
    int? skipIndex,
    String logPrefix = '[lazy]',
  }) async {
    var added = 0;
    for (var i = 0; i < files.length; i++) {
      if (skipIndex != null && i == skipIndex) continue;
      if (!mounted || sessionTag != _lazyLoadSession) return;

      final src = await _sourceFromPickedFile(files[i]);
      if (src == null) continue;
      if (!mounted || sessionTag != _lazyLoadSession) return;

      _playlist.add(src);
      _player.addAudioSource(src);
      added++;

      final msg = _player.getLastError();
      if (msg.isNotEmpty) {
        _logs.insert(0, '$logPrefix $msg');
      }

      if (added % 5 == 0) {
        setState(() {});
      }
    }

    if (!mounted || sessionTag != _lazyLoadSession) return;
    _logs.insert(0, '$logPrefix Added $added tracks in background');
    setState(() {});
  }

  Future<void> _pickSongsAndSetPlaylist() async {
    final hasPermission = await _ensureMediaPermission();
    if (!hasPermission) {
      _logs.insert(0, '[permission] Media permission denied');
      if (mounted) setState(() {});
      return;
    }

    final result = await FilePicker.platform.pickFiles(
      allowMultiple: true,
      type: FileType.custom,
      withReadStream: true,
      allowedExtensions: const ['mp3', 'ogg', 'wav', 'flac', 'm4a', 'aac'],
    );
    if (result == null || result.files.isEmpty) return;

    final sessionTag = ++_lazyLoadSession;
    AudioSource? firstSource;
    var firstIndex = -1;
    for (var i = 0; i < result.files.length; i++) {
      final src = await _sourceFromPickedFile(result.files[i]);
      if (src != null) {
        firstSource = src;
        firstIndex = i;
        break;
      }
    }

    if (firstSource == null) {
      _logs.insert(0, '[playlist] No playable picked files.');
      if (mounted) setState(() {});
      return;
    }

    setState(() {
      _playlist
        ..clear()
        ..add(firstSource!);
    });

    _player.setAudioSources(
      _playlist,
      initialIndex: 0,
      initialPosition: Duration.zero,
      useLazyPreparation: true,
    );

    final msg = _player.getLastError();
    if (msg.isNotEmpty) {
      _logs.insert(0, '[playlist] $msg');
      setState(() {});
    }

    _logs.insert(
      0,
      '[lazy] Playlist ready instantly with 1 track. Loading remaining tracks in background...',
    );
    setState(() {});

    unawaited(
      _appendPickedFilesLazily(
        result.files,
        sessionTag: sessionTag,
        skipIndex: firstIndex,
        logPrefix: '[playlist-lazy]',
      ),
    );
  }

  Future<void> _pickFolderAndSetPlaylist() async {
    final hasPermission = await _ensureMediaPermission();
    if (!hasPermission) {
      _logs.insert(0, '[permission] Media permission denied');
      if (mounted) setState(() {});
      return;
    }

    final String? selectedDirectory =
        await FilePicker.platform.getDirectoryPath();
    if (selectedDirectory == null) return;

    final dir = Directory(selectedDirectory);
    if (!dir.existsSync()) return;

    final List<File> audioFiles = [];
    final extensions = ['.mp3', '.ogg', '.wav', '.flac', '.m4a', '.aac'];

    try {
      await for (final entity in dir.list(recursive: true)) {
        if (entity is File) {
          final lowerPath = entity.path.toLowerCase();
          if (extensions.any((ext) => lowerPath.endsWith(ext))) {
            audioFiles.add(entity);
          }
        }
      }
    } catch (e) {
      _logs.insert(0, '[folder] Error listing files: $e');
    }

    if (audioFiles.isEmpty) {
      _logs.insert(0, '[playlist] No playable files found in folder.');
      if (mounted) setState(() {});
      return;
    }

    // Convert to PlatformFiles to reuse _appendPickedFilesLazily logic or handle directly
    // Since we have Files, we can create AudioSources directly.

    final sources = audioFiles.map((f) => AudioSource.uri(f.uri)).toList();

    setState(() {
      _playlist
        ..clear()
        ..addAll(sources);
    });

    _player.setAudioSources(
      _playlist,
      initialIndex: 0,
      initialPosition: Duration.zero,
      useLazyPreparation: true,
    );
    _logs.insert(
      0,
      '[folder] Playlist ready with ${sources.length} tracks from ${dir.path}',
    );

    final msg = _player.getLastError();
    if (msg.isNotEmpty) {
      _logs.insert(0, '[playlist] $msg');
      setState(() {});
    }
  }

  Future<void> _addSongs() async {
    final hasPermission = await _ensureMediaPermission();
    if (!hasPermission) {
      _logs.insert(0, '[permission] Media permission denied');
      if (mounted) setState(() {});
      return;
    }

    final result = await FilePicker.platform.pickFiles(
      allowMultiple: true,
      type: FileType.custom,
      withReadStream: true,
      allowedExtensions: const ['mp3', 'ogg', 'wav', 'flac', 'm4a', 'aac'],
    );
    if (result == null || result.files.isEmpty) return;

    final sessionTag = ++_lazyLoadSession;
    _logs.insert(
      0,
      '[add-lazy] Adding ${result.files.length} picked songs in background...',
    );
    setState(() {});

    unawaited(
      _appendPickedFilesLazily(
        result.files,
        sessionTag: sessionTag,
        logPrefix: '[add-lazy]',
      ),
    );
  }

  String _nameFromSource(AudioSource source) {
    final uri = source.uri;
    if (uri.scheme == 'file') {
      final p = _safeFilePathFromUri(uri);
      if (p == null || p.isEmpty) {
        return uri.toString();
      }
      final sep = Platform.pathSeparator;
      final i = p.lastIndexOf(sep);
      return i >= 0 ? p.substring(i + 1) : p;
    }
    if (uri.pathSegments.isNotEmpty) {
      final last = uri.pathSegments.last;
      if (last.isNotEmpty) return last;
    }
    return uri.toString();
  }

  String _subtitleFromSource(AudioSource source) {
    final uri = source.uri;
    if (uri.scheme == 'file') {
      final p = _safeFilePathFromUri(uri);
      return (p == null || p.isEmpty) ? uri.toString() : p;
    }
    return uri.toString();
  }

  Widget _buildPlayerScreen() {
    return Column(
      children: [
        Expanded(
          flex: 3,
          child: SingleChildScrollView(
            child: Column(
              children: [
                _buildInputSection(),
                const SizedBox(height: 8),
                ValueListenableBuilder<PlayerStatus>(
                  valueListenable: _status,
                  builder: (context, st, _) => _buildPlayerControls(st),
                ),
              ],
            ),
          ),
        ),
        const Divider(),
        Expanded(
          flex: 2,
          child: ValueListenableBuilder<PlayerStatus>(
            valueListenable: _status,
            builder: (context, st, _) => _buildPlaylist(st),
          ),
        ),
      ],
    );
  }

  Widget _buildInputSection() {
    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(12),
          child: Column(
            children: [
              Row(
                children: [
                  Expanded(
                    child: FilledButton.icon(
                      onPressed: _pickSongsAndSetPlaylist,
                      icon: const Icon(Icons.library_music),
                      label: const Text('Pick Files'),
                    ),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    child: FilledButton.icon(
                      onPressed: _pickFolderAndSetPlaylist,
                      icon: const Icon(Icons.folder_open),
                      label: const Text('Pick Folder'),
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 8),
              Row(
                children: [
                  Expanded(
                    child: OutlinedButton.icon(
                      onPressed: _addSongs,
                      icon: const Icon(Icons.queue_music),
                      label: const Text('Add Songs'),
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12),
          child: Column(
            children: [
              TextField(
                controller: _singleUrlController,
                keyboardType: TextInputType.url,
                decoration: const InputDecoration(
                  labelText:
                      'Single source (local path, file://, or http/https)',
                  hintText:
                      r'C:\Music\song.mp3  or  https://example.com/live.mp3',
                  border: OutlineInputBorder(),
                ),
              ),
              const SizedBox(height: 8),
              Row(
                children: [
                  Expanded(
                    child: FilledButton.icon(
                      onPressed: _playSingleUrl,
                      icon: const Icon(Icons.link),
                      label: const Text('Play URL'),
                    ),
                  ),
                  const SizedBox(width: 8),
                  Expanded(
                    child: FilledButton(
                      child: const Text('Stream'),
                      onPressed: () async {
                        final uri = _parseInputToUri(_singleUrlController.text);
                        if (uri == null) {
                          ScaffoldMessenger.of(context).showSnackBar(
                            const SnackBar(content: Text('Invalid URL/path')),
                          );
                          return;
                        }

                        if (uri.scheme == 'http' || uri.scheme == 'https') {
                          _logs.insert(
                            0,
                            '[stream] Direct push streaming disabled in demo for stability. Falling back to cached URL playback.',
                          );
                          if (mounted) setState(() {});
                        }

                        await _playSingleUrl();
                      },
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 12),
              TextField(
                controller: _multiUrlController,
                keyboardType: TextInputType.multiline,
                minLines: 2,
                maxLines: 4,
                decoration: const InputDecoration(
                  labelText: 'Multiple sources (one per line)',
                  hintText: 'C:\\Music\\a.mp3\nhttps://example.com/b.mp3',
                  border: OutlineInputBorder(),
                ),
              ),
              const SizedBox(height: 8),
              Row(
                children: [
                  Expanded(
                    child: OutlinedButton.icon(
                      onPressed: _setMultiUrlPlaylist,
                      icon: const Icon(
                        Icons.playlist_add_check_circle_outlined,
                      ),
                      label: const Text('Set URL Playlist'),
                    ),
                  ),
                  const SizedBox(width: 8),
                  Expanded(
                    child: OutlinedButton.icon(
                      onPressed: _addMultiUrls,
                      icon: const Icon(Icons.playlist_add),
                      label: const Text('Add URLs'),
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
      ],
    );
  }

  Widget _buildPlayerControls(PlayerStatus st) {
    final duration = Duration(
      milliseconds: (st.durationSeconds * 1000).round(),
    );
    final position = Duration(
      milliseconds: (st.positionSeconds * 1000).round(),
    );
    final maxMs =
        duration.inMilliseconds <= 0 ? 1.0 : duration.inMilliseconds.toDouble();
    final posMs = position.inMilliseconds
        .clamp(0, duration.inMilliseconds <= 0 ? 0 : duration.inMilliseconds)
        .toDouble();
    final currentIndex = st.currentIndex;
    final currentTitle = (currentIndex >= 0 && currentIndex < _playlist.length)
        ? _nameFromSource(_playlist[currentIndex])
        : 'No track selected';
    final currentSubtitle =
        (currentIndex >= 0 && currentIndex < _playlist.length)
            ? _subtitleFromSource(_playlist[currentIndex])
            : 'Pick songs or set URL playlist';

    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12),
          child: Card(
            child: ListTile(
              leading: Icon(
                st.isPlaying ? Icons.graphic_eq : Icons.music_note_outlined,
              ),
              title: Text(
                currentTitle,
                maxLines: 1,
                overflow: TextOverflow.ellipsis,
              ),
              subtitle: Text(
                currentSubtitle,
                maxLines: 1,
                overflow: TextOverflow.ellipsis,
              ),
              trailing: Icon(
                _systemMediaControlsEnabled
                    ? Icons.notifications_active
                    : Icons.notifications_off,
              ),
            ),
          ),
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12),
          child: Column(
            children: [
              Slider(
                value: posMs,
                max: maxMs,
                onChanged: (v) {
                  _player.seekTo(Duration(milliseconds: v.toInt()));
                },
              ),
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [Text(_fmt(position)), Text(_fmt(duration))],
              ),
            ],
          ),
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              IconButton(
                onPressed: _player.seekToPrevious,
                iconSize: 32,
                icon: const Icon(Icons.skip_previous),
              ),
              IconButton(
                onPressed: st.isPlaying ? _player.pause : _player.play,
                iconSize: 42,
                icon: Icon(
                  st.isPlaying ? Icons.pause_circle : Icons.play_circle,
                ),
              ),
              IconButton(
                onPressed: _player.seekToNext,
                iconSize: 32,
                icon: const Icon(Icons.skip_next),
              ),
              const SizedBox(width: 12),
              IconButton(
                onPressed: () {
                  final enabled = !st.shuffleEnabled;
                  _player.setShuffleModeEnabled(enabled);
                },
                icon: Icon(
                  st.shuffleEnabled ? Icons.shuffle_on : Icons.shuffle,
                ),
              ),
              PopupMenuButton<LoopMode>(
                initialValue: st.loopMode,
                icon: Icon(_loopIcon(st.loopMode)),
                onSelected: _player.setLoopMode,
                itemBuilder: (_) => const [
                  PopupMenuItem(value: LoopMode.off, child: Text('Loop Off')),
                  PopupMenuItem(value: LoopMode.all, child: Text('Loop All')),
                  PopupMenuItem(value: LoopMode.one, child: Text('Loop One')),
                ],
              ),
            ],
          ),
        ),
      ],
    );
  }

  Widget _buildPlaylist(PlayerStatus st) {
    return ReorderableListView.builder(
      itemCount: _playlist.length,
      onReorder: (oldIndex, newIndex) {
        if (newIndex > oldIndex) newIndex -= 1;
        final item = _playlist.removeAt(oldIndex);
        _playlist.insert(newIndex, item);
        _player.moveAudioSource(oldIndex, newIndex);
        setState(() {});
      },
      itemBuilder: (context, index) {
        final isCurrent = st.currentIndex == index;
        return ListTile(
          key: ValueKey('song_$index'),
          leading: Icon(isCurrent ? Icons.graphic_eq : Icons.music_note),
          title: Text(_nameFromSource(_playlist[index])),
          subtitle: Text(_subtitleFromSource(_playlist[index])),
          onTap: () => _player.seekTo(Duration.zero, index: index),
          trailing: IconButton(
            icon: const Icon(Icons.delete_outline),
            onPressed: () {
              _player.removeAudioSourceAt(index);
              _playlist.removeAt(index);
              setState(() {});
            },
          ),
        );
      },
    );
  }

  Widget _buildAdvancedSettings() {
    return ExpansionTile(
      title: const Text('Advanced Audio Settings'),
      children: [
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
          child: Column(
            children: [
              // Output Format
              DropdownButtonFormField<AudioFormat>(
                initialValue: _outputFormat,
                decoration: const InputDecoration(labelText: 'Audio Format'),
                items: AudioFormat.values.map((f) {
                  return DropdownMenuItem(value: f, child: Text(f.name));
                }).toList(),
                onChanged: (v) {
                  if (v == null) return;
                  setState(() => _outputFormat = v);
                  _player.setOutputFormat(v);
                },
              ),
              const SizedBox(height: 8),
              // Sample Rate
              DropdownButtonFormField<int>(
                initialValue: _outputSampleRate,
                decoration: const InputDecoration(labelText: 'Sample Rate'),
                items: const [
                  DropdownMenuItem(value: 0, child: Text('Native')),
                  DropdownMenuItem(value: 44100, child: Text('44100 Hz')),
                  DropdownMenuItem(value: 48000, child: Text('48000 Hz')),
                  DropdownMenuItem(value: 96000, child: Text('96000 Hz')),
                ],
                onChanged: (v) {
                  if (v == null) return;
                  setState(() => _outputSampleRate = v);
                  _player.setOutputSampleRate(v);
                },
              ),
              // Mono/Stereo
              SwitchListTile(
                title: const Text('Stereo Output'),
                subtitle: Text(
                    _outputChannels == 2 ? '2 Channels' : '1 Channel (Mono)'),
                value: _outputChannels == 2,
                onChanged: (v) {
                  setState(() => _outputChannels = v ? 2 : 1);
                  _player.setOutputChannels(_outputChannels);
                },
              ),
              const Divider(height: 16),
              SwitchListTile(
                title: const Text('Enable Crossfade'),
                subtitle: Text(
                    'Transition fade between tracks (${_crossfadeDurationMs} ms)'),
                value: _crossfadeEnabled,
                onChanged: (v) {
                  setState(() => _crossfadeEnabled = v);
                  _player.setCrossfadeEnabled(v);
                },
              ),
              ListTile(
                dense: true,
                contentPadding: EdgeInsets.zero,
                title: Text('Crossfade Duration: $_crossfadeDurationMs ms'),
                subtitle: Slider(
                  value: _crossfadeDurationMs.toDouble(),
                  min: 0,
                  max: 10000,
                  divisions: 100,
                  onChanged: (v) {
                    final next = v.round();
                    setState(() => _crossfadeDurationMs = next);
                    _player.setCrossfadeDurationMs(next);
                  },
                ),
              ),
            ],
          ),
        ),
      ],
    );
  }

  Widget _buildEqScreen() {
    return ListView(
      padding: const EdgeInsets.all(12),
      children: [
        _buildAdvancedSettings(),
        const Divider(),
        DropdownButtonFormField<EqScreenMode>(
          value: _eqMode,
          decoration: const InputDecoration(
            labelText: 'EQ mode',
            border: OutlineInputBorder(),
          ),
          items: EqScreenMode.values
              .map(
                (mode) => DropdownMenuItem<EqScreenMode>(
                  value: mode,
                  child: Text(mode.label),
                ),
              )
              .toList(),
          onChanged: (mode) {
            if (mode == null) return;
            setState(() {
              _eqMode = mode;
              if (mode == EqScreenMode.multibandEq) {
                _mixEqEnabled = false;
                _player.setMultibandFxEnabled(false);
              } else {
                _multibandEqEnabled = false;
                _player.setMultibandEqEnabled(false);
              }
            });
          },
        ),
        const SizedBox(height: 8),
        if (_eqMode == EqScreenMode.multibandEq) ...[
          SwitchListTile(
            title: const Text('Enable 10-Band EQ'),
            value: _multibandEqEnabled,
            onChanged: (v) {
              setState(() => _multibandEqEnabled = v);
              if (v) {
                _mixEqEnabled = false;
                _player.setMultibandFxEnabled(false);
              }
              _player.setMultibandEqEnabled(v);
              if (v) {
                _player.initMultibandEq(_eqFrequencies);
                for (int i = 0; i < _eqFrequencies.length; ++i) {
                  _player.setMultibandEqBandGain(i, _eqBandGains[i]);
                }
              }
            },
          ),
          if (_multibandEqEnabled)
            SizedBox(
              height: 300,
              child: ListView.builder(
                scrollDirection: Axis.horizontal,
                itemCount: _eqFrequencies.length,
                itemBuilder: (context, index) {
                  final freq = _eqFrequencies[index];
                  final label = freq >= 1000
                      ? '${(freq / 1000).toStringAsFixed(1)}k'
                      : '${freq.toInt()}';
                  return Column(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Expanded(
                        child: RotatedBox(
                          quarterTurns: 3,
                          child: Slider(
                            value: _eqBandGains[index],
                            min: -12.0,
                            max: 12.0,
                            onChanged: (v) {
                              setState(() => _eqBandGains[index] = v);
                              _player.setMultibandEqBandGain(index, v);
                            },
                          ),
                        ),
                      ),
                      Text(
                        '${_eqBandGains[index].toStringAsFixed(1)} dB',
                        style: const TextStyle(fontSize: 10),
                      ),
                      Text(label, style: const TextStyle(fontSize: 10)),
                    ],
                  );
                },
              ),
            ),
        ] else ...[
          SwitchListTile(
            title: const Text('Enable Mix EQ'),
            value: _mixEqEnabled,
            onChanged: (v) {
              setState(() {
                _mixEqEnabled = v;
                if (v) {
                  _multibandEqEnabled = false;
                  _player.setMultibandEqEnabled(false);
                }
              });
              _applyMixEq();
            },
          ),
          if (_mixEqEnabled)
            ListView.builder(
              itemCount: _mixedEqBands.length,
              shrinkWrap: true,
              physics: const NeverScrollableScrollPhysics(),
              itemBuilder: (context, index) {
                final band = _mixedEqBands[index];
                final freq = band.frequencyHz;
                final label = freq >= 1000
                    ? '${(freq / 1000).toStringAsFixed(1)}k'
                    : '${freq.toStringAsFixed(2)}';
                return Card(
                  margin: const EdgeInsets.only(bottom: 8),
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text('Band ${index + 1} • $label Hz'),
                        DropdownButton<EqBandType>(
                          value: band.type,
                          items: EqBandType.values
                              .map(
                                (t) => DropdownMenuItem<EqBandType>(
                                  value: t,
                                  child: Text(t.name),
                                ),
                              )
                              .toList(),
                          onChanged: (value) {
                            if (value == null) return;
                            setState(
                                () => _updateMixEqBand(index, type: value));
                          },
                        ),
                        Text('Q: ${band.q.toStringAsFixed(2)}'),
                        Slider(
                          value: band.q,
                          min: 0.1,
                          max: 18.0,
                          divisions: 179,
                          onChanged: (v) {
                            setState(() => _updateMixEqBand(index, q: v));
                          },
                        ),
                        Text('Gain: ${band.gainDb.toStringAsFixed(1)} dB'),
                        Slider(
                          value: band.gainDb,
                          min: -12.0,
                          max: 12.0,
                          divisions: 48,
                          onChanged: (v) {
                            setState(() {
                              _eqBandGains[index] = v;
                              _updateMixEqBand(index, gain: v);
                            });
                          },
                        ),
                      ],
                    ),
                  ),
                );
              },
            ),
        ],
        const Divider(),
        SwitchListTile(
          title: const Text('Enable Realtime Analyzer'),
          subtitle: const Text('Raw frame values for chart visualization'),
          value: _analyzerEnabled,
          onChanged: (v) {
            setState(() => _analyzerEnabled = v);
            _player.setAnalyzerEnabled(v);
          },
        ),
        DropdownButtonFormField<int>(
          initialValue: _analyzerFrameSize,
          decoration: const InputDecoration(
            labelText: 'Analyzer frame size',
            border: OutlineInputBorder(),
          ),
          items: const [256, 512, 1024, 2048]
              .map((v) => DropdownMenuItem(value: v, child: Text('$v samples')))
              .toList(),
          onChanged: (v) {
            if (v == null) return;
            setState(() => _analyzerFrameSize = v);
            _player.configureAnalyzer(frameSize: v);
          },
        ),
        const SizedBox(height: 12),
        SizedBox(
          height: 180,
          child: Card(
            child: Padding(
              padding: const EdgeInsets.all(8.0),
              child: _buildAnalyzerChart(),
            ),
          ),
        ),
        const Divider(),
        SwitchListTile(
          title: const Text('Enable 3-Band EQ'),
          value: _eqEnabled,
          onChanged: (v) {
            setState(() => _eqEnabled = v);
            _player.setEqEnabled(v);
          },
        ),
        _slider('Low', _low, 0, 4, (v) {
          setState(() => _low = v);
          _player.setEq(low: _low, mid: _mid, high: _high);
        }),
        _slider('Mid', _mid, 0, 4, (v) {
          setState(() => _mid = v);
          _player.setEq(low: _low, mid: _mid, high: _high);
        }),
        _slider('High', _high, 0, 4, (v) {
          setState(() => _high = v);
          _player.setEq(low: _low, mid: _mid, high: _high);
        }),
        const Divider(),
        _slider('Gain', _gain, 0, 4, (v) {
          setState(() => _gain = v);
          _player.setGain(v);
        }),
        _slider('Pan', _pan, -1, 1, (v) {
          setState(() => _pan = v);
          _player.setPan(v);
        }),
        const Divider(),
        SwitchListTile(
          title: const Text('Enable Reverb'),
          value: _reverbEnabled,
          onChanged: (v) {
            setState(() => _reverbEnabled = v);
            _player.setReverbEnabled(v);
          },
        ),
        _slider('Reverb Mix', _rvMix, 0, 1, (v) {
          setState(() => _rvMix = v);
          _player.setReverb(
            mix: _rvMix,
            feedback: _rvFeedback,
            delayMs: _rvDelay,
          );
        }),
        _slider('Reverb Feedback', _rvFeedback, 0, 0.98, (v) {
          setState(() => _rvFeedback = v);
          _player.setReverb(
            mix: _rvMix,
            feedback: _rvFeedback,
            delayMs: _rvDelay,
          );
        }),
        _slider('Reverb Delay ms', _rvDelay, 20, 350, (v) {
          setState(() => _rvDelay = v);
          _player.setReverb(
            mix: _rvMix,
            feedback: _rvFeedback,
            delayMs: _rvDelay,
          );
        }),
        const Divider(),
        SwitchListTile(
          title: const Text('Enable Low-pass'),
          value: _lowpassEnabled,
          onChanged: (v) {
            setState(() => _lowpassEnabled = v);
            _player.setLowpass(enabled: v, cutoffHz: _lpCutoff);
          },
        ),
        _slider('Low-pass cutoff (Hz)', _lpCutoff, 20, 18000, (v) {
          setState(() => _lpCutoff = v);
          _player.setLowpass(enabled: _lowpassEnabled, cutoffHz: _lpCutoff);
        }),
        SwitchListTile(
          title: const Text('Enable High-pass'),
          value: _highpassEnabled,
          onChanged: (v) {
            setState(() => _highpassEnabled = v);
            _player.setHighpass(enabled: v, cutoffHz: _hpCutoff);
          },
        ),
        _slider('High-pass cutoff (Hz)', _hpCutoff, 10, 5000, (v) {
          setState(() => _hpCutoff = v);
          _player.setHighpass(enabled: _highpassEnabled, cutoffHz: _hpCutoff);
        }),
        const Divider(),
        SwitchListTile(
          title: const Text('Enable Band-pass'),
          value: _bandpassEnabled,
          onChanged: (v) {
            setState(() => _bandpassEnabled = v);
            _player.setBandpass(enabled: v, cutoffHz: _bpCutoff, q: _bpQ);
          },
        ),
        _slider('Band-pass cutoff (Hz)', _bpCutoff, 20, 18000, (v) {
          setState(() => _bpCutoff = v);
          _player.setBandpass(
            enabled: _bandpassEnabled,
            cutoffHz: _bpCutoff,
            q: _bpQ,
          );
        }),
        _slider('Band-pass Q', _bpQ, 0.1, 10.0, (v) {
          setState(() => _bpQ = v);
          _player.setBandpass(
            enabled: _bandpassEnabled,
            cutoffHz: _bpCutoff,
            q: _bpQ,
          );
        }),
        const Divider(),
        SwitchListTile(
          title: const Text('Enable Peak EQ'),
          value: _peakEqEnabled,
          onChanged: (v) {
            setState(() => _peakEqEnabled = v);
            _player.setPeakEq(
              enabled: v,
              gainDb: _peakGainDb,
              q: _peakQ,
              frequencyHz: _peakFreq,
            );
          },
        ),
        _slider('Peak EQ Frequency (Hz)', _peakFreq, 20, 18000, (v) {
          setState(() => _peakFreq = v);
          _player.setPeakEq(
            enabled: _peakEqEnabled,
            gainDb: _peakGainDb,
            q: _peakQ,
            frequencyHz: _peakFreq,
          );
        }),
        _slider('Peak EQ Gain (dB)', _peakGainDb, -24, 24, (v) {
          setState(() => _peakGainDb = v);
          _player.setPeakEq(
            enabled: _peakEqEnabled,
            gainDb: _peakGainDb,
            q: _peakQ,
            frequencyHz: _peakFreq,
          );
        }),
        _slider('Peak EQ Q', _peakQ, 0.1, 10.0, (v) {
          setState(() => _peakQ = v);
          _player.setPeakEq(
            enabled: _peakEqEnabled,
            gainDb: _peakGainDb,
            q: _peakQ,
            frequencyHz: _peakFreq,
          );
        }),
        const Divider(),
        SwitchListTile(
          title: const Text('Enable Notch'),
          value: _notchEnabled,
          onChanged: (v) {
            setState(() => _notchEnabled = v);
            _player.setNotch(enabled: v, q: _notchQ, frequencyHz: _notchFreq);
          },
        ),
        _slider('Notch Frequency (Hz)', _notchFreq, 20, 18000, (v) {
          setState(() => _notchFreq = v);
          _player.setNotch(
            enabled: _notchEnabled,
            q: _notchQ,
            frequencyHz: _notchFreq,
          );
        }),
        _slider('Notch Q', _notchQ, 0.1, 10.0, (v) {
          setState(() => _notchQ = v);
          _player.setNotch(
            enabled: _notchEnabled,
            q: _notchQ,
            frequencyHz: _notchFreq,
          );
        }),
        const Divider(),
        SwitchListTile(
          title: const Text('Enable Low Shelf'),
          value: _lowshelfEnabled,
          onChanged: (v) {
            setState(() => _lowshelfEnabled = v);
            _player.setLowshelf(
              enabled: v,
              gainDb: _lowshelfGainDb,
              slope: _lowshelfSlope,
              frequencyHz: _lowshelfFreq,
            );
          },
        ),
        _slider('Low Shelf Frequency (Hz)', _lowshelfFreq, 20, 2000, (v) {
          setState(() => _lowshelfFreq = v);
          _player.setLowshelf(
            enabled: _lowshelfEnabled,
            gainDb: _lowshelfGainDb,
            slope: _lowshelfSlope,
            frequencyHz: _lowshelfFreq,
          );
        }),
        _slider('Low Shelf Gain (dB)', _lowshelfGainDb, -24, 24, (v) {
          setState(() => _lowshelfGainDb = v);
          _player.setLowshelf(
            enabled: _lowshelfEnabled,
            gainDb: _lowshelfGainDb,
            slope: _lowshelfSlope,
            frequencyHz: _lowshelfFreq,
          );
        }),
        _slider('Low Shelf Slope', _lowshelfSlope, 0.1, 2.0, (v) {
          setState(() => _lowshelfSlope = v);
          _player.setLowshelf(
            enabled: _lowshelfEnabled,
            gainDb: _lowshelfGainDb,
            slope: _lowshelfSlope,
            frequencyHz: _lowshelfFreq,
          );
        }),
        const Divider(),
        SwitchListTile(
          title: const Text('Enable High Shelf'),
          value: _highshelfEnabled,
          onChanged: (v) {
            setState(() => _highshelfEnabled = v);
            _player.setHighshelf(
              enabled: v,
              gainDb: _highshelfGainDb,
              slope: _highshelfSlope,
              frequencyHz: _highshelfFreq,
            );
          },
        ),
        _slider('High Shelf Frequency (Hz)', _highshelfFreq, 1000, 18000, (v) {
          setState(() => _highshelfFreq = v);
          _player.setHighshelf(
            enabled: _highshelfEnabled,
            gainDb: _highshelfGainDb,
            slope: _highshelfSlope,
            frequencyHz: _highshelfFreq,
          );
        }),
        _slider('High Shelf Gain (dB)', _highshelfGainDb, -24, 24, (v) {
          setState(() => _highshelfGainDb = v);
          _player.setHighshelf(
            enabled: _highshelfEnabled,
            gainDb: _highshelfGainDb,
            slope: _highshelfSlope,
            frequencyHz: _highshelfFreq,
          );
        }),
        _slider('High Shelf Slope', _highshelfSlope, 0.1, 2.0, (v) {
          setState(() => _highshelfSlope = v);
          _player.setHighshelf(
            enabled: _highshelfEnabled,
            gainDb: _highshelfGainDb,
            slope: _highshelfSlope,
            frequencyHz: _highshelfFreq,
          );
        }),
        const Divider(),
        SwitchListTile(
          title: const Text('Enable Delay'),
          value: _delayEnabled,
          onChanged: (v) {
            setState(() => _delayEnabled = v);
            _player.setDelay(
              enabled: v,
              mix: _dlMix,
              feedback: _dlFeedback,
              delayMs: _dlDelay,
            );
          },
        ),
        _slider('Delay Mix', _dlMix, 0, 1, (v) {
          setState(() => _dlMix = v);
          _player.setDelay(
            enabled: _delayEnabled,
            mix: _dlMix,
            feedback: _dlFeedback,
            delayMs: _dlDelay,
          );
        }),
        _slider('Delay Feedback', _dlFeedback, 0, 0.98, (v) {
          setState(() => _dlFeedback = v);
          _player.setDelay(
            enabled: _delayEnabled,
            mix: _dlMix,
            feedback: _dlFeedback,
            delayMs: _dlDelay,
          );
        }),
        _slider('Delay Time ms', _dlDelay, 10, 1200, (v) {
          setState(() => _dlDelay = v);
          _player.setDelay(
            enabled: _delayEnabled,
            mix: _dlMix,
            feedback: _dlFeedback,
            delayMs: _dlDelay,
          );
        }),
        _buildAdvancedSettings(),
      ],
    );
  }

  Widget _buildAnalyzerChart() {
    if (!_analyzerEnabled) {
      return const Center(
        child: Text('Enable Realtime Analyzer to view audio frame values.'),
      );
    }

    final spots = <FlSpot>[];
    final len = _analyzerValues.length;
    for (var i = 0; i < len; i++) {
      final normalized = (_analyzerValues[i] * 6.0).clamp(0.0, 1.0);
      spots.add(FlSpot(i.toDouble(), normalized));
    }

    return LineChart(
      LineChartData(
        minX: 0,
        maxX: math.max(1, len - 1).toDouble(),
        minY: 0,
        maxY: 1,
        gridData: const FlGridData(show: true, drawVerticalLine: false),
        titlesData: const FlTitlesData(show: false),
        borderData: FlBorderData(
          show: true,
          border: Border.all(color: Colors.grey.shade300),
        ),
        lineBarsData: [
          LineChartBarData(
            spots: spots,
            isCurved: true,
            color: Colors.deepPurple,
            barWidth: 2,
            dotData: const FlDotData(show: false),
            belowBarData: BarAreaData(
              show: true,
              color: Colors.deepPurple.withValues(alpha: 0.15),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildLogsScreen() {
    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(12),
          child: Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              FilledButton.icon(
                onPressed: () {
                  final msg = _player.getLastError();
                  _logs.insert(0, '[poll] ${msg.isEmpty ? "(no error)" : msg}');
                  setState(() {});
                },
                icon: const Icon(Icons.refresh),
                label: const Text('Poll Error'),
              ),
              OutlinedButton.icon(
                onPressed: () {
                  _player.clearLastError();
                  _logs.insert(0, '[action] Cleared native error state');
                  setState(() {});
                },
                icon: const Icon(Icons.cleaning_services_outlined),
                label: const Text('Clear Native Error'),
              ),
              FilterChip(
                selected: _allowInvalidTlsForDownloads,
                label: const Text('Allow invalid TLS certs (test only)'),
                onSelected: (selected) {
                  setState(() => _allowInvalidTlsForDownloads = selected);
                  _logs.insert(
                    0,
                    selected
                        ? '[security] Invalid TLS cert acceptance enabled for fallback downloads.'
                        : '[security] Invalid TLS cert acceptance disabled.',
                  );
                },
              ),
              OutlinedButton.icon(
                onPressed: () async {
                  if (_logs.isEmpty) {
                    if (!mounted) return;
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('No logs to copy')),
                    );
                    return;
                  }

                  final text = _logs.reversed.join('\n');
                  await Clipboard.setData(ClipboardData(text: text));
                  if (!mounted) return;
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(content: Text('Copied ${_logs.length} log lines')),
                  );
                },
                icon: const Icon(Icons.copy_all_outlined),
                label: const Text('Copy Logs'),
              ),
              OutlinedButton.icon(
                onPressed: () {
                  _logs.clear();
                  setState(() {});
                },
                icon: const Icon(Icons.delete_sweep_outlined),
                label: const Text('Clear Log View'),
              ),
            ],
          ),
        ),
        const Divider(height: 1),
        Expanded(
          child: _logs.isEmpty
              ? const Center(child: Text('No logs yet'))
              : ListView.builder(
                  itemCount: _logs.length,
                  itemBuilder: (_, i) => ListTile(
                    dense: true,
                    visualDensity: VisualDensity.compact,
                    title: Text(_logs[i]),
                  ),
                ),
        ),
      ],
    );
  }

  Widget _slider(
    String title,
    double value,
    double min,
    double max,
    ValueChanged<double> onChanged,
  ) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text('$title: ${value.toStringAsFixed(2)}'),
        Slider(value: value, min: min, max: max, onChanged: onChanged),
      ],
    );
  }

  IconData _loopIcon(LoopMode mode) {
    switch (mode) {
      case LoopMode.off:
        return Icons.repeat;
      case LoopMode.all:
        return Icons.repeat_on;
      case LoopMode.one:
        return Icons.repeat_one_on;
    }
  }

  String _fmt(Duration d) {
    final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
    final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
    final h = d.inHours;
    return h > 0 ? '${h.toString().padLeft(2, '0')}:$m:$s' : '$m:$s';
  }

  Future<bool> _ensureMediaPermission() async {
    if (!Platform.isAndroid) return true;

    final audio = await Permission.audio.request();
    if (audio.isGranted) return true;

    final storage = await Permission.storage.request();
    if (storage.isGranted) return true;

    if (audio.isPermanentlyDenied || storage.isPermanentlyDenied) {
      _logs.insert(0, '[permission] Permanently denied. Open app settings.');
      await openAppSettings();
    }

    return false;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('MiniAudio Playlist Demo')),
      body: IndexedStack(
        index: _tabIndex,
        children: [_buildPlayerScreen(), _buildEqScreen(), _buildLogsScreen()],
      ),
      bottomNavigationBar: NavigationBar(
        selectedIndex: _tabIndex,
        onDestinationSelected: (i) => setState(() => _tabIndex = i),
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.playlist_play),
            label: 'Player',
          ),
          NavigationDestination(
            icon: Icon(Icons.equalizer),
            label: 'Equalizer',
          ),
          NavigationDestination(
            icon: Icon(Icons.article_outlined),
            label: 'Logs',
          ),
        ],
      ),
    );
  }
}
1
likes
130
points
26
downloads

Publisher

verified publishersautiflow.nett.to

Weekly Downloads

High-fidelity, cross-platform Dart/Flutter audio engine for audiophiles, powered by miniaudio and native C++ FFI.

Homepage
Repository (GitHub)
View/report issues

Topics

#audio #miniaudio #streaming #audiophiles #equalizer

Documentation

API reference

License

MIT (license)

Dependencies

audio_service, audio_session, ffi, flutter, http, meta

More

Packages that depend on sautiflow

Packages that implement sautiflow