From 64ace9ba80de131856e45a3dbe7250a2ffe926fa Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Mon, 19 Oct 2026 22:47:21 +0530 Subject: [PATCH] feat: 10-band equalizer, customizable crossfade, movable miniplayer, and static lyrics view - Add 10-band equalizer with presets and Studio Master Clarity DSP filters ported from LastWave-Native - Implement customizable 3-12s perceptual crossfade with early lossless pre-resolve and WASAPI Exclusive / MPV concurrency protection - Support detached, floating, movable miniplayer with persistent positioning - Enhance lyrics view with clean static presentation and improved line-by-line sync --- lib/core/storage/prefs.dart | 30 +- .../equalizer/equalizer_controller.dart | 152 +++++ lib/features/equalizer/equalizer_models.dart | 254 +++++++ lib/features/equalizer/equalizer_panel.dart | 636 ++++++++++++++++++ lib/features/lyrics/karaoke_lyrics_view.dart | 143 ++-- lib/features/lyrics/lyrics_models.dart | 17 + lib/features/lyrics/lyrics_providers.dart | 8 +- lib/features/lyrics/lyrics_repository.dart | 68 +- lib/features/player/playback_service.dart | 441 +++++++++++- lib/ui/app_shell/app_shell.dart | 188 +++++- lib/ui/mini_player/mini_player.dart | 403 +++++++---- lib/ui/player_dock/player_dock.dart | 21 + lib/ui/settings/settings_page.dart | 169 ++++- lib/ui/theme/wave_icons.dart | 1 + 14 files changed, 2252 insertions(+), 279 deletions(-) create mode 100644 lib/features/equalizer/equalizer_controller.dart create mode 100644 lib/features/equalizer/equalizer_models.dart create mode 100644 lib/features/equalizer/equalizer_panel.dart diff --git a/lib/core/storage/prefs.dart b/lib/core/storage/prefs.dart index de92351..179be49 100644 --- a/lib/core/storage/prefs.dart +++ b/lib/core/storage/prefs.dart @@ -112,16 +112,20 @@ class Prefs { bool get crossfadeEnabled => _sp.getBool('lw_crossfade_enabled') ?? false; int get crossfadeSeconds { final v = _sp.getInt('lw_crossfade_seconds') ?? 5; - return v.clamp(1, 12); + return v.clamp(3, 12); } Future setCrossfade(bool enabled, [int? seconds]) async { await _sp.setBool('lw_crossfade_enabled', enabled); if (seconds != null) { - await _sp.setInt('lw_crossfade_seconds', seconds.clamp(1, 12)); + await _sp.setInt('lw_crossfade_seconds', seconds.clamp(3, 12)); } } + Future setCrossfadeSeconds(int seconds) async { + await _sp.setInt('lw_crossfade_seconds', seconds.clamp(3, 12)); + } + bool get bitPerfect => _sp.getBool('lw_bit_perfect') ?? false; Future setBitPerfect(bool v) => _sp.setBool('lw_bit_perfect', v); @@ -325,6 +329,28 @@ class Prefs { 'lw_addon_urls', jsonEncode(urls.map((e) => e.trim()).where((e) => e.isNotEmpty).toList()), ); + + // -- Equalizer & Studio Master Clarity ------------------------------------ + bool get equalizerEnabled => _sp.getBool('lw_eq_enabled') ?? false; + Future setEqualizerEnabled(bool v) => _sp.setBool('lw_eq_enabled', v); + + String get equalizerPreset => _sp.getString('lw_eq_preset') ?? 'Default'; + Future setEqualizerPreset(String v) => + _sp.setString('lw_eq_preset', v); + + String get equalizerGains => _sp.getString('lw_eq_gains') ?? ''; + Future setEqualizerGains(String v) => + _sp.setString('lw_eq_gains', v); + + bool get studioClarityEnabled => + _sp.getBool('lw_studio_clarity_enabled') ?? false; + Future setStudioClarityEnabled(bool v) => + _sp.setBool('lw_studio_clarity_enabled', v); + + String get studioClarityPreset => + _sp.getString('lw_studio_clarity_preset') ?? 'reference'; + Future setStudioClarityPreset(String v) => + _sp.setString('lw_studio_clarity_preset', v); } final prefsProvider = Provider((_) { diff --git a/lib/features/equalizer/equalizer_controller.dart b/lib/features/equalizer/equalizer_controller.dart new file mode 100644 index 0000000..b1bc957 --- /dev/null +++ b/lib/features/equalizer/equalizer_controller.dart @@ -0,0 +1,152 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/storage/prefs.dart'; +import '../player/playback_service.dart'; +import 'equalizer_models.dart'; + +/// Provider for the 10-band Equalizer and Studio Master Clarity controller. +final equalizerProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(prefsProvider); + final playback = ref.watch(playbackServiceProvider.notifier); + return EqualizerNotifier(prefs, playback); +}); + +class EqualizerNotifier extends StateNotifier { + final Prefs _prefs; + final PlaybackService _playback; + + EqualizerNotifier(this._prefs, this._playback) + : super(const EqualizerState()) { + _loadFromPrefs(); + } + + void _loadFromPrefs() { + final enabled = _prefs.equalizerEnabled; + final presetName = _prefs.equalizerPreset; + final studioClarityEnabled = _prefs.studioClarityEnabled; + final clarityPresetKey = _prefs.studioClarityPreset; + + List gains = EqualizerPresets.flat.gainsDb; + final storedGainsRaw = _prefs.equalizerGains; + if (storedGainsRaw.isNotEmpty) { + final parsed = storedGainsRaw + .split(',') + .map((s) => double.tryParse(s.trim())) + .whereType() + .toList(); + if (parsed.length == eqBandFreqsHz.length) { + gains = parsed.map((g) => g.clamp(-eqMaxGainDb, eqMaxGainDb)).toList(); + } + } else { + final found = EqualizerPresets.byName(presetName); + if (found != null) { + gains = found.gainsDb; + } + } + + state = EqualizerState( + enabled: enabled, + presetName: presetName, + gainsDb: gains, + studioClarityEnabled: studioClarityEnabled, + clarityPresetKey: clarityPresetKey, + ); + + _applyToPlayer(); + } + + Future setEnabled(bool enabled) async { + state = state.copyWith(enabled: enabled); + await _prefs.setEqualizerEnabled(enabled); + await _applyToPlayer(); + } + + Future toggleEnabled() async { + await setEnabled(!state.enabled); + } + + Future applyPreset(EqPreset preset) async { + state = state.copyWith( + enabled: true, + presetName: preset.name, + gainsDb: List.of(preset.gainsDb), + ); + await _prefs.setEqualizerEnabled(true); + await _prefs.setEqualizerPreset(preset.name); + await _prefs.setEqualizerGains(state.gainsDb.join(',')); + await _applyToPlayer(); + } + + Future setBandGain(int bandIndex, double gainDb) async { + if (bandIndex < 0 || bandIndex >= eqBandFreqsHz.length) return; + final clamped = gainDb.clamp(-eqMaxGainDb, eqMaxGainDb); + final nextGains = List.from(state.gainsDb); + nextGains[bandIndex] = double.parse(clamped.toStringAsFixed(1)); + + state = state.copyWith( + enabled: true, + presetName: EqualizerPresets.customName, + gainsDb: nextGains, + ); + + await _prefs.setEqualizerEnabled(true); + await _prefs.setEqualizerPreset(EqualizerPresets.customName); + await _prefs.setEqualizerGains(nextGains.join(',')); + await _applyToPlayer(); + } + + Future setStudioClarityEnabled(bool enabled) async { + state = state.copyWith(studioClarityEnabled: enabled); + await _prefs.setStudioClarityEnabled(enabled); + await _applyToPlayer(); + } + + Future toggleStudioClarity() async { + await setStudioClarityEnabled(!state.studioClarityEnabled); + } + + Future setClarityPreset(String key) async { + state = state.copyWith( + clarityPresetKey: key, + studioClarityEnabled: true, + ); + await _prefs.setStudioClarityPreset(key); + await _prefs.setStudioClarityEnabled(true); + await _applyToPlayer(); + } + + Future setClarityTarget(ClarityPreset target) async { + await setClarityPreset(target.key); + } + + Future setPreset(String presetName) async { + final preset = EqualizerPresets.byName(presetName); + if (preset != null) { + await applyPreset(preset); + } + } + + Future reset() async { + await applyPreset(EqualizerPresets.flat); + } + + Future _applyToPlayer() async { + final af = _formatMpvFilter(state); + await _playback.setAudioFilter(af); + } + + static String _formatMpvFilter(EqualizerState s) { + if (!s.isProcessingActive) return ''; + final gains = s.combinedGainsDb; + if (gains.every((g) => g.abs() < 0.05)) return ''; + + final filters = []; + for (int i = 0; i < eqBandFreqsHz.length; i++) { + final freq = eqBandFreqsHz[i]; + final gain = gains[i]; + filters.add('equalizer=f=$freq:t=o:w=1:g=${gain.toStringAsFixed(1)}'); + } + return 'lavfi=[${filters.join(",")}]'; + } +} diff --git a/lib/features/equalizer/equalizer_models.dart b/lib/features/equalizer/equalizer_models.dart new file mode 100644 index 0000000..d041899 --- /dev/null +++ b/lib/features/equalizer/equalizer_models.dart @@ -0,0 +1,254 @@ +/// 10-band Equalizer models and presets ported from LastWave-Native. +/// +/// Center frequencies map directly to the ISO octave bands. Gain range is +/// ±8 dB to guarantee clean, unclipped, audiophile-grade output. +library; + +const List eqBandFreqsHz = [ + 31, + 63, + 125, + 250, + 500, + 1000, + 2000, + 4000, + 8000, + 16000, +]; + +const double eqMaxGainDb = 8.0; + +/// Compact frequency label under each band slider ("31", "125", "1k", "16k"...). +String eqBandLabel(int hz) => hz >= 1000 ? '${hz ~/ 1000}k' : '$hz'; + +class EqPreset { + final String name; + final List gainsDb; + + const EqPreset(this.name, this.gainsDb); +} + +/// Listening-tested curves from LastWave-Native. +class EqualizerPresets { + EqualizerPresets._(); + + static const String customName = 'Custom'; + + static const flat = EqPreset( + 'Default', + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ); + + /// Crystal-open audiophile reference curve for Studio Master Clarity. + /// Tight, controlled sub-bass depth, deliberate low-mid dip that removes + /// congestion, natural vocal body, presence lift and a silky air shelf. + static const studioMaster = EqPreset( + 'Studio Master', + [2.7, 2.2, 0.6, -2.6, -0.6, 1.2, 3.0, 4.0, 4.3, 4.8], + ); + + static const vocalClarity = EqPreset( + 'Vocal Clarity', + [-1.2, -0.5, 0.0, 1.0, 2.5, 3.5, 2.8, 2.0, 1.5, 1.0], + ); + + static const deepBassClean = EqPreset( + 'Deep Bass Clean', + [4.8, 3.5, 2.5, 0.0, -0.5, 0.0, 0.0, 0.0, 0.5, 0.5], + ); + + static const rock = EqPreset( + 'Rock', + [3.8, 3.0, 1.5, -1.0, -0.8, 0.5, 2.0, 3.0, 3.8, 4.0], + ); + + static const pop = EqPreset( + 'Pop', + [-0.8, 0.0, 1.0, 2.0, 1.8, 1.0, 0.5, 1.0, 1.8, 2.5], + ); + + static const electronic = EqPreset( + 'Electronic', + [4.2, 3.0, 2.0, -0.5, 0.5, 2.0, 2.5, 2.5, 3.2, 4.0], + ); + + static const rnb = EqPreset( + 'R&B', + [4.8, 3.5, 2.0, -0.5, -0.8, 0.5, 1.8, 2.5, 3.0, 3.0], + ); + + static const acoustic = EqPreset( + 'Acoustic', + [2.8, 2.0, 1.5, 1.0, 1.8, 2.0, 1.2, 1.0, 1.2, 1.5], + ); + + static const classical = EqPreset( + 'Classical', + [2.8, 2.0, 1.5, 0.0, 0.0, 0.5, 1.8, 2.0, 2.8, 3.0], + ); + + static const jazz = EqPreset( + 'Jazz', + [2.2, 1.5, 1.0, -0.5, 0.2, 1.0, 1.8, 2.0, 2.5, 2.5], + ); + + static const trebleAir = EqPreset( + 'Treble Air', + [0.0, 0.0, -0.5, -0.5, 0.2, 1.0, 1.8, 3.0, 4.0, 5.0], + ); + + static const List all = [ + flat, + studioMaster, + vocalClarity, + deepBassClean, + rock, + pop, + electronic, + rnb, + acoustic, + classical, + jazz, + trebleAir, + ]; + + static EqPreset? byName(String name) { + for (final preset in all) { + if (preset.name.toLowerCase() == name.toLowerCase()) { + return preset; + } + } + return null; + } +} + +/// Output preset descriptor for the native Studio Master Clarity chain. +class ClarityPreset { + final int index; + final String key; + final String displayName; + final String description; + final List trimsDb; + + const ClarityPreset({ + required this.index, + required this.key, + required this.displayName, + required this.description, + required this.trimsDb, + }); +} + +class ClarityPresets { + ClarityPresets._(); + + static const reference = ClarityPreset( + index: 0, + key: 'reference', + displayName: 'Reference', + description: 'Unmodified audiophile reference curve; all stage trims 0 dB.', + trimsDb: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ); + + static const speaker = ClarityPreset( + index: 1, + key: 'speaker', + displayName: 'Speaker', + description: 'Reduced sub-bass (-1.5 dB) and gentler air (-0.5 dB) for small desktop drivers.', + trimsDb: [0.0, -1.5, 0.0, 0.0, 0.0, -0.5, 0.0, 0.0], + ); + + static const headphone = ClarityPreset( + index: 2, + key: 'headphone', + displayName: 'Headphone', + description: 'Lighter bass (-0.5 dB) and softer presence (-1.0 dB) for close-coupled drivers.', + trimsDb: [0.0, -0.5, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0], + ); + + static const dac = ClarityPreset( + index: 3, + key: 'dac', + displayName: 'DAC', + description: 'Gentler air (-1.5 dB shelf, -1.0 dB exciter) for revealing external DAC chains.', + trimsDb: [0.0, 0.0, 0.0, 0.0, 0.0, -1.5, 0.0, -1.0], + ); + + static const List all = [ + reference, + speaker, + headphone, + dac, + ]; + + static ClarityPreset byKey(String key) { + for (final p in all) { + if (p.key.toLowerCase() == key.toLowerCase()) return p; + } + return reference; + } +} + +class EqualizerState { + final bool enabled; + final String presetName; + final List gainsDb; + final bool studioClarityEnabled; + final String clarityPresetKey; + + const EqualizerState({ + this.enabled = false, + this.presetName = 'Default', + this.gainsDb = const [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + this.studioClarityEnabled = false, + this.clarityPresetKey = 'reference', + }); + + EqualizerState copyWith({ + bool? enabled, + String? presetName, + List? gainsDb, + bool? studioClarityEnabled, + String? clarityPresetKey, + }) { + return EqualizerState( + enabled: enabled ?? this.enabled, + presetName: presetName ?? this.presetName, + gainsDb: gainsDb ?? this.gainsDb, + studioClarityEnabled: studioClarityEnabled ?? this.studioClarityEnabled, + clarityPresetKey: clarityPresetKey ?? this.clarityPresetKey, + ); + } + + /// Active ClarityPreset descriptor. + ClarityPreset get clarityPreset => ClarityPresets.byKey(clarityPresetKey); + + /// Combined curve with Studio Master Clarity trims factored in. + List get combinedGainsDb { + return List.generate(eqBandFreqsHz.length, (i) { + double gain = enabled ? gainsDb[i] : 0.0; + if (studioClarityEnabled) { + final clarity = ClarityPresets.byKey(clarityPresetKey); + final smGain = EqualizerPresets.studioMaster.gainsDb[i]; + double trim = 0.0; + if (i == 1) { + trim = clarity.trimsDb[1]; // 63Hz + } else if (i == 3) { + trim = clarity.trimsDb[2]; // 250Hz + } else if (i == 4) { + trim = clarity.trimsDb[3]; // 500Hz + } else if (i == 6 || i == 7) { + trim = clarity.trimsDb[4]; // 2k-4k presence + } else if (i >= 8) { + trim = clarity.trimsDb[5]; // 8k-16k air + } + gain += (smGain + trim); + } + return gain.clamp(-eqMaxGainDb, eqMaxGainDb); + }); + } + + /// Whether any audio filtering is active (EQ enabled or Studio Clarity active). + bool get isProcessingActive => enabled || studioClarityEnabled; +} diff --git a/lib/features/equalizer/equalizer_panel.dart b/lib/features/equalizer/equalizer_panel.dart new file mode 100644 index 0000000..3125aa4 --- /dev/null +++ b/lib/features/equalizer/equalizer_panel.dart @@ -0,0 +1,636 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../ui/components/buttons.dart' show LWTooltip; +import '../../ui/theme/tokens.dart'; +import 'equalizer_controller.dart'; +import 'equalizer_models.dart'; + +/// Contextual 10-band Equalizer and Studio Master Clarity panel. +/// +/// Designed with slide-over drawer styling matching LastWave design tokens. +class WaveEqualizerPanel extends ConsumerStatefulWidget { + final VoidCallback onClose; + const WaveEqualizerPanel({super.key, required this.onClose}); + + @override + ConsumerState createState() => _WaveEqualizerPanelState(); +} + +class _WaveEqualizerPanelState extends ConsumerState { + @override + Widget build(BuildContext context) { + final dark = waveIsDark(context); + final eq = ref.watch(equalizerProvider); + final notifier = ref.read(equalizerProvider.notifier); + + return Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + color: dark ? waveSurface(context) : WaveColors.lightSurface, + border: Border( + left: BorderSide( + color: waveDivider(context).withValues(alpha: dark ? 0.35 : 0.6), + width: 1, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + _buildHeader(context, dark, eq, notifier), + + // Content body + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + children: [ + // Studio Master Clarity card + _buildStudioClarityCard(context, dark, eq, notifier), + const SizedBox(height: 14), + + // Frequency response visualizer + _buildVisualizer(context, dark, eq), + const SizedBox(height: 14), + + // Presets chip selector + _buildPresetSelector(context, dark, eq, notifier), + const SizedBox(height: 16), + + // 10-band slider rack + _buildSliderRack(context, dark, eq, notifier), + const SizedBox(height: 16), + + // Bit-perfect status note + _buildFooterNote(context, dark, eq), + ], + ), + ), + ], + ), + ); + } + + Widget _buildHeader( + BuildContext context, + bool dark, + EqualizerState eq, + EqualizerNotifier notifier, + ) { + return Container( + height: WaveDensity.titleBar + 12, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: waveDivider(context).withValues(alpha: dark ? 0.25 : 0.4), + ), + ), + ), + child: Row( + children: [ + Icon( + FluentIcons.equalizer, + size: 18, + color: eq.isProcessingActive + ? WaveColors.defaultAccent + : waveTextSecondary(context), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Equalizer', + style: WaveType.sectionTitle.copyWith(fontSize: 14), + ), + Text( + eq.isProcessingActive + ? (eq.studioClarityEnabled + ? 'Studio Master Clarity active' + : '${eq.presetName} preset') + : 'Bypassed (Bit-Perfect output)', + style: WaveType.meta.copyWith( + fontSize: 11, + color: eq.isProcessingActive + ? WaveColors.defaultAccent + : waveTextSecondary(context), + ), + ), + ], + ), + ), + // Master switch + ToggleSwitch( + checked: eq.enabled, + onChanged: (val) => notifier.setEnabled(val), + ), + const SizedBox(width: 8), + // Reset button + LWTooltip( + message: 'Reset to flat', + child: IconButton( + icon: Icon( + FluentIcons.refresh, + size: 13, + color: waveTextSecondary(context), + ), + onPressed: notifier.reset, + ), + ), + const SizedBox(width: 4), + // Close button + LWTooltip( + message: 'Close equalizer', + child: IconButton( + icon: Icon( + FluentIcons.chrome_close, + size: 12, + color: waveTextSecondary(context), + ), + onPressed: widget.onClose, + ), + ), + ], + ), + ); + } + + Widget _buildStudioClarityCard( + BuildContext context, + bool dark, + EqualizerState eq, + EqualizerNotifier notifier, + ) { + final active = eq.studioClarityEnabled; + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(WaveRadius.menu), + color: active + ? (dark + ? const Color(0xFF1F1B14) + : const Color(0xFFFFF9ED)) + : (dark + ? waveSurfaceRaised(context) + : WaveColors.lightOverlay), + border: Border.all( + color: active + ? (dark + ? const Color(0xFF8A6D3B).withValues(alpha: 0.6) + : const Color(0xFFE5B760)) + : waveDivider(context).withValues(alpha: dark ? 0.35 : 0.6), + width: active ? 1.5 : 1, + ), + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + FluentIcons.favorite_star_fill, + size: 14, + color: active + ? const Color(0xFFF5A623) + : waveTextSecondary(context), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Studio Master Clarity', + style: WaveType.body.copyWith( + fontWeight: FontWeight.w600, + fontSize: 12.5, + color: active + ? (dark ? Colors.white : const Color(0xFF5A3E00)) + : waveTextPrimary(context), + ), + ), + Text( + 'Audiophile reference curve with targeted acoustic compensation', + style: WaveType.meta.copyWith( + fontSize: 10.5, + color: waveTextSecondary(context), + ), + ), + ], + ), + ), + ToggleSwitch( + checked: active, + onChanged: (val) => notifier.setStudioClarityEnabled(val), + ), + ], + ), + if (active) ...[ + const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: dark + ? Colors.black.withValues(alpha: 0.3) + : Colors.white.withValues(alpha: 0.8), + border: Border.all( + color: dark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06), + ), + ), + padding: const EdgeInsets.all(3), + child: Row( + children: [ + for (final target in ClarityPresets.all) + Expanded( + child: GestureDetector( + onTap: () => notifier.setClarityPreset(target.key), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.symmetric(vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: eq.clarityPresetKey == target.key + ? (dark + ? const Color(0xFF8A6D3B) + : const Color(0xFFE5B760)) + : Colors.transparent, + ), + child: Center( + child: Text( + target.displayName, + style: WaveType.label.copyWith( + fontSize: 10.5, + fontWeight: eq.clarityPresetKey == target.key + ? FontWeight.w600 + : FontWeight.normal, + color: eq.clarityPresetKey == target.key + ? (dark ? Colors.white : Colors.black) + : waveTextSecondary(context), + ), + ), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + Text( + ClarityPresets.byKey(eq.clarityPresetKey).description, + style: WaveType.meta.copyWith( + fontSize: 10.0, + fontStyle: FontStyle.italic, + color: waveTextSecondary(context), + ), + ), + ], + ], + ), + ); + } + + Widget _buildVisualizer( + BuildContext context, + bool dark, + EqualizerState eq, + ) { + return Container( + height: 72, + decoration: BoxDecoration( + color: dark ? const Color(0xFF101010) : const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(WaveRadius.menu), + border: Border.all( + color: waveDivider(context).withValues(alpha: dark ? 0.3 : 0.5), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(WaveRadius.menu - 2), + child: CustomPaint( + painter: _EqCurvePainter( + gainsDb: eq.combinedGainsDb, + active: eq.isProcessingActive, + dark: dark, + ), + child: const SizedBox.expand(), + ), + ), + ); + } + + Widget _buildPresetSelector( + BuildContext context, + bool dark, + EqualizerState eq, + EqualizerNotifier notifier, + ) { + return SizedBox( + height: 28, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: EqualizerPresets.all.length, + separatorBuilder: (_, _) => const SizedBox(width: 6), + itemBuilder: (context, index) { + final preset = EqualizerPresets.all[index]; + final isSelected = eq.presetName.toLowerCase() == preset.name.toLowerCase(); + final isStudioMaster = preset.name == EqualizerPresets.studioMaster.name; + + return GestureDetector( + onTap: () => notifier.applyPreset(preset), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: isSelected + ? (isStudioMaster + ? (dark ? const Color(0xFF8A6D3B) : const Color(0xFFE5B760)) + : (dark ? Colors.white : Colors.black)) + : (dark + ? waveSurfaceRaised(context) + : WaveColors.lightOverlay), + border: Border.all( + color: isSelected + ? Colors.transparent + : waveDivider(context).withValues(alpha: dark ? 0.3 : 0.5), + ), + ), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isStudioMaster) ...[ + Icon( + FluentIcons.favorite_star_fill, + size: 10, + color: isSelected + ? (dark ? Colors.white : Colors.black) + : const Color(0xFFF5A623), + ), + const SizedBox(width: 4), + ], + Text( + preset.name, + style: WaveType.label.copyWith( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? (dark ? Colors.black : Colors.white) + : waveTextSecondary(context), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildSliderRack( + BuildContext context, + bool dark, + EqualizerState eq, + EqualizerNotifier notifier, + ) { + final gains = eq.gainsDb; + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + decoration: BoxDecoration( + color: dark ? waveSurfaceRaised(context) : WaveColors.lightOverlay, + borderRadius: BorderRadius.circular(WaveRadius.menu), + border: Border.all( + color: waveDivider(context).withValues(alpha: dark ? 0.3 : 0.5), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (int i = 0; i < eqBandFreqsHz.length; i++) + _buildBandColumn( + context, + dark, + index: i, + freqHz: eqBandFreqsHz[i], + gainDb: gains[i], + enabled: eq.enabled, + onChanged: (val) => notifier.setBandGain(i, val), + onReset: () => notifier.setBandGain(i, 0.0), + ), + ], + ), + ); + } + + Widget _buildBandColumn( + BuildContext context, + bool dark, { + required int index, + required int freqHz, + required double gainDb, + required bool enabled, + required ValueChanged onChanged, + required VoidCallback onReset, + }) { + final isNonZero = gainDb.abs() >= 0.1; + final gainText = gainDb > 0 + ? '+${gainDb.toStringAsFixed(1)}' + : (gainDb < 0 ? gainDb.toStringAsFixed(1) : '0.0'); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Gain display (double-tap to reset) + GestureDetector( + onDoubleTap: onReset, + child: Text( + gainText, + style: WaveType.meta.copyWith( + fontSize: 10, + fontWeight: isNonZero ? FontWeight.w700 : FontWeight.normal, + color: isNonZero && enabled + ? (gainDb > 0 ? WaveColors.defaultAccent : const Color(0xFF64B5F6)) + : waveTextSecondary(context).withValues(alpha: 0.7), + ), + ), + ), + const SizedBox(height: 6), + + // Vertical Slider + SizedBox( + height: 140, + child: Slider( + vertical: true, + min: -eqMaxGainDb, + max: eqMaxGainDb, + value: gainDb, + onChanged: enabled ? onChanged : null, + ), + ), + const SizedBox(height: 6), + + // Frequency label + GestureDetector( + onDoubleTap: onReset, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: dark + ? Colors.white.withValues(alpha: 0.04) + : Colors.black.withValues(alpha: 0.03), + ), + child: Text( + eqBandLabel(freqHz), + style: WaveType.label.copyWith( + fontSize: 10, + color: waveTextSecondary(context), + ), + ), + ), + ), + ], + ); + } + + Widget _buildFooterNote( + BuildContext context, + bool dark, + EqualizerState eq, + ) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: dark + ? Colors.black.withValues(alpha: 0.2) + : Colors.black.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Icon( + FluentIcons.info, + size: 13, + color: waveTextSecondary(context), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + eq.isProcessingActive + ? '32-bit floating-point IIR filter graph applied in real time via libmpv.' + : 'Bypassed: Audio stream flows directly to audio output with bit-perfect integrity.', + style: WaveType.meta.copyWith( + fontSize: 10.5, + color: waveTextSecondary(context), + ), + ), + ), + ], + ), + ); + } +} + +/// Custom painter for smooth frequency response curve with subtle glow fill. +class _EqCurvePainter extends CustomPainter { + final List gainsDb; + final bool active; + final bool dark; + + _EqCurvePainter({ + required this.gainsDb, + required this.active, + required this.dark, + }); + + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + final zeroY = h / 2; + + // Draw baseline (0 dB) + final gridPaint = Paint() + ..color = (dark ? Colors.white : Colors.black).withValues(alpha: 0.08) + ..strokeWidth = 1.0; + canvas.drawLine(Offset(0, zeroY), Offset(w, zeroY), gridPaint); + + if (gainsDb.isEmpty) return; + + final n = gainsDb.length; + final points = []; + for (int i = 0; i < n; i++) { + final x = (i / (n - 1)) * w; + // Map -8 dB..+8 dB to h..0 + final norm = (gainsDb[i] / eqMaxGainDb).clamp(-1.0, 1.0); + final y = zeroY - (norm * (h * 0.42)); + points.add(Offset(x, y)); + } + + // Build smooth cubic path + final path = Path(); + path.moveTo(points.first.dx, points.first.dy); + for (int i = 0; i < points.length - 1; i++) { + final p0 = points[i]; + final p1 = points[i + 1]; + final cx = (p0.dx + p1.dx) / 2; + path.cubicTo(cx, p0.dy, cx, p1.dy, p1.dx, p1.dy); + } + + // Draw gradient fill below curve + final fillPath = Path.from(path) + ..lineTo(w, zeroY) + ..lineTo(0, zeroY) + ..close(); + + final accentColor = active + ? const Color(0xFFE5B760) + : (dark + ? Colors.white.withValues(alpha: 0.54) + : Colors.black.withValues(alpha: 0.38)); + + final fillPaint = Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + accentColor.withValues(alpha: active ? 0.28 : 0.08), + accentColor.withValues(alpha: 0.0), + ], + ).createShader(Rect.fromLTWH(0, 0, w, h)); + canvas.drawPath(fillPath, fillPaint); + + // Draw stroke line + final linePaint = Paint() + ..color = accentColor.withValues(alpha: active ? 0.95 : 0.45) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0 + ..strokeCap = StrokeCap.round; + canvas.drawPath(path, linePaint); + + // Draw node dots + final dotPaint = Paint() + ..color = accentColor + ..style = PaintingStyle.fill; + for (final p in points) { + canvas.drawCircle(p, 2.5, dotPaint); + } + } + + @override + bool shouldRepaint(covariant _EqCurvePainter oldDelegate) { + return oldDelegate.active != active || + oldDelegate.dark != dark || + oldDelegate.gainsDb != gainsDb; + } +} diff --git a/lib/features/lyrics/karaoke_lyrics_view.dart b/lib/features/lyrics/karaoke_lyrics_view.dart index b603831..d8750df 100644 --- a/lib/features/lyrics/karaoke_lyrics_view.dart +++ b/lib/features/lyrics/karaoke_lyrics_view.dart @@ -381,6 +381,22 @@ class _WaveKaraokeLyricsViewState extends ConsumerState ), ), data: (result) { + final effectiveResult = (result.lines.isEmpty && result.plainLyrics.isNotEmpty) + ? LyricsResult( + lines: result.plainLyrics + .split('\n') + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .map((l) => LyricLine(timeMs: 0, text: l)) + .toList(), + isSynced: false, + isWordSynced: false, + plainLyrics: result.plainLyrics, + isInstrumental: result.isInstrumental, + source: result.source, + ) + : result; + // Header controls (the source switcher + close) must survive the // no-lyrics states. Switching source can legitimately land on a // provider with no match, and dropping the switcher then strands @@ -394,7 +410,7 @@ class _WaveKaraokeLyricsViewState extends ConsumerState children: [ _KaraokeToolbar( track: widget.track, - result: result, + result: effectiveResult, offsetMs: offsetMs, showTransliteration: showTransliteration, following: _following, @@ -416,14 +432,14 @@ class _WaveKaraokeLyricsViewState extends ConsumerState ); } - if (result.isInstrumental) { + if (effectiveResult.isInstrumental) { return withHeader(const WaveEmpty( icon: FluentIcons.music_note, title: 'Instrumental', subtitle: 'No lyrics for this track.', )); } - if (result.isEmpty) { + if (effectiveResult.isEmpty) { return withHeader(WaveEmpty( icon: FluentIcons.microphone, title: 'No lyrics found', @@ -433,27 +449,18 @@ class _WaveKaraokeLyricsViewState extends ConsumerState ref.invalidate(waveLyricsProvider(widget.track)), )); } - if (!result.isSynced && result.lines.length < 2) { - return _KaraokePlainLyrics( - text: result.plainLyrics, - compact: widget.compact, - showHeaderControls: widget.showHeaderControls, - onClose: widget.onClose, - ); - } - if (_currentTrackKey != widget.track.queueKey || - _currentResult != result || + _currentResult != effectiveResult || _lastTransliteration != showTransliteration || _lastWordByWord != wordByWord) { _currentTrackKey = widget.track.queueKey; - _currentResult = result; + _currentResult = effectiveResult; _lastTransliteration = showTransliteration; _lastWordByWord = wordByWord; - if (result.lines.isNotEmpty) { + if (effectiveResult.lines.isNotEmpty && effectiveResult.isSynced) { final lineIndex = - activeLyricLineIndex(result.lines, _interpolatedPositionMs.value); + activeLyricLineIndex(effectiveResult.lines, _interpolatedPositionMs.value); if (_activeLineIndex.value != lineIndex) { _activeLineIndex.value = lineIndex; } @@ -469,7 +476,7 @@ class _WaveKaraokeLyricsViewState extends ConsumerState if (widget.showHeaderControls) _KaraokeToolbar( track: widget.track, - result: result, + result: effectiveResult, offsetMs: offsetMs, showTransliteration: showTransliteration, following: _following, @@ -491,7 +498,7 @@ class _WaveKaraokeLyricsViewState extends ConsumerState Positioned.fill( child: RepaintBoundary( child: _AppleLineLyricsView( - result: result, + result: effectiveResult, activeLineListenable: _activeLineIndex, positionListenable: _interpolatedPositionMs, compact: widget.compact, @@ -505,6 +512,7 @@ class _WaveKaraokeLyricsViewState extends ConsumerState setState(() => _following = true); }, onSeekLineMs: (lineMs) { + if (!effectiveResult.isSynced || lineMs <= 0) return; final seekTargetMs = lineMs + _offsetMs; ref.read(playbackServiceProvider.notifier).seek( Duration( @@ -514,7 +522,7 @@ class _WaveKaraokeLyricsViewState extends ConsumerState ), ), ), - if (!_following) + if (!_following && effectiveResult.isSynced) Positioned( right: 16, bottom: 16, @@ -1328,7 +1336,7 @@ class _KaraokeToolbar extends ConsumerWidget { ), const SizedBox(width: 8), // Timing Offset Controls: [-] offset [+] [Reset] - if (hasLyrics) Container( + if (hasLyrics && result.isSynced) Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), decoration: BoxDecoration( color: (dark ? Colors.white : Colors.black).withValues(alpha: 0.10), @@ -1382,7 +1390,7 @@ class _KaraokeToolbar extends ConsumerWidget { ], ), ), - if (hasLyrics) const SizedBox(width: 8), + if (hasLyrics && result.isSynced) const SizedBox(width: 8), // Transliteration / Romaji Toggle if (hasLyrics) LWTooltip( message: showTransliteration @@ -1394,8 +1402,8 @@ class _KaraokeToolbar extends ConsumerWidget { onTap: transliterationNotifier.toggle, ), ), - if (hasLyrics) const SizedBox(width: 4), - if (hasLyrics) LWTooltip( + if (hasLyrics && result.isSynced) const SizedBox(width: 4), + if (hasLyrics && result.isSynced) LWTooltip( message: following ? 'Pause automatic scrolling' : 'Return to the line being sung', @@ -1577,7 +1585,7 @@ class _AppleLineLyricsViewState extends State<_AppleLineLyricsView> { _lastIndex = -1; } if (widget.following && !oldWidget.following) { - final untimed = lyricsAreUntimed(widget.result.lines); + final untimed = !widget.result.isSynced || lyricsAreUntimed(widget.result.lines); _scrollTo( untimed ? 0 : widget.activeLineListenable.value, ); @@ -1635,7 +1643,7 @@ class _AppleLineLyricsViewState extends State<_AppleLineLyricsView> { @override Widget build(BuildContext context) { - final untimed = lyricsAreUntimed(widget.result.lines); + final untimed = !widget.result.isSynced || lyricsAreUntimed(widget.result.lines); final padding = EdgeInsets.fromLTRB( widget.compact ? 12 : 28, 24, @@ -1650,12 +1658,15 @@ class _AppleLineLyricsViewState extends State<_AppleLineLyricsView> { itemCount: widget.result.lines.length, itemBuilder: (context, i) { final line = widget.result.lines[i]; + final canSeek = widget.result.isSynced && line.timeMs > 0; return Padding( padding: const EdgeInsets.only(bottom: 4), child: MouseRegion( - cursor: SystemMouseCursors.click, + cursor: canSeek + ? SystemMouseCursors.click + : SystemMouseCursors.basic, child: GestureDetector( - onTap: () => widget.onSeekLineMs(line.timeMs), + onTap: canSeek ? () => widget.onSeekLineMs(line.timeMs) : null, child: WaveKaraokeLyricLine( line: line, positionMs: 0, @@ -1714,7 +1725,7 @@ class _AppleLineLyricsViewState extends State<_AppleLineLyricsView> { compact: widget.compact, fontSize: widget.fontSize, showTransliteration: widget.showTransliteration, - karaoke: widget.result.isSynced, + karaoke: widget.result.isWordSynced, softenIdle: true, ), ), @@ -1729,7 +1740,7 @@ class _AppleLineLyricsViewState extends State<_AppleLineLyricsView> { return Stack( children: [ list, - if (!widget.following) + if (!widget.following && widget.result.isSynced) Positioned( right: 16, bottom: 16, @@ -1739,77 +1750,3 @@ class _AppleLineLyricsViewState extends State<_AppleLineLyricsView> { ); } } - -class _KaraokePlainLyrics extends StatelessWidget { - final String text; - final bool compact; - final bool showHeaderControls; - final VoidCallback? onClose; - - const _KaraokePlainLyrics({ - required this.text, - this.compact = false, - this.showHeaderControls = true, - this.onClose, - }); - - @override - Widget build(BuildContext context) { - final dark = waveIsDark(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showHeaderControls) - Padding( - padding: EdgeInsets.symmetric( - horizontal: compact ? 12 : 24, - vertical: 8, - ), - child: Row( - children: [ - Text( - 'PLAIN LYRICS', - style: WaveType.overline.copyWith( - fontSize: 9.5, - color: waveAccent(context), - ), - ), - const Spacer(), - if (onClose != null) - _MiniIconButton( - icon: WaveIcons.close, - onTap: onClose!, - ), - ], - ), - ), - Expanded( - child: ListView( - physics: const ClampingScrollPhysics(), - padding: EdgeInsets.all(compact ? 14 : 28), - children: [ - Align( - alignment: Alignment.centerLeft, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: WaveDensity.lyricMax, - ), - child: SelectableText( - text, - style: WaveType.body.copyWith( - height: 1.75, - fontSize: compact ? 18 : 22, - color: dark - ? WaveColors.textPrimary - : WaveColors.lightTextPrimary, - ), - ), - ), - ), - ], - ), - ), - ], - ); - } -} diff --git a/lib/features/lyrics/lyrics_models.dart b/lib/features/lyrics/lyrics_models.dart index f384482..39673c1 100644 --- a/lib/features/lyrics/lyrics_models.dart +++ b/lib/features/lyrics/lyrics_models.dart @@ -276,6 +276,23 @@ LyricsResult ensureMillisecondTimestamps(LyricsResult result) { /// wipe lasts until the next word instead of flashing then jumping. LyricsResult normalizeKaraokeTimings(LyricsResult result) { result = ensureMillisecondTimestamps(result); + if (result.lines.isEmpty && result.plainLyrics.isNotEmpty) { + final rawLines = result.plainLyrics + .split('\n') + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .toList(); + result = LyricsResult( + lines: [ + for (final l in rawLines) LyricLine(timeMs: 0, text: l), + ], + isSynced: result.isSynced, + isWordSynced: result.isWordSynced, + plainLyrics: result.plainLyrics, + isInstrumental: result.isInstrumental, + source: result.source, + ); + } if (!result.isSynced || result.lines.isEmpty) return result; final src = List.of(result.lines) ..sort((a, b) => a.timeMs.compareTo(b.timeMs)); diff --git a/lib/features/lyrics/lyrics_providers.dart b/lib/features/lyrics/lyrics_providers.dart index c985b2b..eb9c169 100644 --- a/lib/features/lyrics/lyrics_providers.dart +++ b/lib/features/lyrics/lyrics_providers.dart @@ -1769,8 +1769,14 @@ Future fetchGeniusLyrics( } final text = sb.toString().trim(); if (text.isNotEmpty) { + final lines = text + .split('\n') + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .map((l) => LyricLine(timeMs: 0, text: l)) + .toList(); return LyricsResult( - lines: const [], + lines: lines, isSynced: false, isWordSynced: false, plainLyrics: text, diff --git a/lib/features/lyrics/lyrics_repository.dart b/lib/features/lyrics/lyrics_repository.dart index fe954f4..14f619e 100644 --- a/lib/features/lyrics/lyrics_repository.dart +++ b/lib/features/lyrics/lyrics_repository.dart @@ -462,7 +462,7 @@ class LyricsRepository { if (preferred == LyricsProviderId.lrclib) lrclibAttempted = true; final first = normalize(await attempt(preferred.id)); if (first != null) { - if (first.isWordSynced || (first.isInstrumental && wantsAlt)) { + if (first.isWordSynced || first.isSynced || (first.isInstrumental && wantsAlt) || !wordByWord) { _store(key, first, queryTitle: title); return display(first); } @@ -495,11 +495,9 @@ class LyricsRepository { single = null; } if (single != null && plausible(single)) { - if (single.isWordSynced && wordByWord) { - _store(key, single, queryTitle: title); - return display(single); - } - if (single.isInstrumental && wantsAlt) { + if ((single.isWordSynced && wordByWord) || + (single.isSynced && !wordByWord) || + (single.isInstrumental && wantsAlt)) { _store(key, single, queryTitle: title); return display(single); } @@ -581,14 +579,26 @@ class LyricsRepository { return display(won); } } else { - // Line mode: Apple + LRCLIB race for line sources under the same - // deadline (pre-port behavior), with the explicit pick above - // outranking whatever they find. - final pending = >[attempt('apple_music')]; - if (!lrclibAttempted) { - lrclibAttempted = true; - pending.add(attempt('lrclib')); - } + // Line mode: race all candidate providers for line sources under the deadline. + final pending = >[ + for (final id in const [ + 'lastwave', + 'lrc_red', + 'apple_music', + 'better_lyrics', + 'better_lyrics_portato', + 'bini_lyrics', + 'lyrics_plus', + 'unison', + 'kugou', + 'simp_music', + 'lrclib', + ]) + if (id != 'simp_music' || (videoId?.isNotEmpty ?? false)) + if (id != 'lrclib' || !lrclibAttempted) + attempt(id), + ]; + if (!lrclibAttempted) lrclibAttempted = true; try { await drainRace(pending).timeout(raceTotal); } on TimeoutException { @@ -603,46 +613,54 @@ class LyricsRepository { return display(preferredSettled); } final lineSettled = lineFallback; - if (lineSettled != null) { + if (lineSettled != null && lineSettled.isSynced) { _store(key, lineSettled, queryTitle: title); return display(lineSettled); } // Extra line-sync & plain catalogue after the race. final mxm = normalize(await attempt('musixmatch')); - if (mxm != null && plausible(mxm)) { + if (mxm != null && mxm.isSynced && plausible(mxm)) { _store(key, mxm, queryTitle: title); return display(mxm); } final mb = normalize(await attempt('megalobiz')); - if (mb != null && plausible(mb)) { + if (mb != null && mb.isSynced && plausible(mb)) { _store(key, mb, queryTitle: title); return display(mb); } if (videoId?.isNotEmpty ?? false) { final captions = normalize(await attempt('youtube_captions')); - if (captions != null && plausible(captions)) { + if (captions != null && captions.isSynced && plausible(captions)) { _store(key, captions, queryTitle: title); return display(captions); } - - final ytm = normalize(await attempt('youtube_music')); - if (ytm != null && plausible(ytm)) { - _store(key, ytm, queryTitle: title); - return display(ytm); - } } // LRCLIB line fallback (skipped when already tried as preferred). if (!lrclibAttempted) { final lrc = normalize(await attempt('lrclib')); - if (lrc != null) { + if (lrc != null && lrc.isSynced && plausible(lrc)) { _store(key, lrc, queryTitle: title); return display(lrc); } } + // Unsynced fallback if any provider returned unsynced/plain text earlier. + if (lineSettled != null && !lineSettled.isEmpty) { + _store(key, lineSettled, queryTitle: title); + return display(lineSettled); + } + + if (videoId?.isNotEmpty ?? false) { + final ytm = normalize(await attempt('youtube_music')); + if (ytm != null && plausible(ytm)) { + _store(key, ytm, queryTitle: title); + return display(ytm); + } + } + // Genius static text fallback. final gn = normalize(await attempt('genius')); if (gn != null && plausible(gn)) { diff --git a/lib/features/player/playback_service.dart b/lib/features/player/playback_service.dart index 3d3c6fe..9b6de10 100644 --- a/lib/features/player/playback_service.dart +++ b/lib/features/player/playback_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -147,6 +148,23 @@ class PlaybackService extends StateNotifier { /// the next open waits for idle first — otherwise loadfile races /// the dying demuxer and corrupts the heap. bool _lastWasDash = false; + String _currentAudioFilter = ''; + String _currentAudioDevice = 'auto'; + + // -- Dual-engine Crossfade & Standby state ------------------------------- + Player? _standbyPlayer; + bool _standbyArmed = false; + PlayableTrack? _standbyTrack; + ResolvedStream? _standbyStream; + int _standbyIndex = -1; + bool _crossfading = false; + DateTime? _crossfadeStartTime; + Duration? _crossfadeDuration; + Timer? _crossfadeTimer; + double _crossfadeTargetVolume = 1.0; + String _crossfadeResolvedKey = ''; + DateTime _lastCrossfadeResolveAttempt = + DateTime.fromMillisecondsSinceEpoch(0); /// Crash-surviving breadcrumb file (`/lastwave/mpv-ops.log`). /// Written synchronously with flush BEFORE each native op, so the @@ -288,6 +306,11 @@ class PlaybackService extends StateNotifier { // YouTube too — strictly more patient, never less. await dyn.setProperty('stream-lavf-o', 'timeout=15000000'); } catch (_) {} + if (_currentAudioFilter.isNotEmpty) { + try { + await dyn.setProperty('af', _currentAudioFilter); + } catch (_) {} + } } } catch (_) {} // Crash-surviving trail: mpv logs to disk continuously and Dart @@ -311,6 +334,11 @@ class PlaybackService extends StateNotifier { } catch (_) {} } catch (_) {} final p = _player!; + _bindPlayerStreams(p); + await restoreSession(); + } + + void _bindPlayerStreams(Player p) { _subs.addAll([ p.stream.playing.listen((v) { runGuarded('player.playing', () { @@ -338,6 +366,7 @@ class PlaybackService extends StateNotifier { // prefetch is once-per-track. Saves 60Hz DateTime+prefs wakes. _tickScrobble(v); _maybePrefetchFromPosition(v); + _updateCrossfadeSchedule(v); state = state.copyWith(position: v); }); }), @@ -410,7 +439,350 @@ class PlaybackService extends StateNotifier { }); }), ]); - await restoreSession(); + } + + void _rebindPlayerStreams() { + for (final s in _subs) { + try { + s.cancel(); + } catch (_) {} + } + _subs.clear(); + final p = _player; + if (p != null) { + _bindPlayerStreams(p); + } + } + + // -- Crossfade & Standby hand-off engine --------------------------------- + + Future _ensureStandbyPlayer() async { + if (_standbyPlayer != null) return; + MediaKit.ensureInitialized(); + final sb = Player( + configuration: const PlayerConfiguration( + vo: 'null', + title: 'LastWave Standby', + bufferSize: 16 * 1024 * 1024, + ), + ); + _standbyPlayer = sb; + try { + final platform = sb.platform as dynamic; + if (platform != null) { + try { + await platform.setProperty('gapless-audio', 'yes'); + } catch (_) {} + try { + await platform.setProperty('cache', 'yes'); + } catch (_) {} + try { + await platform.setProperty('cache-on-disk', 'yes'); + } catch (_) {} + final cacheDir = mpvCacheDirPath(); + await ensureDirExists(cacheDir); + try { + await platform.setProperty('cache-dir', cacheDir); + } catch (_) {} + try { + await platform.setProperty( + 'demuxer-max-bytes', '${16 * 1024 * 1024}'); + } catch (_) {} + try { + await platform.setProperty('vid', 'no'); + } catch (_) {} + try { + await platform.setProperty('stream-lavf-o', 'timeout=15000000'); + } catch (_) {} + if (_currentAudioDevice.isNotEmpty && _currentAudioDevice != 'auto') { + try { + await sb.setAudioDevice(AudioDevice(_currentAudioDevice, '')); + } catch (_) {} + } + if (_currentAudioFilter.isNotEmpty) { + try { + await platform.setProperty('af', _currentAudioFilter); + } catch (_) {} + } + } + } catch (_) {} + } + + void cancelCrossfade() { + _crossfadeTimer?.cancel(); + _crossfadeTimer = null; + if (_crossfading || _standbyArmed) { + try { + _standbyPlayer?.stop(); + _standbyPlayer?.setVolume(0); + } catch (_) {} + try { + _player?.setVolume(state.volume * 100); + } catch (_) {} + } + _crossfading = false; + _standbyArmed = false; + _standbyTrack = null; + _standbyStream = null; + _standbyIndex = -1; + } + + void _updateCrossfadeSchedule(Duration position) { + if (!_prefs.crossfadeEnabled) { + if (_standbyArmed || _crossfading) cancelCrossfade(); + return; + } + if (exclusiveApplied || _prefs.wasapiExclusive) { + if (_standbyArmed || _crossfading) cancelCrossfade(); + return; + } + if (state.repeatMode == RepeatMode.one) { + if (_standbyArmed || _crossfading) cancelCrossfade(); + return; + } + if (!state.isPlaying || _resolving || state.error != null) return; + final duration = state.duration; + if (duration.inSeconds < 10) return; + final remaining = duration - position; + + final nextIndex = _nextIndex(); + if (nextIndex == null || !_queueIndexValid(nextIndex)) { + if (_standbyArmed) cancelCrossfade(); + return; + } + final nextTrack = state.queue[nextIndex]; + if (_standbyArmed && _standbyTrack?.queueKey != nextTrack.queueKey) { + cancelCrossfade(); + } + + // 1. Pre-resolve next track (lossless first) ~30s before track end + if (remaining <= const Duration(seconds: 30) && remaining > Duration.zero) { + _maybeEarlyResolveNextTrack(nextTrack, nextIndex); + } + + final crossfadeSec = _prefs.crossfadeSeconds.clamp(3, 12); + final fadeDuration = Duration(seconds: crossfadeSec); + + // 2. Arm standby player 6s before fade window + if (remaining <= fadeDuration + const Duration(seconds: 6) && + !_crossfading && + !_standbyArmed) { + _armStandbyForNext(nextTrack, nextIndex); + } + + // 3. Start crossfade when position enters the fade window + if (remaining <= fadeDuration && + _standbyArmed && + !_crossfading && + _standbyPlayer != null) { + _startCrossfade(fadeDuration); + } + } + + void _maybeEarlyResolveNextTrack(PlayableTrack nextTrack, int nextIndex) { + final cacheKey = _playCacheKey(nextTrack, false); + if (_playCache.get(cacheKey) != null) return; + if (_resolveInflight.containsKey(cacheKey)) return; + final now = DateTime.now(); + if (nextTrack.queueKey == _crossfadeResolvedKey && + now.difference(_lastCrossfadeResolveAttempt).inSeconds < 10) { + return; + } + _crossfadeResolvedKey = nextTrack.queueKey; + _lastCrossfadeResolveAttempt = now; + unawaited(_resolveRemote(nextTrack).then((stream) { + if (stream != null) { + final currentNext = _nextIndex(); + if (currentNext == null || !_queueIndexValid(currentNext)) return; + if (state.queue[currentNext].queueKey != nextTrack.queueKey) return; + if (exclusiveApplied || _prefs.wasapiExclusive) return; + if (state.repeatMode == RepeatMode.one) return; + + final pos = state.position; + final dur = state.duration; + final rem = dur - pos; + final fadeSec = _prefs.crossfadeSeconds.clamp(3, 12); + if (rem <= Duration(seconds: fadeSec + 6) && + !_crossfading && + !_standbyArmed && + state.isPlaying) { + _armStandbyForNext(nextTrack, currentNext); + } + } + }, onError: (_) {})); + } + + Future _armStandbyForNext( + PlayableTrack nextTrack, int nextIndex) async { + if (_standbyArmed || _crossfading) return; + if (exclusiveApplied || _prefs.wasapiExclusive) return; + if (state.repeatMode == RepeatMode.one) return; + + final cacheKey = _playCacheKey(nextTrack, false); + final stream = _localStream(nextTrack) ?? _playCache.get(cacheKey); + if (stream == null) { + // Stream resolution is currently in-flight; will arm when finished + return; + } + _standbyArmed = true; + _standbyTrack = nextTrack; + _standbyIndex = nextIndex; + _standbyStream = stream; + + await _ensureStandbyPlayer(); + final sb = _standbyPlayer; + if (sb == null) { + _standbyArmed = false; + _standbyTrack = null; + _standbyStream = null; + _standbyIndex = -1; + return; + } + try { + await sb.setVolume(0); + var playUrl = stream.url; + if (!playUrl.startsWith('http') && !playUrl.startsWith('file:')) { + try { + playUrl = Uri.file(playUrl).toString(); + } catch (_) {} + } + await _serializedMpv(() async { + await sb.open( + Media(playUrl, httpHeaders: stream.requestHeaders), + play: false, + ); + }); + // Safety checks: cancel if playback state or track shifted during open + if (!_standbyArmed || + _standbyTrack?.queueKey != nextTrack.queueKey || + !state.isPlaying || + exclusiveApplied || + _prefs.wasapiExclusive) { + try { + await sb.stop(); + } catch (_) {} + _standbyArmed = false; + _standbyTrack = null; + _standbyStream = null; + _standbyIndex = -1; + return; + } + } catch (_) { + _standbyArmed = false; + _standbyTrack = null; + _standbyStream = null; + _standbyIndex = -1; + } + } + + void _startCrossfade(Duration fadeDuration) { + if (_crossfading || !_standbyArmed || _standbyPlayer == null) return; + _crossfading = true; + _crossfadeDuration = fadeDuration; + _crossfadeStartTime = DateTime.now(); + _crossfadeTargetVolume = state.volume; + + try { + unawaited(_standbyPlayer!.play()); + } catch (_) { + cancelCrossfade(); + return; + } + + _crossfadeTimer?.cancel(); + _crossfadeTimer = + Timer.periodic(const Duration(milliseconds: 25), (timer) { + if (!_crossfading) { + timer.cancel(); + return; + } + final elapsed = DateTime.now().difference(_crossfadeStartTime!); + final progress = (elapsed.inMilliseconds / + _crossfadeDuration!.inMilliseconds) + .clamp(0.0, 1.0); + final masterVol100 = (_crossfadeTargetVolume * 100).clamp(0.0, 100.0); + + // Perceptual equal-power curve (sin/cos preserves energy level) + final outVol = math.cos(progress * math.pi / 2) * masterVol100; + final inVol = math.sin(progress * math.pi / 2) * masterVol100; + + try { + _player?.setVolume(outVol.clamp(0.0, 100.0)); + _standbyPlayer?.setVolume(inVol.clamp(0.0, 100.0)); + } catch (_) {} + + if (progress >= 1.0) { + timer.cancel(); + _completeCrossfade(); + } + }); + } + + void _completeCrossfade() { + if (!_crossfading) return; + _crossfadeTimer?.cancel(); + _crossfadeTimer = null; + + final incomingTrack = _standbyTrack; + final incomingIndex = _standbyIndex; + final incomingStream = _standbyStream; + final sb = _standbyPlayer; + + if (incomingTrack == null || sb == null) { + cancelCrossfade(); + return; + } + + _flushScrobble(completed: true); + + final oldPrimary = _player; + try { + oldPrimary?.stop(); + oldPrimary?.setVolume(0); + } catch (_) {} + + try { + sb.setVolume((_crossfadeTargetVolume * 100).clamp(0.0, 100.0)); + if (state.speed != 1.0) { + sb.setRate(state.speed); + } + } catch (_) {} + _player = sb; + _standbyPlayer = oldPrimary; + + _rebindPlayerStreams(); + + final trackDur = sb.state.duration; + + _activeQueueKey = incomingTrack.queueKey; + _audioClockKey = incomingTrack.queueKey; + _audioClockStart = DateTime.now(); + _audioOpenDoneAt = DateTime.now(); + + state = state.copyWith( + currentIndex: incomingIndex, + current: incomingTrack, + stream: incomingStream, + bitrateKbps: incomingStream?.bitrateKbps, + isPlaying: true, + isBuffering: false, + position: Duration.zero, + duration: trackDur, + clearError: true, + ); + + _beginScrobbleWindow(incomingTrack); + + _crossfading = false; + _standbyArmed = false; + _standbyTrack = null; + _standbyStream = null; + _standbyIndex = -1; + + unawaited(_refreshAoFormat()); + _prefetchNeighbors(); + _maybeRefillRadio(); + _schedulePersist(); } /// Ordered teardown for app quit: awaits the queued mpv stop/dispose @@ -419,6 +791,7 @@ class PlaybackService extends StateNotifier { Future disposePlayer() async { // Flush any past-threshold window before tearing down (manual pause/ // app close would otherwise leave it only as “Scrobbling now”). + cancelCrossfade(); _flushScrobble(completed: false); _resolveGeneration++; _resolving = false; @@ -430,10 +803,14 @@ class PlaybackService extends StateNotifier { _subs.clear(); _sleepTimer?.cancel(); _persistThrottle?.cancel(); - // Queue teardown behind any in-flight open: disposing mid-loadfile - // is the same heap race as overlapping stop/open (Alt+F4 path). - // Awaited (not fire-and-forget): the quit path must not destroy - // the native window while the mpv dispose is still in flight. + final standby = _standbyPlayer; + _standbyPlayer = null; + if (standby != null) { + try { + await standby.stop(); + await standby.dispose(); + } catch (_) {} + } final player = _player; _player = null; if (player != null) { @@ -468,6 +845,7 @@ class PlaybackService extends StateNotifier { bool startShuffled = false, bool endlessRadio = false, }) async { + cancelCrossfade(); await ensurePlayer(); if (tracks.isEmpty) return; var index = startIndex.clamp(0, tracks.length - 1); @@ -619,11 +997,13 @@ class PlaybackService extends StateNotifier { } Future pause() async { + cancelCrossfade(); await _player?.pause(); _schedulePersist(); } Future seek(Duration position) async { + cancelCrossfade(); await _player?.seek(position); state = state.copyWith(position: position); } @@ -635,6 +1015,10 @@ class PlaybackService extends StateNotifier { await _player?.setVolume(100); return; } + if (_crossfading) { + _crossfadeTargetVolume = v; + return; + } await _player?.setVolume(v * 100); } @@ -656,6 +1040,18 @@ class PlaybackService extends StateNotifier { return; } _lockSoftwareVolume = lockSoftwareVolume; + _currentAudioDevice = mpvDevice; + if (exclusive) { + cancelCrossfade(); + final standby = _standbyPlayer; + _standbyPlayer = null; + if (standby != null) { + try { + await standby.stop(); + await standby.dispose(); + } catch (_) {} + } + } final player = _player; if (player == null) { exclusiveApplied = false; @@ -734,6 +1130,25 @@ class PlaybackService extends StateNotifier { unawaited(_refreshAoFormat()); } + /// Sets or clears audio filter (e.g. 10-band equalizer) in libmpv. + Future setAudioFilter(String af) async { + _currentAudioFilter = af; + final player = _player; + if (player != null) { + try { + final dyn = player.platform as dynamic; + await dyn.setProperty('af', af); + } catch (_) {} + } + final sb = _standbyPlayer; + if (sb != null) { + try { + final dyn = sb.platform as dynamic; + await dyn.setProperty('af', af); + } catch (_) {} + } + } + Future _refreshAoFormat() async { final player = _player; if (player == null) return; @@ -764,6 +1179,7 @@ class PlaybackService extends StateNotifier { } Future next() async { + cancelCrossfade(); final nextIndex = _nextIndex(); if (nextIndex == null) return; state = state.copyWith( @@ -778,6 +1194,7 @@ class PlaybackService extends StateNotifier { } Future previous() async { + cancelCrossfade(); if (state.position > const Duration(seconds: 5)) { await seek(Duration.zero); return; @@ -800,6 +1217,7 @@ class PlaybackService extends StateNotifier { Future seekToQueueItem(int index) async { if (index < 0 || index >= state.queue.length) return; + cancelCrossfade(); state = state.copyWith( currentIndex: index, current: state.queue[index], @@ -812,6 +1230,7 @@ class PlaybackService extends StateNotifier { } Future toggleShuffle() async { + cancelCrossfade(); final enabled = !state.shuffleEnabled; _rebuildShuffleOrder(state.queue.length, state.currentIndex); state = state.copyWith(shuffleEnabled: enabled); @@ -819,6 +1238,7 @@ class PlaybackService extends StateNotifier { } Future cycleRepeat() async { + cancelCrossfade(); final next = switch (state.repeatMode) { RepeatMode.off => RepeatMode.all, RepeatMode.all => RepeatMode.one, @@ -876,6 +1296,7 @@ class PlaybackService extends StateNotifier { } Future stopAndClear() async { + cancelCrossfade(); _flushScrobble(completed: false); _resolveGeneration++; _resolving = false; @@ -885,6 +1306,7 @@ class PlaybackService extends StateNotifier { try { await _serializedMpv(() async { await _player?.stop(); + await _standbyPlayer?.stop(); }); } catch (_) {} _lastWasDash = false; @@ -1385,6 +1807,7 @@ class PlaybackService extends StateNotifier { wantedQueueKey)) { return; } + cancelCrossfade(); // Upgrade track artwork if lossless stream supplied official studio artwork var effectiveTrack = track; @@ -1627,6 +2050,11 @@ class PlaybackService extends StateNotifier { } void _onTrackCompleted() { + if (_crossfading) { + _completeCrossfade(); + return; + } + cancelCrossfade(); if (_resolving || state.error != null) return; _flushScrobble(completed: true); if (state.duration - state.position > const Duration(seconds: 10)) { @@ -2169,6 +2597,9 @@ class PrefsHandle { bool get scrobblerEnabled => _prefs.scrobblerEnabled; bool get syncYtHistory => _prefs.syncYtHistory; bool get liveTelemetryEnabled => _prefs.liveTelemetryEnabled; + bool get crossfadeEnabled => _prefs.crossfadeEnabled; + int get crossfadeSeconds => _prefs.crossfadeSeconds; + bool get wasapiExclusive => _prefs.wasapiExclusive; Prefs get rawPrefs => _prefs; } diff --git a/lib/ui/app_shell/app_shell.dart b/lib/ui/app_shell/app_shell.dart index cbdf57a..9c75a79 100644 --- a/lib/ui/app_shell/app_shell.dart +++ b/lib/ui/app_shell/app_shell.dart @@ -12,6 +12,7 @@ import 'package:window_manager/window_manager.dart'; import '../../app/window.dart'; import '../../app/window_lifecycle.dart'; import '../../core/audio/stream_models.dart'; +import '../../features/equalizer/equalizer_panel.dart'; import '../../features/player/playback_service.dart'; import '../../features/search/search_repository.dart'; import '../mini_player/mini_player.dart'; @@ -78,8 +79,97 @@ class _WaveShellState extends ConsumerState { bool _railExpanded = false; bool _queueOpen = false; bool _lyricsOpen = false; + bool _equalizerOpen = false; bool _miniOpen = false; bool _draggingFiles = false; + + Size? _savedWindowSize; + Offset? _savedWindowPos; + bool _savedWasMaximized = false; + Offset? _lastMiniPos; + + void _toggleMiniPlayer() { + if (_miniOpen) { + _exitMiniPlayer(); + } else { + _enterMiniPlayer(); + } + } + + Future _enterMiniPlayer() async { + final hasTrack = + ref.read(playbackServiceProvider.select((s) => s.current != null)); + if (!hasTrack) return; + + try { + _savedWasMaximized = await windowManager.isMaximized(); + if (_savedWasMaximized) { + await windowManager.unmaximize(); + await Future.delayed(const Duration(milliseconds: 60)); + } + _savedWindowSize = await windowManager.getSize(); + _savedWindowPos = await windowManager.getPosition(); + } catch (_) {} + + if (!mounted) return; + setState(() { + _miniOpen = true; + _queueOpen = false; + _lyricsOpen = false; + _equalizerOpen = false; + }); + + try { + await windowManager.setMinimumSize(const Size(300, 64)); + const miniSize = Size(380, 78); + await windowManager.setSize(miniSize); + if (_lastMiniPos != null) { + await windowManager.setPosition(_lastMiniPos!); + } else if (_savedWindowPos != null && _savedWindowSize != null) { + final targetX = _savedWindowPos!.dx + + (_savedWindowSize!.width - miniSize.width - 24); + final targetY = _savedWindowPos!.dy + + (_savedWindowSize!.height - miniSize.height - 24); + await windowManager.setPosition(Offset( + targetX.clamp(10.0, 10000.0), + targetY.clamp(10.0, 10000.0), + )); + } + await windowManager.setAlwaysOnTop(true); + } catch (_) {} + } + + Future _exitMiniPlayer({bool expand = false}) async { + if (!_miniOpen) return; + + try { + _lastMiniPos = await windowManager.getPosition(); + } catch (_) {} + + if (!mounted) return; + setState(() { + _miniOpen = false; + }); + + try { + await windowManager.setAlwaysOnTop(false); + await windowManager.setMinimumSize(const Size(1024, 640)); + if (_savedWasMaximized) { + await windowManager.maximize(); + } else { + final restoreSize = _savedWindowSize ?? const Size(1360, 860); + final restorePos = _savedWindowPos; + if (restorePos != null) { + await windowManager.setPosition(restorePos); + } + await windowManager.setSize(restoreSize); + } + } catch (_) {} + + if (expand) { + _go('/now'); + } + } late final TextEditingController _searchController; final FocusNode _searchFocus = FocusNode(); final List _backStack = []; @@ -94,6 +184,7 @@ class _WaveShellState extends ConsumerState { // only on visibility flips (reopen refollows to the current line). Widget? _queuePanelCache; Widget? _lyricsPanelCache; + Widget? _equalizerPanelCache; bool _lyricsCacheVisible = false; // Memoized title bar (owns the search box + its TextField machinery): // the shell rebuilds on every navigation, which would otherwise @@ -110,10 +201,12 @@ class _WaveShellState extends ConsumerState { Widget? _dockCache; bool _dockLyricsActive = false; bool _dockQueueActive = false; + bool _dockEqualizerActive = false; bool _dockIsLyrics = false; void _closeQueue() => setState(() => _queueOpen = false); void _closeLyrics() => setState(() => _lyricsOpen = false); + void _closeEqualizer() => setState(() => _equalizerOpen = false); /// Cached dock: same instance across navigations (glyphs + cover art /// skip reconstruction). Rebuilt only when a behaviorally relevant @@ -121,14 +214,17 @@ class _WaveShellState extends ConsumerState { Widget _dockFor({ required bool lyricsActive, required bool queueActive, + required bool equalizerActive, required bool isLyrics, }) { if (_dockCache == null || _dockLyricsActive != lyricsActive || _dockQueueActive != queueActive || + _dockEqualizerActive != equalizerActive || _dockIsLyrics != isLyrics) { _dockLyricsActive = lyricsActive; _dockQueueActive = queueActive; + _dockEqualizerActive = equalizerActive; _dockIsLyrics = isLyrics; // The dock is structural: reserves 80px on every view, including // Now Playing — its in-page transport was removed so this dock is @@ -137,20 +233,34 @@ class _WaveShellState extends ConsumerState { onExpand: () => _go('/now'), lyricsActive: lyricsActive, queueActive: queueActive, - onToggleMini: () => setState(() => _miniOpen = !_miniOpen), + equalizerActive: equalizerActive, + onToggleMini: _toggleMiniPlayer, onToggleLyrics: () { if (isLyrics) { _go('/now'); } else { setState(() { _lyricsOpen = !_lyricsOpen; - if (_lyricsOpen) _queueOpen = false; + if (_lyricsOpen) { + _queueOpen = false; + _equalizerOpen = false; + } }); } }, onToggleQueue: () => setState(() { _queueOpen = !_queueOpen; - if (_queueOpen) _lyricsOpen = false; + if (_queueOpen) { + _lyricsOpen = false; + _equalizerOpen = false; + } + }), + onToggleEqualizer: () => setState(() { + _equalizerOpen = !_equalizerOpen; + if (_equalizerOpen) { + _queueOpen = false; + _lyricsOpen = false; + } }), ); } @@ -269,6 +379,9 @@ class _WaveShellState extends ConsumerState { if (_searchFocus.hasFocus) { _searchFocus.unfocus(); return true; + } else if (_equalizerOpen) { + setState(() => _equalizerOpen = false); + return true; } else if (_lyricsOpen) { setState(() => _lyricsOpen = false); return true; @@ -276,7 +389,7 @@ class _WaveShellState extends ConsumerState { setState(() => _queueOpen = false); return true; } else if (_miniOpen) { - setState(() => _miniOpen = false); + _exitMiniPlayer(); return true; } else if (_lastCollapsed == false) { // Overlay rail light-dismisses via keyboard too. @@ -423,6 +536,7 @@ class _WaveShellState extends ConsumerState { setState(() { _queueOpen = false; _lyricsOpen = false; + _equalizerOpen = false; }); if (path == widget.location) { _historyLocked = false; @@ -482,6 +596,7 @@ class _WaveShellState extends ConsumerState { setState(() { _queueOpen = false; _lyricsOpen = false; + _equalizerOpen = false; }); context.go('/search?q=${Uri.encodeComponent(q)}'); _searchFocus.unfocus(); @@ -560,6 +675,16 @@ class _WaveShellState extends ConsumerState { _lastCollapsed = collapsed; _lastIsNowPlaying = isNowPlaying; + if (_miniOpen) { + return Mica( + backgroundColor: waveBackground(context), + child: WaveMiniPlayer( + onClose: () => _exitMiniPlayer(), + onExpand: () => _exitMiniPlayer(expand: true), + ), + ); + } + // NOTE: no CallbackShortcuts / Focus(onKeyEvent) wrapper here on // purpose. Those only fire via focus-bubbling, so focused buttons and // lists ate Space/arrows before the shell saw them. All app-wide keys @@ -605,10 +730,18 @@ class _WaveShellState extends ConsumerState { final lw = (constraints.maxWidth * 0.46) .clamp(460.0, 720.0) .toDouble(); + final ew = (constraints.maxWidth * 0.52) + .clamp(440.0, 560.0) + .toDouble(); final showOverlay = - _queueOpen || (_lyricsOpen && hasTrack); + _queueOpen || + _equalizerOpen || + (_lyricsOpen && hasTrack); final lyricsOnly = - _lyricsOpen && hasTrack && !_queueOpen; + _lyricsOpen && + hasTrack && + !_queueOpen && + !_equalizerOpen; return Stack( children: [ @@ -672,7 +805,9 @@ class _WaveShellState extends ConsumerState { top: 0, bottom: 0, right: showOverlay - ? (_queueOpen ? qw : lw) + ? (_queueOpen + ? qw + : (_equalizerOpen ? ew : lw)) : 0, child: IgnorePointer( ignoring: !showOverlay, @@ -684,6 +819,7 @@ class _WaveShellState extends ConsumerState { onTap: () => setState(() { _queueOpen = false; _lyricsOpen = false; + _equalizerOpen = false; }), child: Container( color: Colors.black @@ -725,6 +861,28 @@ class _WaveShellState extends ConsumerState { ), ), ), + // Contextual Equalizer & Studio Master Clarity drawer — slides smoothly from right. + AnimatedPositioned( + duration: WaveMotion.normal, + curve: Curves.easeOutCubic, + top: 0, + bottom: 0, + right: _equalizerOpen ? 0 : -(ew + 12), + width: ew, + child: ExcludeFocus( + excluding: !_equalizerOpen, + child: IgnorePointer( + ignoring: !_equalizerOpen, + child: Offstage( + offstage: !_equalizerOpen, + child: _equalizerPanelCache ??= + WaveEqualizerPanel( + onClose: _closeEqualizer, + ), + ), + ), + ), + ), // Contextual Apple Music Karaoke Lyrics drawer — slides smoothly from right. AnimatedPositioned( duration: WaveMotion.normal, @@ -756,21 +914,6 @@ class _WaveShellState extends ConsumerState { ), ), ), - // Mini player — floats ABOVE the dock. - if (_miniOpen && hasTrack) - Positioned( - right: 16, - bottom: 16, - child: WaveMiniPlayer( - onClose: () => setState( - () => _miniOpen = false), - onExpand: () { - setState( - () => _miniOpen = false); - _go('/now'); - }, - ), - ), ], ); }, @@ -780,6 +923,7 @@ class _WaveShellState extends ConsumerState { _dockFor( lyricsActive: _lyricsOpen || isLyrics, queueActive: _queueOpen, + equalizerActive: _equalizerOpen, isLyrics: isLyrics, ), ], diff --git a/lib/ui/mini_player/mini_player.dart b/lib/ui/mini_player/mini_player.dart index 0d8fe4a..33c510e 100644 --- a/lib/ui/mini_player/mini_player.dart +++ b/lib/ui/mini_player/mini_player.dart @@ -8,18 +8,19 @@ import '../components/buttons.dart' show LWTooltip; import '../theme/tokens.dart'; import '../theme/wave_icons.dart'; -/// Small floating mini player — 320×72 card. +/// Small floating mini player window content. /// -/// `artwork + title/artist + play/pause + next + expand + close` with a -/// 2px progress hairline. Position ticks are scoped to [_MiniProgress] -/// only. Best-effort always-on-top via `windowManager.setAlwaysOnTop` -/// while mounted (restored on dispose) — harmless if the backend is -/// missing. +/// `grip + artwork + title/artist + prev + play/pause + next + minimize + expand + close` +/// with an interactive scrubbable progress hairline. +/// Clicking and dragging anywhere on the non-button areas moves the native desktop window. class WaveMiniPlayer extends ConsumerStatefulWidget { final VoidCallback onClose; final VoidCallback onExpand; - const WaveMiniPlayer( - {super.key, required this.onClose, required this.onExpand}); + const WaveMiniPlayer({ + super.key, + required this.onClose, + required this.onExpand, + }); @override ConsumerState createState() => _WaveMiniPlayerState(); @@ -47,7 +48,6 @@ class _WaveMiniPlayerState extends ConsumerState { @override Widget build(BuildContext context) { final dark = waveIsDark(context); - // NOTE: position/duration excluded — _MiniProgress subscribes alone. final snap = ref.watch(playbackServiceProvider.select((s) => ( current: s.current, isPlaying: s.isPlaying, @@ -55,97 +55,214 @@ class _WaveMiniPlayerState extends ConsumerState { ))); final notifier = ref.read(playbackServiceProvider.notifier); final current = snap.current; - if (current == null) return const SizedBox.shrink(); - return Container( - constraints: const BoxConstraints( - minWidth: 280, - maxWidth: 360, - minHeight: 64, + // Draggable card container that fills the miniplayer window + return GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (_) { + try { + windowManager.startDragging(); + } catch (_) {} + }, + onDoubleTap: widget.onExpand, + child: Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + color: dark ? waveSurfaceRaised(context) : WaveColors.lightSurface, + border: Border.all( + color: waveDivider(context).withValues(alpha: dark ? 0.35 : 0.6), + width: 1, + ), + ), + child: current == null + ? _buildEmptyState(context, dark) + : _buildPlayerContent(context, dark, current, snap, notifier), ), - width: 320, - height: 72, - decoration: BoxDecoration( - color: dark ? waveSurfaceRaised(context) : WaveColors.lightSurface, - borderRadius: BorderRadius.circular(WaveRadius.menu), - border: Border.all(color: waveDivider(context)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: dark ? 0.5 : 0.18), - blurRadius: dark ? 28 : 16, - offset: const Offset(0, 12), + ); + } + + Widget _buildEmptyState(BuildContext context, bool dark) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + MouseRegion( + cursor: SystemMouseCursors.move, + child: Icon( + FluentIcons.gripper_dots_vertical, + size: 14, + color: waveTextSecondary(context).withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + Icon(WaveIcons.music, size: 16, color: waveTextSecondary(context)), + const SizedBox(width: 8), + Expanded( + child: Text( + 'No track playing', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: WaveType.meta.copyWith(fontSize: 12), + ), + ), + _MiniGlyph( + tooltip: 'Minimize', + icon: FluentIcons.chrome_minimize, + onTap: () { + try { + windowManager.minimize(); + } catch (_) {} + }, + size: 11, + ), + _MiniGlyph( + tooltip: 'Expand to window', + icon: WaveIcons.expand, + onTap: widget.onExpand, + size: 12, + ), + _MiniGlyph( + tooltip: 'Close mini player', + icon: FluentIcons.chrome_close, + onTap: widget.onClose, + danger: true, + size: 12, ), ], ), - child: ClipRRect( - borderRadius: BorderRadius.circular(WaveRadius.menu), - child: Column( - children: [ - Expanded( - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 10), - child: Row( - children: [ - GestureDetector( - onTap: widget.onExpand, - child: WaveArtwork( - url: current.artworkUrl, - videoId: current.videoId, - size: 48, - radius: WaveRadius.artwork, - label: current.title, - ), + ); + } + + Widget _buildPlayerContent( + BuildContext context, + bool dark, + dynamic current, + dynamic snap, + dynamic notifier, + ) { + return Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + // Grip indicator for dragging window + MouseRegion( + cursor: SystemMouseCursors.move, + child: Container( + padding: const EdgeInsets.only(right: 6, left: 2), + color: Colors.transparent, + child: Icon( + FluentIcons.gripper_dots_vertical, + size: 14, + color: waveTextSecondary(context).withValues(alpha: 0.5), ), - const SizedBox(width: 10), - Expanded( - child: GestureDetector( - onTap: widget.onExpand, - child: Column( - mainAxisAlignment: - MainAxisAlignment.center, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text(current.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: WaveType.trackTitle - .copyWith(fontSize: 12.5)), - Text(current.artist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: WaveType.meta - .copyWith(fontSize: 11.5)), - ], - ), + ), + ), + // Artwork + GestureDetector( + onTap: widget.onExpand, + child: WaveArtwork( + url: current.artworkUrl, + videoId: current.videoId, + size: 46, + radius: WaveRadius.artwork, + label: current.title, + ), + ), + const SizedBox(width: 8), + // Title and Artist (draggable on pan, expand on tap) + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onExpand, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + current.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: WaveType.trackTitle.copyWith( + fontSize: 12.0, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 1), + Text( + current.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: WaveType.meta.copyWith(fontSize: 11.0), + ), + ], ), ), - _MiniPlay( - playing: snap.isPlaying, - buffering: snap.isBuffering, - onTap: notifier.toggle, - ), - _MiniGlyph( - tooltip: 'Next', - icon: WaveIcons.next, - onTap: notifier.next), - _MiniGlyph( - tooltip: 'Expand', - icon: WaveIcons.expand, - onTap: widget.onExpand), - _MiniGlyph( - tooltip: 'Close mini player', - icon: FluentIcons.chrome_close, - onTap: widget.onClose), - ], + ), ), - ), + const SizedBox(width: 4), + // Transport controls: Previous, Play/Pause, Next + _MiniGlyph( + tooltip: 'Previous', + icon: WaveIcons.previous, + onTap: notifier.previous, + size: 13, + ), + const SizedBox(width: 2), + _MiniPlay( + playing: snap.isPlaying, + buffering: snap.isBuffering, + onTap: notifier.toggle, + ), + const SizedBox(width: 2), + _MiniGlyph( + tooltip: 'Next', + icon: WaveIcons.next, + onTap: notifier.next, + size: 13, + ), + const SizedBox(width: 4), + // Subtle divider between transport and window actions + Container( + width: 1, + height: 18, + color: waveDivider(context).withValues(alpha: 0.6), + ), + const SizedBox(width: 4), + _MiniGlyph( + tooltip: 'Minimize', + icon: FluentIcons.chrome_minimize, + onTap: () { + try { + windowManager.minimize(); + } catch (_) {} + }, + size: 11, + ), + _MiniGlyph( + tooltip: 'Expand to window', + icon: WaveIcons.expand, + onTap: widget.onExpand, + size: 12, + ), + _MiniGlyph( + tooltip: 'Close mini player', + icon: FluentIcons.chrome_close, + onTap: widget.onClose, + danger: true, + size: 12, + ), + ], ), - const RepaintBoundary(child: _MiniProgress()), - ], + ), ), - ), + const RepaintBoundary(child: _MiniProgress()), + ], ); } } @@ -200,7 +317,7 @@ class _MiniPlay extends StatelessWidget { } } -/// Sole position subscriber in the mini player. +/// Sole position subscriber in the mini player with click-to-seek support. class _MiniProgress extends ConsumerWidget { const _MiniProgress(); @override @@ -210,22 +327,41 @@ class _MiniProgress extends ConsumerWidget { position: s.position, duration: s.duration, ))); - final progress = clock.duration.inMilliseconds > 0 - ? (clock.position.inMilliseconds / - clock.duration.inMilliseconds) - .clamp(0.0, 1.0) + final totalMs = clock.duration.inMilliseconds; + final progress = totalMs > 0 + ? (clock.position.inMilliseconds / totalMs).clamp(0.0, 1.0) : 0.0; - return RepaintBoundary( - child: Container( - height: 2, - alignment: Alignment.centerLeft, - color: - (dark ? Colors.white : Colors.black).withValues(alpha: 0.1), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: progress, + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) { + if (totalMs > 0) { + final box = context.findRenderObject() as RenderBox?; + if (box != null && box.size.width > 0) { + final ratio = + (details.localPosition.dx / box.size.width).clamp(0.0, 1.0); + final targetMs = (ratio * totalMs).round(); + ref + .read(playbackServiceProvider.notifier) + .seek(Duration(milliseconds: targetMs)); + } + } + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + height: 4, + alignment: Alignment.bottomCenter, child: Container( - color: dark ? Colors.white : Colors.black, + height: 2.5, + alignment: Alignment.centerLeft, + color: (dark ? Colors.white : Colors.black).withValues(alpha: 0.12), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: progress, + child: Container( + color: WaveColors.defaultAccent, + ), + ), ), ), ), @@ -233,26 +369,63 @@ class _MiniProgress extends ConsumerWidget { } } -class _MiniGlyph extends StatelessWidget { +class _MiniGlyph extends StatefulWidget { final String tooltip; final IconData icon; final VoidCallback onTap; - const _MiniGlyph( - {required this.tooltip, required this.icon, required this.onTap}); + final bool danger; + final double size; + const _MiniGlyph({ + required this.tooltip, + required this.icon, + required this.onTap, + this.danger = false, + this.size = 13, + }); + + @override + State<_MiniGlyph> createState() => _MiniGlyphState(); +} + +class _MiniGlyphState extends State<_MiniGlyph> { + bool _hover = false; + @override Widget build(BuildContext context) { + final dark = waveIsDark(context); return LWTooltip( - message: tooltip, + message: widget.tooltip, child: MouseRegion( cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), child: GestureDetector( - onTap: onTap, - child: Container( - width: 34, - height: 36, - color: Colors.transparent, - child: Icon(icon, - size: 15, color: waveTextSecondary(context)), + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _hover + ? (widget.danger + ? WaveColors.danger.withValues(alpha: 0.85) + : (dark + ? Colors.white.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.06))) + : Colors.transparent, + ), + child: Center( + child: Icon( + widget.icon, + size: widget.size, + color: _hover + ? (widget.danger + ? Colors.white + : (dark ? Colors.white : Colors.black)) + : waveTextSecondary(context), + ), + ), ), ), ), diff --git a/lib/ui/player_dock/player_dock.dart b/lib/ui/player_dock/player_dock.dart index dc7d22a..00393ae 100644 --- a/lib/ui/player_dock/player_dock.dart +++ b/lib/ui/player_dock/player_dock.dart @@ -34,16 +34,20 @@ class WavePlayerDock extends ConsumerWidget { final VoidCallback onExpand; final VoidCallback onToggleQueue; final VoidCallback onToggleLyrics; + final VoidCallback? onToggleEqualizer; final bool queueActive; final bool lyricsActive; + final bool equalizerActive; final VoidCallback onToggleMini; const WavePlayerDock({ super.key, required this.onExpand, required this.onToggleQueue, required this.onToggleLyrics, + this.onToggleEqualizer, this.queueActive = false, this.lyricsActive = false, + this.equalizerActive = false, required this.onToggleMini, }); @@ -311,6 +315,13 @@ class WavePlayerDock extends ConsumerWidget { active: queueActive, onTap: onToggleQueue, ), + if (onToggleEqualizer != null) + _Glyph( + tooltip: 'Equalizer', + icon: WaveIcons.equalizer, + active: equalizerActive, + onTap: onToggleEqualizer!, + ), if (showDevice) const _DeviceGlyph(), if (showVolumeSlider) const _VolumeSection() @@ -338,6 +349,7 @@ class WavePlayerDock extends ConsumerWidget { onToggleLyrics: onToggleLyrics, onExpand: onExpand, onToggleMini: onToggleMini, + onToggleEqualizer: onToggleEqualizer, ), ], ), @@ -1270,6 +1282,7 @@ class _DockOverflow extends ConsumerWidget { final VoidCallback onToggleLyrics; final VoidCallback onExpand; final VoidCallback onToggleMini; + final VoidCallback? onToggleEqualizer; const _DockOverflow({ this.showLyricsItem = false, this.showQualityItem = false, @@ -1279,6 +1292,7 @@ class _DockOverflow extends ConsumerWidget { required this.onToggleLyrics, required this.onExpand, required this.onToggleMini, + this.onToggleEqualizer, }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -1344,6 +1358,13 @@ class _DockOverflow extends ConsumerWidget { label: 'Mini player', onPressed: onToggleMini, ), + if (onToggleEqualizer != null) + WaveMenuAction( + leading: + const Icon(WaveIcons.equalizer, size: 15), + label: 'Equalizer', + onPressed: onToggleEqualizer!, + ), if (hasCollapsed) const WaveMenuSeparator(), WaveMenuAction( leading: const Icon(WaveIcons.gauge, size: 15), diff --git a/lib/ui/settings/settings_page.dart b/lib/ui/settings/settings_page.dart index ba05140..dc924d6 100644 --- a/lib/ui/settings/settings_page.dart +++ b/lib/ui/settings/settings_page.dart @@ -27,6 +27,8 @@ import '../../features/audio_output/output_path_sheet.dart'; import '../../features/lyrics/lyrics_providers.dart'; import '../theme/tokens.dart'; import '../theme/brand_icons.dart'; +import '../../features/equalizer/equalizer_controller.dart'; +import '../../features/equalizer/equalizer_models.dart'; import '../../features/presence/discord_presence_service.dart'; import '../../features/telemetry/live_telemetry_service.dart'; @@ -542,7 +544,96 @@ class _Audio extends ConsumerWidget { ), ], ), - ) + ), + const SizedBox(height: 8), + _Group( + title: 'Equalizer & Studio Master Clarity', + subtitle: '10-band ISO curves with acoustic & stage compensation', + child: const _EqualizerSettingsSummary(), + ), + ], + ); + } +} + +class _EqualizerSettingsSummary extends ConsumerWidget { + const _EqualizerSettingsSummary(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final eq = ref.watch(equalizerProvider); + final notifier = ref.read(equalizerProvider.notifier); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SwitchRow( + value: eq.enabled, + onChanged: (v) => notifier.setEnabled(v), + title: '10-Band Equalizer', + subtitle: eq.enabled + ? 'Active: ${eq.presetName} (-8 dB to +8 dB per band)' + : 'Bit-perfect direct stream bypass (filter removed)', + ), + const SizedBox(height: 8), + _SwitchRow( + value: eq.studioClarityEnabled, + onChanged: (v) => notifier.setStudioClarityEnabled(v), + title: 'Studio Master Clarity', + subtitle: 'Acoustic & stage compensation (${eq.clarityPreset.displayName})', + ), + if (eq.studioClarityEnabled) ...[ + const SizedBox(height: 8), + Row( + children: [ + const Text('Clarity target', style: WaveType.trackTitle), + const Spacer(), + ComboBox( + value: eq.clarityPreset.key, + items: [ + for (final t in ClarityPresets.all) + ComboBoxItem( + value: t.key, + child: Text(t.displayName), + ), + ], + onChanged: (key) { + if (key == null) return; + notifier.setClarityPreset(key); + }, + ), + ], + ), + ], + const SizedBox(height: 8), + Row( + children: [ + const Text('Equalizer preset', style: WaveType.trackTitle), + const Spacer(), + ComboBox( + value: EqualizerPresets.all.any((p) => p.name == eq.presetName) + ? eq.presetName + : 'Custom', + items: [ + for (final p in EqualizerPresets.all) + ComboBoxItem( + value: p.name, + child: Text(p.name), + ), + if (!EqualizerPresets.all.any((p) => p.name == eq.presetName)) + const ComboBoxItem( + value: 'Custom', + child: Text('Custom'), + ), + ], + onChanged: (preset) { + if (preset != null && preset != 'Custom') { + notifier.setPreset(preset); + } + }, + ), + ], + ), ], ); } @@ -2044,12 +2135,22 @@ class _General extends ConsumerWidget { } } -class _Playback extends ConsumerWidget { +class _Playback extends ConsumerStatefulWidget { final Future Function(Future Function(Prefs)) onUpdate; const _Playback({required this.onUpdate}); + @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState<_Playback> createState() => _PlaybackState(); +} + +class _PlaybackState extends ConsumerState<_Playback> { + double? _dragSeconds; + + @override + Widget build(BuildContext context) { final prefs = ref.watch(prefsProvider); + final currentSeconds = _dragSeconds ?? prefs.crossfadeSeconds.toDouble(); + return _Group( title: 'Playback', subtitle: 'Gapless, crossfade and output behaviour', @@ -2057,7 +2158,7 @@ class _Playback extends ConsumerWidget { children: [ _SwitchRow( value: prefs.autoplaySimilar, - onChanged: (v) => onUpdate((p) => p.setAutoplaySimilar(v)), + onChanged: (v) => widget.onUpdate((p) => p.setAutoplaySimilar(v)), title: 'Autoplay similar', subtitle: 'Keep playing related tracks after a queue ends', ), @@ -2065,12 +2166,68 @@ class _Playback extends ConsumerWidget { _SwitchRow( value: prefs.crossfadeEnabled, onChanged: (v) async { - await onUpdate((p) => p.setCrossfade(v)); + await widget.onUpdate((p) => p.setCrossfade(v)); ref.read(audioOutputProvider.notifier).refreshPath(); }, title: 'Crossfade', - subtitle: '${prefs.crossfadeSeconds}s · gapless otherwise', + subtitle: prefs.crossfadeEnabled + ? '${prefs.crossfadeSeconds}s crossfade duration' + : 'Gapless direct transition (crossfade disabled)', ), + if (prefs.crossfadeEnabled) ...[ + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Crossfade duration', + style: WaveType.trackTitle, + ), + const Spacer(), + Text( + '${currentSeconds.round()}s', + style: WaveType.meta.copyWith( + color: waveAccent(context), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 6), + Slider( + value: currentSeconds.clamp(3.0, 12.0), + min: 3.0, + max: 12.0, + divisions: 9, + onChanged: (v) => setState(() => _dragSeconds = v), + onChangeEnd: (v) async { + setState(() => _dragSeconds = null); + final secs = v.round().clamp(3, 12); + await widget.onUpdate((p) => p.setCrossfadeSeconds(secs)); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('3s (Short)', + style: WaveType.meta + .copyWith(color: waveTextSecondary(context))), + Text('7s (Standard)', + style: WaveType.meta + .copyWith(color: waveTextSecondary(context))), + Text('12s (Long)', + style: WaveType.meta + .copyWith(color: waveTextSecondary(context))), + ], + ), + ], + ), + ), + ], ], ), ); diff --git a/lib/ui/theme/wave_icons.dart b/lib/ui/theme/wave_icons.dart index c763b8f..42c3c01 100644 --- a/lib/ui/theme/wave_icons.dart +++ b/lib/ui/theme/wave_icons.dart @@ -77,6 +77,7 @@ class WaveIcons { static const IconData miniPlayer = FluentIcons.mini_expand; static const IconData device = FluentIcons.speakers; static const IconData streamPath = FluentIcons.equalizer; + static const IconData equalizer = FluentIcons.equalizer; // Community — DO NOT use these generic fallbacks for UI. // Use TelegramIcon / DiscordIcon from brand_icons.dart (official marks).